Skip to content
Merged
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
16 changes: 5 additions & 11 deletions src/chat/inbound.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>): 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<string, string>();
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;
Expand Down Expand Up @@ -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<string, string>();

for (const msg of raw.messages) {
if (!isRec(msg)) continue;
Expand All @@ -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": {
Expand Down
58 changes: 51 additions & 7 deletions tests/chat-completions-endpoint.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
Expand All @@ -2158,17 +2154,65 @@ 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"}' } },
],
},
],
});
const calls = (body.input as Array<Record<string, unknown>>).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<Record<string, unknown>> = [{ 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<unknown, unknown>,
key: unknown,
value: unknown,
) => Map<unknown, unknown>;
let matchingSetCalls = 0;
let body: Record<string, unknown> | null = null;
const countingSet: typeof Map.prototype.set = function <K, V>(
this: Map<K, V>,
key: K,
value: V,
): Map<K, V> {
if (typeof key === "string" && key.startsWith("linear_call_") && value === "exec_command") {
matchingSetCalls += 1;
}
return Reflect.apply(nativeSet, this, [key, value]) as Map<K, V>;
};

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):
Expand Down
Loading