Implement background command support for bash tool

- Added `background` option to `bash` tool to allow long-lived commands to run without blocking the turn.
- Introduced `bash_status` tool to check the status and output of background commands.
- Added `bash_stop` tool to explicitly stop running background commands.
- Updated command parsing to handle `/bash` commands for listing, stopping individual, and stopping all background commands.
- Enhanced session management to reap stale background commands on agent shutdown.
- Updated UI to reflect background command status and allow user interruption via ctrl-c.
- Added tests for background command functionality and command parsing.
- Documented new features and usage in background-bash.md.
This commit is contained in:
asepharyana
2026-09-11 18:52:50 +07:00
parent deeb138cd9
commit 76043aeb38
12 changed files with 724 additions and 18 deletions
+19
View File
@@ -158,3 +158,22 @@ test('/registry add with no name returns usage rather than fetching anything', (
test('a bare word after /registry is treated as a search', () => {
expect(parseCommand('/registry migration')).toEqual({ type: 'registry', action: 'search', arg: 'migration' });
});
test('/bash with no verb lists background commands', () => {
expect(parseCommand('/bash')).toEqual({ type: 'bash', action: 'list' });
expect(parseCommand('/bash list')).toEqual({ type: 'bash', action: 'list' });
});
test('/bash stop carries the handle', () => {
expect(parseCommand('/bash stop 3')).toEqual({ type: 'bash', action: 'stop', arg: '3' });
});
test('/bash stop all stops everything', () => {
expect(parseCommand('/bash stop all')).toEqual({ type: 'bash', action: 'stop-all' });
});
test('/bash with an unknown verb returns usage', () => {
const out = parseCommand('/bash frobnicate');
expect(out.type).toBe('info');
expect((out as { text: string }).text).toContain('usage: /bash');
});
+6 -1
View File
@@ -55,7 +55,12 @@ export function testHooks(over: Partial<AppHooks> = {}): AppHooks {
remove: async (name) => `removed ${name}`,
},
initPrompt: 'write AGENTS.md',
scaffoldWorkflow: () => [],
scaffoldWorkflow: () => [],
backgroundCommands: {
list: () => 'no background commands',
stop: async (handle) => `stopped ${handle}`,
stopAll: async () => 'stopped all',
},
history: [],
recordPrompt: () => {},
...over,
+58 -1
View File
@@ -8,7 +8,7 @@ import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { Session } from '../src/session';
import { interruptBash } from '../src/tools';
import { backgroundHandles, interruptBash } from '../src/tools';
const usage = usageOf(10, 5);
@@ -373,3 +373,60 @@ test('an interrupted command becomes a tool error and the turn carries on', asyn
expect(kinds.at(-1)).toBe('done');
expect(call).toBe(2);
}), 30_000);
test('a background command runs, streams via the listener, and can be stopped', async () =>
inTempDir(async () => {
const script = process.platform === 'win32' ? 'ping -n 3 127.0.0.1 > nul' : 'sleep 0.2; echo bg-line';
let call = 0;
let modelCalls = 0;
const session = new Session({
yolo: true,
model: new MockLanguageModelV4({
doStream: async () => {
const n = call++;
modelCalls += 1;
if (n === 0) return stream(toolCall('c1', 'bash', { command: script, background: true, name: 'bgtest' }));
// The model polls whatever handle the start actually produced — the
// counter is module-global, so it is not necessarily 1 when other
// test files ran in the same process first.
const handles = backgroundHandles().sort((a, b) => a - b);
const handle = handles.at(-1) ?? 1;
if (n === 1) return stream(toolCall('c2', 'bash_status', { handle }));
// Give the script time to finish, then poll once more and stop it.
await Bun.sleep(1500);
if (n === 2) return stream(toolCall('c3', 'bash_status', { handle }));
if (n === 3) return stream(toolCall('c4', 'bash_stop', { handle }));
return stream(text('all done'));
},
}),
askApproval: async (req) => {
// In this session-scoped test the only gated call is the bash background
// start itself; approve it once so the model's own calls never see the prompt.
return req.toolName === 'bash' ? 'once' : 'always';
},
});
const results: string[] = [];
const streamed: string[] = [];
for await (const ev of session.send('run the dev server in the background and check it')) {
if (ev.type === 'tool-result') results.push(String(ev.output));
if (ev.type === 'tool-output') streamed.push(String(ev.chunk));
}
// The start returned a handle; the first poll saw running; the final poll saw it finish.
// Don't hardcode the handle number — read it out of the first tool result,
// since the counter is shared module-global state and the process may have
// been stopped by the final bash_stop by the time these assertions run.
const actualHandle = Number(results[0]?.match(/background (\d+):/)?.[1]);
expect(actualHandle).toBeGreaterThan(0);
expect(results[0]).toContain(`background ${actualHandle}:`);
expect(results[1]).toContain('running');
expect(results[2]).toContain('finished');
expect(results[3]).toContain(`killed ${actualHandle}`);
// Output streamed through the session's tool-output events (the live panel).
expect(streamed.join('')).toContain('bg-line');
expect(modelCalls).toBeGreaterThanOrEqual(4);
// Nothing left running after the turn's tool calls.
const { shutdownBackgrounds } = await import('../src/tools');
await shutdownBackgrounds();
}), 30_000);
+97
View File
@@ -5,6 +5,8 @@ import { join } from 'node:path';
import {
applyPatchTool,
bashTool,
bashStatusTool,
bashStopTool,
deleteFileTool,
editFileTool,
globTool,
@@ -22,6 +24,10 @@ import {
tools,
toolSetOf,
writeFileTool,
shutdownBackgrounds,
startBackground,
stopBackground,
statusBackground,
} from '../src/tools';
let dir: string;
@@ -581,3 +587,94 @@ test('a command that finished is no longer interruptible', async () => {
await run(bashTool, { command: 'echo done' });
expect(interruptBash()).toEqual([]);
}, 20_000);
// --- background commands (dev servers, watchers) ---
const bgScript = process.platform === 'win32' ? 'ping -n 3 127.0.0.1 > nul' : 'sleep 0.6; echo bg-done';
test('bash background returns immediately with a handle', async () => {
const started = Date.now();
const out = await run(bashTool, { command: 'sleep 30', background: true, name: 'sleeper' });
// The counter is module-global, so the number depends on what ran before in
// the same process — extract it rather than assuming it is 1.
const handle = Number(out.match(/background (\d+):/)?.[1]);
expect(handle).toBeGreaterThan(0);
expect(out).toContain('running');
expect(out).toContain('bash_status');
// Returns before the 30s command finishes.
expect(Date.now() - started).toBeLessThan(5_000);
await shutdownBackgrounds();
}, 20_000);
test('bash_status reports running then finished with the exit code', async () => {
const out = await run(bashTool, { command: bgScript, background: true, name: 'bgtest' });
const handle = Number(out.match(/background (\d+)/)?.[1]);
expect(handle).toBeGreaterThan(0);
const early = statusBackground(handle);
expect(early).toContain('running');
// Wait for the script to finish.
let status = '';
const deadline = Date.now() + 10_000;
while (Date.now() < deadline) {
await Bun.sleep(200);
status = statusBackground(handle);
if (status.includes('finished')) break;
}
expect(status).toContain('finished');
expect(status).toContain('exit 0');
await shutdownBackgrounds();
}, 25_000);
test('bash_status unknown handle reports no such handle', async () => {
expect(statusBackground(999)).toContain('no such handle');
});
test('bash_stop kills a running background command', async () => {
const out = await run(bashTool, { command: 'sleep 30', background: true, name: 'killer' });
const handle = Number(out.match(/background (\d+)/)?.[1]);
const started = Date.now();
const msg = await run(bashStopTool, { handle });
expect(msg).toContain(`killed ${handle}`);
expect(msg).toContain('sleep 30');
// Killed, not waited out.
expect(Date.now() - started).toBeLessThan(10_000);
await shutdownBackgrounds();
}, 25_000);
test('bash_stop unknown handle is reported without throwing', async () => {
const out = await run(bashStopTool, { handle: 4242 });
expect(out).toContain('no such handle');
});
test('bash_status streams output into its buffer for a later read', async () => {
const out = await run(bashTool, { command: bgScript, background: true, name: 'stream' });
const handle = Number(out.match(/background (\d+)/)?.[1]);
// Give the script time to print its line.
await Bun.sleep(1200);
const status = statusBackground(handle);
expect(status.toLowerCase()).toContain('bg-done');
await shutdownBackgrounds();
}, 20_000);
test('background commands are not interrupted by interruptBash (foreground)', async () => {
const out = await run(bashTool, { command: 'sleep 30', background: true, name: 'immune' });
await Bun.sleep(200);
// Foreground interrupt must not touch background commands.
expect(interruptBash()).toEqual([]);
const handle = Number(out.match(/background (\d+)/)?.[1]);
expect(statusBackground(handle)).toContain('running');
await shutdownBackgrounds();
}, 20_000);
test('shutdownBackgrounds kills everything live and can be called twice', async () => {
await run(bashTool, { command: 'sleep 30', background: true, name: 'cleanup' });
const before = Date.now();
await shutdownBackgrounds();
// Killed, not waited out.
expect(Date.now() - before).toBeLessThan(10_000);
// Second call is a no-op.
await shutdownBackgrounds();
}, 20_000);