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
1 change: 1 addition & 0 deletions core/config/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -306,6 +306,7 @@ declare global {
export interface ToolCallDelta {
id?: string;
index?: number;
type?: "function";
function?: {
name?: string;
Expand Down
1 change: 1 addition & 0 deletions core/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -364,6 +364,7 @@ export interface ToolCall {

export interface ToolCallDelta {
id?: string;
index?: number;
type?: "function";
function?: {
name?: string;
Expand Down
28 changes: 27 additions & 1 deletion core/llm/openaiTypeConverters.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import { toResponsesInput, isItemType } from "./openaiTypeConverters";
import {
toResponsesInput,
isItemType,
fromChatCompletionChunk,
} from "./openaiTypeConverters";
import { ChatMessage } from "..";
import type {
EasyInputMessage,
Expand Down Expand Up @@ -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"}');
});
});
});
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 as any).index,
type: "function" as const,
function: {
name: (tool_call as any).function?.name,
Expand Down
9 changes: 8 additions & 1 deletion gui/src/redux/slices/sessionSlice.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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
Expand Down
Loading