From b92cd76e6df007dd6b42c05b02cec058dfd471a1 Mon Sep 17 00:00:00 2001 From: Roo Code Date: Fri, 30 Jan 2026 13:50:15 +0000 Subject: [PATCH] fix: prevent "Attempting to finalize unknown tool call" warning for unstarted tool calls - Add hasStarted check in processFinishReason() to only emit tool_call_end events for tool calls that have been properly started (received a name) - Add diagnostic warning when skipping unstarted tool calls - Add comprehensive tests for the hasStarted behavior with started, unstarted, and mixed scenarios - Fixes issue #11071 where GLM models send tool call IDs without names, causing synchronization issues between rawChunkTracker and streamingToolCalls This prevents the warning "Attempting to finalize unknown tool call" that appears when GLM models send incomplete tool call data (ID without name). --- .../assistant-message/NativeToolCallParser.ts | 19 ++- .../__tests__/NativeToolCallParser.spec.ts | 118 ++++++++++++++++++ 2 files changed, 133 insertions(+), 4 deletions(-) diff --git a/src/core/assistant-message/NativeToolCallParser.ts b/src/core/assistant-message/NativeToolCallParser.ts index 72c34f94a07..c8d9d471a6b 100644 --- a/src/core/assistant-message/NativeToolCallParser.ts +++ b/src/core/assistant-message/NativeToolCallParser.ts @@ -166,16 +166,27 @@ export class NativeToolCallParser { /** * Process stream finish reason. * Emits end events when finish_reason is 'tool_calls'. + * Only emits events for tool calls that have been properly started (have a name). */ public static processFinishReason(finishReason: string | null | undefined): ToolCallStreamEvent[] { const events: ToolCallStreamEvent[] = [] if (finishReason === "tool_calls" && this.rawChunkTracker.size > 0) { for (const [, tracked] of this.rawChunkTracker.entries()) { - events.push({ - type: "tool_call_end", - id: tracked.id, - }) + // Only emit end event if the tool call was properly started + // (i.e., we received a name and emitted tool_call_start) + if (tracked.hasStarted) { + events.push({ + type: "tool_call_end", + id: tracked.id, + }) + } else { + // Log diagnostic warning for unstarted tool calls + console.warn( + `[NativeToolCallParser] Skipping tool_call_end for unstarted tool call: ${tracked.id} ` + + `(no name received, hasStarted=false)`, + ) + } } } diff --git a/src/core/assistant-message/__tests__/NativeToolCallParser.spec.ts b/src/core/assistant-message/__tests__/NativeToolCallParser.spec.ts index db0dc00de41..4a95b23a87e 100644 --- a/src/core/assistant-message/__tests__/NativeToolCallParser.spec.ts +++ b/src/core/assistant-message/__tests__/NativeToolCallParser.spec.ts @@ -343,4 +343,122 @@ describe("NativeToolCallParser", () => { }) }) }) + + describe("processFinishReason", () => { + describe("hasStarted check", () => { + it("should emit tool_call_end for started tool calls", () => { + // Simulate a tool call that has been properly started + NativeToolCallParser.processRawChunk({ + index: 0, + id: "call_started", + name: "read_file", + arguments: '{"path":"test.ts"}', + }) + + const events = NativeToolCallParser.processFinishReason("tool_calls") + + expect(events).toHaveLength(1) + expect(events[0]).toEqual({ + type: "tool_call_end", + id: "call_started", + }) + }) + + it("should NOT emit tool_call_end for unstarted tool calls", () => { + // Simulate a tool call that received an ID but no name (not started) + NativeToolCallParser.processRawChunk({ + index: 0, + id: "call_unstarted", + // No name provided - tool call is tracked but not started + }) + + const events = NativeToolCallParser.processFinishReason("tool_calls") + + // Should not emit any events because the tool call wasn't started + expect(events).toHaveLength(0) + }) + + it("should handle mixed started and unstarted tool calls", () => { + // First tool call: properly started + NativeToolCallParser.processRawChunk({ + index: 0, + id: "call_started_1", + name: "read_file", + arguments: '{"path":"test1.ts"}', + }) + + // Second tool call: tracked but not started (no name) + NativeToolCallParser.processRawChunk({ + index: 1, + id: "call_unstarted", + // No name - won't be started + }) + + // Third tool call: properly started + NativeToolCallParser.processRawChunk({ + index: 2, + id: "call_started_2", + name: "write_to_file", + arguments: '{"path":"output.ts"}', + }) + + const events = NativeToolCallParser.processFinishReason("tool_calls") + + // Should only emit end events for the two started tool calls + expect(events).toHaveLength(2) + expect(events[0]).toEqual({ + type: "tool_call_end", + id: "call_started_1", + }) + expect(events[1]).toEqual({ + type: "tool_call_end", + id: "call_started_2", + }) + }) + + it("should not emit events when finish_reason is not tool_calls", () => { + // Set up a started tool call + NativeToolCallParser.processRawChunk({ + index: 0, + id: "call_started", + name: "read_file", + arguments: '{"path":"test.ts"}', + }) + + // Process with different finish reason + const events = NativeToolCallParser.processFinishReason("stop") + + expect(events).toHaveLength(0) + }) + + it("should handle tool call that receives name in a separate chunk", () => { + // First chunk: ID only + NativeToolCallParser.processRawChunk({ + index: 0, + id: "call_delayed_name", + }) + + // At this point, tool call is tracked but not started + let events = NativeToolCallParser.processFinishReason("tool_calls") + expect(events).toHaveLength(0) + + // Clear state and try again with name + NativeToolCallParser.clearRawChunkState() + + // Simulate proper sequence with name + NativeToolCallParser.processRawChunk({ + index: 0, + id: "call_delayed_name", + name: "read_file", + }) + + events = NativeToolCallParser.processFinishReason("tool_calls") + expect(events).toHaveLength(1) + expect(events[0]).toEqual({ + type: "tool_call_end", + id: "call_delayed_name", + }) + }) + }) + }) })