From 987d0dfed23470de74e4c6088760d98bf29690d8 Mon Sep 17 00:00:00 2001 From: Jingtao Wang <7776499+jt-wang@users.noreply.github.com> Date: Mon, 31 Aug 2026 09:16:51 +0900 Subject: [PATCH] Fix tool history hygiene on current main Integrate jt-wang's PR #36 (629debd2ea069f2964f2a418e3b13d88242ac006). The original patch predates current gateway/native relay changes. Preserve its reused-call identity and Chat-to-Responses conversion work and tests, adapting them into the current shared pairing boundary. Flatten mixed dialects before identity, pairing and deduplication. Keep completed invocations distinct, coalesce identical pending copies, avoid alias collisions, and retain structured output and visible reasoning. Reject genuinely ambiguous pending calls instead of guessing their results. Test all routed normalizers and the Chat bridge from one history fixture. Replay a sanitized 1,659-item session plus eight mixed tool rounds through the built bundle, including Chat/Responses switching and compaction. Full Windows suite: 1141 passed, 7 skipped, 0 failed (1148 total). Live Go and Command Code probes were blocked by account quota limits; no successful live-provider inference is claimed. Original-PR: https://github.com/architectds/modeldock/pull/36 Original-Commit: 629debd2ea069f2964f2a418e3b13d88242ac006 --- src/gateway.mjs | 151 ++++++++++++++------ src/local-chat-bridge.mjs | 2 +- test/codex-wire-full-opencode-chat.test.mjs | 91 +++++++++++- test/gateway.test.mjs | 87 +++++++++-- test/tool-history.test.mjs | 112 +++++++++++++++ 5 files changed, 384 insertions(+), 59 deletions(-) create mode 100644 test/tool-history.test.mjs diff --git a/src/gateway.mjs b/src/gateway.mjs index c409021..0d64836 100644 --- a/src/gateway.mjs +++ b/src/gateway.mjs @@ -4,6 +4,7 @@ import { mkdirSync, writeFileSync } from "node:fs"; import { homedir } from "node:os"; import path from "node:path"; import { createHash, randomUUID } from "node:crypto"; +import { isDeepStrictEqual } from "node:util"; import { addressedProviderOf, allProfiles, PROVIDER_SEPARATOR, bareModelId, modelEntryFor, profileById, providerForModel, upstreamTargetFor } from "./profiles.mjs"; import { compressConversation } from "./compress.mjs"; import { normalizeOllamaBase } from "./ollama.mjs"; @@ -15,7 +16,7 @@ import { stateDir } from "./state-dir.mjs"; import { customEndpointFor } from "./custom-endpoint-routing.mjs"; import { historicalImageSpawnHint, hasOpaqueCollaboration, isOpaqueEncryptedContent, promoteCollaborationNewTask } from "./subagent-guidance.mjs"; import { createUsageTee, forEachSseEvent, parseSseData } from "./sse.mjs"; -import { chatCompletionToResponse, normalizeLlamaServerTimings, pipeChatCompletionStream, responsesToChat } from "./local-chat-bridge.mjs"; +import { chatCompletionToResponse, chatReasoningText, normalizeLlamaServerTimings, pipeChatCompletionStream, responsesToChat } from "./local-chat-bridge.mjs"; import { MIN_IMAGE_TRANSPORT_WIRE_BYTES } from "./image-transport.mjs"; import { NATIVE_CODEX_BASE } from "./native-endpoint.mjs"; import { NATIVE_PROVIDER_ID } from "./native-provider.mjs"; @@ -609,6 +610,103 @@ function isToolOutputItem(item) { return item?.type === "function_call_output" || item?.type === "custom_tool_call_output"; } +function chatToolCallId(call) { + if (!call || typeof call !== "object") return undefined; + const id = call.id ?? call.call_id; + return typeof id === "string" && id ? id : undefined; +} + +// Based on jt-wang's PR #36: reuse after a completed pair is a new invocation, +// not a duplicate stream item. Chat rows are flattened before this pass, so +// there is only one pairing dialect. A duplicate pending call gets no second +// queue entry, and first-seen ids win to keep earlier request prefixes stable. +export function uniquifyReusedToolCallIds(input) { + if (!Array.isArray(input)) return input; + const used = new Set(); + const itemIds = new Set(); + const counts = new Map(); + const byOriginal = new Map(); + let changed = false; + const out = input.map((item) => { + const original = item?.call_id; + if ((!isToolCallItem(item) && !isToolOutputItem(item)) || typeof original !== "string" || !original) return item; + const repeatedItemId = item.id && itemIds.has(item.id); + if (item.id) itemIds.add(item.id); + let entry = byOriginal.get(original); + if (isToolCallItem(item)) { + // Chat and Responses copies may carry object versus serialized arguments. + // Compare their meaning without changing the original wire payload. + let args = item.arguments; + if (typeof args === "string") { + try { args = JSON.parse(args); } catch { /* Keep freeform arguments exact. */ } + } + const signature = [item.type, item.namespace, item.name, args, item.input]; + if (entry && !entry.closed) { + if (!isDeepStrictEqual(entry.signature, signature)) throw new Error("Ambiguous tool history: different pending calls share call_id " + original + "."); + } else { + let alias = original; + let n = counts.get(original) || 1; + while (used.has(alias)) alias = original + "__" + (++n); + counts.set(original, n); + used.add(alias); + entry = { alias, signature, closed: false }; + byOriginal.set(original, entry); + } + } else if (entry) { + entry.closed = true; + } + // A leading orphan output is left for the existing pairing pass, not + // interpreted as a completed invocation that never appeared in the input. + if (!entry || entry.alias === original) return item; + changed = true; + const next = { ...item, call_id: entry.alias }; + if (repeatedItemId) { + next.id = nativeResponsesItemId({ ...next, id: "reused:" + item.id + ":" + entry.alias }, 0); + } + return next; + }); + return changed ? out : input; +} + +// PR #36's Chat-to-Responses boundary, without a second output lookup/pairing +// map. Keep results in place and preserve structured content; only the common +// pairing pass below decides which result belongs to which invocation. +export function flattenChatToolCallsToResponses(input) { + if (!Array.isArray(input)) return input; + let changed = false; + const out = []; + for (const item of input) { + if (item?.type === "message" && item?.role === "tool") { + changed = true; + const { type, role, tool_call_id, content, name, ...rest } = item; + out.push({ ...rest, type: "function_call_output", call_id: tool_call_id, output: item.output ?? content ?? "" }); + continue; + } + if (item?.type === "message" && item?.role === "assistant" && Array.isArray(item.tool_calls) && item.tool_calls.length) { + changed = true; + const { tool_calls, reasoning_content, reasoning, reasoning_text, ...assistant } = item; + const thought = chatReasoningText(item); + if (thought) out.push({ type: "reasoning", summary: [], content: [{ type: "reasoning_text", text: thought }] }); + const hasContent = Array.isArray(item.content) ? item.content.length > 0 : typeof item.content === "string" && item.content.trim() !== ""; + if (hasContent) out.push(assistant); + for (const call of tool_calls) { + const id = chatToolCallId(call); + if (!id) continue; + out.push({ + type: "function_call", + call_id: id, + name: call?.function?.name ?? call?.name, + arguments: call?.function?.arguments ?? call?.arguments ?? "{}", + ...(call.namespace ? { namespace: call.namespace } : {}), + }); + } + continue; + } + out.push(item); + } + return changed ? out : input; +} + // Go (Console Go) validates tool pairing strictly and rejects the whole request // when a tool call has no matching output ("No tool output found for tool call // ..."). Codex genuinely produces such orphans - a remote compact task slices @@ -616,56 +714,23 @@ function isToolOutputItem(item) { // emits are paired here: the Responses shape (top-level function_call / // custom_tool_call items with function_call_output / custom_tool_call_output) // and the chat shape (an assistant message carrying a `tool_calls` array whose -// results are role:"tool" messages with tool_call_id). The unpaired side is -// dropped in both directions so the turn survives; paired history is untouched. +// results are role:"tool" messages with tool_call_id). Normalize the dialect +// and reused identities once, then drop only the unpaired side. Valid Responses +// pairs are unchanged; mixed history is represented as canonical Responses pairs. export function dropUnpairedToolItems(input) { if (!Array.isArray(input)) return input; + input = uniquifyReusedToolCallIds(flattenChatToolCallsToResponses(input)); const callIds = new Set(); const outputIds = new Set(); for (const item of input) { if (isToolCallItem(item)) callIds.add(item.call_id); if (isToolOutputItem(item)) outputIds.add(item.call_id); - if (item?.type === "message" && item?.role === "assistant" && Array.isArray(item.tool_calls)) { - for (const call of item.tool_calls) { - const id = typeof call === "object" && call !== null ? (call.id ?? call.call_id) : undefined; - if (typeof id === "string" && id) callIds.add(id); - } - } - if (item?.type === "message" && item?.role === "tool" && typeof item.tool_call_id === "string" && item.tool_call_id) { - outputIds.add(item.tool_call_id); - } } - const paired = input - .map((item) => { - if (isToolCallItem(item)) { - return outputIds.has(item.call_id) ? item : null; - } - if (isToolOutputItem(item)) { - return callIds.has(item.call_id) ? item : null; - } - if (item?.type === "message" && item?.role === "tool") { - return callIds.has(item.tool_call_id) ? item : null; - } - if (item?.type === "message" && item?.role === "assistant" && Array.isArray(item.tool_calls)) { - const kept = item.tool_calls.filter((call) => { - const id = typeof call === "object" && call !== null ? (call.id ?? call.call_id) : undefined; - return outputIds.has(id); - }); - if (kept.length === item.tool_calls.length) return item; - // A message whose calls all got severed and that carries no other text - // would reach the upstream as an empty assistant turn, which strict - // upstreams reject ("content or tool_calls must be set"). Drop it. - const hasContent = Array.isArray(item.content) - ? item.content.length > 0 - : typeof item.content === "string" && item.content.trim() !== ""; - if (kept.length === 0 && !hasContent) return null; - const next = { ...item, tool_calls: kept }; - if (kept.length === 0) delete next.tool_calls; - return next; - } - return item; - }) - .filter((item) => item !== null); + const paired = input.filter((item) => { + if (isToolCallItem(item)) return outputIds.has(item.call_id); + if (isToolOutputItem(item)) return callIds.has(item.call_id); + return true; + }); return relocateToolOutputs(paired); } diff --git a/src/local-chat-bridge.mjs b/src/local-chat-bridge.mjs index fc452c8..f894d08 100644 --- a/src/local-chat-bridge.mjs +++ b/src/local-chat-bridge.mjs @@ -287,7 +287,7 @@ export function normalizeLlamaServerTimings(value) { }); } -function chatReasoningText(message) { +export function chatReasoningText(message) { if (!message || typeof message !== "object") return ""; for (const field of ["reasoning_content", "reasoning", "reasoning_text"]) { if (typeof message[field] === "string" && message[field]) return message[field]; diff --git a/test/codex-wire-full-opencode-chat.test.mjs b/test/codex-wire-full-opencode-chat.test.mjs index 4641f2d..f42d25f 100644 --- a/test/codex-wire-full-opencode-chat.test.mjs +++ b/test/codex-wire-full-opencode-chat.test.mjs @@ -20,6 +20,7 @@ import { gunzipSync } from "node:zlib"; const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); const bundle = path.join(repoRoot, "dist", "modeldock.mjs"); const fixture = JSON.parse(gunzipSync(readFileSync(new URL("./fixtures/codex-xai-full-2026-08-21.json.gz", import.meta.url))).toString("utf8")); +const longFixture = JSON.parse(gunzipSync(readFileSync(new URL("./fixtures/voxel-commandcode-native-compact-2026-09-02.json.gz", import.meta.url))).toString("utf8")); function listen(server) { return new Promise((resolve, reject) => { @@ -112,6 +113,24 @@ function textStream(text) { }]); } +function assertChatPairs(messages) { + const used = new Set(); + const pending = new Set(); + for (const message of messages) { + if (message.role === "tool") { + assert.ok(pending.delete(message.tool_call_id), "result must belong to the immediately preceding call group"); + } else { + assert.equal(pending.size, 0, "all parallel results must arrive before another message"); + for (const call of message.tool_calls || []) { + assert.ok(!used.has(call.id), "each invocation must have a distinct id"); + used.add(call.id); + pending.add(call.id); + } + } + } + assert.equal(pending.size, 0, "history must not end in an orphan call"); +} + test("built bundle bridges the complete original Codex package to strict OpenCode Chat", async (t) => { assert.equal(fixture.capture.kind, "full_original_codex_request"); assert.equal(fixture.capture.originalToolCount, 164); @@ -124,6 +143,24 @@ test("built bundle bridges the complete original Codex package to strict OpenCod for await (const chunk of req) chunks.push(chunk); const body = JSON.parse(Buffer.concat(chunks).toString("utf8")); requests.push(body); + if (req.url === "/v1/responses") { + try { + assert.equal(body.model, "deepseek-v4-flash"); + const calls = body.input.filter((item) => ["function_call", "custom_tool_call"].includes(item.type)); + const results = body.input.filter((item) => ["function_call_output", "custom_tool_call_output"].includes(item.type)); + assert.equal(new Set(calls.map((item) => item.call_id)).size, calls.length); + assert.deepEqual(results.map((item) => item.call_id), calls.map((item) => item.call_id)); + assert.ok(body.input.every((item) => !item.tool_calls && item.role !== "tool")); + res.writeHead(200, { "content-type": "text/event-stream" }); + res.end(sse([{ type: "response.completed", response: { id: "resp_history", status: "completed", output: [ + { type: "message", role: "assistant", content: [{ type: "output_text", text: "RESPONSES_HISTORY_OK" }] }, + ] } }])); + } catch (error) { + res.writeHead(422, { "content-type": "application/json" }); + res.end(JSON.stringify({ error: error.message })); + } + return; + } if (req.url !== "/v1/chat/completions") { res.writeHead(404, { "content-type": "application/json" }); res.end(JSON.stringify({ error: "expected Chat Completions endpoint" })); @@ -144,6 +181,13 @@ test("built bundle bridges the complete original Codex package to strict OpenCod res.end(JSON.stringify({ error: "wrong Go Chat model" })); return; } + try { + assertChatPairs(body.messages); + } catch (error) { + res.writeHead(422, { "content-type": "application/json" }); + res.end(JSON.stringify({ error: error.message })); + return; + } if (body.stream === false) { if (body.tools?.length || body.tool_choice !== "none" || !body.messages.at(-1)?.content.includes("CONTEXT CHECKPOINT COMPACTION")) { res.writeHead(400, { "content-type": "application/json" }); @@ -223,11 +267,11 @@ test("built bundle bridges the complete original Codex package to strict OpenCod } }); await waitForStatus(gatewayPort); - const send = async (input, sessionId, stream = true) => { + const send = async (input, sessionId, stream = true, model = "qwen3.8-flash@opencode-go") => { const response = await fetch(`http://127.0.0.1:${gatewayPort}/v1/responses`, { method: "POST", headers: { "content-type": "application/json", "x-codex-session-id": sessionId }, - body: JSON.stringify({ ...fixture.request, model: "qwen3.8-flash@opencode-go", stream, input }), + body: JSON.stringify({ ...fixture.request, model, stream, input }), }); const text = await response.text(); const rejectedTools = requests.at(-1)?.tools || []; @@ -267,7 +311,40 @@ test("built bundle bridges the complete original Codex package to strict OpenCod ], "full-go-chat-fixture"); assert.match(third, /GO_TOOL_LOOP_COMPLETE/); assert.equal(requests[2].messages.find((message) => message.tool_calls?.some((call) => call.id === "call_go_c"))?.reasoning_content, "The first tools completed; continue the same task."); - const compact = await send([...fixture.request.input, + // Reuse the captured long-session history and complete tool table, not a + // shortened hand-authored envelope. The added rounds exercise #36's mixed + // dialect and reused-id transitions before and after compaction. + const history = [...longFixture.input.filter((item) => item.type !== "compaction_trigger"), + ...firstTurn, ...secondOutput, { type: "function_call_output", call_id: "call_go_c", output: "done" }]; + const markers = []; + for (let round = 1; round <= 8; round += 1) { + const id = round === 3 ? "reuse__2" : "reuse"; + const call = { type: "function_call", call_id: id, name: "exec_command", arguments: JSON.stringify({ cmd: `echo round_${round}` }) }; + const chat = { type: "message", role: "assistant", content: null, tool_calls: [ + { id, type: "function", function: { name: call.name, arguments: call.arguments } }, + ] }; + const marker = `ROUND_${round}_RESULT`; + markers.push(marker); + history.push({ type: "message", role: "user", content: `Run round ${round}.` }, round % 2 ? chat : call); + if (round % 2) history.push(call); // Same pending invocation in both dialects. + history.push(round % 2 + ? { type: "function_call_output", call_id: id, output: marker } + : { type: "message", role: "tool", tool_call_id: id, content: marker }); + history.push( + { type: "custom_tool_call", call_id: "patch_reused", name: "apply_patch", input: `*** Begin Patch\n*** Add File: round${round}.txt\n+fixture\n*** End Patch` }, + { type: "custom_tool_call_output", call_id: "patch_reused", output: `PATCH_${round}_RESULT` }, + ); + await send(history, "full-go-chat-fixture"); + const chatResults = requests.at(-1).messages.filter((item) => item.role === "tool").map((item) => item.content); + assert.deepEqual(chatResults.filter((text) => /^ROUND_\d+_RESULT$/.test(text)), markers); + assert.equal(chatResults.filter((text) => /^PATCH_\d+_RESULT$/.test(text)).length, round); + } + const switched = await send(history, "full-go-chat-fixture", true, "deepseek-v4-flash@opencode-go"); + assert.match(switched, /RESPONSES_HISTORY_OK/); + const responseResults = requests.at(-1).input.filter((item) => item.type === "function_call_output").map((item) => item.output); + assert.deepEqual(responseResults.filter((text) => /^ROUND_\d+_RESULT$/.test(text)), markers); + + const compact = await send([...history, { type: "message", role: "user", content: [{ type: "input_text", text: "Summarize the completed work." }] }, { type: "compaction_trigger" }, ], "full-go-chat-fixture", false); @@ -280,4 +357,12 @@ test("built bundle bridges the complete original Codex package to strict OpenCod { type: "message", role: "user", content: [{ type: "input_text", text: "Resume after compaction." }] }, ], "full-go-chat-fixture"); assert.match(resumed, /GO_COMPACTION_RESUMED/); + // The first post-compaction tool invocation may reuse a pre-compaction id. + await send([ + ...fixture.request.input, compacted.output[0], + { type: "function_call", call_id: "reuse", name: "exec_command", arguments: '{"cmd":"echo after_compact"}' }, + { type: "function_call_output", call_id: "reuse", output: "AFTER_COMPACT_RESULT" }, + ], "full-go-chat-fixture"); + const afterResults = requests.at(-1).messages.filter((item) => item.role === "tool").map((item) => item.content); + assert.deepEqual(afterResults, ["AFTER_COMPACT_RESULT"], "pre-compaction pairing state must not leak into the next history"); }); diff --git a/test/gateway.test.mjs b/test/gateway.test.mjs index b171405..7822c5f 100644 --- a/test/gateway.test.mjs +++ b/test/gateway.test.mjs @@ -23,6 +23,7 @@ import { decodeCompactionSummary, describeInputShape, dropUnpairedToolItems, + flattenChatToolCallsToResponses, encodeCompactionSummary, freeResponseFailure, hiddenToolNamesForModel, @@ -50,6 +51,7 @@ import { redactBearer, relayCompaction, relayNativeAuxiliary, + uniquifyReusedToolCallIds, relayNativeResponses, relayOpaqueCollaboration, relayResponses, @@ -254,18 +256,74 @@ test("normalizeGatewayInput keeps paired tool history untouched", () => { assert.deepEqual(normalized, input); }); -test("normalizeGatewayInput keeps one complete pair for a repeated call id", () => { +test("uniquifyReusedToolCallIds keeps later turns when Codex reuses call_ids", () => { + const input = [ + { type: "function_call", call_id: "exec_command_0", name: "exec_command", arguments: "{}" }, + { type: "function_call_output", call_id: "exec_command_0", output: "first" }, + { type: "function_call", call_id: "exec_command_0", name: "exec_command", arguments: "{}" }, + { type: "function_call_output", call_id: "exec_command_0", output: "second" }, + { type: "function_call", call_id: "exec_command_0", name: "exec_command", arguments: "{}" }, + { type: "function_call_output", call_id: "exec_command_0", output: "third" }, + ]; + const unique = uniquifyReusedToolCallIds(input); + assert.deepEqual(unique.map((item) => item.call_id), [ + "exec_command_0", + "exec_command_0", + "exec_command_0__2", + "exec_command_0__2", + "exec_command_0__3", + "exec_command_0__3", + ]); + const normalized = normalizeGatewayInput(input); + assert.equal(normalized.filter((item) => item.type === "function_call").length, 3); + assert.equal(normalized.filter((item) => item.type === "function_call_output").length, 3); +}); + +test("flattenChatToolCallsToResponses converts chat tool turns into Responses pairs", () => { + const input = [ + { type: "message", role: "user", content: [{ type: "input_text", text: "run" }] }, + { + type: "message", + role: "assistant", + content: [{ type: "output_text", text: "executing" }], + tool_calls: [ + { id: "exec_command:4", type: "function", function: { name: "exec_command", arguments: "{}" } }, + { id: "exec_command:5", type: "function", function: { name: "exec_command", arguments: "{}" } }, + ], + }, + { type: "function_call_output", call_id: "exec_command:4", output: "out4" }, + { type: "function_call_output", call_id: "exec_command:5", output: "out5" }, + { type: "message", role: "user", content: [{ type: "input_text", text: "continue" }] }, + ]; + const out = flattenChatToolCallsToResponses(input); + assert.deepEqual(out.map((item) => item.call_id ?? item.role ?? item.type), [ + "user", + "assistant", + "exec_command:4", + "exec_command:5", + "exec_command:4", + "exec_command:5", + "user", + ]); + assert.equal(out[1].tool_calls, undefined); +}); + +test("normalizeGatewayInput uniquifies repeated call ids after an earlier pair closes", () => { const input = [ { type: "message", role: "user", content: [{ type: "input_text", text: "continue" }] }, { type: "function_call", id: "fc_first", call_id: "call_duplicate", name: "shell_command", arguments: "{\"command\":\"dir\"}" }, - { type: "function_call", id: "fc_repeated", call_id: "call_duplicate", name: "shell_command", arguments: "{\"command\":\"dir\"}" }, { type: "function_call_output", id: "fco_first", call_id: "call_duplicate", output: "first result" }, + { type: "function_call", id: "fc_repeated", call_id: "call_duplicate", name: "shell_command", arguments: "{\"command\":\"dir\"}" }, { type: "function_call_output", id: "fco_repeated", call_id: "call_duplicate", output: "repeated result" }, ]; const normalized = normalizeGatewayInput(input); assert.deepEqual( - normalized.filter((item) => item.call_id === "call_duplicate").map((item) => [item.type, item.id]), - [["function_call", "fc_first"], ["function_call_output", "fco_first"]], + normalized.filter((item) => item.type === "function_call").map((item) => [item.call_id, item.id]), + [["call_duplicate", "fc_first"], ["call_duplicate__2", "fc_repeated"]], + ); + assert.deepEqual( + normalized.filter((item) => item.type === "function_call_output").map((item) => item.call_id), + ["call_duplicate", "call_duplicate__2"], ); }); @@ -539,7 +597,7 @@ test("normalizeOpenCodeProInput drops empty assistant placeholders before custom assert.equal(normalized[3].role, "user", "the original user continuation stays in place"); }); -test("normalizeOpenCodeProInput keeps empty chat assistants that carry tool calls", () => { +test("normalizeOpenCodeProInput preserves chat tool pairs without an empty assistant placeholder", () => { const input = [ { type: "message", @@ -550,9 +608,12 @@ test("normalizeOpenCodeProInput keeps empty chat assistants that carry tool call { type: "message", role: "tool", tool_call_id: "call_1", content: "ok" }, ]; const normalized = normalizeOpenCodeProInput(input); - assert.equal(normalized.length, 2); - assert.equal(normalized[0].content, ""); - assert.equal(normalized[0].tool_calls[0].id, "call_1"); + assert.equal(normalized[0].type, "function_call"); + assert.equal(normalized[0].call_id, "call_1"); + assert.equal(normalized[1].type, "function_call_output"); + assert.equal(normalized[1].call_id, "call_1"); + assert.equal(normalized[1].output, "ok"); + assert.equal(normalized.some((item) => item.role === "assistant"), false); }); test("normalizeOpenCodeProInput interleaves parallel calls with their outputs", () => { @@ -681,10 +742,12 @@ test("dropUnpairedToolItems pairs the chat shape (message.tool_calls + role:tool { type: "message", role: "user", content: [{ type: "input_text", text: "go on" }] }, ]; const out = dropUnpairedToolItems(input); - assert.equal(out.length, 3, "dangling tool message is dropped"); - assert.deepEqual(out[0].tool_calls.map((call) => call.id), ["call_00_paired"], "orphaned chat call is trimmed from the assistant message"); - assert.equal(out[1].tool_call_id, "call_00_paired"); - assert.equal(out[2].type, "message"); + assert.equal(out.length, 4, "dangling tool message is dropped, readable assistant text is retained"); + assert.equal(out[0].content[0].text, "let me check"); + assert.equal(out[1].call_id, "call_00_paired"); + assert.equal(out[2].call_id, "call_00_paired"); + assert.equal(out[2].type, "function_call_output"); + assert.equal(out[3].role, "user"); }); test("dropUnpairedToolItems drops an assistant message whose chat calls all lack results", () => { diff --git a/test/tool-history.test.mjs b/test/tool-history.test.mjs new file mode 100644 index 0000000..0a20e20 --- /dev/null +++ b/test/tool-history.test.mjs @@ -0,0 +1,112 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { + normalizeGatewayInput, normalizeOpenCodeFlashInput, normalizeOpenCodeProInput, + normalizeXaiInput, normalizeOllamaInput, normalizeNativeInput, +} from "../src/gateway.mjs"; +import { responsesToChat } from "../src/local-chat-bridge.mjs"; + +const call = (id, n = 1) => ({ type: "function_call", call_id: id, name: "exec_command", arguments: JSON.stringify({ cmd: `echo ${n}` }) }); +const output = (id, text) => ({ type: "function_call_output", call_id: id, output: text }); +const pair = (id, n) => [call(id, n), output(id, `result ${n}`)]; +const chatCall = (id, n = 1) => ({ type: "message", role: "assistant", content: "checking", tool_calls: [{ id, type: "function", function: { name: "exec_command", arguments: call(id, n).arguments } }] }); +const outputs = (input) => input.filter((item) => item.type === "function_call_output").map((item) => item.output); + +test("closed calls may reuse an id without losing any later round", () => { + const input = [1, 2, 3].flatMap((n) => pair("exec_command_0", n)); + const normalized = normalizeGatewayInput(input); + assert.deepEqual(outputs(normalized), ["result 1", "result 2", "result 3"]); + assert.equal(new Set(normalized.filter((i) => i.type === "function_call").map((i) => i.call_id)).size, 3); +}); + +test("a duplicate pending call must not steal the next invocation's result", () => { + const input = [call("x"), call("x"), output("x", "first"), ...pair("x", 2)]; + assert.deepEqual(outputs(normalizeGatewayInput(input)), ["first", "result 2"]); +}); + +test("identical work repeated after completion is still a new invocation", () => { + assert.deepEqual(outputs(normalizeGatewayInput([...pair("x", 1), ...pair("x", 1)])), ["result 1", "result 1"]); +}); + +test("mixed pending copies with object and serialized arguments count as one call", () => { + const first = { ...call("x"), arguments: '{"b":2,"a":1}' }; + const duplicate = chatCall("x"); + duplicate.tool_calls[0].function.arguments = { a: 1, b: 2 }; + const normalized = normalizeGatewayInput([first, duplicate, output("x", "first"), ...pair("x", 2)]); + assert.deepEqual(outputs(normalized), ["first", "result 2"]); + assert.equal(normalized.filter((i) => i.type === "function_call").length, 2); + assert.equal(normalized[0].arguments, first.arguments); +}); + +test("generated aliases never collide with later original ids or rewrite an earlier prefix", () => { + const before = normalizeGatewayInput([...pair("x", 1), ...pair("x", 2)]); + const generated = before[2].call_id; + const after = normalizeGatewayInput([...pair("x", 1), ...pair("x", 2), ...pair(generated, 3)]); + assert.deepEqual(after.slice(0, before.length), before); + assert.deepEqual(outputs(after), ["result 1", "result 2", "result 3"]); + assert.equal(new Set(after.filter((i) => i.type === "function_call").map((i) => i.call_id)).size, 3); +}); + +test("different pending calls sharing one id fail explicitly instead of guessing their output mapping", () => { + assert.throws(() => normalizeGatewayInput([call("x", 1), call("x", 2), output("x", "ambiguous")]), /ambiguous.*tool.*history/i); +}); + +test("reused correlation ids also produce distinct Responses item ids", () => { + const first = { ...call("x", 1), id: "fc_reused" }; + const second = { ...call("x", 2), id: "fc_reused" }; + const normalized = normalizeGatewayInput([first, output("x", "first"), second, output("x", "second")]); + const calls = normalized.filter((i) => i.type === "function_call"); + assert.notEqual(calls[0].id, calls[1].id); + assert.ok(calls.every((i) => i.id.startsWith("fc_"))); +}); + +for (const [name, normalize] of Object.entries({ generic: normalizeGatewayInput, flash: normalizeOpenCodeFlashInput, pro: normalizeOpenCodeProInput, xai: normalizeXaiInput, local: normalizeOllamaInput })) { + test(`${name} preserves mixed-dialect results in order and through the Chat bridge`, () => { + const input = [chatCall("x", 1), output("x", "first"), chatCall("x", 2), { type: "message", role: "tool", tool_call_id: "x", content: "second" }]; + const original = structuredClone(input); + const normalized = normalize(input); + assert.deepEqual(outputs(normalized), ["first", "second"]); + assert.deepEqual(input, original, "normalization must not modify Codex's input object"); + assert.deepEqual(normalizeGatewayInput(normalized), normalized, "the shared history normalization must be idempotent after provider adaptation"); + const chat = responsesToChat({ model: "fixture", input: normalized, tools: [] }).payload; + assert.deepEqual(chat.messages.filter((m) => m.role === "tool").map((m) => m.content), ["first", "second"]); + const declared = chat.messages.flatMap((m) => m.tool_calls || []).map((c) => c.id); + assert.deepEqual(chat.messages.filter((m) => m.role === "tool").map((m) => m.tool_call_id), declared); + }); +} + +test("Chat flattening preserves parallel groups, reasoning, structured output and namespace", () => { + const image = { type: "input_image", image_url: "data:image/png;base64,fixture" }; + const input = [ + { ...chatCall("a"), content: null, reasoning_content: "check both", tool_calls: [ + { id: "a", type: "function", namespace: "tools", function: { name: "one", arguments: { a: 1 } } }, + { id: "b", type: "function", function: { name: "two", arguments: "{}" } }, + ] }, + { type: "message", role: "tool", tool_call_id: "b", content: "B" }, + output("a", [{ type: "input_text", text: "A" }, image]), + ]; + const normalized = normalizeGatewayInput(input); + assert.equal(normalized[0].type, "reasoning"); + assert.equal(normalized[0].content[0].text, "check both"); + assert.deepEqual(normalized.slice(1).map((i) => [i.type, i.call_id]), [ + ["function_call", "a"], ["function_call", "b"], ["function_call_output", "a"], ["function_call_output", "b"], + ]); + assert.deepEqual(normalized[1].arguments, { a: 1 }); + assert.equal(normalized[1].namespace, "tools"); + assert.deepEqual(normalized[3].output, [{ type: "input_text", text: "A" }, image]); +}); + +test("custom tool call payloads and pair types survive id reuse", () => { + const input = [1, 2].flatMap((n) => [ + { type: "custom_tool_call", call_id: "patch", name: "apply_patch", input: `patch ${n}` }, + { type: "custom_tool_call_output", call_id: "patch", output: `applied ${n}` }, + ]); + const normalized = normalizeGatewayInput(input); + assert.deepEqual(normalized.map((i) => i.input || i.output), ["patch 1", "applied 1", "patch 2", "applied 2"]); + assert.deepEqual(normalized.map((i) => i.type), input.map((i) => i.type)); +}); + +test("well-formed native history stays on its existing native path", () => { + const input = [{ ...call("native"), id: "fc_native" }, output("native", "ok")]; + assert.deepEqual(normalizeNativeInput(input), input); +});