diff --git a/src/services/tools/bash.ts b/src/services/tools/bash.ts index d25f7ef9b4..3154ee7d07 100644 --- a/src/services/tools/bash.ts +++ b/src/services/tools/bash.ts @@ -112,6 +112,10 @@ export const createBashTool: ToolFactory = (config: ToolConfiguration) => { let exitCode: number | null = null; let resolved = false; + // Forward-declare teardown function that will be defined below + // eslint-disable-next-line prefer-const + let teardown: () => void; + // Helper to resolve once const resolveOnce = (result: BashToolResult) => { if (!resolved) { @@ -124,13 +128,20 @@ export const createBashTool: ToolFactory = (config: ToolConfiguration) => { } }; - // Set up abort signal listener - cancellation is handled by runtime + // Set up abort signal listener - immediately resolve on abort let abortListener: (() => void) | null = null; if (abortSignal) { abortListener = () => { if (!resolved) { - // Runtime handles the actual cancellation - // We just need to clean up our side + // Immediately resolve with abort error to unblock AI SDK stream + // The runtime will handle killing the actual process + teardown(); + resolveOnce({ + success: false, + error: "Command execution was aborted", + exitCode: -2, + wall_duration_ms: Math.round(performance.now() - startTime), + }); } }; abortSignal.addEventListener("abort", abortListener); @@ -163,8 +174,8 @@ export const createBashTool: ToolFactory = (config: ToolConfiguration) => { // eslint-disable-next-line prefer-const let finalize: () => void; - // Helper to tear down streams and readline interfaces - const teardown = () => { + // Define teardown (already declared above) + teardown = () => { stdoutReader.close(); stderrReader.close(); stdoutNodeStream.destroy(); diff --git a/tests/ipcMain/sendMessage.test.ts b/tests/ipcMain/sendMessage.test.ts index e318f9b123..5f0f2a9b3a 100644 --- a/tests/ipcMain/sendMessage.test.ts +++ b/tests/ipcMain/sendMessage.test.ts @@ -133,6 +133,61 @@ describeIntegration("IpcMain sendMessage integration tests", () => { 15000 ); + test.concurrent( + "should interrupt stream with pending bash tool call near-instantly", + async () => { + // Setup test environment + const { env, workspaceId, cleanup } = await setupWorkspace(provider); + try { + // Ask the model to run a long-running bash command + // Use explicit instruction to ensure tool call happens + const message = "Use the bash tool to run: sleep 60"; + void sendMessageWithModel(env.mockIpcRenderer, workspaceId, message, provider, model); + + // Wait for stream to start (more reliable than waiting for tool-call-start) + const collector = createEventCollector(env.sentEvents, workspaceId); + await collector.waitForEvent("stream-start", 10000); + + // Give model time to start calling the tool (sleep command should be in progress) + // This ensures we're actually interrupting a running command + await new Promise((resolve) => setTimeout(resolve, 2000)); + + // Record interrupt time + const interruptStartTime = performance.now(); + + // Interrupt the stream + const interruptResult = await env.mockIpcRenderer.invoke( + IPC_CHANNELS.WORKSPACE_INTERRUPT_STREAM, + workspaceId + ); + + const interruptDuration = performance.now() - interruptStartTime; + + // Should succeed + expect(interruptResult.success).toBe(true); + + // Interrupt should complete near-instantly (< 2 seconds) + // This validates that we don't wait for the sleep 60 command to finish + expect(interruptDuration).toBeLessThan(2000); + + // Wait for abort event + const abortOrEndReceived = await waitFor(() => { + collector.collect(); + const hasAbort = collector + .getEvents() + .some((e) => "type" in e && e.type === "stream-abort"); + const hasEnd = collector.hasStreamEnd(); + return hasAbort || hasEnd; + }, 5000); + + expect(abortOrEndReceived).toBe(true); + } finally { + await cleanup(); + } + }, + 25000 + ); + test.concurrent( "should include tokens and timestamp in delta events", async () => {