diff --git a/CHANGELOG.md b/CHANGELOG.md index 870b3a5b62..899e0d4353 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,8 @@ ### 🔧 Bug Fixes +- fix(providers): strip orphan tool_result blocks on the Antigravity MITM path before forwarding to Claude ([#6026](https://github.com/diegosouzapw/OmniRoute/issues/6026)) + - **dashboard ("Update now" → Internal Server Error):** clicking **Update now** on the dashboard home could crash the page with a blank "Internal Server Error" screen (`Minified React error #31`). The handler POSTs the loopback-only `/api/system/version` auto-update endpoint and, on a non-OK JSON response (e.g. a `403` when the dashboard is reached through a reverse proxy / non-loopback origin), passed the raw error envelope object `{ error: { code, message, correlation_id } }` straight to `notify.error()`, which rendered the object as a React child and threw #31. The update-error path now funnels the body through `extractApiErrorMessage()` (the same safe extractor added in #5340), so a readable string always reaches the toast. Regression guard: `tests/unit/ui/home-update-error-render-5991.test.ts`. ([#5991](https://github.com/diegosouzapw/OmniRoute/issues/5991)) - **kiro (system prompt leaked as raw user text):** when Claude Code routed through the Kiro/CodeWhisperer backend, the `system` message was normalized to a `user` turn with no wrapper, so the entire system prompt (environment info, tool definitions, memory instructions, etc.) appeared as if the user had typed it — polluting the model context. System-origin content is now wrapped in `` tags before being merged into the Kiro user message, so the model can distinguish it from real user input. Real user turns are untouched. Regression guard: `tests/unit/kiro-system-reminder-2306.test.ts`. (thanks @VitzS7) diff --git a/open-sse/translator/request/antigravity-to-openai.ts b/open-sse/translator/request/antigravity-to-openai.ts index d2ae57fcb2..032d509127 100644 --- a/open-sse/translator/request/antigravity-to-openai.ts +++ b/open-sse/translator/request/antigravity-to-openai.ts @@ -1,6 +1,7 @@ import { register } from "../registry.ts"; import { FORMATS } from "../formats.ts"; import { adjustMaxTokens } from "../helpers/maxTokensHelper.ts"; +import { fixToolPairs } from "../../services/contextManager.ts"; type JsonRecord = Record; @@ -96,6 +97,19 @@ export function antigravityToOpenAIRequest(model, body, stream) { } } + // Guard against orphan tool_result/tool_use pairs (#6026). Antigravity IDE can ship a + // truncated history whose first turn is a `functionResponse` with no preceding + // `functionCall`. Left untouched, that becomes an orphan `role:"tool"` message here and, + // after the openai→claude step, an orphan `tool_result` block — which Anthropic (Vertex + // `claude-opus-4.6`) rejects with `unexpected tool_use_id found in tool_result blocks`. + // `fixToolPairs` strips only genuine orphans and is idempotent on well-formed histories, + // so paired functionCall/functionResponse turns pass through unchanged. This mirrors the + // executor-side guard in `executors/base.ts` / `services/claudeCodeCompatible.ts`; the + // Antigravity MITM path did not run it (no `fixToolPairs` under `src/mitm/`). We do NOT + // run `fixToolAdjacency` here because this stage still emits OpenAI-format messages and + // Claude's adjacency rule is enforced downstream per provider. + result.messages = fixToolPairs(result.messages) as JsonRecord[]; + return result; } diff --git a/tests/unit/antigravity-orphan-toolresult-6026.test.ts b/tests/unit/antigravity-orphan-toolresult-6026.test.ts new file mode 100644 index 0000000000..1fe8091720 --- /dev/null +++ b/tests/unit/antigravity-orphan-toolresult-6026.test.ts @@ -0,0 +1,127 @@ +/** + * Regression test for #6026. + * + * Antigravity IDE (via AgentBridge/MITM → `/v1/antigravity` → translator) can ship a + * truncated history whose FIRST turn is a tool result (`functionResponse`) with no + * preceding tool call. When that survives the antigravity→openai→claude chain, Anthropic + * (Vertex `claude-opus-4.6`) rejects it with HTTP 400: + * + * messages.0.content.1: unexpected tool_use_id found in tool_result blocks: + * toolu_vrtx_...: Each tool_result block must have a corresponding tool_use block in + * the previous message. + * + * The fix strips orphan tool_results at the antigravity message-assembly point + * (`antigravityToOpenAIRequest`) by reusing `fixToolPairs`, so the orphan never reaches + * the upstream Claude request. + * + * PURE-FUNCTION ONLY — this test imports the translator + sanitizer functions directly. + * It must NEVER start the MITM proxy, bind :443/:80, touch /etc/hosts, or install a CA. + */ +import test from "node:test"; +import assert from "node:assert/strict"; + +const { antigravityToOpenAIRequest } = await import( + "../../open-sse/translator/request/antigravity-to-openai.ts" +); +const { fixToolPairs } = await import("../../open-sse/services/contextManager.ts"); + +test("#6026: antigravityToOpenAIRequest strips an orphan functionResponse (no preceding functionCall)", () => { + const result = antigravityToOpenAIRequest( + "ag/claude-opus-4-6", + { + request: { + contents: [ + { + // First (and only) turn is a tool result with NO preceding tool call. + role: "user", + parts: [ + { + functionResponse: { + id: "toolu_vrtx_test", + name: "read_file", + response: { result: { ok: true } }, + }, + }, + ], + }, + ], + }, + }, + false + ); + + // The orphan tool message must be gone — otherwise the openai→claude step would emit an + // orphan tool_result block and Anthropic would 400. + const orphan = result.messages.find( + (m: Record) => m.role === "tool" && m.tool_call_id === "toolu_vrtx_test" + ); + assert.equal(orphan, undefined, "orphan tool_result message must be stripped"); + assert.equal( + result.messages.some((m: Record) => m.role === "tool"), + false, + "no orphan tool messages should remain" + ); +}); + +test("#6026: well-formed functionCall/functionResponse pair is preserved (no regression)", () => { + const result = antigravityToOpenAIRequest( + "ag/claude-opus-4-6", + { + request: { + contents: [ + { + role: "model", + parts: [{ functionCall: { id: "toolu_vrtx_ok", name: "read_file", args: {} } }], + }, + { + role: "user", + parts: [ + { + functionResponse: { + id: "toolu_vrtx_ok", + name: "read_file", + response: { result: { ok: true } }, + }, + }, + ], + }, + ], + }, + }, + false + ); + + const assistant = result.messages.find( + (m: Record) => m.role === "assistant" + ); + const tool = result.messages.find((m: Record) => m.role === "tool"); + assert.ok(assistant, "assistant tool_call message must survive"); + assert.ok(tool, "matched tool_result message must survive"); + assert.equal((tool as Record).tool_call_id, "toolu_vrtx_ok"); +}); + +test("#6026: fixToolPairs removes the exact Anthropic-shape orphan tool_result block", () => { + // Mirrors the reporter's failing body: messages[0] is a user message whose content array + // holds a tool_result block with no matching tool_use anywhere in the request. + const messages: Record[] = [ + { + role: "user", + content: [ + { type: "text", text: "continue" }, + { type: "tool_result", tool_use_id: "toolu_vrtx_test", content: "stale" }, + ], + }, + ]; + + const fixed = fixToolPairs(messages); + + const stillHasOrphan = fixed.some( + (m) => + m.role === "user" && + Array.isArray(m.content) && + (m.content as Record[]).some( + (b) => b.type === "tool_result" && b.tool_use_id === "toolu_vrtx_test" + ) + ); + assert.equal(stillHasOrphan, false, "orphan tool_result block must be stripped"); +}); diff --git a/tests/unit/translator-antigravity-to-openai.test.ts b/tests/unit/translator-antigravity-to-openai.test.ts index 1cd2de11a4..1463538c6d 100644 --- a/tests/unit/translator-antigravity-to-openai.test.ts +++ b/tests/unit/translator-antigravity-to-openai.test.ts @@ -121,7 +121,11 @@ test("Antigravity -> OpenAI extracts string system instructions and text-only me ]); }); -test("Antigravity -> OpenAI returns tool messages when content contains only function responses", () => { +test("Antigravity -> OpenAI strips a lone function response with no matching function call (#6026)", () => { + // A `functionResponse` with no preceding `functionCall` is an orphan tool_result. Left in + // place it becomes an orphan `tool_result` block after the openai→claude step, which + // Anthropic (Vertex claude-opus-4.6) rejects with HTTP 400 (#6026). `fixToolPairs` now + // strips it at the antigravity assembly point, so the upstream request stays valid. const result = antigravityToOpenAIRequest( "gpt-4o", { @@ -145,16 +149,10 @@ test("Antigravity -> OpenAI returns tool messages when content contains only fun false ); - assert.deepEqual(result.messages, [ - { - role: "tool", - tool_call_id: "call_2", - content: '{"ok":true}', - }, - ]); + assert.deepEqual(result.messages, []); }); -test("Antigravity -> OpenAI keeps co-located function response, function call and text", () => { +test("Antigravity -> OpenAI keeps co-located function call and text but strips the orphan function response (#6026)", () => { const result = antigravityToOpenAIRequest( "gpt-4o", { @@ -174,11 +172,12 @@ test("Antigravity -> OpenAI keeps co-located function response, function call an false ); - // Both the tool-result message AND the accompanying assistant message must survive. + // The accompanying assistant message (text + tool_call) survives, but the co-located + // function response `call_9` has no matching function call, so it is an orphan tool_result + // and must be stripped (#6026) — otherwise Anthropic 400s on the openai→claude request. const toolMsg = result.messages.find((m) => m.role === "tool"); const assistantMsg = result.messages.find((m) => m.role === "assistant"); - assert.ok(toolMsg, "expected a role:tool message"); - assert.equal(toolMsg.tool_call_id, "call_9"); + assert.equal(toolMsg, undefined, "orphan function-response tool message must be stripped"); assert.ok(assistantMsg, "expected a role:assistant message"); assert.equal(assistantMsg.content, "Let me look that up."); assert.deepEqual(assistantMsg.tool_calls, [