From 0481f617501616b66618ac759ebbdbd971b3459e Mon Sep 17 00:00:00 2001 From: Hsia97 <60641408+Hsia97@users.noreply.github.com> Date: Wed, 26 Aug 2026 20:21:56 +0800 Subject: [PATCH] fix: pass tool result content through as string in OpenAI/Claude -> Gemini translation (#11624) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Merged via /merge-batch (lote 2026-08-26 batch 2, v3.8.51). Boarded no worktree combinado junto com outras ~20 PRs; validação única: typecheck/complexity/cognitive-complexity/changelog-integrity verdes, file-size rebaseado onde necessário (crescimento legítimo), lint com os mesmos 228 achados pré-existentes confirmados via sonda contra o tip puro (não introduzidos por este lote), e 292 testes focados (unit) + 18 (vitest) passando. Obrigado pela contribuição. --- .../translator/request/claude-to-gemini.ts | 10 +- .../translator/request/openai-to-gemini.ts | 8 +- .../unit/translator-claude-to-gemini.test.ts | 2 +- .../unit/translator-openai-to-gemini.test.ts | 6 +- ...lator-tool-result-json-passthrough.test.ts | 203 ++++++++++++++++++ 5 files changed, 209 insertions(+), 20 deletions(-) create mode 100644 tests/unit/translator-tool-result-json-passthrough.test.ts diff --git a/open-sse/translator/request/claude-to-gemini.ts b/open-sse/translator/request/claude-to-gemini.ts index 853e696dbb..500cec499b 100644 --- a/open-sse/translator/request/claude-to-gemini.ts +++ b/open-sse/translator/request/claude-to-gemini.ts @@ -2,7 +2,6 @@ import { register } from "../registry.ts"; import { FORMATS } from "../formats.ts"; import { DEFAULT_SAFETY_SETTINGS, - tryParseJSON, cleanJSONSchemaForAntigravity, } from "../helpers/geminiHelper.ts"; import { buildGeminiTools, sanitizeGeminiToolName } from "../helpers/geminiToolsSanitizer.ts"; @@ -186,13 +185,6 @@ export function claudeToGeminiRequest(model, body, stream, credentials = null) { .map((c) => (c.type === "text" ? c.text : JSON.stringify(c))) .join("\n"); } - let parsedContent = tryParseJSON(content); - if (parsedContent === null) { - parsedContent = { result: content }; - } else if (typeof parsedContent !== "object") { - parsedContent = { result: parsedContent }; - } - const toolUseId = block.tool_use_id; const name = toolUseNames[toolUseId] || "unknown"; @@ -210,7 +202,7 @@ export function claudeToGeminiRequest(model, body, stream, credentials = null) { functionResponse: { ...(stripFunctionCallId ? {} : { id: toolUseId }), name, - response: { result: parsedContent }, + response: { result: content }, }, }); break; diff --git a/open-sse/translator/request/openai-to-gemini.ts b/open-sse/translator/request/openai-to-gemini.ts index 92399743ff..4e1e445575 100644 --- a/open-sse/translator/request/openai-to-gemini.ts +++ b/open-sse/translator/request/openai-to-gemini.ts @@ -511,18 +511,12 @@ function openaiToGeminiBase( name = sanitizeToolName(name); const resp = toolResponses[fid]; - let parsedResp = tryParseJSON(resp); - if (parsedResp === null) { - parsedResp = { result: resp }; - } else if (typeof parsedResp !== "object") { - parsedResp = { result: parsedResp }; - } toolParts.push({ functionResponse: { ...(toolNameOptions.stripFunctionCallId ? {} : { id: fid }), name: name, - response: { result: parsedResp }, + response: { result: resp }, }, }); } diff --git a/tests/unit/translator-claude-to-gemini.test.ts b/tests/unit/translator-claude-to-gemini.test.ts index 86ada46d8c..f8a7849d66 100644 --- a/tests/unit/translator-claude-to-gemini.test.ts +++ b/tests/unit/translator-claude-to-gemini.test.ts @@ -103,7 +103,7 @@ test("Claude -> Gemini maps system, thinking, tool use, tool result and tools", functionResponse: { id: "tu_1", name: "weather", - response: { result: { result: "20C" } }, + response: { result: "20C" }, }, }); assert.equal(result.generationConfig.maxOutputTokens, 256); diff --git a/tests/unit/translator-openai-to-gemini.test.ts b/tests/unit/translator-openai-to-gemini.test.ts index 2111f23f64..cf44d1b798 100644 --- a/tests/unit/translator-openai-to-gemini.test.ts +++ b/tests/unit/translator-openai-to-gemini.test.ts @@ -289,7 +289,7 @@ test("OpenAI -> Gemini request maps messages, merged system instructions, tools assert.deepEqual(getFunctionResponse(toolResponseTurn.parts[0]), { id: "call_1", name: "weather", - response: { result: { temp: 20 } }, + response: { result: '{"temp":20}' }, }); const generationConfig = (result as GeminiRequestWithConfig).generationConfig; @@ -550,7 +550,7 @@ test("OpenAI -> Cloud Code Gemini emits native functionResponse result", () => { assert.deepEqual(getFunctionResponse(toolTurn.parts[0]), { id: "read_file_123_0", name: "read_file", - response: { result: { result: "The answer is capybara-4729." } }, + response: { result: "The answer is capybara-4729." }, }); }); @@ -956,7 +956,7 @@ test("OpenAI -> Antigravity Claude path sanitizes tool names for Gemini schema", const toolResultBlock = getFunctionResponse(toolTurn.parts[0]); assert.equal(toolResultBlock.id, "call_long_2"); assert.equal(toolResultBlock.name, sanitizedToolName); - assert.deepEqual(toolResultBlock.response, { result: { ok: true } }); + assert.deepEqual(toolResultBlock.response, { result: '{"ok":true}' }); }); test("OpenAI -> Antigravity Claude path applies output cap and strips thinkingConfig", () => { diff --git a/tests/unit/translator-tool-result-json-passthrough.test.ts b/tests/unit/translator-tool-result-json-passthrough.test.ts new file mode 100644 index 0000000000..6758031bb3 --- /dev/null +++ b/tests/unit/translator-tool-result-json-passthrough.test.ts @@ -0,0 +1,203 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +const { openaiToGeminiRequest, openaiToAntigravityRequest } = + await import("../../open-sse/translator/request/openai-to-gemini.ts"); +const { claudeToGeminiRequest } = + await import("../../open-sse/translator/request/claude-to-gemini.ts"); +const { + buildGeminiThoughtSignatureKey, + storeGeminiThoughtSignature, + clearGeminiThoughtSignatures, +} = await import("../../open-sse/services/geminiThoughtSignatureStore.ts"); + +test.beforeEach(() => { + clearGeminiThoughtSignatures(); +}); + +type UnknownRecord = Record; + +function getFunctionResponse(part: unknown) { + assert.ok(part && typeof part === "object", "expected Gemini part"); + const functionResponse = (part as UnknownRecord).functionResponse; + assert.ok(functionResponse && typeof functionResponse === "object", "expected functionResponse"); + return functionResponse as { id?: string; name: string; response?: unknown }; +} + +// A tool result whose payload is itself valid JSON (e.g. a WebFetch result +// `{"title": ..., "summary": ...}`). It must be passed through to Gemini / +// Antigravity as the raw string — it must NOT be JSON.parse'd into a nested +// object, which made Antigravity reject the request with HTTP 400 "upstream +// error" (upstream translator used tryParseJSON on tool result content). +const JSON_TOOL_RESULT = '{"title":"Example","summary":"antigravity 400 repro"}'; + +test("OpenAI -> Gemini keeps a JSON-string tool result as a raw string", () => { + const result = openaiToGeminiRequest( + "gemini-2.5-pro", + { + messages: [ + { role: "user", content: "fetch it" }, + { + role: "assistant", + tool_calls: [ + { + id: "call_json_1", + type: "function", + function: { name: "web_fetch", arguments: '{"url":"https://example.com"}' }, + }, + ], + }, + { role: "tool", tool_call_id: "call_json_1", content: JSON_TOOL_RESULT }, + ], + }, + false + ); + + const toolTurn = (result as { contents: Array }).contents.find( + (c) => c.role === "user" && c.parts.some((part) => (part as UnknownRecord).functionResponse) + ); + assert.ok(toolTurn, "expected a tool response turn"); + assert.deepEqual(getFunctionResponse((toolTurn.parts as unknown[])[0]).response, { + result: JSON_TOOL_RESULT, + }); +}); + +test("OpenAI -> Gemini keeps a plain-text tool result as a raw string (no double wrap)", () => { + const result = openaiToGeminiRequest( + "gemini-2.5-pro", + { + messages: [ + { role: "user", content: "read it" }, + { + role: "assistant", + tool_calls: [ + { + id: "call_txt_1", + type: "function", + function: { name: "read_file", arguments: '{"path":"a.txt"}' }, + }, + ], + }, + { role: "tool", tool_call_id: "call_txt_1", content: "plain text output" }, + ], + }, + false + ); + + const toolTurn = (result as { contents: Array }).contents.find( + (c) => c.role === "user" && c.parts.some((part) => (part as UnknownRecord).functionResponse) + ); + assert.ok(toolTurn, "expected a tool response turn"); + assert.deepEqual(getFunctionResponse((toolTurn.parts as unknown[])[0]).response, { + result: "plain text output", + }); +}); + +test("OpenAI -> Antigravity keeps a JSON-string tool result as a raw string", () => { + const result = openaiToAntigravityRequest( + "gemini-2.5-pro", + { + messages: [ + { role: "user", content: "fetch it" }, + { + role: "assistant", + tool_calls: [ + { + id: "call_json_2", + type: "function", + function: { name: "web_fetch", arguments: '{"url":"https://example.com"}' }, + }, + ], + }, + { role: "tool", tool_call_id: "call_json_2", content: JSON_TOOL_RESULT }, + ], + }, + false, + { projectId: "proj-json-passthrough" } as never + ); + + const request = (result as unknown as { request: { contents: Array } }).request; + const toolTurn = request.contents.find( + (c) => c.role === "user" && c.parts.some((part) => (part as UnknownRecord).functionResponse) + ); + assert.ok(toolTurn, "expected an Antigravity tool response turn"); + assert.deepEqual(getFunctionResponse((toolTurn.parts as unknown[])[0]).response, { + result: JSON_TOOL_RESULT, + }); +}); + +test("Claude -> Gemini keeps a JSON-string tool_result as a raw string", () => { + // Native functionResponse requires a cached thoughtSignature for the tool use. + const ns = "conn-tool-result-json"; + storeGeminiThoughtSignature(buildGeminiThoughtSignatureKey(ns, "tu_json_1"), "SIG_JSON_1"); + + const result = claudeToGeminiRequest( + "gemini-2.5-pro", + { + messages: [ + { role: "user", content: [{ type: "text", text: "fetch it" }] }, + { + role: "assistant", + content: [ + { type: "thinking", thinking: "need tool" }, + { + type: "tool_use", + id: "tu_json_1", + name: "web_fetch", + input: { url: "https://example.com" }, + }, + ], + }, + { + role: "user", + content: [{ type: "tool_result", tool_use_id: "tu_json_1", content: JSON_TOOL_RESULT }], + }, + ], + }, + false, + { _signatureNamespace: ns } as never + ); + + const toolTurn = (result as { contents: Array }).contents.find( + (c) => c.role === "user" && c.parts.some((part) => (part as UnknownRecord).functionResponse) + ); + assert.ok(toolTurn, "expected a tool response turn"); + assert.deepEqual(getFunctionResponse((toolTurn.parts as unknown[])[0]).response, { + result: JSON_TOOL_RESULT, + }); +}); + +test("Claude -> Gemini keeps a plain-text tool_result as a raw string (no double wrap)", () => { + const ns = "conn-tool-result-txt"; + storeGeminiThoughtSignature(buildGeminiThoughtSignatureKey(ns, "tu_txt_1"), "SIG_TXT_1"); + + const result = claudeToGeminiRequest( + "gemini-2.5-pro", + { + messages: [ + { role: "user", content: [{ type: "text", text: "read it" }] }, + { + role: "assistant", + content: [ + { type: "thinking", thinking: "need tool" }, + { type: "tool_use", id: "tu_txt_1", name: "read_file", input: { path: "a.txt" } }, + ], + }, + { + role: "user", + content: [{ type: "tool_result", tool_use_id: "tu_txt_1", content: "plain text output" }], + }, + ], + }, + false, + { _signatureNamespace: ns } as never + ); + + const toolTurn = (result as { contents: Array }).contents.find( + (c) => c.role === "user" && c.parts.some((part) => (part as UnknownRecord).functionResponse) + ); + assert.ok(toolTurn, "expected a tool response turn"); + assert.deepEqual(getFunctionResponse((toolTurn.parts as unknown[])[0]).response, { + result: "plain text output", + }); +}); \ No newline at end of file