Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions core/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -518,6 +520,8 @@ interface ToolCallState {
toolCall: ToolCall;
status: ToolStatus;
parsedArgs: any;
/** Correlation index retained across streamed updates. */
index?: number;
processedArgs?: Record<string, any>; // Added in preprocesing step
output?: ContextItem[];
tool?: Tool;
Expand Down
1 change: 1 addition & 0 deletions core/llm/openaiTypeConverters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
113 changes: 113 additions & 0 deletions gui/src/redux/slices/sessionSlice.parallelToolCalls.test.ts
Original file line number Diff line number Diff line change
@@ -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"}');
});
});
5 changes: 5 additions & 0 deletions gui/src/redux/slices/sessionSlice.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions gui/src/util/toolCallState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ?? "";
Expand Down Expand Up @@ -66,6 +67,7 @@ export function addToolCallDeltaToState(
},
toolCallId: callId,
parsedArgs,
index: callIndex,
};
}

Expand Down
Loading