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
+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);