From c5d2516f1f181280eb301714d4ef4487a7893a15 Mon Sep 17 00:00:00 2001 From: canblmz1 <116688414+canblmz1@users.noreply.github.com> Date: Fri, 4 Sep 2026 19:38:19 +0300 Subject: [PATCH] fix(gui): correlate id-less tool-call fragments by provider index --- core/index.d.ts | 4 + core/llm/openaiTypeConverters.ts | 1 + .../sessionSlice.parallelToolCalls.test.ts | 113 ++++++++++++++++++ gui/src/redux/slices/sessionSlice.ts | 5 + gui/src/util/toolCallState.ts | 2 + 5 files changed, 125 insertions(+) create mode 100644 gui/src/redux/slices/sessionSlice.parallelToolCalls.test.ts diff --git a/core/index.d.ts b/core/index.d.ts index bec3e0e0ff8..33bbe51134a 100644 --- a/core/index.d.ts +++ b/core/index.d.ts @@ -364,6 +364,8 @@ export interface ToolCall { export interface ToolCallDelta { id?: string; + /** Provider-local correlation index for streamed tool-call fragments. */ + index?: number; type?: "function"; function?: { name?: string; @@ -518,6 +520,8 @@ interface ToolCallState { toolCall: ToolCall; status: ToolStatus; parsedArgs: any; + /** Correlation index retained across streamed updates. */ + index?: number; processedArgs?: Record; // Added in preprocesing step output?: ContextItem[]; tool?: Tool; diff --git a/core/llm/openaiTypeConverters.ts b/core/llm/openaiTypeConverters.ts index fb4673e11be..2e67d8fc3dd 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.index, type: "function" as const, function: { name: (tool_call as any).function?.name, diff --git a/gui/src/redux/slices/sessionSlice.parallelToolCalls.test.ts b/gui/src/redux/slices/sessionSlice.parallelToolCalls.test.ts new file mode 100644 index 00000000000..40f972c3bda --- /dev/null +++ b/gui/src/redux/slices/sessionSlice.parallelToolCalls.test.ts @@ -0,0 +1,113 @@ +import { fromChatCompletionChunk } from "core/llm/openaiTypeConverters"; +import type { ChatCompletionChunk } from "openai/resources/index"; +import { describe, expect, it } from "vitest"; +import { ChatHistoryItemWithMessageId, sessionSlice } from "./sessionSlice"; + +// Regression test: OpenAI's streamed ToolCall type requires `index` while +// `id` is optional, so two interleaved parallel tool calls whose later +// fragments omit `id` must still be correlated by `index`, not by array +// position. Drives the real, unmocked chain: raw ChatCompletionChunk -> +// fromChatCompletionChunk() -> session/streamUpdate reducer -> +// applyToolCallDelta() -> addToolCallDeltaToState() -> final ToolCallState[]. + +function makeChunk( + toolCalls: ChatCompletionChunk.Choice.Delta.ToolCall[], +): ChatCompletionChunk { + return { + id: "chatcmpl-regression-test", + created: 0, + model: "gpt-4-test", + object: "chat.completion.chunk", + choices: [ + { + index: 0, + finish_reason: null, + delta: { tool_calls: toolCalls } as ChatCompletionChunk.Choice["delta"], + } as ChatCompletionChunk.Choice, + ], + }; +} + +function createInitialState() { + return { + lastSessionId: undefined, + allSessionMetadata: [], + history: [ + { + message: { + role: "user" as const, + content: "call tool_a and tool_b in parallel", + id: "initial-user-message", + }, + contextItems: [], + }, + ] as ChatHistoryItemWithMessageId[], + isStreaming: false, + title: "Test Session", + id: "test-session-id", + streamAborter: new AbortController(), + symbols: {}, + mode: "chat" as const, + isInEdit: false, + codeBlockApplyStates: { states: [], curIndex: 0 }, + newestToolbarPreviewForInput: {}, + isSessionMetadataLoading: false, + compactionLoading: {}, + }; +} + +describe("REGRESSION: parallel OpenAI tool-call streaming identity", () => { + it("correlates id-less continuation fragments by provider tool_call.index", () => { + let state = createInitialState(); + + const dispatchChunk = (raw: ChatCompletionChunk) => { + const message = fromChatCompletionChunk(raw); + expect(message).toBeDefined(); + state = sessionSlice.reducer(state, { + type: "session/streamUpdate", + payload: [message], + }) as typeof state; + }; + + // A discovery: index 0 + id call_A + dispatchChunk( + makeChunk([ + { + index: 0, + id: "call_A", + type: "function", + function: { name: "tool_a", arguments: "" }, + }, + ]), + ); + // B discovery: index 1 + id call_B + dispatchChunk( + makeChunk([ + { + index: 1, + id: "call_B", + type: "function", + function: { name: "tool_b", arguments: "" }, + }, + ]), + ); + // A continuation: index 0, id absent + dispatchChunk( + makeChunk([{ index: 0, function: { arguments: '{"target":"A_ONLY"}' } }]), + ); + // B continuation: index 1, id absent + dispatchChunk( + makeChunk([{ index: 1, function: { arguments: '{"target":"B_ONLY"}' } }]), + ); + + const finalStates = + state.history[state.history.length - 1].toolCallStates ?? []; + expect(finalStates).toHaveLength(2); + + const callA = finalStates.find((s) => s.toolCall.id === "call_A"); + const callB = finalStates.find((s) => s.toolCall.id === "call_B"); + + expect(callA?.toolCall.function.arguments).toBe('{"target":"A_ONLY"}'); + expect(callB?.toolCall.function.arguments).toBe('{"target":"B_ONLY"}'); + }); +}); diff --git a/gui/src/redux/slices/sessionSlice.ts b/gui/src/redux/slices/sessionSlice.ts index 8784d0c41dc..42638983544 100644 --- a/gui/src/redux/slices/sessionSlice.ts +++ b/gui/src/redux/slices/sessionSlice.ts @@ -127,6 +127,11 @@ function applyToolCallDelta( existingStateIndex = toolCallStates.findIndex( (state) => state.toolCallId === toolCallDelta.id, ); + } else if (typeof toolCallDelta.index === "number") { + // No ID, but the delta carries the provider's correlation index. + existingStateIndex = toolCallStates.findIndex( + (state) => state.index === toolCallDelta.index, + ); } else { // No ID in delta (common in OpenAI streaming fragments) // Strategy: Update the most recently added tool call that's still being generated diff --git a/gui/src/util/toolCallState.ts b/gui/src/util/toolCallState.ts index c7b9bcbd7df..8c1000c5dfd 100644 --- a/gui/src/util/toolCallState.ts +++ b/gui/src/util/toolCallState.ts @@ -23,6 +23,7 @@ export function addToolCallDeltaToState( // These will/should not be partially streamed const callType = toolCallDelta.type ?? "function"; const callId = currentCall?.id || toolCallDelta.id || ""; + const callIndex = currentState?.index ?? toolCallDelta.index; // These may be streamed in chunks const currentName = currentCall?.function.name ?? ""; @@ -66,6 +67,7 @@ export function addToolCallDeltaToState( }, toolCallId: callId, parsedArgs, + index: callIndex, }; }