diff --git a/core/config/types.ts b/core/config/types.ts index 2500042e887..c696421e10d 100644 --- a/core/config/types.ts +++ b/core/config/types.ts @@ -306,6 +306,7 @@ declare global { export interface ToolCallDelta { id?: string; + index?: number; type?: "function"; function?: { name?: string; diff --git a/core/index.d.ts b/core/index.d.ts index bec3e0e0ff8..375741f6341 100644 --- a/core/index.d.ts +++ b/core/index.d.ts @@ -364,6 +364,7 @@ export interface ToolCall { export interface ToolCallDelta { id?: string; + index?: number; type?: "function"; function?: { name?: string; diff --git a/core/llm/openaiTypeConverters.test.ts b/core/llm/openaiTypeConverters.test.ts index f597f002262..c3a4ae430d5 100644 --- a/core/llm/openaiTypeConverters.test.ts +++ b/core/llm/openaiTypeConverters.test.ts @@ -1,4 +1,8 @@ -import { toResponsesInput, isItemType } from "./openaiTypeConverters"; +import { + toResponsesInput, + isItemType, + fromChatCompletionChunk, +} from "./openaiTypeConverters"; import { ChatMessage } from ".."; import type { EasyInputMessage, @@ -847,4 +851,26 @@ describe("openaiTypeConverters", () => { }); }); }); + + describe("fromChatCompletionChunk", () => { + it("should preserve tool_call.index so id-less fragments correlate (issue #13223)", () => { + const chunk: any = { + choices: [ + { + delta: { + tool_calls: [ + { + index: 1, + function: { name: "tool_b", arguments: '{"target":"B"}' }, + }, + ], + }, + }, + ], + }; + const msg = fromChatCompletionChunk(chunk) as any; + expect(msg?.toolCalls?.[0]?.index).toBe(1); + expect(msg?.toolCalls?.[0]?.function?.arguments).toBe('{"target":"B"}'); + }); + }); }); diff --git a/core/llm/openaiTypeConverters.ts b/core/llm/openaiTypeConverters.ts index fb4673e11be..8adb6e1c0ba 100644 --- a/core/llm/openaiTypeConverters.ts +++ b/core/llm/openaiTypeConverters.ts @@ -367,6 +367,7 @@ export function fromChatCompletionChunk( .filter((tool_call) => !tool_call.type || tool_call.type === "function") .map((tool_call) => ({ id: tool_call.id, + index: (tool_call as any).index, type: "function" as const, function: { name: (tool_call as any).function?.name, diff --git a/gui/src/redux/slices/sessionSlice.ts b/gui/src/redux/slices/sessionSlice.ts index 8784d0c41dc..1585831bd1f 100644 --- a/gui/src/redux/slices/sessionSlice.ts +++ b/gui/src/redux/slices/sessionSlice.ts @@ -113,7 +113,7 @@ export function handleToolCallsInMessage( * @param toolCallDelta - The incoming tool call delta from the LLM stream * @param toolCallStates - Array of existing tool call states (modified in place) */ -function applyToolCallDelta( +export function applyToolCallDelta( toolCallDelta: ToolCallDelta, toolCallStates: ToolCallState[], ): void { @@ -127,6 +127,13 @@ function applyToolCallDelta( existingStateIndex = toolCallStates.findIndex( (state) => state.toolCallId === toolCallDelta.id, ); + } else if (toolCallDelta.index !== undefined) { + // No ID but a stream index is present (OpenAI parallel tool calls): + // initial fragments carry both, continuation fragments carry index only. + // Match by the position in the toolCalls array so one call's args + // are never applied to another call. + existingStateIndex = + toolCallDelta.index < toolCallStates.length ? toolCallDelta.index : -1; } else { // No ID in delta (common in OpenAI streaming fragments) // Strategy: Update the most recently added tool call that's still being generated