From b39080d09e492c5bfdeef88ea18d04400e314d38 Mon Sep 17 00:00:00 2001 From: YingchaoX Date: Fri, 12 Jun 2026 16:59:31 +0800 Subject: [PATCH 01/10] fix(core): ignore duplicate provider tool-call ids --- packages/cli/src/nonInteractiveCli.test.ts | 51 +++++++++ packages/cli/src/nonInteractiveCli.ts | 36 +++++- .../core/src/agents/runtime/agent-core.ts | 59 +++++++++- .../src/agents/runtime/agent-headless.test.ts | 103 ++++++++++++++++++ packages/core/src/core/turn.test.ts | 43 ++++++++ packages/core/src/core/turn.ts | 34 +++++- 6 files changed, 318 insertions(+), 8 deletions(-) diff --git a/packages/cli/src/nonInteractiveCli.test.ts b/packages/cli/src/nonInteractiveCli.test.ts index 47685aada15..be5d8f6dc1a 100644 --- a/packages/cli/src/nonInteractiveCli.test.ts +++ b/packages/cli/src/nonInteractiveCli.test.ts @@ -443,6 +443,57 @@ describe('runNonInteractive', () => { ).toHaveBeenCalled(); }); + it('should ignore duplicate provider tool-call ids across rounds', async () => { + setupMetricsMock(); + vi.mocked(mockConfig.getMaxToolCalls).mockReturnValue(1); + const toolCallEvent: ServerGeminiStreamEvent = { + type: GeminiEventType.ToolCallRequest, + value: { + callId: 'tool-1', + providerCallId: 'tool-1', + name: 'testTool', + args: { arg1: 'value1' }, + isClientInitiated: false, + prompt_id: 'prompt-id-dup', + }, + }; + const toolResponse: Part[] = [{ text: 'Tool response' }]; + mockCoreExecuteToolCall.mockResolvedValue({ responseParts: toolResponse }); + + mockGeminiClient.sendMessageStream + .mockReturnValueOnce(createStreamFromEvents([toolCallEvent])) + .mockReturnValueOnce(createStreamFromEvents([toolCallEvent])) + .mockReturnValueOnce( + createStreamFromEvents([ + { type: GeminiEventType.Content, value: 'Final answer' }, + { + type: GeminiEventType.Finished, + value: { + reason: undefined, + usageMetadata: { totalTokenCount: 10 }, + }, + }, + ]), + ); + + await runNonInteractive( + mockConfig, + mockSettings, + 'Use a tool', + 'prompt-id-dup', + ); + + expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledTimes(3); + expect(mockCoreExecuteToolCall).toHaveBeenCalledTimes(1); + expect(mockGeminiClient.recordCompletedToolCall).toHaveBeenCalledTimes(1); + + const duplicateParts = mockGeminiClient.sendMessageStream.mock.calls[2][0]; + expect(duplicateParts[0].functionResponse?.response?.['error']).toContain( + 'Duplicate provider tool call id "tool-1"', + ); + expect(processStdoutSpy).toHaveBeenCalledWith('Final answer\n'); + }); + it('should handle error during tool execution and should send error back to the model', async () => { setupMetricsMock(); const toolCallEvent: ServerGeminiStreamEvent = { diff --git a/packages/cli/src/nonInteractiveCli.ts b/packages/cli/src/nonInteractiveCli.ts index 2804512d55a..08c9be5f4a7 100644 --- a/packages/cli/src/nonInteractiveCli.ts +++ b/packages/cli/src/nonInteractiveCli.ts @@ -30,6 +30,7 @@ import { TeamEventType, ApprovalMode, ToolConfirmationOutcome, + createDuplicateProviderToolCallResponse, } from '@qwen-code/qwen-code-core'; import type { Content, Part, PartListUnion } from '@google/genai'; import type { CLIUserMessage, PermissionMode } from './nonInteractive/types.js'; @@ -737,11 +738,34 @@ export async function runNonInteractive( * helper returns (main-turn → emitStructuredSuccess(); drain-turn * → return so the post-drain code emits success). */ + const handledProviderToolCallIds = new Set(); + const processToolCallBatch = async ( batchRequests: ToolCallRequestInfo[], setModelOverride: (override: string | undefined) => void, ): Promise => { const toolResponseParts: Part[] = []; + const respondedCallIds = new Set(); + const executableBatchRequests: ToolCallRequestInfo[] = []; + + for (const requestInfo of batchRequests) { + if (!requestInfo.providerCallId) { + executableBatchRequests.push(requestInfo); + continue; + } + + if (!handledProviderToolCallIds.has(requestInfo.providerCallId)) { + handledProviderToolCallIds.add(requestInfo.providerCallId); + executableBatchRequests.push(requestInfo); + continue; + } + + const toolResponse = + createDuplicateProviderToolCallResponse(requestInfo); + respondedCallIds.add(requestInfo.callId); + adapter.emitToolResult(requestInfo, toolResponse); + toolResponseParts.push(...toolResponse.responseParts); + } // Pre-scan: when --json-schema is active and the model emitted // a `structured_output` call alongside other tools in the same @@ -750,16 +774,18 @@ export async function runNonInteractive( // suppress every non-structured sibling. See the multi-shape // examples in the main loop's prior comment for the // [bad/good/side-effect] permutations. - let requestsToExecute = batchRequests; + let requestsToExecute = executableBatchRequests; if ( config.getJsonSchema() && - batchRequests.some((r) => r.name === ToolNames.STRUCTURED_OUTPUT) + executableBatchRequests.some( + (r) => r.name === ToolNames.STRUCTURED_OUTPUT, + ) ) { - requestsToExecute = batchRequests.filter( + requestsToExecute = executableBatchRequests.filter( (r) => r.name === ToolNames.STRUCTURED_OUTPUT, ); } - const executedCallIds = new Set(); + const executedCallIds = new Set(respondedCallIds); for (const requestInfo of requestsToExecute) { executedCallIds.add(requestInfo.callId); @@ -886,7 +912,7 @@ export async function runNonInteractive( // emitted event log pairs every tool_use with a tool_result // AND the retry-turn payload (when reached) doesn't leave // Anthropic / OpenAI staring at unpaired tool_use blocks. - const unexecutedCalls = batchRequests.filter( + const unexecutedCalls = executableBatchRequests.filter( (r) => !executedCallIds.has(r.callId), ); if (unexecutedCalls.length > 0) { diff --git a/packages/core/src/agents/runtime/agent-core.ts b/packages/core/src/agents/runtime/agent-core.ts index 50c500010b1..b8129937df5 100644 --- a/packages/core/src/agents/runtime/agent-core.ts +++ b/packages/core/src/agents/runtime/agent-core.ts @@ -28,7 +28,10 @@ import { runWithRuntimeContentGenerator, type RuntimeContentGeneratorView, } from './agent-context.js'; -import { type ToolCallRequestInfo } from '../../core/turn.js'; +import { + createDuplicateProviderToolCallResponse, + type ToolCallRequestInfo, +} from '../../core/turn.js'; import { CoreToolScheduler, type ToolCall, @@ -654,6 +657,7 @@ export class AgentCore { let turnCounter = 0; let finalText = ''; let terminateMode: AgentTerminateMode | null = null; + const handledProviderToolCallIds = new Set(); while (true) { // Check abort before starting a new round — prevents unnecessary API @@ -815,6 +819,7 @@ export class AgentCore { toolsList, currentResponseId, wasOutputTruncated, + handledProviderToolCallIds, ); const externalInputs = this.drainExternalInputs(options); @@ -1090,6 +1095,7 @@ export class AgentCore { toolsList: FunctionDeclaration[], responseId?: string, wasOutputTruncated = false, + handledProviderToolCallIds = new Set(), ): Promise { const toolResponseParts: Part[] = []; @@ -1100,9 +1106,57 @@ export class AgentCore { const authorizedCalls: FunctionCall[] = []; for (const fc of functionCalls) { const callId = fc.id ?? `${fc.name}-${Date.now()}`; + const providerCallId = fc.id; + const toolName = String(fc.name); + + if (providerCallId) { + if (handledProviderToolCallIds.has(providerCallId)) { + const args = (fc.args ?? {}) as Record; + const request: ToolCallRequestInfo = { + callId, + providerCallId, + name: toolName, + args, + isClientInitiated: true, + prompt_id: promptId, + response_id: responseId, + wasOutputTruncated, + }; + const response = createDuplicateProviderToolCallResponse(request); + const errorMessage = response.error?.message; + const eventCallId = `${callId}:duplicate:${currentRound}:${toolResponseParts.length}`; + + this.eventEmitter?.emit(AgentEventType.TOOL_CALL, { + subagentId: this.subagentId, + round: currentRound, + callId: eventCallId, + name: toolName, + args, + description: errorMessage, + isOutputMarkdown: false, + timestamp: Date.now(), + } as AgentToolCallEvent); + + this.eventEmitter?.emit(AgentEventType.TOOL_RESULT, { + subagentId: this.subagentId, + round: currentRound, + callId: eventCallId, + name: toolName, + success: false, + error: errorMessage, + responseParts: response.responseParts, + resultDisplay: response.resultDisplay, + durationMs: 0, + timestamp: Date.now(), + } as AgentToolResultEvent); + + toolResponseParts.push(...response.responseParts); + continue; + } + handledProviderToolCallIds.add(providerCallId); + } if (!allowedToolNames.has(fc.name)) { - const toolName = String(fc.name); const errorMessage = `Tool "${toolName}" not found. Tools must use the exact names provided.`; // Emit TOOL_CALL event for visibility @@ -1338,6 +1392,7 @@ export class AgentCore { const args = (fc.args ?? {}) as Record; const request: ToolCallRequestInfo = { callId, + ...(fc.id ? { providerCallId: fc.id } : {}), name: toolName, args, isClientInitiated: true, diff --git a/packages/core/src/agents/runtime/agent-headless.test.ts b/packages/core/src/agents/runtime/agent-headless.test.ts index 6c9b85c52ba..80f269ab198 100644 --- a/packages/core/src/agents/runtime/agent-headless.test.ts +++ b/packages/core/src/agents/runtime/agent-headless.test.ts @@ -1030,6 +1030,109 @@ describe('subagent.ts', () => { expect(scope.getTerminateMode()).toBe(AgentTerminateMode.GOAL); }); + + it('should ignore duplicate provider tool-call ids across rounds', async () => { + const listFilesToolDef: FunctionDeclaration = { + name: 'list_files', + description: 'Lists files', + parameters: { type: Type.OBJECT, properties: {} }, + }; + + const { config } = await createMockConfig({ + getFunctionDeclarationsFiltered: vi + .fn() + .mockReturnValue([listFilesToolDef]), + getTool: vi.fn().mockReturnValue(undefined), + }); + const toolConfig: ToolConfig = { tools: ['list_files'] }; + + mockSendMessageStream.mockImplementation( + createMockStream([ + [ + { + id: 'call_1', + name: 'list_files', + args: { path: '.' }, + }, + ], + [ + { + id: 'call_1', + name: 'list_files', + args: { path: '.' }, + }, + ], + 'stop', + ]), + ); + + const listFilesInvocation = { + params: { path: '.' }, + getDescription: vi.fn().mockReturnValue('List files'), + toolLocations: vi.fn().mockReturnValue([]), + getDefaultPermission: vi.fn().mockResolvedValue('allow'), + execute: vi.fn().mockResolvedValue({ + llmContent: 'file1.txt\nfile2.ts', + returnDisplay: 'Listed 2 files', + }), + }; + const listFilesTool = { + name: 'list_files', + displayName: 'List Files', + description: 'List files in directory', + kind: 'READ' as const, + schema: listFilesToolDef, + build: vi.fn().mockImplementation(() => listFilesInvocation), + canUpdateOutput: false, + isOutputMarkdown: true, + } as unknown as AnyDeclarativeTool; + vi.mocked( + (config.getToolRegistry() as unknown as ToolRegistry).getTool, + ).mockImplementation((name: string) => + name === 'list_files' ? listFilesTool : undefined, + ); + + const toolCallEvents: AgentToolCallEvent[] = []; + const toolResultEvents: AgentToolResultEvent[] = []; + const eventEmitter = new AgentEventEmitter(); + eventEmitter.on(AgentEventType.TOOL_CALL, (event: unknown) => { + toolCallEvents.push(event as AgentToolCallEvent); + }); + eventEmitter.on(AgentEventType.TOOL_RESULT, (event: unknown) => { + toolResultEvents.push(event as AgentToolResultEvent); + }); + + const scope = await AgentHeadless.create( + 'test-agent', + config, + promptConfig, + defaultModelConfig, + defaultRunConfig, + toolConfig, + eventEmitter, + ); + + await scope.execute(new ContextState()); + + expect(listFilesInvocation.execute).toHaveBeenCalledTimes(1); + expect(toolCallEvents).toHaveLength(2); + expect(toolResultEvents).toHaveLength(2); + expect(toolCallEvents[0].callId).toBe('call_1'); + expect(toolResultEvents[0].callId).toBe('call_1'); + expect(toolCallEvents[1].callId).toMatch(/^call_1:duplicate:/); + expect(toolResultEvents[1].callId).toBe(toolCallEvents[1].callId); + expect(toolResultEvents[1].error).toContain( + 'Duplicate provider tool call id "call_1"', + ); + + const thirdCallArgs = mockSendMessageStream.mock.calls[2][1]; + const parts = thirdCallArgs.message as Part[]; + expect(parts[0].functionResponse?.id).toBe('call_1'); + expect(parts[0].functionResponse?.response?.['error']).toContain( + 'Duplicate provider tool call id "call_1"', + ); + expect(scope.getTerminateMode()).toBe(AgentTerminateMode.GOAL); + }); }); describe('execute - Termination and Recovery', () => { diff --git a/packages/core/src/core/turn.test.ts b/packages/core/src/core/turn.test.ts index 6626f56c524..0f4720823c3 100644 --- a/packages/core/src/core/turn.test.ts +++ b/packages/core/src/core/turn.test.ts @@ -392,6 +392,49 @@ describe('Turn', () => { }); }); + it('should preserve provider tool-call ids separately from generated call ids', async () => { + const mockResponseStream = (async function* () { + yield { + type: StreamEventType.CHUNK, + value: { + candidates: [], + functionCalls: [ + { id: 'fc1', name: 'tool1', args: { arg1: 'val1' } }, + { name: 'tool2', args: { arg2: 'val2' } }, + ], + }, + }; + })(); + mockSendMessageStream.mockResolvedValue(mockResponseStream); + + const events = []; + for await (const event of turn.run( + 'test-model', + [{ text: 'Test provider ids' }], + new AbortController().signal, + )) { + events.push(event); + } + + expect(events.length).toBe(2); + + const event1 = events[0] as ServerGeminiToolCallRequestEvent; + expect(event1.value).toMatchObject({ + callId: 'fc1', + providerCallId: 'fc1', + name: 'tool1', + args: { arg1: 'val1' }, + }); + + const event2 = events[1] as ServerGeminiToolCallRequestEvent; + expect(event2.value.callId).toMatch(/^tool2-/); + expect(event2.value.providerCallId).toBeUndefined(); + expect(event2.value).toMatchObject({ + name: 'tool2', + args: { arg2: 'val2' }, + }); + }); + it('should yield finished event when response has finish reason', async () => { const mockResponseStream = (async function* () { yield { diff --git a/packages/core/src/core/turn.ts b/packages/core/src/core/turn.ts index d87a7a08cec..2ae5909914f 100644 --- a/packages/core/src/core/turn.ts +++ b/packages/core/src/core/turn.ts @@ -18,7 +18,7 @@ import type { ToolResult, ToolResultDisplay, } from '../tools/tools.js'; -import type { ToolErrorType } from '../tools/tool-error.js'; +import { ToolErrorType } from '../tools/tool-error.js'; import { getResponseText } from '../utils/partUtils.js'; import { reportError } from '../utils/errorReporting.js'; import { @@ -98,6 +98,11 @@ export interface GeminiFinishedEventValue { export interface ToolCallRequestInfo { callId: string; + /** + * Original tool-call id emitted by the provider/model. When present, this is + * the idempotency key for suppressing duplicate provider tool calls. + */ + providerCallId?: string; name: string; args: Record; isClientInitiated: boolean; @@ -117,6 +122,32 @@ export interface ToolCallResponseInfo { modelOverride?: string; } +function duplicateProviderToolCallMessage(providerCallId: string): string { + return `Duplicate provider tool call id "${providerCallId}" was already handled. The duplicate tool call was ignored and not executed again.`; +} + +export function createDuplicateProviderToolCallResponse( + request: ToolCallRequestInfo, +): ToolCallResponseInfo { + const providerCallId = request.providerCallId ?? request.callId; + const message = duplicateProviderToolCallMessage(providerCallId); + return { + callId: request.callId, + responseParts: [ + { + functionResponse: { + id: request.callId, + name: request.name, + response: { error: message }, + }, + }, + ], + resultDisplay: message, + error: new Error(message), + errorType: ToolErrorType.EXECUTION_FAILED, + }; +} + export interface ServerToolCallConfirmationDetails { request: ToolCallRequestInfo; details: ToolCallConfirmationDetails; @@ -466,6 +497,7 @@ export class Turn { const toolCallRequest: ToolCallRequestInfo = { callId, + ...(fnCall.id ? { providerCallId: fnCall.id } : {}), name, args, isClientInitiated: false, From 8997280c37dbcc9b82e4cdae00d7df33092d476d Mon Sep 17 00:00:00 2001 From: YingchaoX Date: Fri, 12 Jun 2026 19:44:23 +0800 Subject: [PATCH 02/10] Update packages/cli/src/nonInteractiveCli.ts Co-authored-by: Shaojin Wen --- packages/cli/src/nonInteractiveCli.ts | 28 ++++++++++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/nonInteractiveCli.ts b/packages/cli/src/nonInteractiveCli.ts index 08c9be5f4a7..62092aa7342 100644 --- a/packages/cli/src/nonInteractiveCli.ts +++ b/packages/cli/src/nonInteractiveCli.ts @@ -764,7 +764,33 @@ export async function runNonInteractive( createDuplicateProviderToolCallResponse(requestInfo); respondedCallIds.add(requestInfo.callId); adapter.emitToolResult(requestInfo, toolResponse); - toolResponseParts.push(...toolResponse.responseParts); + const toolResponse = + createDuplicateProviderToolCallResponse(requestInfo); + respondedCallIds.add(requestInfo.callId); + adapter.emitToolResult(requestInfo, toolResponse); + duplicatePendingResponses.push(...toolResponse.responseParts); + } + + // Pre-scan: when --json-schema is active and the model emitted + // a `structured_output` call alongside other tools in the same + // turn, the structured call is the terminal contract. Execute + // every structured_output in original order until one succeeds, + // suppress every non-structured sibling. See the multi-shape + // examples in the main loop's prior comment for the + // [bad/good/side-effect] permutations. + let requestsToExecute = executableBatchRequests; + const structuredOutputActive = + config.getJsonSchema() && + executableBatchRequests.some( + (r) => r.name === ToolNames.STRUCTURED_OUTPUT, + ); + if (structuredOutputActive) { + requestsToExecute = executableBatchRequests.filter( + (r) => r.name === ToolNames.STRUCTURED_OUTPUT, + ); + } else { + toolResponseParts.push(...duplicatePendingResponses); + } } // Pre-scan: when --json-schema is active and the model emitted From 1684ddcd3fc301a90f5a222edad81ddebcaaacd5 Mon Sep 17 00:00:00 2001 From: YingchaoX Date: Fri, 12 Jun 2026 19:44:36 +0800 Subject: [PATCH 03/10] Update packages/core/src/agents/runtime/agent-core.ts Co-authored-by: Shaojin Wen --- packages/core/src/agents/runtime/agent-core.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/core/src/agents/runtime/agent-core.ts b/packages/core/src/agents/runtime/agent-core.ts index b8129937df5..073930ff3fb 100644 --- a/packages/core/src/agents/runtime/agent-core.ts +++ b/packages/core/src/agents/runtime/agent-core.ts @@ -1151,6 +1151,8 @@ export class AgentCore { } as AgentToolResultEvent); toolResponseParts.push(...response.responseParts); + this.recordToolCallStats(toolName, false, 0, errorMessage); + continue; continue; } handledProviderToolCallIds.add(providerCallId); From 825e0ed8a05980314c54ff5b5908f3ead020e678 Mon Sep 17 00:00:00 2001 From: YingchaoX Date: Fri, 12 Jun 2026 19:51:56 +0800 Subject: [PATCH 04/10] fix(cli): clean up duplicate tool-call review changes --- packages/cli/src/nonInteractiveCli.ts | 25 +------------------ .../core/src/agents/runtime/agent-core.ts | 1 - 2 files changed, 1 insertion(+), 25 deletions(-) diff --git a/packages/cli/src/nonInteractiveCli.ts b/packages/cli/src/nonInteractiveCli.ts index 62092aa7342..b33856a5a62 100644 --- a/packages/cli/src/nonInteractiveCli.ts +++ b/packages/cli/src/nonInteractiveCli.ts @@ -747,6 +747,7 @@ export async function runNonInteractive( const toolResponseParts: Part[] = []; const respondedCallIds = new Set(); const executableBatchRequests: ToolCallRequestInfo[] = []; + const duplicatePendingResponses: Part[] = []; for (const requestInfo of batchRequests) { if (!requestInfo.providerCallId) { @@ -760,10 +761,6 @@ export async function runNonInteractive( continue; } - const toolResponse = - createDuplicateProviderToolCallResponse(requestInfo); - respondedCallIds.add(requestInfo.callId); - adapter.emitToolResult(requestInfo, toolResponse); const toolResponse = createDuplicateProviderToolCallResponse(requestInfo); respondedCallIds.add(requestInfo.callId); @@ -791,26 +788,6 @@ export async function runNonInteractive( } else { toolResponseParts.push(...duplicatePendingResponses); } - } - - // Pre-scan: when --json-schema is active and the model emitted - // a `structured_output` call alongside other tools in the same - // turn, the structured call is the terminal contract. Execute - // every structured_output in original order until one succeeds, - // suppress every non-structured sibling. See the multi-shape - // examples in the main loop's prior comment for the - // [bad/good/side-effect] permutations. - let requestsToExecute = executableBatchRequests; - if ( - config.getJsonSchema() && - executableBatchRequests.some( - (r) => r.name === ToolNames.STRUCTURED_OUTPUT, - ) - ) { - requestsToExecute = executableBatchRequests.filter( - (r) => r.name === ToolNames.STRUCTURED_OUTPUT, - ); - } const executedCallIds = new Set(respondedCallIds); for (const requestInfo of requestsToExecute) { diff --git a/packages/core/src/agents/runtime/agent-core.ts b/packages/core/src/agents/runtime/agent-core.ts index 073930ff3fb..36d5e6bbba6 100644 --- a/packages/core/src/agents/runtime/agent-core.ts +++ b/packages/core/src/agents/runtime/agent-core.ts @@ -1153,7 +1153,6 @@ export class AgentCore { toolResponseParts.push(...response.responseParts); this.recordToolCallStats(toolName, false, 0, errorMessage); continue; - continue; } handledProviderToolCallIds.add(providerCallId); } From 0972eeaedb4ac75f9fe7329546dcb37c1df14ba9 Mon Sep 17 00:00:00 2001 From: YingchaoX Date: Sat, 13 Jun 2026 00:10:55 +0800 Subject: [PATCH 05/10] fix(core): cover duplicate provider tool-call review feedback --- packages/cli/src/nonInteractiveCli.test.ts | 205 +++++++++++++++ packages/cli/src/nonInteractiveCli.ts | 10 +- .../cli/src/ui/hooks/useGeminiStream.test.tsx | 233 ++++++++++++++++++ packages/cli/src/ui/hooks/useGeminiStream.ts | 115 ++++++++- .../core/src/agents/runtime/agent-core.ts | 146 ++++++----- .../src/agents/runtime/agent-headless.test.ts | 216 ++++++++++++++++ 6 files changed, 852 insertions(+), 73 deletions(-) diff --git a/packages/cli/src/nonInteractiveCli.test.ts b/packages/cli/src/nonInteractiveCli.test.ts index be5d8f6dc1a..d5a426d6120 100644 --- a/packages/cli/src/nonInteractiveCli.test.ts +++ b/packages/cli/src/nonInteractiveCli.test.ts @@ -494,6 +494,75 @@ describe('runNonInteractive', () => { expect(processStdoutSpy).toHaveBeenCalledWith('Final answer\n'); }); + it('should ignore duplicate provider tool-call ids in the same batch', async () => { + setupMetricsMock(); + vi.mocked(mockConfig.getMaxToolCalls).mockReturnValue(1); + const firstToolCall: ServerGeminiStreamEvent = { + type: GeminiEventType.ToolCallRequest, + value: { + callId: 'tool-1', + providerCallId: 'tool-1', + name: 'testTool', + args: { arg1: 'value1' }, + isClientInitiated: false, + prompt_id: 'prompt-id-same-batch-dup', + }, + }; + const duplicateToolCall: ServerGeminiStreamEvent = { + type: GeminiEventType.ToolCallRequest, + value: { + callId: 'tool-1', + providerCallId: 'tool-1', + name: 'testTool', + args: { arg1: 'value1' }, + isClientInitiated: false, + prompt_id: 'prompt-id-same-batch-dup', + }, + }; + mockCoreExecuteToolCall.mockResolvedValue({ + responseParts: [{ text: 'Tool response' }], + }); + + mockGeminiClient.sendMessageStream + .mockReturnValueOnce( + createStreamFromEvents([firstToolCall, duplicateToolCall]), + ) + .mockReturnValueOnce( + createStreamFromEvents([ + { type: GeminiEventType.Content, value: 'Final answer' }, + { + type: GeminiEventType.Finished, + value: { + reason: undefined, + usageMetadata: { totalTokenCount: 10 }, + }, + }, + ]), + ); + + await runNonInteractive( + mockConfig, + mockSettings, + 'Use a tool', + 'prompt-id-same-batch-dup', + ); + + expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledTimes(2); + expect(mockCoreExecuteToolCall).toHaveBeenCalledTimes(1); + expect(mockGeminiClient.recordCompletedToolCall).toHaveBeenCalledTimes(1); + + const toolResultParts = mockGeminiClient.sendMessageStream.mock.calls[1][0]; + expect(toolResultParts).toHaveLength(2); + expect(toolResultParts).toContainEqual({ text: 'Tool response' }); + const duplicatePart = toolResultParts.find( + (part: Part) => part.functionResponse?.id === 'tool-1', + ); + expect(duplicatePart?.functionResponse?.response?.['error']).toContain( + 'Duplicate provider tool call id "tool-1"', + ); + expect(processStdoutSpy).toHaveBeenCalledWith('Final answer\n'); + }); + it('should handle error during tool execution and should send error back to the model', async () => { setupMetricsMock(); const toolCallEvent: ServerGeminiStreamEvent = { @@ -3156,6 +3225,142 @@ describe('runNonInteractive', () => { ).not.toMatch(/Skipped:/); }); + it('keeps duplicate provider responses when structured_output fails validation', async () => { + (mockConfig.getJsonSchema as Mock).mockReturnValue({ + type: 'object', + properties: { summary: { type: 'string' } }, + required: ['summary'], + }); + (mockConfig.getOutputFormat as Mock).mockReturnValue(OutputFormat.JSON); + setupMetricsMock(); + + const firstSideEffectCall: ServerGeminiStreamEvent = { + type: GeminiEventType.ToolCallRequest, + value: { + callId: 'tool-side', + providerCallId: 'tool-side', + name: 'side_effect_tool', + args: { path: '/tmp/first' }, + isClientInitiated: false, + prompt_id: 'prompt-id-dup-structured', + }, + }; + const duplicateSideEffectCall: ServerGeminiStreamEvent = { + type: GeminiEventType.ToolCallRequest, + value: { + callId: 'tool-side', + providerCallId: 'tool-side', + name: 'side_effect_tool', + args: { path: '/tmp/second' }, + isClientInitiated: false, + prompt_id: 'prompt-id-dup-structured', + }, + }; + const badStructuredCall: ServerGeminiStreamEvent = { + type: GeminiEventType.ToolCallRequest, + value: { + callId: 'tool-structured-bad', + name: 'structured_output', + args: { wrong: 'shape' }, + isClientInitiated: false, + prompt_id: 'prompt-id-dup-structured', + }, + }; + const goodStructuredCall: ServerGeminiStreamEvent = { + type: GeminiEventType.ToolCallRequest, + value: { + callId: 'tool-structured-good', + name: 'structured_output', + args: { summary: 'retry ok' }, + isClientInitiated: false, + prompt_id: 'prompt-id-dup-structured', + }, + }; + + mockGeminiClient.sendMessageStream + .mockReturnValueOnce(createStreamFromEvents([firstSideEffectCall])) + .mockReturnValueOnce( + createStreamFromEvents([duplicateSideEffectCall, badStructuredCall]), + ) + .mockReturnValueOnce(createStreamFromEvents([goodStructuredCall])); + + mockCoreExecuteToolCall + .mockResolvedValueOnce({ + responseParts: [ + { + functionResponse: { + id: 'tool-side', + name: 'side_effect_tool', + response: { output: 'first side effect' }, + }, + }, + ], + }) + .mockResolvedValueOnce({ + error: new Error('args invalid'), + errorType: 'TOOL_INVALID_ARGUMENTS', + responseParts: [ + { + functionResponse: { + id: 'tool-structured-bad', + name: 'structured_output', + response: { error: 'args invalid' }, + }, + }, + ], + }) + .mockResolvedValueOnce({ + responseParts: [{ text: 'ok' }], + }); + + await runNonInteractive( + mockConfig, + mockSettings, + 'Emit structured output', + 'prompt-id-dup-structured', + ); + + const executedNames = mockCoreExecuteToolCall.mock.calls.map( + (call) => (call[1] as { name: string }).name, + ); + expect(executedNames).toEqual([ + 'side_effect_tool', + 'structured_output', + 'structured_output', + ]); + + expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledTimes(3); + const retryParts = mockGeminiClient.sendMessageStream.mock.calls[2][0] as + | Array<{ + functionResponse?: { + id?: string; + name?: string; + response?: unknown; + }; + }> + | undefined; + const retryPartsTyped = retryParts || []; + const duplicateResponse = retryPartsTyped.find((part) => + String( + (part.functionResponse?.response as { error?: unknown } | undefined) + ?.error, + ).includes('Duplicate provider tool call id "tool-side"'), + ); + const failedStructured = retryPartsTyped.find( + (part) => part.functionResponse?.id === 'tool-structured-bad', + ); + expect(duplicateResponse?.functionResponse?.id).toBe('tool-side'); + expect(duplicateResponse?.functionResponse?.name).toBe( + 'side_effect_tool', + ); + expect(failedStructured?.functionResponse?.name).toBe( + 'structured_output', + ); + expect( + JSON.stringify(failedStructured?.functionResponse?.response), + ).toContain('args invalid'); + }); + it('captures structured_output emitted from a drain-turn (queued notification)', async () => { // Main turn ends with plain text → control falls into the drain // block. A monitor notification then arrives and the model's reply diff --git a/packages/cli/src/nonInteractiveCli.ts b/packages/cli/src/nonInteractiveCli.ts index b33856a5a62..770f363500d 100644 --- a/packages/cli/src/nonInteractiveCli.ts +++ b/packages/cli/src/nonInteractiveCli.ts @@ -763,11 +763,19 @@ export async function runNonInteractive( const toolResponse = createDuplicateProviderToolCallResponse(requestInfo); + debugLogger.debug( + `[runNonInteractive] Suppressing duplicate provider tool-call id: ${requestInfo.providerCallId} (tool: ${requestInfo.name})`, + ); respondedCallIds.add(requestInfo.callId); adapter.emitToolResult(requestInfo, toolResponse); duplicatePendingResponses.push(...toolResponse.responseParts); } + // Duplicate responses must always reach the model. They pair with a + // tool call the provider already emitted, even when structured_output + // is the only executable sibling in this batch. + toolResponseParts.push(...duplicatePendingResponses); + // Pre-scan: when --json-schema is active and the model emitted // a `structured_output` call alongside other tools in the same // turn, the structured call is the terminal contract. Execute @@ -785,8 +793,6 @@ export async function runNonInteractive( requestsToExecute = executableBatchRequests.filter( (r) => r.name === ToolNames.STRUCTURED_OUTPUT, ); - } else { - toolResponseParts.push(...duplicatePendingResponses); } const executedCallIds = new Set(respondedCallIds); diff --git a/packages/cli/src/ui/hooks/useGeminiStream.test.tsx b/packages/cli/src/ui/hooks/useGeminiStream.test.tsx index 43a762bbd92..02c70813acf 100644 --- a/packages/cli/src/ui/hooks/useGeminiStream.test.tsx +++ b/packages/cli/src/ui/hooks/useGeminiStream.test.tsx @@ -1049,6 +1049,239 @@ describe('useGeminiStream', () => { }); }); + it('suppresses duplicate provider tool-call ids before TUI scheduling', async () => { + let capturedOnComplete: + | ((completedTools: TrackedToolCall[]) => Promise) + | null = null; + mockUseReactToolScheduler.mockImplementation((onComplete) => { + capturedOnComplete ??= onComplete; + return [[], mockScheduleToolCalls, mockMarkToolsAsSubmitted]; + }); + + mockSendMessageStream + .mockReturnValueOnce( + (async function* () { + yield { + type: ServerGeminiEventType.ToolCallRequest, + value: { + callId: 'tool-dup', + providerCallId: 'tool-dup', + name: 'shell', + args: { command: 'echo first' }, + isClientInitiated: false, + prompt_id: 'prompt-tui-dup', + }, + }; + yield { + type: ServerGeminiEventType.ToolCallRequest, + value: { + callId: 'tool-dup', + providerCallId: 'tool-dup', + name: 'shell', + args: { command: 'echo second' }, + isClientInitiated: false, + prompt_id: 'prompt-tui-dup', + }, + }; + })(), + ) + .mockReturnValueOnce( + (async function* () { + yield { + type: ServerGeminiEventType.Content, + value: 'done', + }; + yield { + type: ServerGeminiEventType.Finished, + value: { reason: undefined, usageMetadata: { totalTokenCount: 1 } }, + }; + })(), + ); + + const client = new MockedGeminiClientClass(mockConfig); + const { result } = renderHook(() => + useGeminiStream( + client, + [], + mockAddItem, + mockConfig, + mockLoadedSettings, + mockOnDebugMessage, + mockHandleSlashCommand, + false, + () => 'vscode' as EditorType, + () => {}, + () => Promise.resolve(), + false, + () => {}, + () => {}, + () => {}, + () => {}, + 80, + 24, + ), + ); + + await act(async () => { + await result.current.submitQuery('run shell'); + }); + + await waitFor(() => { + expect(result.current.streamingState).toBe(StreamingState.Idle); + }); + + expect(mockScheduleToolCalls).toHaveBeenCalledTimes(1); + expect(mockScheduleToolCalls.mock.calls[0][0]).toEqual([ + expect.objectContaining({ + callId: 'tool-dup', + providerCallId: 'tool-dup', + args: { command: 'echo first' }, + }), + ]); + + const completedToolCall = { + request: { + callId: 'tool-dup', + providerCallId: 'tool-dup', + name: 'shell', + args: { command: 'echo first' }, + isClientInitiated: false, + prompt_id: 'prompt-tui-dup', + }, + status: 'success', + responseSubmittedToGemini: false, + response: { + callId: 'tool-dup', + responseParts: [ + { + functionResponse: { + id: 'tool-dup', + name: 'shell', + response: { output: 'first' }, + }, + }, + ], + resultDisplay: 'first', + error: undefined, + errorType: undefined, + }, + tool: { + name: 'shell', + displayName: 'Shell', + description: 'Run a command', + build: vi.fn(), + } as any, + invocation: { + getDescription: () => 'echo first', + } as unknown as AnyToolInvocation, + } as unknown as TrackedCompletedToolCall; + + await act(async () => { + if (capturedOnComplete) { + await capturedOnComplete([completedToolCall]); + } + }); + + await waitFor(() => { + expect(mockSendMessageStream).toHaveBeenCalledTimes(2); + }); + const toolResultParts = mockSendMessageStream.mock.calls[1][0] as Part[]; + expect(toolResultParts).toHaveLength(2); + expect(toolResultParts[0].functionResponse?.response?.['output']).toBe( + 'first', + ); + expect(toolResultParts[1].functionResponse?.response?.['error']).toContain( + 'Duplicate provider tool call id "tool-dup"', + ); + expect(client.recordCompletedToolCall).toHaveBeenCalledTimes(1); + }); + + it('submits a synthetic response for history-paired duplicate provider ids without scheduling', async () => { + const client = new MockedGeminiClientClass(mockConfig); + client.getHistoryFunctionResponseIds = vi + .fn() + .mockReturnValue(new Set(['tool-history'])); + + mockSendMessageStream + .mockReturnValueOnce( + (async function* () { + yield { + type: ServerGeminiEventType.ToolCallRequest, + value: { + callId: 'tool-history', + providerCallId: 'tool-history', + name: 'shell', + args: { command: 'echo duplicate' }, + isClientInitiated: false, + prompt_id: 'prompt-tui-history', + }, + }; + })(), + ) + .mockReturnValueOnce( + (async function* () { + yield { + type: ServerGeminiEventType.Finished, + value: { reason: undefined, usageMetadata: { totalTokenCount: 1 } }, + }; + })(), + ); + + const { result } = renderTestHook([], client); + + await act(async () => { + await result.current.submitQuery('run shell'); + }); + + expect(mockScheduleToolCalls).not.toHaveBeenCalled(); + expect(mockSendMessageStream).toHaveBeenCalledTimes(2); + const toolResultParts = mockSendMessageStream.mock.calls[1][0] as Part[]; + expect(toolResultParts[0].functionResponse?.id).toBe('tool-history'); + expect(toolResultParts[0].functionResponse?.response?.['error']).toContain( + 'Duplicate provider tool call id "tool-history"', + ); + expect(client.recordCompletedToolCall).not.toHaveBeenCalled(); + }); + + it('does not deduplicate tool calls without provider ids in the TUI stream', async () => { + mockSendMessageStream.mockReturnValueOnce( + (async function* () { + yield { + type: ServerGeminiEventType.ToolCallRequest, + value: { + callId: 'generated-1', + name: 'shell', + args: { command: 'pwd' }, + isClientInitiated: false, + prompt_id: 'prompt-tui-no-provider', + }, + }; + yield { + type: ServerGeminiEventType.ToolCallRequest, + value: { + callId: 'generated-2', + name: 'shell', + args: { command: 'pwd' }, + isClientInitiated: false, + prompt_id: 'prompt-tui-no-provider', + }, + }; + })(), + ); + + const { result } = renderTestHook(); + + await act(async () => { + await result.current.submitQuery('run shell twice'); + }); + + expect(mockScheduleToolCalls).toHaveBeenCalledTimes(1); + expect(mockScheduleToolCalls.mock.calls[0][0]).toEqual([ + expect.objectContaining({ callId: 'generated-1' }), + expect.objectContaining({ callId: 'generated-2' }), + ]); + }); + it('drops a late tool result whose callId is already paired in chat.history (Race A dedup)', async () => { // Race A repro: the chat-internal repair pass already synthesized a // functionResponse for this callId on the Retry push (because the diff --git a/packages/cli/src/ui/hooks/useGeminiStream.ts b/packages/cli/src/ui/hooks/useGeminiStream.ts index 5a65db7f4e4..ad85115846b 100644 --- a/packages/cli/src/ui/hooks/useGeminiStream.ts +++ b/packages/cli/src/ui/hooks/useGeminiStream.ts @@ -56,6 +56,7 @@ import { activeGoalEquals, setActiveGoal, clearActiveGoal, + createDuplicateProviderToolCallResponse, } from '@qwen-code/qwen-code-core'; import { type Part, type PartListUnion, FinishReason } from '@google/genai'; import type { @@ -96,6 +97,12 @@ import process from 'node:process'; const debugLogger = createDebugLogger('GEMINI_STREAM'); +interface PendingDuplicateToolResponses { + executableCallIds: Set; + promptId: string | undefined; + responseParts: Part[]; +} + /** * Pull the assistant's most recent visible text from the UI history. Used as * an intent prefix for tool-use summary generation so the summarizer knows @@ -382,6 +389,14 @@ export const useGeminiStream = ( const processedMemoryToolsRef = useRef>(new Set()); const submitPromptOnCompleteRef = useRef<(() => Promise) | null>(null); const modelOverrideRef = useRef(undefined); + const handledProviderToolCallIdsRef = useRef>(new Set()); + const pendingDuplicateToolResponsesRef = useRef< + PendingDuplicateToolResponses[] + >([]); + const immediateDuplicateToolResponsesRef = useRef<{ + promptId: string | undefined; + responseParts: Part[]; + } | null>(null); // --- Real-time token display --- // Accumulates output character count across the whole turn (not per API call). // Uses a ref to avoid re-renders on every text_delta. @@ -1662,7 +1677,59 @@ export const useGeminiStream = ( } dualOutput?.finalizeAssistantMessage(); if (toolCallRequests.length > 0) { - scheduleToolCalls(toolCallRequests, signal); + const executableToolCallRequests: ToolCallRequestInfo[] = []; + const duplicateResponseParts: Part[] = []; + let duplicatePromptId: string | undefined; + const historyCallIdsWithResponse: Set = geminiClient + ? geminiClient.getHistoryFunctionResponseIds() + : new Set(); + + for (const request of toolCallRequests) { + const providerCallId = request.providerCallId; + if (!providerCallId) { + executableToolCallRequests.push(request); + continue; + } + + if ( + handledProviderToolCallIdsRef.current.has(providerCallId) || + historyCallIdsWithResponse.has(providerCallId) + ) { + const response = createDuplicateProviderToolCallResponse(request); + debugLogger.debug( + `[processGeminiStreamEvents] Suppressing duplicate provider tool-call id: ${providerCallId} (tool: ${request.name})`, + ); + dualOutput?.emitToolResult(request, response); + duplicateResponseParts.push(...response.responseParts); + duplicatePromptId ??= request.prompt_id; + continue; + } + + handledProviderToolCallIdsRef.current.add(providerCallId); + executableToolCallRequests.push(request); + } + + if (duplicateResponseParts.length > 0) { + if (executableToolCallRequests.length > 0) { + pendingDuplicateToolResponsesRef.current.push({ + executableCallIds: new Set( + executableToolCallRequests.map((request) => request.callId), + ), + promptId: + duplicatePromptId ?? executableToolCallRequests[0]?.prompt_id, + responseParts: duplicateResponseParts, + }); + } else { + immediateDuplicateToolResponsesRef.current = { + promptId: duplicatePromptId, + responseParts: duplicateResponseParts, + }; + } + } + + if (executableToolCallRequests.length > 0) { + scheduleToolCalls(executableToolCallRequests, signal); + } } return StreamProcessingStatus.Completed; }, @@ -1672,6 +1739,7 @@ export const useGeminiStream = ( handleUserCancelledEvent, handleErrorEvent, scheduleToolCalls, + geminiClient, handleChatCompressionEvent, handleFinishedEvent, handleMaxSessionTurnsEvent, @@ -1741,6 +1809,9 @@ export const useGeminiStream = ( ) { lastTurnUserItemRef.current = null; turnSawContentEventRef.current = false; + handledProviderToolCallIdsRef.current.clear(); + pendingDuplicateToolResponsesRef.current = []; + immediateDuplicateToolResponsesRef.current = null; } const userMessageTimestamp = Date.now(); @@ -1909,6 +1980,17 @@ export const useGeminiStream = ( addItem(pendingHistoryItemRef.current, userMessageTimestamp); setPendingHistoryItem(null); } + + const immediateDuplicateToolResponses = + immediateDuplicateToolResponsesRef.current; + if (immediateDuplicateToolResponses) { + immediateDuplicateToolResponsesRef.current = null; + await submitQuery( + immediateDuplicateToolResponses.responseParts, + SendMessageType.ToolResult, + immediateDuplicateToolResponses.promptId, + ); + } // Only clear auto-retry countdown errors (those with an active timer). // Do NOT clear static error+hint from handleErrorEvent — those should // remain visible until the user presses Ctrl+Y to retry or starts @@ -2222,6 +2304,26 @@ export const useGeminiStream = ( !t.request.isClientInitiated && !historyCallIdsWithResponse.has(t.request.callId), ); + const completedCallIds = new Set( + completedAndReadyToSubmitTools.map( + (toolCall) => toolCall.request.callId, + ), + ); + const readyDuplicateBatches: PendingDuplicateToolResponses[] = []; + pendingDuplicateToolResponsesRef.current = + pendingDuplicateToolResponsesRef.current.filter((batch) => { + const isReady = [...batch.executableCallIds].some((callId) => + completedCallIds.has(callId), + ); + if (isReady) { + readyDuplicateBatches.push(batch); + } + return !isReady; + }); + const pendingDuplicateResponseParts = readyDuplicateBatches.flatMap( + (batch) => batch.responseParts, + ); + const pendingDuplicatePromptId = readyDuplicateBatches[0]?.promptId; for (const toolCall of geminiTools) { geminiClient?.recordCompletedToolCall( @@ -2230,7 +2332,10 @@ export const useGeminiStream = ( ); } - if (geminiTools.length === 0) { + if ( + geminiTools.length === 0 && + pendingDuplicateResponseParts.length === 0 + ) { return; } @@ -2239,7 +2344,7 @@ export const useGeminiStream = ( (tc) => tc.status === 'cancelled', ); - if (allToolsCancelled) { + if (allToolsCancelled && pendingDuplicateResponseParts.length === 0) { if (geminiClient) { // We need to manually add the function responses to the history // so the model knows the tools were cancelled. @@ -2265,6 +2370,7 @@ export const useGeminiStream = ( const responsesToSend: Part[] = geminiTools.flatMap( (toolCall) => toolCall.response.responseParts, ); + responsesToSend.push(...pendingDuplicateResponseParts); const callIdsToMarkAsSubmitted = geminiTools.map( (toolCall) => toolCall.request.callId, ); @@ -2272,6 +2378,7 @@ export const useGeminiStream = ( const prompt_ids = geminiTools.map( (toolCall) => toolCall.request.prompt_id, ); + const promptId = prompt_ids[0] ?? pendingDuplicatePromptId; // Persist model override from skill tool results (last one wins). // Uses `in` so that undefined (from inherit/no-model skills) clears a @@ -2406,7 +2513,7 @@ export const useGeminiStream = ( } } - submitQuery(responsesToSend, SendMessageType.ToolResult, prompt_ids[0]); + submitQuery(responsesToSend, SendMessageType.ToolResult, promptId); }, [ isResponding, diff --git a/packages/core/src/agents/runtime/agent-core.ts b/packages/core/src/agents/runtime/agent-core.ts index 36d5e6bbba6..2e81a4bf212 100644 --- a/packages/core/src/agents/runtime/agent-core.ts +++ b/packages/core/src/agents/runtime/agent-core.ts @@ -1079,6 +1079,48 @@ export class AgentCore { // ─── Tool Execution ─────────────────────────────────────── + private emitSyntheticToolError(params: { + callId: string; + name: string; + args: Record; + errorMessage: string | undefined; + responseParts: Part[]; + resultDisplay: ToolResultDisplay | undefined; + currentRound: number; + durationMs?: number; + }): void { + this.eventEmitter?.emit(AgentEventType.TOOL_CALL, { + subagentId: this.subagentId, + round: params.currentRound, + callId: params.callId, + name: params.name, + args: params.args, + description: params.errorMessage, + isOutputMarkdown: false, + timestamp: Date.now(), + } as AgentToolCallEvent); + + this.eventEmitter?.emit(AgentEventType.TOOL_RESULT, { + subagentId: this.subagentId, + round: params.currentRound, + callId: params.callId, + name: params.name, + success: false, + error: params.errorMessage, + responseParts: params.responseParts, + resultDisplay: params.resultDisplay, + durationMs: params.durationMs ?? 0, + timestamp: Date.now(), + } as AgentToolResultEvent); + + this.recordToolCallStats( + params.name, + false, + params.durationMs ?? 0, + params.errorMessage, + ); + } + /** * Processes a list of function calls via CoreToolScheduler. * @@ -1104,14 +1146,39 @@ export class AgentCore { // Filter unauthorized tool calls before scheduling const authorizedCalls: FunctionCall[] = []; + let duplicateEventIndex = 0; for (const fc of functionCalls) { const callId = fc.id ?? `${fc.name}-${Date.now()}`; const providerCallId = fc.id; const toolName = String(fc.name); + const args = (fc.args ?? {}) as Record; + + if (!allowedToolNames.has(fc.name)) { + const errorMessage = `Tool "${toolName}" not found. Tools must use the exact names provided.`; + const functionResponsePart = { + functionResponse: { + id: callId, + name: toolName, + response: { error: errorMessage }, + }, + }; + + this.emitSyntheticToolError({ + callId, + name: toolName, + args, + errorMessage, + responseParts: [functionResponsePart], + resultDisplay: errorMessage, + currentRound, + }); + + toolResponseParts.push(functionResponsePart); + continue; + } if (providerCallId) { if (handledProviderToolCallIds.has(providerCallId)) { - const args = (fc.args ?? {}) as Record; const request: ToolCallRequestInfo = { callId, providerCallId, @@ -1124,84 +1191,29 @@ export class AgentCore { }; const response = createDuplicateProviderToolCallResponse(request); const errorMessage = response.error?.message; - const eventCallId = `${callId}:duplicate:${currentRound}:${toolResponseParts.length}`; + const eventCallId = `${callId}:duplicate:${currentRound}:${duplicateEventIndex++}`; - this.eventEmitter?.emit(AgentEventType.TOOL_CALL, { - subagentId: this.subagentId, - round: currentRound, - callId: eventCallId, - name: toolName, - args, - description: errorMessage, - isOutputMarkdown: false, - timestamp: Date.now(), - } as AgentToolCallEvent); + this.runtimeContext + .getDebugLogger() + ?.debug( + `[processFunctionCalls] Suppressing duplicate provider tool-call id: ${providerCallId} (tool: ${toolName}, round: ${currentRound})`, + ); - this.eventEmitter?.emit(AgentEventType.TOOL_RESULT, { - subagentId: this.subagentId, - round: currentRound, + this.emitSyntheticToolError({ callId: eventCallId, name: toolName, - success: false, - error: errorMessage, + args, + errorMessage, responseParts: response.responseParts, resultDisplay: response.resultDisplay, - durationMs: 0, - timestamp: Date.now(), - } as AgentToolResultEvent); + currentRound, + }); toolResponseParts.push(...response.responseParts); - this.recordToolCallStats(toolName, false, 0, errorMessage); continue; } handledProviderToolCallIds.add(providerCallId); } - - if (!allowedToolNames.has(fc.name)) { - const errorMessage = `Tool "${toolName}" not found. Tools must use the exact names provided.`; - - // Emit TOOL_CALL event for visibility - this.eventEmitter?.emit(AgentEventType.TOOL_CALL, { - subagentId: this.subagentId, - round: currentRound, - callId, - name: toolName, - args: fc.args ?? {}, - description: `Tool "${toolName}" not found`, - isOutputMarkdown: false, - timestamp: Date.now(), - } as AgentToolCallEvent); - - // Build function response part (used for both event and LLM) - const functionResponsePart = { - functionResponse: { - id: callId, - name: toolName, - response: { error: errorMessage }, - }, - }; - - // Emit TOOL_RESULT event with error - this.eventEmitter?.emit(AgentEventType.TOOL_RESULT, { - subagentId: this.subagentId, - round: currentRound, - callId, - name: toolName, - success: false, - error: errorMessage, - responseParts: [functionResponsePart], - resultDisplay: errorMessage, - durationMs: 0, - timestamp: Date.now(), - } as AgentToolResultEvent); - - // Record blocked tool call in stats - this.recordToolCallStats(toolName, false, 0, errorMessage); - - // Add function response for LLM - toolResponseParts.push(functionResponsePart); - continue; - } authorizedCalls.push(fc); } diff --git a/packages/core/src/agents/runtime/agent-headless.test.ts b/packages/core/src/agents/runtime/agent-headless.test.ts index 80f269ab198..02c8ebf01bc 100644 --- a/packages/core/src/agents/runtime/agent-headless.test.ts +++ b/packages/core/src/agents/runtime/agent-headless.test.ts @@ -1133,6 +1133,222 @@ describe('subagent.ts', () => { ); expect(scope.getTerminateMode()).toBe(AgentTerminateMode.GOAL); }); + + it('should ignore duplicate provider tool-call ids in the same batch', async () => { + const listFilesToolDef: FunctionDeclaration = { + name: 'list_files', + description: 'Lists files', + parameters: { type: Type.OBJECT, properties: {} }, + }; + + const { config } = await createMockConfig({ + getFunctionDeclarationsFiltered: vi + .fn() + .mockReturnValue([listFilesToolDef]), + getTool: vi.fn().mockReturnValue(undefined), + }); + const toolConfig: ToolConfig = { tools: ['list_files'] }; + + mockSendMessageStream.mockImplementation( + createMockStream([ + [ + { + id: 'call_same_batch', + name: 'list_files', + args: { path: '.' }, + }, + { + id: 'call_same_batch', + name: 'list_files', + args: { path: '.' }, + }, + { + id: 'call_same_batch', + name: 'list_files', + args: { path: '.' }, + }, + ], + 'stop', + ]), + ); + + const listFilesInvocation = { + params: { path: '.' }, + getDescription: vi.fn().mockReturnValue('List files'), + toolLocations: vi.fn().mockReturnValue([]), + getDefaultPermission: vi.fn().mockResolvedValue('allow'), + execute: vi.fn().mockResolvedValue({ + llmContent: 'file1.txt\nfile2.ts', + returnDisplay: 'Listed 2 files', + }), + }; + const listFilesTool = { + name: 'list_files', + displayName: 'List Files', + description: 'List files in directory', + kind: 'READ' as const, + schema: listFilesToolDef, + build: vi.fn().mockImplementation(() => listFilesInvocation), + canUpdateOutput: false, + isOutputMarkdown: true, + } as unknown as AnyDeclarativeTool; + vi.mocked( + (config.getToolRegistry() as unknown as ToolRegistry).getTool, + ).mockImplementation((name: string) => + name === 'list_files' ? listFilesTool : undefined, + ); + + const toolCallEvents: AgentToolCallEvent[] = []; + const toolResultEvents: AgentToolResultEvent[] = []; + const eventEmitter = new AgentEventEmitter(); + eventEmitter.on(AgentEventType.TOOL_CALL, (event: unknown) => { + toolCallEvents.push(event as AgentToolCallEvent); + }); + eventEmitter.on(AgentEventType.TOOL_RESULT, (event: unknown) => { + toolResultEvents.push(event as AgentToolResultEvent); + }); + + const scope = await AgentHeadless.create( + 'test-agent', + config, + promptConfig, + defaultModelConfig, + defaultRunConfig, + toolConfig, + eventEmitter, + ); + + await scope.execute(new ContextState()); + + expect(listFilesInvocation.execute).toHaveBeenCalledTimes(1); + expect(toolCallEvents).toHaveLength(3); + expect(toolResultEvents).toHaveLength(3); + const duplicateCallIds = toolCallEvents + .filter((event) => event.callId !== 'call_same_batch') + .map((event) => event.callId); + expect( + toolCallEvents.some((event) => event.callId === 'call_same_batch'), + ).toBe(true); + expect(duplicateCallIds[0]).toMatch(/^call_same_batch:duplicate:/); + expect(duplicateCallIds[1]).toMatch(/^call_same_batch:duplicate:/); + expect(duplicateCallIds[0]).not.toBe(duplicateCallIds[1]); + expect( + toolResultEvents.some((event) => event.callId === 'call_same_batch'), + ).toBe(true); + expect( + toolResultEvents.some( + (event) => event.callId === duplicateCallIds[0], + ), + ).toBe(true); + expect( + toolResultEvents.some( + (event) => event.callId === duplicateCallIds[1], + ), + ).toBe(true); + + const secondCallArgs = mockSendMessageStream.mock.calls[1][1]; + const parts = secondCallArgs.message as Part[]; + expect(parts).toHaveLength(3); + const realToolResponse = parts.find( + (part) => part.functionResponse?.response?.['output'] !== undefined, + ); + const duplicateResponses = parts.filter((part) => + String(part.functionResponse?.response?.['error']).includes( + 'Duplicate provider tool call id "call_same_batch"', + ), + ); + expect(realToolResponse?.functionResponse?.response?.['output']).toBe( + 'file1.txt\nfile2.ts', + ); + expect(duplicateResponses).toHaveLength(2); + expect(scope.getTerminateMode()).toBe(AgentTerminateMode.GOAL); + }); + + it('should report unauthorized tool names before duplicate provider ids', async () => { + const listFilesToolDef: FunctionDeclaration = { + name: 'list_files', + description: 'Lists files', + parameters: { type: Type.OBJECT, properties: {} }, + }; + + const { config } = await createMockConfig({ + getFunctionDeclarationsFiltered: vi + .fn() + .mockReturnValue([listFilesToolDef]), + getTool: vi.fn().mockReturnValue(undefined), + }); + const toolConfig: ToolConfig = { tools: ['list_files'] }; + + mockSendMessageStream.mockImplementation( + createMockStream([ + [ + { + id: 'call_reused', + name: 'list_files', + args: { path: '.' }, + }, + ], + [ + { + id: 'call_reused', + name: 'write_file', + args: { path: 'x.txt', content: 'x' }, + }, + ], + 'stop', + ]), + ); + + const listFilesInvocation = { + params: { path: '.' }, + getDescription: vi.fn().mockReturnValue('List files'), + toolLocations: vi.fn().mockReturnValue([]), + getDefaultPermission: vi.fn().mockResolvedValue('allow'), + execute: vi.fn().mockResolvedValue({ + llmContent: 'file1.txt\nfile2.ts', + returnDisplay: 'Listed 2 files', + }), + }; + const listFilesTool = { + name: 'list_files', + displayName: 'List Files', + description: 'List files in directory', + kind: 'READ' as const, + schema: listFilesToolDef, + build: vi.fn().mockImplementation(() => listFilesInvocation), + canUpdateOutput: false, + isOutputMarkdown: true, + } as unknown as AnyDeclarativeTool; + vi.mocked( + (config.getToolRegistry() as unknown as ToolRegistry).getTool, + ).mockImplementation((name: string) => + name === 'list_files' ? listFilesTool : undefined, + ); + + const scope = await AgentHeadless.create( + 'test-agent', + config, + promptConfig, + defaultModelConfig, + defaultRunConfig, + toolConfig, + ); + + await scope.execute(new ContextState()); + + expect(listFilesInvocation.execute).toHaveBeenCalledTimes(1); + const thirdCallArgs = mockSendMessageStream.mock.calls[2][1]; + const parts = thirdCallArgs.message as Part[]; + expect(parts[0].functionResponse?.id).toBe('call_reused'); + expect(parts[0].functionResponse?.name).toBe('write_file'); + expect(parts[0].functionResponse?.response?.['error']).toContain( + 'Tool "write_file" not found', + ); + expect(parts[0].functionResponse?.response?.['error']).not.toContain( + 'Duplicate provider tool call id', + ); + expect(scope.getTerminateMode()).toBe(AgentTerminateMode.GOAL); + }); }); describe('execute - Termination and Recovery', () => { From 9681a7a08b4fab7b57700de36085830db5ae281a Mon Sep 17 00:00:00 2001 From: YingchaoX Date: Sat, 13 Jun 2026 01:02:38 +0800 Subject: [PATCH 06/10] Update packages/core/src/agents/runtime/agent-core.ts Co-authored-by: Shaojin Wen --- packages/core/src/agents/runtime/agent-core.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/src/agents/runtime/agent-core.ts b/packages/core/src/agents/runtime/agent-core.ts index 2e81a4bf212..252aadbe82d 100644 --- a/packages/core/src/agents/runtime/agent-core.ts +++ b/packages/core/src/agents/runtime/agent-core.ts @@ -1084,7 +1084,7 @@ export class AgentCore { name: string; args: Record; errorMessage: string | undefined; - responseParts: Part[]; + errorMessage: string; resultDisplay: ToolResultDisplay | undefined; currentRound: number; durationMs?: number; From 0295f7b297e2e9879b53aabc58dc02f3314fb123 Mon Sep 17 00:00:00 2001 From: YingchaoX Date: Sun, 14 Jun 2026 22:57:50 +0800 Subject: [PATCH 07/10] fix(core): restore synthetic tool error type --- packages/core/src/agents/runtime/agent-core.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/core/src/agents/runtime/agent-core.ts b/packages/core/src/agents/runtime/agent-core.ts index 252aadbe82d..e1601148eea 100644 --- a/packages/core/src/agents/runtime/agent-core.ts +++ b/packages/core/src/agents/runtime/agent-core.ts @@ -1083,8 +1083,8 @@ export class AgentCore { callId: string; name: string; args: Record; - errorMessage: string | undefined; errorMessage: string; + responseParts: Part[]; resultDisplay: ToolResultDisplay | undefined; currentRound: number; durationMs?: number; @@ -1190,7 +1190,9 @@ export class AgentCore { wasOutputTruncated, }; const response = createDuplicateProviderToolCallResponse(request); - const errorMessage = response.error?.message; + const errorMessage = + response.error?.message ?? + 'Duplicate provider tool call was ignored.'; const eventCallId = `${callId}:duplicate:${currentRound}:${duplicateEventIndex++}`; this.runtimeContext From 665014e93b485ecc3bab8e42bdd58091d95f904b Mon Sep 17 00:00:00 2001 From: yingchao xiong Date: Mon, 15 Jun 2026 12:48:40 +0800 Subject: [PATCH 08/10] test(cli): stabilize at-completion suggestions assertion --- .../cli/src/ui/hooks/useAtCompletion.test.ts | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/packages/cli/src/ui/hooks/useAtCompletion.test.ts b/packages/cli/src/ui/hooks/useAtCompletion.test.ts index 69bcc5aa035..97d480b0dcf 100644 --- a/packages/cli/src/ui/hooks/useAtCompletion.test.ts +++ b/packages/cli/src/ui/hooks/useAtCompletion.test.ts @@ -116,12 +116,16 @@ describe('useAtCompletion', () => { expect(result.current.suggestions.length).toBeGreaterThan(0); }); - expect(result.current.suggestions.map((s) => s.value)).toEqual([ - 'src/', - 'src/components/', - 'src/components/Button.tsx', - 'src/index.js', - ]); + const suggestionValues = result.current.suggestions.map((s) => s.value); + expect(suggestionValues).toHaveLength(4); + expect(suggestionValues).toEqual( + expect.arrayContaining([ + 'src/', + 'src/components/', + 'src/components/Button.tsx', + 'src/index.js', + ]), + ); }); it('should append a trailing slash to directory paths in suggestions', async () => { From bfa002816dbde8b604c132e70e5236cd1f74974b Mon Sep 17 00:00:00 2001 From: YingchaoX Date: Mon, 15 Jun 2026 17:11:13 +0800 Subject: [PATCH 09/10] Update packages/core/src/utils/filesearch/crawler.ts Co-authored-by: Shaojin Wen --- packages/core/src/utils/filesearch/crawler.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/src/utils/filesearch/crawler.ts b/packages/core/src/utils/filesearch/crawler.ts index ff63f54d0e8..76c53fb2292 100644 --- a/packages/core/src/utils/filesearch/crawler.ts +++ b/packages/core/src/utils/filesearch/crawler.ts @@ -1226,7 +1226,7 @@ function collectDirectoryRows(options: CrawlOptions): string[] { rows.push(row); } - if (options.maxDepth !== undefined && depth >= options.maxDepth) { + if (options.maxDepth !== undefined && depth > options.maxDepth) { return; } From cdbeb4805d800fd48ce513ae00664b71d4bdf105 Mon Sep 17 00:00:00 2001 From: yingchao xiong Date: Wed, 17 Jun 2026 18:14:27 +0800 Subject: [PATCH 10/10] fix(cli): dedupe ACP provider tool calls --- .../acp-integration/session/Session.test.ts | 210 ++++++++++++++++++ .../src/acp-integration/session/Session.ts | 101 ++++++++- 2 files changed, 308 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/acp-integration/session/Session.test.ts b/packages/cli/src/acp-integration/session/Session.test.ts index c5cb9e5bbda..f984b1cedf2 100644 --- a/packages/cli/src/acp-integration/session/Session.test.ts +++ b/packages/cli/src/acp-integration/session/Session.test.ts @@ -241,6 +241,7 @@ describe('Session', () => { addHistory: vi.fn(), getHistory: vi.fn().mockReturnValue([]), getHistoryShallow: vi.fn().mockReturnValue([]), + getHistoryFunctionResponseIds: vi.fn().mockReturnValue(new Set()), getLastModelMessageText: vi.fn().mockReturnValue(''), setHistory: vi.fn(), truncateHistory: vi.fn(), @@ -5125,6 +5126,215 @@ describe('Session', () => { expect(mockChatRecordingService.recordToolResult).toHaveBeenCalledOnce(); }); + it('suppresses duplicate provider functionCall ids already answered in history', async () => { + const execute = vi.fn().mockResolvedValue({ + llmContent: 'should not run', + returnDisplay: 'should not run', + }); + const build = vi.fn().mockReturnValue({ + params: { file_path: 'b.ts' }, + execute, + getDefaultPermission: vi.fn().mockResolvedValue('allow'), + getDescription: vi.fn().mockReturnValue('Read file'), + toolLocations: vi.fn().mockReturnValue([]), + }); + mockToolRegistry.getTool.mockReturnValue({ + name: 'read_file', + kind: core.Kind.Read, + displayName: 'Read File', + description: 'Read file', + build, + canUpdateOutput: false, + isOutputMarkdown: true, + }); + vi.mocked(mockChat.getHistoryFunctionResponseIds).mockReturnValue( + new Set(['shell_1']), + ); + const [duplicatePart] = core.normalizeModelToolCallIds( + [ + { + functionCall: { + id: 'shell_1', + name: 'read_file', + args: { file_path: 'b.ts' }, + }, + }, + ], + new Set(['shell_1']), + new Set(), + ); + const duplicateCall = duplicatePart.functionCall!; + + const parts = await ( + session as unknown as ToolCallInternals + ).runToolCalls(new AbortController().signal, 'prompt-history-dup', [ + duplicateCall, + ]); + + expect(mockToolRegistry.getTool).not.toHaveBeenCalled(); + expect(build).not.toHaveBeenCalled(); + expect(execute).not.toHaveBeenCalled(); + expect(parts).toHaveLength(1); + expect(parts[0].functionResponse?.id).toBe('shell_1__qwen_dup_2'); + expect(parts[0].functionResponse?.response).toEqual({ + error: expect.stringContaining( + 'Duplicate provider tool call id "shell_1"', + ), + }); + expect(mockChatRecordingService.recordToolResult).toHaveBeenCalledWith( + parts, + expect.objectContaining({ + callId: 'shell_1__qwen_dup_2', + status: 'error', + resultDisplay: expect.stringContaining( + 'Duplicate provider tool call id "shell_1"', + ), + error: expect.any(Error), + }), + ); + expect(mockClient.sessionUpdate).toHaveBeenCalledWith( + expect.objectContaining({ + update: expect.objectContaining({ + sessionUpdate: 'tool_call_update', + toolCallId: 'shell_1__qwen_dup_2', + status: 'failed', + }), + }), + ); + expect(mockClient.sessionUpdate).not.toHaveBeenCalledWith( + expect.objectContaining({ + update: expect.objectContaining({ + sessionUpdate: 'tool_call', + toolCallId: 'shell_1__qwen_dup_2', + }), + }), + ); + }); + + it('suppresses duplicate TodoWrite calls without emitting plan updates', async () => { + vi.mocked(mockChat.getHistoryFunctionResponseIds).mockReturnValue( + new Set(['todo_1']), + ); + const [duplicatePart] = core.normalizeModelToolCallIds( + [ + { + functionCall: { + id: 'todo_1', + name: core.ToolNames.TODO_WRITE, + args: { + todos: [ + { + id: 'task-1', + content: 'Do not replay this', + status: 'pending', + }, + ], + }, + }, + }, + ], + new Set(['todo_1']), + new Set(), + ); + + const parts = await ( + session as unknown as ToolCallInternals + ).runToolCalls(new AbortController().signal, 'prompt-todo-dup', [ + duplicatePart.functionCall!, + ]); + + expect(mockToolRegistry.getTool).not.toHaveBeenCalled(); + expect(parts[0].functionResponse?.id).toBe('todo_1__qwen_dup_2'); + expect(parts[0].functionResponse?.response).toEqual({ + error: expect.stringContaining( + 'Duplicate provider tool call id "todo_1"', + ), + }); + expect(mockClient.sessionUpdate).toHaveBeenCalledWith( + expect.objectContaining({ + update: expect.objectContaining({ + sessionUpdate: 'tool_call_update', + toolCallId: 'todo_1__qwen_dup_2', + status: 'failed', + }), + }), + ); + expect(mockClient.sessionUpdate).not.toHaveBeenCalledWith( + expect.objectContaining({ + update: expect.objectContaining({ + sessionUpdate: 'plan', + }), + }), + ); + expect(mockChatRecordingService.recordToolResult).toHaveBeenCalledWith( + parts, + expect.objectContaining({ + callId: 'todo_1__qwen_dup_2', + status: 'error', + }), + ); + }); + + it('keeps duplicate synthetic responses ordered with executable calls', async () => { + const execute = vi.fn(async () => ({ + llmContent: 'ran', + returnDisplay: 'ran', + })); + mockToolRegistry.getTool.mockReturnValue({ + name: 'read_file', + kind: core.Kind.Read, + displayName: 'Read File', + description: 'Read file', + build: vi.fn().mockReturnValue({ + params: { file_path: 'x.ts' }, + execute, + getDefaultPermission: vi.fn().mockResolvedValue('allow'), + getDescription: vi.fn().mockReturnValue('Read file'), + toolLocations: vi.fn().mockReturnValue([]), + }), + canUpdateOutput: false, + isOutputMarkdown: true, + }); + const historyIds = new Set(['dup_mid']); + vi.mocked(mockChat.getHistoryFunctionResponseIds).mockReturnValue( + historyIds, + ); + const [duplicatePart] = core.normalizeModelToolCallIds( + [ + { + functionCall: { + id: 'dup_mid', + name: 'read_file', + args: { file_path: 'b.ts' }, + }, + }, + ], + new Set(['dup_mid']), + new Set(), + ); + + const parts = await ( + session as unknown as ToolCallInternals + ).runToolCalls(new AbortController().signal, 'prompt-mixed-dup', [ + { id: 'call_a', name: 'read_file', args: { file_path: 'a.ts' } }, + duplicatePart.functionCall!, + { id: 'call_c', name: 'read_file', args: { file_path: 'c.ts' } }, + ]); + + expect(execute).toHaveBeenCalledTimes(2); + expect(parts.map((part) => part.functionResponse?.id)).toEqual([ + 'call_a', + 'dup_mid__qwen_dup_2', + 'call_c', + ]); + expect(parts[1].functionResponse?.response).toEqual({ + error: expect.stringContaining( + 'Duplicate provider tool call id "dup_mid"', + ), + }); + expect(historyIds).toEqual(new Set(['dup_mid'])); + }); + it('does not dedupe function calls with empty ids in one batch', async () => { const execute = vi.fn().mockResolvedValue({ llmContent: 'result', diff --git a/packages/cli/src/acp-integration/session/Session.ts b/packages/cli/src/acp-integration/session/Session.ts index 12d791d8c8f..eee6f7365e3 100644 --- a/packages/cli/src/acp-integration/session/Session.ts +++ b/packages/cli/src/acp-integration/session/Session.ts @@ -26,12 +26,15 @@ import type { AutoModeDecision, AutoModeOutcome, GoalTerminalEvent, + ToolCallRequestInfo, + ToolCallResponseInfo, } from '@qwen-code/qwen-code-core'; import { AuthType, ApprovalMode, CompressionStatus, convertToFunctionResponse, + createDuplicateProviderToolCallResponse, createDebugLogger, DiscoveredMCPTool, StreamEventType, @@ -95,6 +98,7 @@ import { setGoalTerminalObserver, sessionIdContext, dedupeToolCallsById, + getProviderToolCallId, } from '@qwen-code/qwen-code-core'; import { NOT_CURRENTLY_GENERATING_CANCEL_MESSAGE } from '@qwen-code/acp-bridge/bridgeErrors'; // Single source of truth shared with the daemon-side answerer (BridgeClient), @@ -2806,15 +2810,101 @@ export class Session implements SessionContext { promptId: string, functionCalls: FunctionCall[], ): Promise { - type Batch = { concurrent: boolean; calls: FunctionCall[] }; + type ExecutableBatch = { + kind: 'execute'; + concurrent: boolean; + calls: FunctionCall[]; + }; + type DuplicateBatch = { + kind: 'duplicate'; + request: ToolCallRequestInfo; + response: ToolCallResponseInfo; + }; + type Batch = ExecutableBatch | DuplicateBatch; const batches: Batch[] = []; + const handledProviderToolCallIds = new Set( + this.#getCurrentChat().getHistoryFunctionResponseIds(), + ); + + const pushDuplicateBatch = (request: ToolCallRequestInfo): void => { + const response = createDuplicateProviderToolCallResponse(request); + debugLogger.debug( + `[Session.runToolCalls] Suppressing duplicate provider tool-call id: ` + + `${request.providerCallId} (tool: ${request.name})`, + ); + batches.push({ kind: 'duplicate', request, response }); + }; + + const emitDuplicateBatch = async (batch: DuplicateBatch): Promise => { + const { request, response } = batch; + if (request.name === ToolNames.TODO_WRITE) { + const provenance = ToolCallEmitter.resolveToolProvenance(request.name); + await this.sendUpdate({ + sessionUpdate: 'tool_call_update', + toolCallId: response.callId, + status: 'failed', + content: [ + { + type: 'content', + content: { + type: 'text', + text: response.error?.message ?? String(response.resultDisplay), + }, + }, + ], + rawOutput: response.resultDisplay, + _meta: { + toolName: request.name, + provenance: provenance.provenance, + ...(provenance.serverId ? { serverId: provenance.serverId } : {}), + }, + }); + } else { + await this.toolCallEmitter.emitResult({ + callId: response.callId, + toolName: request.name, + args: request.args, + message: response.responseParts, + resultDisplay: response.resultDisplay, + error: response.error, + success: false, + }); + } + this.config + .getChatRecordingService() + ?.recordToolResult(response.responseParts, { + callId: response.callId, + status: 'error', + resultDisplay: response.resultDisplay, + error: response.error, + errorType: response.errorType, + }); + }; + for (const fc of dedupeToolCallsById(functionCalls)) { + const providerCallId = getProviderToolCallId(fc) ?? fc.id; + if (providerCallId) { + if (handledProviderToolCallIds.has(providerCallId)) { + const callId = fc.id ?? `${fc.name}-${Date.now()}`; + pushDuplicateBatch({ + callId, + providerCallId, + name: fc.name ?? 'unknown_tool', + args: (fc.args ?? {}) as Record, + isClientInitiated: false, + prompt_id: promptId, + }); + continue; + } + handledProviderToolCallIds.add(providerCallId); + } + const isAgent = fc.name === ToolNames.AGENT; const last = batches[batches.length - 1]; - if (isAgent && last?.concurrent) { + if (isAgent && last?.kind === 'execute' && last.concurrent) { last.calls.push(fc); } else { - batches.push({ concurrent: isAgent, calls: [fc] }); + batches.push({ kind: 'execute', concurrent: isAgent, calls: [fc] }); } } @@ -2851,6 +2941,11 @@ export class Session implements SessionContext { const parts: Part[] = []; for (const batch of batches) { + if (batch.kind === 'duplicate') { + await emitDuplicateBatch(batch); + parts.push(...batch.response.responseParts); + continue; + } if (batch.concurrent && batch.calls.length > 1) { const results = await runBounded(batch.calls); for (const r of results) parts.push(...r);