diff --git a/packages/core/src/tools/shellBackgroundTools.test.ts b/packages/core/src/tools/shellBackgroundTools.test.ts index 363b5600ddc..34389fcc964 100644 --- a/packages/core/src/tools/shellBackgroundTools.test.ts +++ b/packages/core/src/tools/shellBackgroundTools.test.ts @@ -331,4 +331,29 @@ describe('Background Tools', () => { fs.unlinkSync(logPath); }); + + it('read_background_output should abort the delay_ms wait when the signal is aborted', async () => { + const invocation = readTool.build({ pid: 12345, delay_ms: 60_000 }); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (invocation as any).context = { config: { getSessionId: () => 'default' } }; + + const controller = new AbortController(); + const promise = invocation.execute({ abortSignal: controller.signal }); + controller.abort(); + + await expect(promise).rejects.toMatchObject({ name: 'AbortError' }); + }); + + it('read_background_output should reject immediately when the signal is already aborted', async () => { + const invocation = readTool.build({ pid: 12345, delay_ms: 60_000 }); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (invocation as any).context = { config: { getSessionId: () => 'default' } }; + + const controller = new AbortController(); + controller.abort(); + + await expect( + invocation.execute({ abortSignal: controller.signal }), + ).rejects.toMatchObject({ name: 'AbortError' }); + }); }); diff --git a/packages/core/src/tools/shellBackgroundTools.ts b/packages/core/src/tools/shellBackgroundTools.ts index 00220b24fc6..f63b95958b4 100644 --- a/packages/core/src/tools/shellBackgroundTools.ts +++ b/packages/core/src/tools/shellBackgroundTools.ts @@ -5,6 +5,7 @@ */ import fs from 'node:fs'; +import { setTimeout as delay } from 'node:timers/promises'; import { ShellExecutionService } from '../services/shellExecutionService.js'; import { BaseDeclarativeTool, @@ -130,11 +131,15 @@ class ReadBackgroundOutputInvocation extends BaseToolInvocation< return `Reading output for background process ${this.params.pid}`; } - async execute({ abortSignal: _signal }: ExecuteOptions): Promise { + async execute({ abortSignal }: ExecuteOptions): Promise { const pid = this.params.pid; if (this.params.delay_ms && this.params.delay_ms > 0) { - await new Promise((resolve) => setTimeout(resolve, this.params.delay_ms)); + // Abort-aware delay: rejects with an AbortError when the user cancels, + // which the tool executor converts into a Cancelled result. Without + // this, cancellation would leave the scheduler blocked until the + // timer fires. + await delay(this.params.delay_ms, undefined, { signal: abortSignal }); } // Verify process belongs to this session to prevent reading logs of processes from other sessions/users