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:
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user