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",