fix(providers): strip orphan tool_result on Antigravity MITM path (#6026) (#6115)

This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-07-03 12:50:26 -03:00
committed by GitHub
parent 2a20e5ca47
commit 29e04b7089
4 changed files with 154 additions and 12 deletions

View File

@@ -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 `<system-reminder>` 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)

View File

@@ -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<string, unknown>;
@@ -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;
}

View File

@@ -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<string, unknown>) => 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<string, unknown>) => 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<string, unknown>) => m.role === "assistant"
);
const tool = result.messages.find((m: Record<string, unknown>) => 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<string, unknown>).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<string, unknown>[] = [
{
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<string, unknown>[]).some(
(b) => b.type === "tool_result" && b.tool_use_id === "toolu_vrtx_test"
)
);
assert.equal(stillHasOrphan, false, "orphan tool_result block must be stripped");
});

View File

@@ -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, [