Skip to content
This repository was archived by the owner on May 15, 2026. It is now read-only.

Commit b92cd76

Browse files
committed
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).
1 parent fd46e31 commit b92cd76

2 files changed

Lines changed: 133 additions & 4 deletions

File tree

src/core/assistant-message/NativeToolCallParser.ts

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -166,16 +166,27 @@ export class NativeToolCallParser {
166166
/**
167167
* Process stream finish reason.
168168
* Emits end events when finish_reason is 'tool_calls'.
169+
* Only emits events for tool calls that have been properly started (have a name).
169170
*/
170171
public static processFinishReason(finishReason: string | null | undefined): ToolCallStreamEvent[] {
171172
const events: ToolCallStreamEvent[] = []
172173

173174
if (finishReason === "tool_calls" && this.rawChunkTracker.size > 0) {
174175
for (const [, tracked] of this.rawChunkTracker.entries()) {
175-
events.push({
176-
type: "tool_call_end",
177-
id: tracked.id,
178-
})
176+
// Only emit end event if the tool call was properly started
177+
// (i.e., we received a name and emitted tool_call_start)
178+
if (tracked.hasStarted) {
179+
events.push({
180+
type: "tool_call_end",
181+
id: tracked.id,
182+
})
183+
} else {
184+
// Log diagnostic warning for unstarted tool calls
185+
console.warn(
186+
`[NativeToolCallParser] Skipping tool_call_end for unstarted tool call: ${tracked.id} ` +
187+
`(no name received, hasStarted=false)`,
188+
)
189+
}
179190
}
180191
}
181192

src/core/assistant-message/__tests__/NativeToolCallParser.spec.ts

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -343,4 +343,122 @@ describe("NativeToolCallParser", () => {
343343
})
344344
})
345345
})
346+
347+
describe("processFinishReason", () => {
348+
describe("hasStarted check", () => {
349+
it("should emit tool_call_end for started tool calls", () => {
350+
// Simulate a tool call that has been properly started
351+
NativeToolCallParser.processRawChunk({
352+
index: 0,
353+
id: "call_started",
354+
name: "read_file",
355+
arguments: '{"path":"test.ts"}',
356+
})
357+
358+
const events = NativeToolCallParser.processFinishReason("tool_calls")
359+
360+
expect(events).toHaveLength(1)
361+
expect(events[0]).toEqual({
362+
type: "tool_call_end",
363+
id: "call_started",
364+
})
365+
})
366+
367+
it("should NOT emit tool_call_end for unstarted tool calls", () => {
368+
// Simulate a tool call that received an ID but no name (not started)
369+
NativeToolCallParser.processRawChunk({
370+
index: 0,
371+
id: "call_unstarted",
372+
// No name provided - tool call is tracked but not started
373+
})
374+
375+
const events = NativeToolCallParser.processFinishReason("tool_calls")
376+
377+
// Should not emit any events because the tool call wasn't started
378+
expect(events).toHaveLength(0)
379+
})
380+
381+
it("should handle mixed started and unstarted tool calls", () => {
382+
// First tool call: properly started
383+
NativeToolCallParser.processRawChunk({
384+
index: 0,
385+
id: "call_started_1",
386+
name: "read_file",
387+
arguments: '{"path":"test1.ts"}',
388+
})
389+
390+
// Second tool call: tracked but not started (no name)
391+
NativeToolCallParser.processRawChunk({
392+
index: 1,
393+
id: "call_unstarted",
394+
// No name - won't be started
395+
})
396+
397+
// Third tool call: properly started
398+
NativeToolCallParser.processRawChunk({
399+
index: 2,
400+
id: "call_started_2",
401+
name: "write_to_file",
402+
arguments: '{"path":"output.ts"}',
403+
})
404+
405+
const events = NativeToolCallParser.processFinishReason("tool_calls")
406+
407+
// Should only emit end events for the two started tool calls
408+
expect(events).toHaveLength(2)
409+
expect(events[0]).toEqual({
410+
type: "tool_call_end",
411+
id: "call_started_1",
412+
})
413+
expect(events[1]).toEqual({
414+
type: "tool_call_end",
415+
id: "call_started_2",
416+
})
417+
})
418+
419+
it("should not emit events when finish_reason is not tool_calls", () => {
420+
// Set up a started tool call
421+
NativeToolCallParser.processRawChunk({
422+
index: 0,
423+
id: "call_started",
424+
name: "read_file",
425+
arguments: '{"path":"test.ts"}',
426+
})
427+
428+
// Process with different finish reason
429+
const events = NativeToolCallParser.processFinishReason("stop")
430+
431+
expect(events).toHaveLength(0)
432+
})
433+
434+
it("should handle tool call that receives name in a separate chunk", () => {
435+
// First chunk: ID only
436+
NativeToolCallParser.processRawChunk({
437+
index: 0,
438+
id: "call_delayed_name",
439+
})
440+
441+
// At this point, tool call is tracked but not started
442+
let events = NativeToolCallParser.processFinishReason("tool_calls")
443+
expect(events).toHaveLength(0)
444+
445+
// Clear state and try again with name
446+
NativeToolCallParser.clearRawChunkState()
447+
448+
// Simulate proper sequence with name
449+
NativeToolCallParser.processRawChunk({
450+
index: 0,
451+
id: "call_delayed_name",
452+
name: "read_file",
453+
})
454+
455+
events = NativeToolCallParser.processFinishReason("tool_calls")
456+
expect(events).toHaveLength(1)
457+
expect(events[0]).toEqual({
458+
type: "tool_call_end",
459+
id: "call_delayed_name",
460+
})
461+
})
462+
})
463+
})
346464
})

0 commit comments

Comments
 (0)