diff --git a/.changeset/tool-call-manager-repeat-start.md b/.changeset/tool-call-manager-repeat-start.md new file mode 100644 index 0000000000..bccb6fe17a --- /dev/null +++ b/.changeset/tool-call-manager-repeat-start.md @@ -0,0 +1,5 @@ +--- +'@tanstack/ai': patch +--- + +Fix `ToolCallManager.addToolCallStartEvent` running a tool call twice (or wiping its accumulated arguments) when a producer sends a repeat `TOOL_CALL_START` for a `toolCallId` that is already tracked. AG-UI's `TOOL_CALL_START` carries no `index`, so a custom/malformed stream that re-sends START for the same id could either overwrite the tracked entry's arguments back to `''` (same index) or insert a duplicate row that `getToolCalls()` returned twice (missing/different index). Repeats for an already-tracked id are now ignored; first-party adapters, which only emit START once per call, are unaffected. diff --git a/packages/ai/src/activities/chat/tools/tool-calls.ts b/packages/ai/src/activities/chat/tools/tool-calls.ts index 78637427ac..4d5cf811f6 100644 --- a/packages/ai/src/activities/chat/tools/tool-calls.ts +++ b/packages/ai/src/activities/chat/tools/tool-calls.ts @@ -233,6 +233,15 @@ export class ToolCallManager< * Add a TOOL_CALL_START event to begin tracking a tool call (AG-UI) */ addToolCallStartEvent(event: ToolCallStartEvent): void { + // AG-UI's TOOL_CALL_START carries no index, and a non-first-party or + // malformed producer can send a second START for a toolCallId that is + // already tracked. Without this guard, a repeat with the same index + // overwrites the slot (wiping any TOOL_CALL_ARGS already accumulated), + // and a repeat with a missing/different index inserts a duplicate row + // that getToolCalls() returns twice, running the tool twice. + for (const toolCall of this.toolCallsMap.values()) { + if (toolCall.id === event.toolCallId) return + } const index = (event as AdapterYieldChunk).index ?? this.toolCallsMap.size const name = event.toolCallName ?? event.toolName this.toolCallsMap.set(index, { diff --git a/packages/ai/tests/tool-call-manager.test.ts b/packages/ai/tests/tool-call-manager.test.ts index f1e45c691e..26158a66f4 100644 --- a/packages/ai/tests/tool-call-manager.test.ts +++ b/packages/ai/tests/tool-call-manager.test.ts @@ -491,6 +491,52 @@ describe('ToolCallManager', () => { expect(toolCalls).toHaveLength(1) expect(toolCalls[0]?.function.arguments).toBe('{"location":"New York"}') }) + + it('should ignore a repeat TOOL_CALL_START for an already-tracked toolCallId (same explicit index)', () => { + const manager = new ToolCallManager([mockWeatherTool]) + + // AG-UI's TOOL_CALL_START carries no `index`, but first-party adapter + // yields still attach one (see AdapterYieldChunk). Build the event + // directly so the explicit index survives, matching this issue's repro. + const startWithIndex = (index: number) => + ({ + ...toolCallStart({ + toolCallId: 'call_123', + toolCallName: 'get_weather', + }), + index, + }) as ToolCallStartEvent + + manager.addToolCallStartEvent(startWithIndex(0)) + manager.addToolCallArgsEvent( + toolCallArgs({ toolCallId: 'call_123', delta: '{"location":"Paris"}' }), + ) + // A malformed/custom-server stream re-sends START for the same id/index. + manager.addToolCallStartEvent(startWithIndex(0)) + + const toolCalls = manager.getToolCalls() + expect(toolCalls).toHaveLength(1) + // Accumulated arguments must survive the repeat START, not reset to ''. + expect(toolCalls[0]?.function.arguments).toBe('{"location":"Paris"}') + }) + + it('should ignore a repeat TOOL_CALL_START for an already-tracked toolCallId (no index)', () => { + const manager = new ToolCallManager([mockWeatherTool]) + + manager.addToolCallStartEvent( + toolCallStart({ toolCallId: 'call_123', toolCallName: 'get_weather' }), + ) + // Repeat START with no index — must not insert a second row, which + // would otherwise make getToolCalls() return the id twice and run + // the tool twice. + manager.addToolCallStartEvent( + toolCallStart({ toolCallId: 'call_123', toolCallName: 'get_weather' }), + ) + + const toolCalls = manager.getToolCalls() + expect(toolCalls).toHaveLength(1) + expect(toolCalls.filter((tc) => tc.id === 'call_123')).toHaveLength(1) + }) }) })