From fffbe67b6685ad37e6c31eeebdce2a41b1eab975 Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Mon, 12 Oct 2026 11:23:37 -0400 Subject: [PATCH] fix(tools): reject cancellation after async interception --- src/tools/catalog-cancellation.test.mjs | 37 +++++++++++++++++++++++++ src/tools/catalog.js | 12 ++++++-- 2 files changed, 47 insertions(+), 2 deletions(-) diff --git a/src/tools/catalog-cancellation.test.mjs b/src/tools/catalog-cancellation.test.mjs index a374bd71e..a4a2b7c9d 100644 --- a/src/tools/catalog-cancellation.test.mjs +++ b/src/tools/catalog-cancellation.test.mjs @@ -39,3 +39,40 @@ test('cancelled catalog calls cannot execute or return late results', async () = await assert.rejects(pending, { name: 'AbortError' }); assert.equal(calls, 1); }); + +test('cancellation during an awaited interceptor cannot publish its late result', async () => { + const controller = new AbortController(); + const reason = new Error('interceptor caller canceled'); + let calls = 0; + const action = defineTool({ + name: 'fixture_intercepted_action', + kind: 'action', + title: 'Fixture', + description: 'Offline fixture', + inputSchema: { type: 'object' }, + run: async () => { + calls++; + return { summary: 'completed action', data: {} }; + }, + }); + const catalog = composeCatalog({ + tools: [action], + interceptors: [ + async (call, next) => { + const result = await next(call); + await Promise.resolve(); + controller.abort(reason); + return result; + }, + ], + }); + await assert.rejects( + catalog.call( + 'fixture_intercepted_action', + {}, + { signal: controller.signal }, + ), + (error) => error === reason, + ); + assert.equal(calls, 1, 'completed actions are not rolled back or rerun'); +}); diff --git a/src/tools/catalog.js b/src/tools/catalog.js index 6faf82ce5..ea0e37460 100644 --- a/src/tools/catalog.js +++ b/src/tools/catalog.js @@ -169,13 +169,21 @@ export function composeCatalog({ ); const index = new Map(available.map((tool) => [tool.name, tool])); - const invoke = (name, args, signal, parent) => { + const invoke = async (name, args, signal, parent) => { + signal?.throwIfAborted(); const tool = index.get(name); if (!tool) return Promise.reject( new ToolError('unsupported', `No tool named ${name} is available`), ); - return chain({ tool, args, signal, ...(parent ? { parent } : {}) }); + const result = await chain({ + tool, + args, + signal, + ...(parent ? { parent } : {}), + }); + signal?.throwIfAborted(); + return result; }; const execute = async ({ tool, args, signal }) => { signal?.throwIfAborted();