Skip to content

Commit df8a180

Browse files
authored
🤖 feat: allow unlimited bash_output timeout with queued message interruption (#1053)
## Summary Allow `bash_output` to wait for any amount of time (removing the previous 15-second limit), while ensuring the chat stays responsive by detecting when users queue new messages. ## Changes ### 1. Remove timeout limit - Removed `.max(15)` constraint from the schema - Updated description to guide agents: "Only use long timeouts (>15s) when no other useful work can be done in parallel" ### 2. Add abort signal support - `getOutput()` accepts optional `abortSignal` parameter - Returns `status: "interrupted"` when stream is cancelled ### 3. Add queued message detection - Added `setMessageQueued()` / `hasQueuedMessage()` to `BackgroundProcessManager` - `queueMessage()` sets the flag; `sendQueuedMessages()` / `clearQueue()` clears it - `getOutput()` checks for queued messages in polling loop and returns early ### Flow when user sends message during `bash_output` wait: 1. `queueMessage()` → `setMessageQueued(workspaceId, true)` 2. `bash_output`'s `getOutput()` polling loop detects `hasQueuedMessage() = true` 3. Returns `{ status: "interrupted", output: "(waiting interrupted)" }` 4. `tool-call-end` fires → `sendQueuedMessages()` processes queued message ### 4. UI update - `ProcessStatusBadge` now supports "interrupted" status with warning color ## Testing - Added test for abort signal interruption - Added test for queued message interruption - All 14 `bash_output` tests pass --- _Generated with `mux`_
1 parent cb1683e commit df8a180

File tree

7 files changed

+166
-13
lines changed

7 files changed

+166
-13
lines changed

‎src/browser/components/tools/shared/ToolPrimitives.tsx‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -215,10 +215,10 @@ export const ExitCodeBadge: React.FC<ExitCodeBadgeProps> = ({ exitCode, classNam
215215
);
216216

217217
/**
218-
* Badge for displaying process status (exited, killed, failed)
218+
* Badge for displaying process status (exited, killed, failed, interrupted)
219219
*/
220220
interface ProcessStatusBadgeProps {
221-
status: "exited" | "killed" | "failed";
221+
status: "exited" | "killed" | "failed" | "interrupted";
222222
exitCode?: number;
223223
className?: string;
224224
}
@@ -233,7 +233,9 @@ export const ProcessStatusBadge: React.FC<ProcessStatusBadgeProps> = ({
233233
"inline-block shrink-0 rounded px-1.5 py-0.5 text-[10px] font-medium whitespace-nowrap",
234234
status === "exited" && exitCode === 0
235235
? "bg-success text-on-success"
236-
: "bg-danger text-on-danger",
236+
: status === "interrupted"
237+
? "bg-warning text-on-warning"
238+
: "bg-danger text-on-danger",
237239
className
238240
)}
239241
>

‎src/common/types/tools.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -235,7 +235,7 @@ export interface BashOutputToolArgs {
235235
export type BashOutputToolResult =
236236
| {
237237
success: true;
238-
status: "running" | "exited" | "killed" | "failed";
238+
status: "running" | "exited" | "killed" | "failed" | "interrupted";
239239
output: string;
240240
exitCode?: number;
241241
note?: string; // Agent-only message (not displayed in UI)

‎src/common/utils/tools/toolDefinitions.ts‎

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -257,12 +257,11 @@ export const TOOL_DEFINITIONS = {
257257
timeout_secs: z
258258
.number()
259259
.min(0)
260-
.max(15)
261260
.describe(
262-
"Seconds to wait for new output (0-15). " +
261+
"Seconds to wait for new output. " +
263262
"If no output is immediately available and process is still running, " +
264263
"blocks up to this duration. Returns early when output arrives or process exits. " +
265-
"Use this instead of polling in a loop."
264+
"Only use long timeouts (>15s) when no other useful work can be done in parallel."
266265
),
267266
}),
268267
},

‎src/node/services/agentSession.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -659,12 +659,15 @@ export class AgentSession {
659659
this.assertNotDisposed("queueMessage");
660660
this.messageQueue.add(message, options);
661661
this.emitQueuedMessageChanged();
662+
// Signal to bash_output that it should return early to process the queued message
663+
this.backgroundProcessManager.setMessageQueued(this.workspaceId, true);
662664
}
663665

664666
clearQueue(): void {
665667
this.assertNotDisposed("clearQueue");
666668
this.messageQueue.clear();
667669
this.emitQueuedMessageChanged();
670+
this.backgroundProcessManager.setMessageQueued(this.workspaceId, false);
668671
}
669672

670673
/**
@@ -703,6 +706,9 @@ export class AgentSession {
703706
* Called when tool execution completes, stream ends, or user clicks send immediately.
704707
*/
705708
sendQueuedMessages(): void {
709+
// Clear the queued message flag (even if queue is empty, to handle race conditions)
710+
this.backgroundProcessManager.setMessageQueued(this.workspaceId, false);
711+
706712
if (!this.messageQueue.isEmpty()) {
707713
const { message, options } = this.messageQueue.produceMessage();
708714
this.messageQueue.clear();

‎src/node/services/backgroundProcessManager.ts‎

Lines changed: 40 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -99,12 +99,33 @@ export class BackgroundProcessManager extends EventEmitter<BackgroundProcessMana
9999
// Tracks foreground processes (started via runtime.exec) that can be backgrounded
100100
// Key is toolCallId to support multiple parallel foreground processes per workspace
101101
private foregroundProcesses = new Map<string, ForegroundProcess>();
102+
// Tracks workspaces with queued messages (for bash_output to return early)
103+
private queuedMessageWorkspaces = new Set<string>();
102104

103105
constructor(bgOutputDir: string) {
104106
super();
105107
this.bgOutputDir = bgOutputDir;
106108
}
107109

110+
/**
111+
* Mark whether a workspace has a queued user message.
112+
* Used by bash_output to return early when user has sent a new message.
113+
*/
114+
setMessageQueued(workspaceId: string, queued: boolean): void {
115+
if (queued) {
116+
this.queuedMessageWorkspaces.add(workspaceId);
117+
} else {
118+
this.queuedMessageWorkspaces.delete(workspaceId);
119+
}
120+
}
121+
122+
/**
123+
* Check if a workspace has a queued user message.
124+
*/
125+
hasQueuedMessage(workspaceId: string): boolean {
126+
return this.queuedMessageWorkspaces.has(workspaceId);
127+
}
128+
108129
/** Emit a change event for a workspace */
109130
private emitChange(workspaceId: string): void {
110131
this.emit("change", workspaceId);
@@ -409,23 +430,27 @@ export class BackgroundProcessManager extends EventEmitter<BackgroundProcessMana
409430
* Returns only NEW output since the last call (tracked per process).
410431
* @param processId Process ID to get output from
411432
* @param filter Optional regex pattern to filter output lines (non-matching lines are discarded permanently)
412-
* @param timeout Seconds to wait for output if none available (0-15, default 0 = non-blocking)
433+
* @param timeout Seconds to wait for output if none available (default 0 = non-blocking)
434+
* @param abortSignal Optional signal to abort waiting early (e.g., when stream is cancelled)
435+
* @param workspaceId Optional workspace ID to check for queued messages (return early to process them)
413436
*/
414437
async getOutput(
415438
processId: string,
416439
filter?: string,
417-
timeout?: number
440+
timeout?: number,
441+
abortSignal?: AbortSignal,
442+
workspaceId?: string
418443
): Promise<
419444
| {
420445
success: true;
421-
status: "running" | "exited" | "killed" | "failed";
446+
status: "running" | "exited" | "killed" | "failed" | "interrupted";
422447
output: string;
423448
exitCode?: number;
424449
elapsed_ms: number;
425450
}
426451
| { success: false; error: string }
427452
> {
428-
const timeoutSecs = Math.min(Math.max(timeout ?? 0, 0), 15); // Clamp to 0-15
453+
const timeoutSecs = Math.max(timeout ?? 0, 0);
429454
log.debug(
430455
`BackgroundProcessManager.getOutput(${processId}, filter=${filter ?? "none"}, timeout=${timeoutSecs}s) called`
431456
);
@@ -470,10 +495,21 @@ export class BackgroundProcessManager extends EventEmitter<BackgroundProcessMana
470495
// 1. We have output
471496
// 2. Process is no longer running (exited/killed/failed)
472497
// 3. Timeout elapsed
498+
// 4. Abort signal received (user sent a new message)
473499
if (output.length > 0 || currentStatus !== "running") {
474500
break;
475501
}
476502

503+
if (abortSignal?.aborted || (workspaceId && this.hasQueuedMessage(workspaceId))) {
504+
const elapsed_ms = Date.now() - startTime;
505+
return {
506+
success: true,
507+
status: "interrupted",
508+
output: "(waiting interrupted)",
509+
elapsed_ms,
510+
};
511+
}
512+
477513
const elapsed = Date.now() - startTime;
478514
if (elapsed >= timeoutMs) {
479515
break;

‎src/node/services/tools/bash_output.test.ts‎

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -481,4 +481,104 @@ describe("bash_output tool", () => {
481481
await manager.cleanup("test-workspace");
482482
tempDir[Symbol.dispose]();
483483
});
484+
485+
it("should return early with 'interrupted' status when abortSignal is triggered", async () => {
486+
const tempDir = new TestTempDir("test-bash-output");
487+
const manager = new BackgroundProcessManager(tempDir.path);
488+
489+
const runtime = createTestRuntime();
490+
const config = createTestToolConfig(process.cwd(), { sessionsDir: tempDir.path });
491+
config.runtimeTempDir = tempDir.path;
492+
config.backgroundProcessManager = manager;
493+
494+
const processId = `abort-test-${Date.now()}`;
495+
496+
// Spawn a long-running process with no output
497+
const spawnResult = await manager.spawn(runtime, "test-workspace", "sleep 60", {
498+
cwd: process.cwd(),
499+
displayName: processId,
500+
});
501+
502+
if (!spawnResult.success) {
503+
throw new Error("Failed to spawn process");
504+
}
505+
506+
const tool = createBashOutputTool(config);
507+
const abortController = new AbortController();
508+
509+
// Abort after 200ms
510+
setTimeout(() => abortController.abort(), 200);
511+
512+
// Call with long timeout - should be interrupted by abort signal
513+
const start = Date.now();
514+
const result = (await tool.execute!(
515+
{ process_id: spawnResult.processId, timeout_secs: 30 },
516+
{ ...mockToolCallOptions, abortSignal: abortController.signal }
517+
)) as BashOutputToolResult;
518+
const elapsed = Date.now() - start;
519+
520+
expect(result.success).toBe(true);
521+
if (result.success) {
522+
expect(result.status).toBe("interrupted");
523+
expect(result.output).toBe("(waiting interrupted)");
524+
// Should have returned quickly after abort, not waiting full 30s
525+
expect(elapsed).toBeLessThan(1000);
526+
expect(elapsed).toBeGreaterThan(150); // At least waited until abort
527+
}
528+
529+
// Cleanup
530+
await manager.terminate(spawnResult.processId);
531+
await manager.cleanup("test-workspace");
532+
tempDir[Symbol.dispose]();
533+
});
534+
535+
it("should return early with 'interrupted' status when message is queued", async () => {
536+
const tempDir = new TestTempDir("test-bash-output");
537+
const manager = new BackgroundProcessManager(tempDir.path);
538+
539+
const runtime = createTestRuntime();
540+
const config = createTestToolConfig(process.cwd(), { sessionsDir: tempDir.path });
541+
config.runtimeTempDir = tempDir.path;
542+
config.backgroundProcessManager = manager;
543+
544+
const processId = `queued-msg-test-${Date.now()}`;
545+
546+
// Spawn a long-running process with no output
547+
const spawnResult = await manager.spawn(runtime, "test-workspace", "sleep 60", {
548+
cwd: process.cwd(),
549+
displayName: processId,
550+
});
551+
552+
if (!spawnResult.success) {
553+
throw new Error("Failed to spawn process");
554+
}
555+
556+
const tool = createBashOutputTool(config);
557+
558+
// Queue a message after 200ms
559+
setTimeout(() => manager.setMessageQueued("test-workspace", true), 200);
560+
561+
// Call with long timeout - should be interrupted by queued message
562+
const start = Date.now();
563+
const result = (await tool.execute!(
564+
{ process_id: spawnResult.processId, timeout_secs: 30 },
565+
mockToolCallOptions
566+
)) as BashOutputToolResult;
567+
const elapsed = Date.now() - start;
568+
569+
expect(result.success).toBe(true);
570+
if (result.success) {
571+
expect(result.status).toBe("interrupted");
572+
expect(result.output).toBe("(waiting interrupted)");
573+
// Should have returned quickly after queued message, not waiting full 30s
574+
expect(elapsed).toBeLessThan(1000);
575+
expect(elapsed).toBeGreaterThan(150); // At least waited until message was queued
576+
}
577+
578+
// Cleanup
579+
manager.setMessageQueued("test-workspace", false);
580+
await manager.terminate(spawnResult.processId);
581+
await manager.cleanup("test-workspace");
582+
tempDir[Symbol.dispose]();
583+
});
484584
});

‎src/node/services/tools/bash_output.ts‎

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,10 @@ export const createBashOutputTool: ToolFactory = (config: ToolConfiguration) =>
1111
return tool({
1212
description: TOOL_DEFINITIONS.bash_output.description,
1313
inputSchema: TOOL_DEFINITIONS.bash_output.schema,
14-
execute: async ({ process_id, filter, timeout_secs }): Promise<BashOutputToolResult> => {
14+
execute: async (
15+
{ process_id, filter, timeout_secs },
16+
{ abortSignal }
17+
): Promise<BashOutputToolResult> => {
1518
if (!config.backgroundProcessManager) {
1619
return {
1720
success: false,
@@ -36,7 +39,14 @@ export const createBashOutputTool: ToolFactory = (config: ToolConfiguration) =>
3639
}
3740

3841
// Get incremental output with blocking wait
39-
return await config.backgroundProcessManager.getOutput(process_id, filter, timeout_secs);
42+
// Pass workspaceId so getOutput can check for queued messages
43+
return await config.backgroundProcessManager.getOutput(
44+
process_id,
45+
filter,
46+
timeout_secs,
47+
abortSignal,
48+
config.workspaceId
49+
);
4050
},
4151
});
4252
};

0 commit comments

Comments
 (0)