This commit is contained in:
roomote-v0[bot] 2026-04-23 10:30:04 +00:00 committed by GitHub
commit b6e145098c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
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",
})
})
})
})
})