diff --git a/packages/agent-core-v2/src/_base/utils/abort.ts b/packages/agent-core-v2/src/_base/utils/abort.ts index 09b2860fb0..be74262747 100644 --- a/packages/agent-core-v2/src/_base/utils/abort.ts +++ b/packages/agent-core-v2/src/_base/utils/abort.ts @@ -31,15 +31,28 @@ export function isUserCancellation(value: unknown): value is UserCancellationErr } export function abortable(promise: Promise, signal: AbortSignal): Promise { - if (signal.aborted) return Promise.reject(abortReason(signal)); return new Promise((resolve, reject) => { const onAbort = () => { reject(abortReason(signal)); }; - signal.addEventListener('abort', onAbort, { once: true }); - promise.then(resolve, reject).finally(() => { + const cleanup = () => { signal.removeEventListener('abort', onAbort); - }); + }; + promise.then( + (value) => { + cleanup(); + resolve(value); + }, + (error: unknown) => { + cleanup(); + reject(error); + }, + ); + if (signal.aborted) { + onAbort(); + return; + } + signal.addEventListener('abort', onAbort, { once: true }); }); } diff --git a/packages/agent-core-v2/test/_base/utils/abort.test.ts b/packages/agent-core-v2/test/_base/utils/abort.test.ts index 310e637896..887bd85bc6 100644 --- a/packages/agent-core-v2/test/_base/utils/abort.test.ts +++ b/packages/agent-core-v2/test/_base/utils/abort.test.ts @@ -1,3 +1,10 @@ +/** + * Covers abort classification and promise ownership for abort-signal helpers. + * + * Uses real AbortController and Node rejection events. Run with: + * pnpm --filter @moonshot-ai/agent-core-v2 exec vitest run test/_base/utils/abort.test.ts + */ + import { describe, expect, it } from 'vitest'; import { @@ -40,6 +47,32 @@ describe('abortable', () => { await expect(abortable(Promise.resolve('ok'), controller.signal)).rejects.toBe(reason); }); + it('observes the source rejection when the signal is already aborted', async () => { + const controller = new AbortController(); + const reason = userCancellationReason(); + controller.abort(reason); + const sourceError = new Error('source failed'); + let rejectSource: (reason: unknown) => void = () => {}; + const source = new Promise((_resolve, reject) => { + rejectSource = reject; + }); + let sourceWasUnhandled = false; + const onUnhandledRejection = (_reason: unknown, promise: Promise): void => { + if (promise === source) sourceWasUnhandled = true; + }; + process.on('unhandledRejection', onUnhandledRejection); + + try { + await expect(abortable(source, controller.signal)).rejects.toBe(reason); + rejectSource(sourceError); + await new Promise((resolve) => setImmediate(resolve)); + } finally { + process.off('unhandledRejection', onUnhandledRejection); + } + + expect(sourceWasUnhandled).toBe(false); + }); + it('rejects with the signal reason when aborted while pending', async () => { const controller = new AbortController(); const reason = userCancellationReason();