From 8eff0bda0160323f96d8af993b71a1a22e4f92dc Mon Sep 17 00:00:00 2001 From: dhaern Date: Sat, 30 May 2026 01:26:23 +0000 Subject: [PATCH] fix(antigravity): avoid visible signatureless tool history --- .../translator/request/openai-to-gemini.ts | 55 +++++++-- .../unit/translator-openai-to-gemini.test.ts | 105 ++++++++++++++---- 2 files changed, 128 insertions(+), 32 deletions(-) diff --git a/open-sse/translator/request/openai-to-gemini.ts b/open-sse/translator/request/openai-to-gemini.ts index 3ad6aa7687..5959713867 100644 --- a/open-sse/translator/request/openai-to-gemini.ts +++ b/open-sse/translator/request/openai-to-gemini.ts @@ -108,7 +108,7 @@ type GeminiToolNameOptions = { stripNamespace?: boolean; functionResponseShape?: "result" | "output"; signatureNamespace?: string | null; - signaturelessToolCallMode?: "native" | "text"; + signaturelessToolCallMode?: "native" | "text" | "context"; }; type OpenAIToolCallLike = { @@ -209,6 +209,24 @@ function buildInertHistoricalToolResponseText(name: string, response: unknown): ].join("\n"); } +function escapeHistoricalContextAttribute(value: string): string { + return value + .replaceAll("&", "&") + .replaceAll('"', """) + .replaceAll("<", "<") + .replaceAll(">", ">"); +} + +function buildHistoricalToolResultContext(name: string, response: unknown): string { + const source = escapeHistoricalContextAttribute(name || "unknown"); + const result = typeof response === "string" ? response : stringifyHistoricalToolArguments(response); + return [ + ``, + result, + "", + ].join("\n"); +} + // Core: Convert OpenAI request to Gemini format (base for all variants) function openaiToGeminiBase( model: string, @@ -367,8 +385,10 @@ function openaiToGeminiBase( } let shouldUseEmbeddedSignature = !parts.some((p) => p.thoughtSignature); - const stringifySignaturelessToolCalls = - toolNameOptions.signaturelessToolCallMode === "text"; + const signaturelessToolCallMode = toolNameOptions.signaturelessToolCallMode; + const stringifySignaturelessToolCalls = signaturelessToolCallMode === "text"; + const contextualizeSignaturelessToolResponses = + signaturelessToolCallMode === "text" || signaturelessToolCallMode === "context"; for (const tc of toolCalls) { if (tc.type !== "function") continue; @@ -378,6 +398,9 @@ function openaiToGeminiBase( if (!fn) continue; const signatureForToolCall = resolvedSignatures.get(id); + if (!signatureForToolCall && contextualizeSignaturelessToolResponses) { + if (!toolCallIds.includes(id)) toolCallIds.push(id); + } if (!signatureForToolCall && stringifySignaturelessToolCalls) { const args = fn.arguments || "{}"; parts.push({ @@ -385,6 +408,9 @@ function openaiToGeminiBase( }); continue; } + if (!signatureForToolCall && signaturelessToolCallMode === "context") { + continue; + } const args = tryParseJSON(fn.arguments || "{}"); const embeddedThoughtSignature = shouldUseEmbeddedSignature @@ -405,7 +431,9 @@ function openaiToGeminiBase( }, }); - toolCallIds.push(id); + if (!contextualizeSignaturelessToolResponses || signatureForToolCall) { + toolCallIds.push(id); + } } if (parts.length > 0) { @@ -414,7 +442,7 @@ function openaiToGeminiBase( // Check if there are actual tool responses in the next messages const hasSignaturelessTextResponses = - stringifySignaturelessToolCalls && + contextualizeSignaturelessToolResponses && toolCalls.some((tc) => { const id = tc.id as string; return tc.type === "function" && !resolvedSignatures.has(id) && toolResponses[id]; @@ -426,6 +454,7 @@ function openaiToGeminiBase( const toolParts: GeminiPart[] = []; for (const fid of toolCallIds) { if (!toolResponses[fid]) continue; + if (contextualizeSignaturelessToolResponses && !resolvedSignatures.has(fid)) continue; let name = tcID2Name[fid]; if (!name) { @@ -458,10 +487,13 @@ function openaiToGeminiBase( }); } - if (stringifySignaturelessToolCalls) { + if (contextualizeSignaturelessToolResponses) { // Signature-less historical tool responses are represented as text // so strict Gemini/Antigravity endpoints don't reject them as native // functionResponse parts missing a matching thoughtSignature. + // In context mode the matching historical functionCall is omitted, + // avoiding pseudo tool-call records that Gemini Flash can repeat as + // the visible final answer. for (const tc of toolCalls) { const id = tc.id as string; if (tc.type !== "function" || !id) continue; @@ -470,7 +502,10 @@ function openaiToGeminiBase( const name = tcID2Name[id] || fn?.name || "unknown"; const resp = toolResponses[id]; toolParts.push({ - text: buildInertHistoricalToolResponseText(name, resp), + text: + signaturelessToolCallMode === "text" + ? buildInertHistoricalToolResponseText(name, resp) + : buildHistoricalToolResultContext(name, resp), }); } } @@ -551,7 +586,7 @@ export function openaiToGeminiRequest( stream: boolean, credentials: Record | null = null, options: { - signaturelessToolCallMode?: "native" | "text"; + signaturelessToolCallMode?: "native" | "text" | "context"; } = {} ) { // Thread the signature namespace so a thinking model's thoughtSignature (cached on the @@ -576,7 +611,7 @@ export function openaiToGeminiCLIRequest( options: { functionResponseShape?: "result" | "output"; signatureNamespace?: string | null; - signaturelessToolCallMode?: "native" | "text"; + signaturelessToolCallMode?: "native" | "text" | "context"; } = {} ) { return openaiToGeminiBase(model, body, stream, { @@ -697,7 +732,7 @@ export function openaiToAntigravityRequest(model, body, stream, credentials = nu : null; const geminiCLI = openaiToGeminiCLIRequest(model, body, stream, { signatureNamespace, - signaturelessToolCallMode: isThinkingGemini ? "text" : "native", + signaturelessToolCallMode: isThinkingGemini ? "context" : "native", }); if (isClaude) { diff --git a/tests/unit/translator-openai-to-gemini.test.ts b/tests/unit/translator-openai-to-gemini.test.ts index d08523baec..db592b07f1 100644 --- a/tests/unit/translator-openai-to-gemini.test.ts +++ b/tests/unit/translator-openai-to-gemini.test.ts @@ -14,9 +14,16 @@ const { tryParseJSON, } = await import("../../open-sse/translator/helpers/geminiHelper.ts"); const { ANTIGRAVITY_DEFAULT_SYSTEM } = await import("../../open-sse/config/constants.ts"); +const { clearGeminiThoughtSignatures } = await import( + "../../open-sse/services/geminiThoughtSignatureStore.ts" +); type UnknownRecord = Record; +test.beforeEach(() => { + clearGeminiThoughtSignatures(); +}); + function getFunctionCall(part: unknown) { assert.ok(part && typeof part === "object", "expected Gemini functionCall part"); const functionCall = (part as UnknownRecord).functionCall; @@ -629,7 +636,7 @@ test("OpenAI -> Antigravity wraps Gemini requests in a Cloud Code envelope", () }); }); -test("OpenAI -> Antigravity Gemini preserves signature-less historical tool calls as inert text", () => { +test("OpenAI -> Antigravity Gemini omits signature-less historical tool calls and keeps response context", () => { const result = openaiToAntigravityRequest( "gemini-3.5-flash-low", { @@ -666,26 +673,23 @@ test("OpenAI -> Antigravity Gemini preserves signature-less historical tool call ); const modelTurn = result.request.contents.find((content) => content.role === "model"); - assert.ok(modelTurn, "expected a model turn"); assert.ok( - modelTurn.parts.some( - (part) => - typeof part.text === "string" && - part.text.includes("Historical tool-call record only") && - part.text.includes("Tool name: default_api:todowrite_ide") && - part.text.includes('Tool arguments JSON: {"todos":[]}') - ), - "expected signature-less tool call to be preserved as inert text" + !modelTurn || + !modelTurn.parts.some( + (part) => + typeof part.text === "string" && part.text.includes("Historical tool-call record only") + ), + "signature-less historical call must not be emitted as visible historical text" ); assert.equal( - modelTurn.parts.some( + modelTurn?.parts.some( (part) => typeof part.text === "string" && part.text.includes("[Tool call:") - ), + ) ?? false, false, "signature-less historical call must not use executable textual tool-call markers" ); assert.equal( - modelTurn.parts.some((part) => part.functionCall), + modelTurn?.parts.some((part) => part.functionCall) ?? false, false, "signature-less historical call must not be emitted as native functionCall" ); @@ -696,12 +700,11 @@ test("OpenAI -> Antigravity Gemini preserves signature-less historical tool call content.parts.some( (part) => typeof part.text === "string" && - part.text.includes("Historical tool-response record only") && - part.text.includes("Tool name: default_api:todowrite_ide") && - part.text.includes("Tool result: []") + part.text.includes('') && + part.text.includes("[]") ) ); - assert.ok(toolTurn, "expected signature-less tool response to be preserved as inert text"); + assert.ok(toolTurn, "expected signature-less tool response to be preserved as safe context"); assert.equal( toolTurn.parts.some( (part) => typeof part.text === "string" && part.text.includes("[Tool response:") @@ -716,7 +719,7 @@ test("OpenAI -> Antigravity Gemini preserves signature-less historical tool call ); }); -test("OpenAI -> Antigravity preserves multiple signature-less historical tool responses as text", () => { +test("OpenAI -> Antigravity preserves multiple signature-less historical tool responses as context", () => { const result = openaiToAntigravityRequest( "gemini-3.5-flash-low", { @@ -755,15 +758,27 @@ test("OpenAI -> Antigravity preserves multiple signature-less historical tool re ); const text = JSON.stringify(result.request.contents); - assert.ok(text.includes("Historical tool-call record only"), "expected signature-less calls as text"); - assert.ok(text.includes("Tool name: terminal"), "expected signature-less calls as text"); + assert.equal( + text.includes("Historical tool-call record only"), + false, + "signature-less calls must not be emitted as visible historical text" + ); + assert.equal( + text.includes("Tool arguments JSON"), + false, + "signature-less call arguments must not be emitted as visible text" + ); + assert.ok( + text.includes(''), + "expected signature-less responses as safe context" + ); assert.ok( text.includes("data/db.json: No such file"), - "expected first signature-less tool response as text" + "expected first signature-less tool response as context" ); assert.ok( text.includes("storage.sqlite"), - "expected second signature-less tool response as text" + "expected second signature-less tool response as context" ); assert.equal( result.request.contents.some((content) => content.parts.some((part) => part.functionResponse)), @@ -772,6 +787,52 @@ test("OpenAI -> Antigravity preserves multiple signature-less historical tool re ); }); +test("OpenAI -> Antigravity preserves signed Gemini tool calls in native form", async () => { + const { buildGeminiThoughtSignatureKey, storeGeminiThoughtSignature } = + await import("../../open-sse/services/geminiThoughtSignatureStore.ts"); + const ns = "conn-antigravity-signed"; + const toolId = "call_signed_history"; + storeGeminiThoughtSignature(buildGeminiThoughtSignatureKey(ns, toolId), "SIG_AG_SIGNED_XYZ"); + + const result = openaiToAntigravityRequest( + "gemini-3.5-flash-low", + { + messages: [ + { role: "user", content: "Read status" }, + { + role: "assistant", + tool_calls: [ + { + id: toolId, + type: "function", + function: { name: "read_file", arguments: '{"path":"status.txt"}' }, + }, + ], + }, + { role: "tool", tool_call_id: toolId, content: "ready" }, + ], + }, + false, + { projectId: "proj-antigravity-gemini", _signatureNamespace: ns } as any + ); + + const text = JSON.stringify(result.request.contents); + assert.ok(text.includes("SIG_AG_SIGNED_XYZ"), "cached signature must be preserved"); + assert.equal( + text.includes("previous_tool_result_context"), + false, + "signed tool calls must stay native, not context text" + ); + assert.ok( + result.request.contents.some((content) => content.parts.some((part) => part.functionCall)), + "signed historical call must be emitted as native functionCall" + ); + assert.ok( + result.request.contents.some((content) => content.parts.some((part) => part.functionResponse)), + "signed historical response must be emitted as native functionResponse" + ); +}); + test("OpenAI -> Antigravity maps Claude-family models to Gemini-compatible schema", () => { const result = openaiToAntigravityRequest( "claude-3-7-sonnet",