diff --git a/src/chat/inbound.ts b/src/chat/inbound.ts index c72efb4715..1012354b2d 100644 --- a/src/chat/inbound.ts +++ b/src/chat/inbound.ts @@ -99,17 +99,8 @@ function pushSystemText(parts: string[], content: unknown): void { if (text) parts.push(text); } -function toolCallsToItems(toolCalls: unknown, input: Rec[]): void { +function toolCallsToItems(toolCalls: unknown, input: Rec[], knownNameByCallId: Map): void { if (!Array.isArray(toolCalls)) return; - // Recover names from earlier function_call items in the same transcript when a client - // re-sends tool_calls with only id/arguments (replace-style merge lost function.name). - const knownNameByCallId = new Map(); - for (const item of input) { - if (!isRec(item) || item.type !== "function_call") continue; - if (typeof item.call_id === "string" && typeof item.name === "string" && item.name.length > 0) { - knownNameByCallId.set(item.call_id, item.name); - } - } for (const raw of toolCalls) { if (!isRec(raw)) continue; const fn = isRec(raw.function) ? raw.function : null; @@ -243,6 +234,9 @@ export function chatCompletionsToResponsesBody(raw: unknown): Rec { const systemParts: string[] = []; const input: Rec[] = []; + // Recover replace-style tool calls incrementally instead of rebuilding the + // call-id index from the entire translated transcript for every message. + const knownNameByCallId = new Map(); for (const msg of raw.messages) { if (!isRec(msg)) continue; @@ -260,7 +254,7 @@ export function chatCompletionsToResponsesBody(raw: unknown): Rec { case "assistant": { const blocks = assistantContentToBlocks(msg.content); if (blocks.length > 0) input.push({ type: "message", role: "assistant", content: blocks }); - if (msg.tool_calls !== undefined) toolCallsToItems(msg.tool_calls, input); + if (msg.tool_calls !== undefined) toolCallsToItems(msg.tool_calls, input, knownNameByCallId); break; } case "tool": { diff --git a/tests/chat-completions-endpoint.test.ts b/tests/chat-completions-endpoint.test.ts index ac4112632a..24d0a56bc6 100644 --- a/tests/chat-completions-endpoint.test.ts +++ b/tests/chat-completions-endpoint.test.ts @@ -2141,10 +2141,6 @@ test("responsesSseToChatCompletionsSse uses finalized arguments when the item do }); test("chatCompletionsToResponsesBody recovers tool_calls function.name from earlier call_id", () => { - // Simulate replace-style client history: a later assistant tool_call has id+args but empty name, - // while an earlier function_call in the same transcript already named it. - // Our translator processes messages in order; recovery looks at previously emitted function_calls. - // First push a prior named call via a previous assistant message, then a nameless replay. const body = chatCompletionsToResponsesBody({ model: "gpt-test", messages: [ @@ -2158,9 +2154,10 @@ test("chatCompletionsToResponsesBody recovers tool_calls function.name from earl { role: "assistant", content: null, - // Client lost the name on a re-serialized tool_call with same id (should still recover if same turn - // already registered the name earlier in the same tool_calls array / prior items). tool_calls: [ + // The client lost call_a's name while re-serializing an earlier message. + { id: "call_a", type: "function", function: { arguments: '{"cmd":"ls"}' } }, + // Same-array recovery remains supported as well. { id: "call_b", type: "function", function: { name: "exec_command", arguments: '{"cmd":"pwd"}' } }, { id: "call_b", type: "function", function: { arguments: '{"cmd":"pwd"}' } }, ], @@ -2168,7 +2165,54 @@ test("chatCompletionsToResponsesBody recovers tool_calls function.name from earl ], }); const calls = (body.input as Array>).filter(i => i.type === "function_call"); - expect(calls.some(c => c.call_id === "call_b" && c.name === "exec_command")).toBe(true); + expect(calls.filter(c => c.call_id === "call_a").map(c => c.name)).toEqual(["exec_command", "exec_command"]); + expect(calls.filter(c => c.call_id === "call_b").map(c => c.name)).toEqual(["exec_command", "exec_command"]); +}); + +test("chatCompletionsToResponsesBody indexes tool-call names once per call", () => { + const count = 1_000; + const messages: Array> = [{ role: "user", content: "start" }]; + for (let i = 0; i < count; i++) { + messages.push({ + role: "assistant", + content: null, + tool_calls: [{ + id: `linear_call_${i}`, + type: "function", + function: { name: "exec_command", arguments: "{}" }, + }], + }); + } + + const descriptor = Object.getOwnPropertyDescriptor(Map.prototype, "set"); + if (!descriptor || typeof descriptor.value !== "function") throw new Error("Map.prototype.set is unavailable"); + const nativeSet = descriptor.value as ( + this: Map, + key: unknown, + value: unknown, + ) => Map; + let matchingSetCalls = 0; + let body: Record | null = null; + const countingSet: typeof Map.prototype.set = function ( + this: Map, + key: K, + value: V, + ): Map { + if (typeof key === "string" && key.startsWith("linear_call_") && value === "exec_command") { + matchingSetCalls += 1; + } + return Reflect.apply(nativeSet, this, [key, value]) as Map; + }; + + Object.defineProperty(Map.prototype, "set", { ...descriptor, value: countingSet }); + try { + body = chatCompletionsToResponsesBody({ model: "gpt-test", messages }); + } finally { + Object.defineProperty(Map.prototype, "set", descriptor); + } + + expect((body!.input as unknown[]).length).toBe(count + 1); + expect(matchingSetCalls).toBe(count); }); // Local-stack fixup regressions (Sol audit of #279, devlog 100_merge_records.md WP5):