Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 16 additions & 5 deletions src/services/tools/bash.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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);
Expand Down Expand Up @@ -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();
Expand Down
55 changes: 55 additions & 0 deletions tests/ipcMain/sendMessage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down