diff --git a/open-sse/handlers/chatCore.js b/open-sse/handlers/chatCore.js index 8c967a835..83c372e4b 100644 --- a/open-sse/handlers/chatCore.js +++ b/open-sse/handlers/chatCore.js @@ -24,7 +24,7 @@ import { resolveStreamFlag } from "./chatCore/streamFlag.js"; import { createEmptyRetryStream } from "./chatCore/emptyStreamGuard.js"; import { detectClientTool, isNativePassthrough } from "../utils/clientDetector.js"; import { dedupeTools } from "../utils/toolDeduper.js"; -import { stripOrphanedToolResults } from "../translator/concerns/toolCall.js"; +import { salvageOrphanedToolResults, fixMissingToolResponses } from "../translator/concerns/toolCall.js"; import { injectCaveman } from "../rtk/caveman.js"; import { injectPonytail } from "../rtk/ponytail.js"; import { compressMessages, formatRtkLog } from "../rtk/index.js"; @@ -34,13 +34,6 @@ import { getCapabilitiesForModel } from "../providers/capabilities.js"; import { stripUnsupportedModalities } from "../translator/concerns/modality.js"; import { prefetchRemoteImages } from "../translator/concerns/prefetch.js"; -const NATIVE_TOOL_RESULT_FORMATS = new Set([ - FORMATS.GEMINI, - FORMATS.GEMINI_CLI, - FORMATS.ANTIGRAVITY, - FORMATS.VERTEX, -]); - function isCompactResponsesEndpoint(endpoint) { const path = String(endpoint || "").split(/[?#]/, 1)[0].replace(/\/+$/, ""); return path.endsWith("/v1/responses/compact"); @@ -77,17 +70,6 @@ function stripLegacyCompactMarker(body, clientRawRequest) { return { body: cleanBody, clientRawRequest: cleanRawRequest }; } -/** - * Gemini-family clients legitimately send functionResponse turns without the - * originating functionCall after trimming their local history. Their native - * APIs accept that history and the Gemini translators preserve the content. - * Applying the generic orphan cleaner to those wire formats silently deletes - * user-visible tool output before dispatch. - */ -export function shouldStripOrphanedToolResults(format) { - return !NATIVE_TOOL_RESULT_FORMATS.has(format); -} - /** * Core chat handler - shared between SSE and Worker * @param {object} options.body - Request body @@ -203,14 +185,13 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred } catch (e) { log?.warn?.("MODALITY", `image prefetch failed: ${e.message}`); } } - // Strip orphaned tool results before translation so the translator never sees - // stale call_id references that client-side history truncation left behind. - const preStripped = shouldStripOrphanedToolResults(sourceFormat) - ? stripOrphanedToolResults(body) - : 0; - if (preStripped > 0) { - log?.debug?.("TOOLCLEAN", `pre-translation: stripped ${preStripped} orphaned tool result(s)`); - } + // Salvage orphaned tool results before translation so the translator never + // sees stale call_id references that client-side history truncation left + // behind. Salvage folds orphan text into user text (non-lossy) rather than + // deleting it, preserving Kiro's orphan-salvage semantics. Runs + // unconditionally: salvage understands messages[] and contents[]. + salvageOrphanedToolResults(body); + fixMissingToolResponses(body); let translatedBody; let toolNameMap; @@ -288,15 +269,13 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred } } else if (headroomEnabled) log?.warn?.("HEADROOM", `skipped: ${headroomDiagnostics.reason || "compression unavailable"}${headroomDiagnostics.endpoint ? ` (${headroomDiagnostics.endpoint})` : ""}`); - // Strip orphaned tool results again after RTK/Headroom compression — both + // Re-run salvage + fixMissing after RTK/Headroom compression — both // compressors can remove assistant turns containing tool_calls, which would // otherwise leave dangling tool results that strict providers reject with 400. - const postStripped = shouldStripOrphanedToolResults(finalFormat) - ? stripOrphanedToolResults(translatedBody) - : 0; - if (postStripped > 0) { - log?.debug?.("TOOLCLEAN", `post-compression: stripped ${postStripped} orphaned tool result(s)`); - } + // Salvage first (fold orphan text), then fixMissing (re-insert empty results + // for any call that lost its response) to restore the tool-pairing invariant. + salvageOrphanedToolResults(translatedBody); + fixMissingToolResponses(translatedBody); // Caveman: inject terse-style system prompt if (cavemanEnabled && cavemanLevel) { @@ -327,13 +306,9 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred try { onPxpipeEvent?.({ provider, model: cleanModel, ...pxpipeSummary }); } catch { /* stats must not break requests */ } } - // Re-strip after PXPIPE in case compression removed assistant/tool turns. - const pxpipeStripped = shouldStripOrphanedToolResults(finalFormat) - ? stripOrphanedToolResults(translatedBody) - : 0; - if (pxpipeStripped > 0) { - log?.debug?.("TOOLCLEAN", `post-pxpipe: stripped ${pxpipeStripped} orphaned tool result(s)`); - } + // Re-salvage + re-fix after PXPIPE in case compression removed assistant/tool turns. + salvageOrphanedToolResults(translatedBody); + fixMissingToolResponses(translatedBody); const executor = getExecutor(provider); trackPendingRequest(cleanModel, provider, connectionId, true); diff --git a/open-sse/translator/concerns/toolCall.js b/open-sse/translator/concerns/toolCall.js index 312a517b7..d79ea829d 100644 --- a/open-sse/translator/concerns/toolCall.js +++ b/open-sse/translator/concerns/toolCall.js @@ -270,3 +270,204 @@ export function stripOrphanedToolResults(body) { return stripped; } + +// Extract text content from a tool_result block (Claude-shaped) or tool message (OpenAI-shaped). +// Returns "" when there is no text (e.g. image-only tool_result). +function extractToolResultText(content) { + if (typeof content === "string") return content.trim(); + if (!Array.isArray(content)) return ""; + return content + .filter(block => block?.type === "text" && typeof block.text === "string") + .map(block => block.text) + .join("\n") + .trim(); +} + +// Merge consecutive same-role user messages so downstream translators that don't +// merge (notably openai-to-gemini) don't emit adjacent user turns after salvage +// (Gemini rejects consecutive user turns with 400 INVALID_ARGUMENT). +// Only merges string-content user messages; array-content messages are left as-is +// (they may carry structured blocks like tool_result that shouldn't be concatenated). +// Clone-on-merge: concatenating replaces the last entry with a new object instead +// of mutating in place, preserving the caller's original message reference. +function mergeConsecutiveUserMessages(messages) { + const merged = []; + for (const msg of messages) { + const last = merged[merged.length - 1]; + if (last && last.role === "user" && msg.role === "user" + && typeof last.content === "string" && typeof msg.content === "string") { + merged[merged.length - 1] = { ...last, content: `${last.content}\n${msg.content}` }; + } else { + merged.push(msg); + } + } + return merged; +} + +/** + * Salvage orphaned tool results — results that reference a tool call no longer + * present in the same request. Mirrors fixMissingToolResponses on the result + * side: that helper ensures every call has a result; this one ensures every + * result has a call. + * + * Unlike stripOrphanedToolResults (which deletes orphans), this folds the + * orphan's text content into a user-text block: `[Tool result: ]`. + * Non-lossy across all formats and preserves Kiro's + * reconcileOrphanedToolResults salvage semantics (orphan text is kept as user + * text rather than dropped). Image-only results (no text) are still dropped. + * + * Handles three wire envelopes: + * - OpenAI messages[] : orphaned role:"tool" -> user text + * - Anthropic messages[] : orphaned tool_result block -> text block + * - Gemini/Antigravity contents[]: orphaned functionResponse -> text part + * + * Responses API function_call_output is handled separately in + * openai-responses.js (stripOrphanedToolOutputs): Responses items have no text + * representation to salvage — they are structural call/output pairs, not + * content blocks. + * + * After salvaging messages[], consecutive same-role user messages are merged so + * downstream translators that don't merge (notably openai-to-gemini) don't emit + * adjacent user turns that trigger Gemini 400 INVALID_ARGUMENT. + * + * Fail-open: any error returns the body unchanged. + * + * @param {object} body request body (mutated in place; messages[]/contents[]) + * @returns {object} the same body reference + */ +export function salvageOrphanedToolResults(body) { + if (!body || typeof body !== "object") return body; + + try { + let changed = false; + + // ── OpenAI/Claude messages[] ─────────────────────────────────────────── + if (Array.isArray(body.messages)) { + const knownCallIds = new Set(); + for (const msg of body.messages) { + // Only assistant turns carry tool calls — collecting from other roles + // causes false positives (a user message with tool_use would mask orphans). + if (msg.role !== "assistant") continue; + if (Array.isArray(msg.tool_calls)) { + for (const tc of msg.tool_calls) { + if (typeof tc?.id === "string") knownCallIds.add(tc.id); + } + } + if (Array.isArray(msg.content)) { + for (const block of msg.content) { + if (block?.type === "tool_use" && typeof block.id === "string") { + knownCallIds.add(block.id); + } + } + } + } + + const salvagedMessages = []; + for (const msg of body.messages) { + // OpenAI shape: orphaned role:"tool" message -> salvage to user text + if (msg.role === "tool" && msg.tool_call_id) { + if (knownCallIds.has(msg.tool_call_id)) { + salvagedMessages.push(msg); + } else { + const text = extractToolResultText(msg.content); + if (text) salvagedMessages.push({ role: "user", content: `[Tool result: ${text}]` }); + changed = true; + } + continue; + } + + // Claude shape: orphaned tool_result block in user content -> salvage text + if (Array.isArray(msg.content)) { + let orphanCount = 0; + const rebuiltContent = []; + for (const block of msg.content) { + if (block?.type !== "tool_result") { + rebuiltContent.push(block); + continue; + } + if (typeof block.tool_use_id === "string" && knownCallIds.has(block.tool_use_id)) { + rebuiltContent.push(block); + continue; + } + orphanCount++; + const text = extractToolResultText(block.content); + if (text) rebuiltContent.push({ type: "text", text: `[Tool result: ${text}]` }); + } + if (orphanCount > 0) { + changed = true; + if (rebuiltContent.length === 0) continue; // all blocks image-only orphans + msg.content = rebuiltContent; + } + } + + salvagedMessages.push(msg); + } + + if (changed) body.messages = mergeConsecutiveUserMessages(salvagedMessages); + } + + // ── Gemini/Antigravity contents[] ────────────────────────────────────── + if (Array.isArray(body.contents)) { + const knownFnIds = new Set(); + for (const turn of body.contents) { + // Only model turns carry functionCall — a malformed user turn with + // functionCall would otherwise mask real orphans. + if (turn.role !== "model") continue; + if (!Array.isArray(turn.parts)) continue; + for (const part of turn.parts) { + if (part?.functionCall) { + const key = part.functionCall.id ?? part.functionCall.name; + if (key) knownFnIds.add(key); + } + } + } + + // Only salvage when at least one functionCall exists to match against. + // Gemini-family clients legitimately send functionResponse turns without + // the originating functionCall after trimming local history; with no call + // set, every response would look orphaned and be dropped. Preserving them + // here keeps that standalone tool output visible (matches the previous + // per-format skip for gemini/gemini-cli/antigravity/vertex). + if (knownFnIds.size > 0 && body.contents.some(t => Array.isArray(t.parts) && t.parts.some(p => p.functionResponse))) { + const salvagedContents = []; + for (const turn of body.contents) { + if (!Array.isArray(turn.parts) || !turn.parts.some(p => p.functionResponse)) { + salvagedContents.push(turn); + continue; + } + let orphanCount = 0; + const rebuiltParts = []; + for (const part of turn.parts) { + if (!part.functionResponse) { + rebuiltParts.push(part); + continue; + } + const key = part.functionResponse.id ?? part.functionResponse.name; + if (key && knownFnIds.has(key)) { + rebuiltParts.push(part); + continue; + } + orphanCount++; + const resp = part.functionResponse.response; + const raw = resp?.result ?? resp; + const text = typeof raw === "string" ? raw.trim() : (raw ? JSON.stringify(raw).trim() : ""); + if (text) rebuiltParts.push({ text: `[Tool result: ${text}]` }); + } + if (orphanCount > 0) { + changed = true; + // Drop the turn entirely if all parts were image-only orphans — + // Gemini rejects turns with empty parts[] (400 INVALID_ARGUMENT). + if (rebuiltParts.length === 0) continue; + turn.parts = rebuiltParts; + } + salvagedContents.push(turn); + } + if (changed) body.contents = salvagedContents; + } + } + + return body; + } catch { + return body; + } +} diff --git a/open-sse/translator/index.js b/open-sse/translator/index.js index 149e1adba..13db2f235 100644 --- a/open-sse/translator/index.js +++ b/open-sse/translator/index.js @@ -1,5 +1,5 @@ import { FORMATS } from "./formats.js"; -import { ensureToolCallIds, fixMissingToolResponses, stripOrphanedToolResults } from "./concerns/toolCall.js"; +import { ensureToolCallIds, fixMissingToolResponses, salvageOrphanedToolResults } from "./concerns/toolCall.js"; import { prepareClaudeRequest } from "./formats/claude.js"; import { cloakClaudeTools } from "../utils/claudeCloaking.js"; import { filterToOpenAIFormat } from "./formats/openai.js"; @@ -71,22 +71,26 @@ export function translateRequest(sourceFormat, targetFormat, model, body, stream // Fix missing tool responses (insert empty tool_result if needed) fixMissingToolResponses(result); - // Strip orphaned tool results (tool_result with no matching tool_call). - // Skip for Kiro: its request translator salvages orphaned tool_results by - // folding their content into user text (reconcileOrphanedToolResults / - // flattenClaudeToolInteractions) rather than dropping it. Stripping here - // first would delete that content before the Kiro translator can preserve it. - // Gemini / Antigravity pre-translation cleanup is handled inside the Gemini - // -> OpenAI request translator (geminiToOpenAIRequestFixed splits - // functionResponse parts into their own contents). Running the generic - // OpenAI/Anthropic orphan stripper here would strip legitimate tool - // messages whose ids don't match a functionCall in the same body. - const skipStrip = sourceFormat === FORMATS.GEMINI - || sourceFormat === FORMATS.GEMINI_CLI - || sourceFormat === FORMATS.ANTIGRAVITY - || sourceFormat === FORMATS.VERTEX; - if (targetFormat !== FORMATS.KIRO && !skipStrip) { - stripOrphanedToolResults(result); + // Salvage orphaned tool results (tool_result with no matching tool_call). + // Folds orphan content into user text (`[Tool result: ...]`) instead of + // deleting — non-lossy for the translated messages[] shape (OpenAI/Claude) + // and preserves Kiro's reconcileOrphanedToolResults salvage semantics. + // + // MUST skip the Gemini family (gemini/gemini-cli/antigravity/vertex): at this + // point the body still carries native contents[] whose functionResponse ids + // are keyed per-part, not against the global functionCall set salvage builds, + // so an unconditional run rewrites legitimate functionResponses into + // `[Tool result: ...]` text before the gemini->openai conversion can read + // them. Those formats are salvaged downstream of their own conversion if at + // all. Responses API function_call_output is structural-stripped separately + // inside openai-responses.js. + const skipSalvage = + sourceFormat === FORMATS.GEMINI || + sourceFormat === FORMATS.GEMINI_CLI || + sourceFormat === FORMATS.ANTIGRAVITY || + sourceFormat === FORMATS.VERTEX; + if (!skipSalvage) { + salvageOrphanedToolResults(result); } // Capture thinking intent from the original (pre-translation) body, before any diff --git a/tests/translator/port-2190-thinking-hidden.test.js b/tests/translator/port-2190-thinking-hidden.test.js new file mode 100644 index 000000000..a78a6ed41 --- /dev/null +++ b/tests/translator/port-2190-thinking-hidden.test.js @@ -0,0 +1,50 @@ +// Regression for upstream decolua/9router#2190 — keep thinking out of visible content. +// A Claude→OpenAI stream must emit reasoning via the reasoning channel, never +// as literal ``/`` text inside `delta.content`. The bug surfaced +// as think tags leaking into user-visible content at thinking block start/stop. +import { describe, it, expect } from "vitest"; +import "./registerAll.js"; +import { claudeToOpenAIResponse } from "../../open-sse/translator/response/claude-to-openai.js"; + +const freshState = () => ({ messageId: "m1", model: "m", toolCallIndex: 0 }); + +function collect(events) { + const state = freshState(); + const out = []; + for (const ev of events) out.push(...(claudeToOpenAIResponse(ev, state) || [])); + return out; +} + +describe("port #2190: thinking stays out of visible content", () => { + it("emits no literal / text in content deltas", () => { + const out = collect([ + { type: "message_start", message: { id: "m1", usage: { input_tokens: 1 } } }, + { type: "content_block_start", index: 0, content_block: { type: "thinking", thinking: "" } }, + { type: "content_block_delta", index: 0, delta: { type: "thinking_delta", thinking: "secret" } }, + { type: "content_block_stop", index: 0 }, + { type: "content_block_start", index: 1, content_block: { type: "text", text: "" } }, + { type: "content_block_delta", index: 1, delta: { type: "text_delta", text: "answer" } }, + { type: "content_block_stop", index: 1 }, + ]); + const contentText = out + .map((e) => e?.choices?.[0]?.delta?.content) + .filter((s) => typeof s === "string") + .join(""); + expect(contentText, " leaked into visible content").not.toContain(""); + expect(contentText, " leaked into visible content").not.toContain(""); + }); + + it("still surfaces reasoning via the reasoning channel and keeps visible text", () => { + const out = collect([ + { type: "message_start", message: { id: "m1", usage: { input_tokens: 1 } } }, + { type: "content_block_start", index: 0, content_block: { type: "thinking", thinking: "" } }, + { type: "content_block_delta", index: 0, delta: { type: "thinking_delta", thinking: "secret" } }, + { type: "content_block_stop", index: 0 }, + { type: "content_block_start", index: 1, content_block: { type: "text", text: "" } }, + { type: "content_block_delta", index: 1, delta: { type: "text_delta", text: "answer" } }, + ]); + const json = JSON.stringify(out); + expect(json, "reasoning content dropped").toContain("secret"); + expect(json, "visible text dropped").toContain("answer"); + }); +}); diff --git a/tests/translator/port-2237-salvage-orphans.test.js b/tests/translator/port-2237-salvage-orphans.test.js new file mode 100644 index 000000000..0c02eee28 --- /dev/null +++ b/tests/translator/port-2237-salvage-orphans.test.js @@ -0,0 +1,156 @@ +// Regression for upstream decolua/9router#2237 — orphaned tool results across formats. +// One test per envelope; every assertion defends the non-lossy salvage contract: +// orphan text is folded into user text (`[Tool result: ...]`) rather than dropped, +// and no synthetic tool_call/tool_result id is invented for orphan-only history. +import { describe, it, expect } from "vitest"; +import "./registerAll.js"; +import { translateRequest } from "../../open-sse/translator/index.js"; +import { FORMATS } from "../../open-sse/translator/formats.js"; +import { salvageOrphanedToolResults } from "../../open-sse/translator/concerns/toolCall.js"; + +describe("port #2237: salvage orphaned tool results across formats", () => { + it("OpenAI messages[]: orphaned role:tool folds into user text, not dropped", () => { + const body = { + messages: [ + { role: "user", content: "hi" }, + // assistant turn that owned call_1 was truncated away → orphan below + { role: "tool", tool_call_id: "call_1", content: "the answer is 42" }, + { role: "user", content: "thanks" }, + ], + }; + const out = translateRequest(FORMATS.OPENAI, FORMATS.CLAUDE, "m", body); + const json = JSON.stringify(out); + expect(json, "orphan tool output lost").toContain("the answer is 42"); + expect(json, "orphan not surfaced as user text").toContain("[Tool result: the answer is 42]"); + // No synthetic id invented for the orphan + expect(out.messages.some((m) => m.tool_call_id === "call_1"), "stale tool_call_id survived").toBe(false); + }); + + it("Claude messages[]: orphaned tool_result block salvaged to text, kept paired ones intact", () => { + const body = { + messages: [ + { role: "assistant", content: [{ type: "tool_use", id: "keep_1", name: "f", input: {} }] }, + { role: "user", content: [ + { type: "tool_result", tool_use_id: "keep_1", content: "ok" }, + { type: "tool_result", tool_use_id: "gone_1", content: "lost output" }, + ] }, + ], + }; + const out = translateRequest(FORMATS.CLAUDE, FORMATS.OPENAI, "m", body); + const json = JSON.stringify(out); + expect(json, "paired tool_result dropped").toContain("keep_1"); + expect(json, "orphan text lost").toContain("lost output"); + expect(json, "orphan not folded to text").toContain("[Tool result: lost output]"); + }); + + it("consecutive same-role user messages merge after salvage (Gemini 400 guard)", () => { + const out = salvageOrphanedToolResults({ + messages: [ + { role: "user", content: "before" }, + { role: "tool", tool_call_id: "orphan", content: "payload" }, + { role: "user", content: "after" }, + ], + }); + const users = out.messages.filter((m) => m.role === "user"); + // Exactly one merged string user turn; adjacent users must not survive + // (openai-to-gemini would otherwise emit consecutive user turns → 400). + expect(users, "adjacent user turns not merged").toHaveLength(1); + expect(users[0].content).toBe("before\n[Tool result: payload]\nafter"); + }); + + it("Gemini contents[]: orphaned functionResponse salvaged to text part; paired kept", () => { + const body = { + contents: [ + { role: "model", parts: [{ functionCall: { id: "fc_1", name: "f", args: {} } }] }, + { role: "user", parts: [ + { functionResponse: { id: "fc_1", name: "f", response: { result: "paired" } } }, + { functionResponse: { id: "fc_orphan", name: "g", response: { result: "dropped output" } } }, + ] }, + ], + }; + const out = salvageOrphanedToolResults(body); + const json = JSON.stringify(out); + expect(json, "paired functionResponse dropped").toContain("fc_1"); + expect(json, "orphan response text lost").toContain("dropped output"); + expect(json, "orphan not folded to text part").toContain("[Tool result: dropped output]"); + }); + + it("orphan-only history invents no synthetic tool_call or tool_result id", () => { + const out = salvageOrphanedToolResults({ + messages: [ + { role: "user", content: "ctx" }, + { role: "tool", tool_call_id: "ghost", content: "stale" }, + ], + }); + expect(out.messages.some((m) => m.tool_call_id === "ghost"), "ghost id survived").toBe(false); + expect(out.messages.some((m) => m.role === "assistant" && m.tool_calls), "synthetic assistant call invented").toBe(false); + expect(JSON.stringify(out)).toContain("[Tool result: stale]"); + }); + + it("Responses API input[]: orphaned function_call_output is structurally stripped (not salvaged)", () => { + const out = translateRequest(FORMATS.OPENAI_RESPONSES, FORMATS.OPENAI, "m", { + input: [ + { type: "message", role: "user", content: "hi" }, + { type: "function_call_output", call_id: "missing_call", output: "stale" }, + ], + }); + const json = JSON.stringify(out); + expect(json, "orphaned function_call_output forwarded").not.toContain("missing_call"); + }); + + // Guards the Gemini-family skip in translator/index.js: salvage MUST NOT run + // on native contents[] before gemini->openai conversion, otherwise it rewrites + // legitimate functionResponses (keyed per-part) into `[Tool result: ...]` text + // because its global knownCall set never contains sibling parts. Asserts BOTH + // that the input object is byte-for-byte unchanged (salvage never touched it) + // and that the translated OpenAI output still emits the tool result. + it("Gemini native contents[]: salvage skipped — input unchanged, tool result survives translation", () => { + const body = { + contents: [ + { role: "user", parts: [{ text: "run both" }] }, + { + role: "model", + parts: [ + { functionCall: { id: "call_a", name: "tool_a", args: { x: 1 } } }, + { functionCall: { id: "call_b", name: "tool_b", args: { y: 2 } } }, + ], + }, + { + role: "user", + parts: [ + { functionResponse: { id: "call_a", name: "tool_a", response: { result: "A" } } }, + { functionResponse: { id: "call_b", name: "tool_b", response: { result: "B" } } }, + ], + }, + ], + }; + const before = JSON.stringify(body); + const out = translateRequest(FORMATS.GEMINI, FORMATS.OPENAI, "gemini-pro", body, false); + expect(JSON.stringify(body), "salvage mutated native Gemini contents[] before conversion").toBe(before); + const toolMsgs = out.messages.filter((m) => m.role === "tool"); + expect(toolMsgs, "both functionResponses must survive as role:tool messages").toHaveLength(2); + expect(toolMsgs.map((m) => m.tool_call_id).sort()).toEqual(["call_a", "call_b"]); + }); + + it("Gemini native contents[]: co-located functionCall+functionResponse not collapsed to text", () => { + // Regression for run 29122517702: one content with functionCall(call_a) + // plus functionResponse(call_b) — salvage keyed call_b as orphan against + // the {call_a} set and rewrote it to text, dropping the role:tool message. + const body = { + contents: [ + { + role: "model", + parts: [ + { functionCall: { id: "call_a", name: "tool_a", args: {} } }, + { functionResponse: { id: "call_b", name: "tool_b", response: { result: "B" } } }, + ], + }, + ], + }; + const before = JSON.stringify(body); + const out = translateRequest(FORMATS.GEMINI, FORMATS.OPENAI, "gemini-pro", body, false); + expect(JSON.stringify(body)).toBe(before); + expect(out.messages.find((m) => m.role === "tool"), "call_b functionResponse must survive").toBeDefined(); + expect(out.messages.find((m) => m.role === "assistant" && m.tool_calls), "call_a tool_call must survive").toBeDefined(); + }); +}); diff --git a/tests/translator/port-2279-claude-finish-guard.test.js b/tests/translator/port-2279-claude-finish-guard.test.js new file mode 100644 index 000000000..b03d4b31c --- /dev/null +++ b/tests/translator/port-2279-claude-finish-guard.test.js @@ -0,0 +1,63 @@ +// Regression for upstream decolua/9router#2279 — doubled tool args openai→claude. +// Some OpenAI-compatible models emit a tool_call's arguments as the same JSON +// object twice ({"q":"x"}{"q":"x"}). The Claude response translator must +// collapse that to a single valid JSON object in the input_json_delta it emits +// at finish; otherwise the Claude consumer receives malformed tool input. +import { describe, it, expect } from "vitest"; +import "./registerAll.js"; +import { openaiToClaudeResponse } from "../../open-sse/translator/response/openai-to-claude.js"; + +const chunk = (delta, extra = {}) => ({ + id: "chatcmpl-x", + object: "chat.completion.chunk", + created: 0, + model: "m", + choices: [{ index: 0, delta, finish_reason: extra.finish_reason ?? null }], + ...extra, +}); + +const freshState = () => ({ + toolCalls: new Map(), + textBlockStarted: false, + thinkingBlockStarted: false, +}); + +function drive(argsChunk) { + const state = freshState(); + const out = []; + out.push(...(openaiToClaudeResponse(chunk({ content: null }), state) || [])); // message_start + out.push(...(openaiToClaudeResponse(chunk({ tool_calls: [{ index: 0, id: "call_1", function: { name: "Read" } }] }), state) || [])); + out.push(...(openaiToClaudeResponse(chunk({ tool_calls: [{ index: 0, function: { arguments: argsChunk } }] }), state) || [])); + out.push(...(openaiToClaudeResponse(chunk({}, { finish_reason: "tool_calls" }), state) || [])); + return { state, out }; +} + +describe("port #2279: doubled tool args openai→claude collapse to one object", () => { + it("deduplicates doubled JSON arguments into a single parseable object", () => { + const original = { file_path: "/tmp/a.ts", limit: 10 }; + const doubled = JSON.stringify(original) + JSON.stringify(original); + const { out } = drive(doubled); + const deltas = out.filter((e) => e?.type === "content_block_delta" && e.delta?.type === "input_json_delta"); + expect(deltas.length, "no input_json_delta emitted").toBeGreaterThan(0); + const partial = deltas[deltas.length - 1].delta.partial_json; + // Must parse exactly once — not doubled, not the raw concatenation. + const parsed = JSON.parse(partial); + expect(parsed).toEqual(original); + expect(partial, "doubled JSON not collapsed").not.toBe(doubled); + }); + + it("passes already-single JSON arguments through unchanged", () => { + const original = { query: "hello", n: 3 }; + const { out } = drive(JSON.stringify(original)); + const deltas = out.filter((e) => e?.type === "content_block_delta" && e.delta?.type === "input_json_delta"); + expect(deltas.length).toBeGreaterThan(0); + expect(JSON.parse(deltas[deltas.length - 1].delta.partial_json)).toEqual(original); + }); + + it("emits message_stop exactly once on finish (finish guard side-effect)", () => { + const { out, state } = drive(JSON.stringify({ q: 1 })); + const stops = out.filter((e) => e?.type === "message_stop"); + expect(stops, "expected exactly one message_stop").toHaveLength(1); + expect(state.claudeFinishHandled).toBe(true); + }); +}); diff --git a/tests/translator/port-2318-strip-responses-fields.test.js b/tests/translator/port-2318-strip-responses-fields.test.js new file mode 100644 index 000000000..602ab40af --- /dev/null +++ b/tests/translator/port-2318-strip-responses-fields.test.js @@ -0,0 +1,40 @@ +// Regression for upstream decolua/9router#2318 — strip Responses-API-only fields. +// `client_metadata`, `background`, and `truncation` are valid on the OpenAI +// Responses API but rejected by third-party / chat-completions providers with +// HTTP 400. They must be removed before the translated request is forwarded. +import { describe, it, expect } from "vitest"; +import "./registerAll.js"; +import { translateRequest } from "../../open-sse/translator/index.js"; +import { FORMATS } from "../../open-sse/translator/formats.js"; + +describe("port #2318: strip Responses-API-only fields before forwarding", () => { + it("drops client_metadata / background / truncation from Responses→Chat body", () => { + const out = translateRequest(FORMATS.OPENAI_RESPONSES, FORMATS.OPENAI, "m", { + input: [{ type: "message", role: "user", content: "hi" }], + client_metadata: { user_id: "u1", trace: "t" }, + background: true, + truncation: "auto", + }); + expect(out, "client_metadata leaked").not.toHaveProperty("client_metadata"); + expect(out, "background leaked").not.toHaveProperty("background"); + expect(out, "truncation leaked").not.toHaveProperty("truncation"); + }); + + it("keeps the translated messages intact after stripping", () => { + const out = translateRequest(FORMATS.OPENAI_RESPONSES, FORMATS.OPENAI, "m", { + input: [{ type: "message", role: "user", content: "hi" }], + client_metadata: { user_id: "u1" }, + }); + expect(Array.isArray(out.messages), "messages lost during translation").toBe(true); + expect(JSON.stringify(out)).toContain("hi"); + }); + + it("negative control: OPENAI→OPENAI preserves client_metadata (strip is Responses-only)", () => { + const out = translateRequest(FORMATS.OPENAI, FORMATS.OPENAI, "m", { + messages: [{ role: "user", content: "hi" }], + client_metadata: { user_id: "u1" }, + }); + // The Responses-only strip must not overreach into plain chat payloads. + expect(out.client_metadata, "client_metadata wrongly stripped on chat path").toEqual({ user_id: "u1" }); + }); +}); diff --git a/tests/unit/headroom-chat-core.test.js b/tests/unit/headroom-chat-core.test.js index 302ef1a92..e8c823768 100644 --- a/tests/unit/headroom-chat-core.test.js +++ b/tests/unit/headroom-chat-core.test.js @@ -33,7 +33,7 @@ vi.mock("@/lib/usageDb.js", () => ({ saveRequestDetail: vi.fn(async () => {}), })); -const { handleChatCore, shouldStripOrphanedToolResults } = await import("../../open-sse/handlers/chatCore.js"); +const { handleChatCore } = await import("../../open-sse/handlers/chatCore.js"); describe("handleChatCore Headroom diagnostics", () => { beforeEach(() => { @@ -56,20 +56,6 @@ describe("handleChatCore Headroom diagnostics", () => { }); }); - it.each(["gemini", "gemini-cli", "antigravity", "vertex"])( - "preserves native %s tool-result history at every cleanup point", - (format) => { - expect(shouldStripOrphanedToolResults(format)).toBe(false); - }, - ); - - it.each(["openai", "claude", "openai-responses"])( - "cleans orphaned tool results for strict %s payloads", - (format) => { - expect(shouldStripOrphanedToolResults(format)).toBe(true); - }, - ); - it("logs why Headroom was skipped on chat completions", async () => { const log = { debug: vi.fn(), info: vi.fn(), warn: vi.fn() };