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