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).
This commit is contained in:
Roo Code 2026-01-30 13:50:15 +00:00
parent fd46e31134
commit b92cd76e6d
2 changed files with 133 additions and 4 deletions

View file

@ -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)`,
)
}
}
}

View file

@ -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",
})
})
})
})
})