From 2a52c402ce15979f044304ce908ac3e490717abc Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Fri, 10 Jul 2026 19:39:44 -0300 Subject: [PATCH] fix(antigravity): surface aborted Gemini tool calls off end_turn (#6713) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(antigravity): surface aborted Gemini tool calls off end_turn Gemini/Antigravity aborts a turn with finishReason MALFORMED_FUNCTION_CALL (or a sibling like UNEXPECTED_TOOL_CALL) instead of completing cleanly. Both Claude-facing translators collapsed these to a clean end_turn, hiding the aborted tool call as a successful completion: - the OpenAI hub path (openai-to-claude.ts convertFinishReason default), and - the DIRECT Gemini->Claude path (gemini-to-claude.ts), which is the one Claude Code actually hits through an antigravity/Gemini-routed model. Add isAbortFinishReason() to finishReason.ts and map these reasons to tool_use on both paths; genuinely unknown reasons still fall back to end_turn. Co-authored-by: anhdiepmmk Inspired-by: https://github.com/decolua/9router/pull/2462 * chore(6713): re-sync onto release tip; CHANGELOG → changelog.d fragment (fragments-first) --------- Co-authored-by: anhdiepmmk --- .../6713-antigravity-abort-finish-reason.md | 1 + .../translator/response/gemini-to-claude.ts | 9 + .../translator/response/gemini-to-openai.ts | 6 + .../translator/response/openai-to-claude.ts | 12 +- open-sse/utils/finishReason.ts | 25 +++ ...d-function-call-finish-reason-2462.test.ts | 156 ++++++++++++++++++ 6 files changed, 208 insertions(+), 1 deletion(-) create mode 100644 changelog.d/fixes/6713-antigravity-abort-finish-reason.md create mode 100644 tests/unit/gemini-malformed-function-call-finish-reason-2462.test.ts diff --git a/changelog.d/fixes/6713-antigravity-abort-finish-reason.md b/changelog.d/fixes/6713-antigravity-abort-finish-reason.md new file mode 100644 index 0000000000..f94984faf6 --- /dev/null +++ b/changelog.d/fixes/6713-antigravity-abort-finish-reason.md @@ -0,0 +1 @@ +- **fix(antigravity):** surface aborted/malformed Gemini tool calls (e.g. `MALFORMED_FUNCTION_CALL`) as an explicit non-`end_turn` finish reason instead of a silent clean completion. (thanks @anhdiepmmk) diff --git a/open-sse/translator/response/gemini-to-claude.ts b/open-sse/translator/response/gemini-to-claude.ts index 2019c46100..f2c96bf137 100644 --- a/open-sse/translator/response/gemini-to-claude.ts +++ b/open-sse/translator/response/gemini-to-claude.ts @@ -1,5 +1,6 @@ import { register } from "../registry.ts"; import { FORMATS } from "../formats.ts"; +import { isAbortFinishReason } from "../../utils/finishReason.ts"; /** * Direct Gemini → Claude response translator. @@ -178,6 +179,14 @@ export function geminiToClaudeResponse(chunk, state) { // reason has already been emitted to the client — this is unavoidable in // SSE streaming. Map to end_turn (Claude has no "content blocked" reason). stopReason = "end_turn"; + } else if (isAbortFinishReason(reason)) { + // Aborted/malformed tool call (e.g. MALFORMED_FUNCTION_CALL, + // UNEXPECTED_TOOL_CALL). Surface as tool_use rather than a clean end_turn + // so the client sees the turn did not complete normally. Same fix as the + // hub path (openai-to-claude.ts) — this direct Gemini→Claude translator is + // the one Claude Code hits through an antigravity/Gemini-routed model. + // Port of decolua/9router#2462 by @anhdiepmmk. + stopReason = "tool_use"; } else { stopReason = "end_turn"; } diff --git a/open-sse/translator/response/gemini-to-openai.ts b/open-sse/translator/response/gemini-to-openai.ts index 9d8a8629fe..464a3feee3 100644 --- a/open-sse/translator/response/gemini-to-openai.ts +++ b/open-sse/translator/response/gemini-to-openai.ts @@ -729,6 +729,12 @@ export function geminiToOpenAIResponse(chunk, state) { // normalizeOpenAICompatibleFinishReasonString lowercases, maps max_tokens→length, // and folds Gemini safety reasons (safety/recitation/blocklist/...) → content_filter // so downstream clients can distinguish a blocked completion from a normal stop. + // Abort reasons (MALFORMED_FUNCTION_CALL, UNEXPECTED_TOOL_CALL, ...) are NOT in + // either mapped set, so they surface here unchanged (e.g. raw + // "malformed_function_call") rather than being folded into a misleading "stop" — + // isAbortFinishReason() (finishReason.ts) is what the openai→claude hub step + // uses downstream to recognize this raw value and keep it off a clean end_turn + // (9router#2462 sub-bug #2). let finishReason = normalizeOpenAICompatibleFinishReasonString(candidate.finishReason); if (finishReason === "stop" && state.toolCalls.size > 0) { finishReason = "tool_calls"; diff --git a/open-sse/translator/response/openai-to-claude.ts b/open-sse/translator/response/openai-to-claude.ts index e899778d4c..3998021733 100644 --- a/open-sse/translator/response/openai-to-claude.ts +++ b/open-sse/translator/response/openai-to-claude.ts @@ -3,6 +3,7 @@ import { FORMATS } from "../formats.ts"; import { CLAUDE_OAUTH_TOOL_PREFIX } from "../request/openai-to-claude.ts"; import { hasToolCallShim, applyToolCallShimToBuffer } from "../helpers/toolCallShim.ts"; import { appendToolCallArgumentDelta } from "../../utils/toolCallArguments.ts"; +import { isAbortFinishReason } from "../../utils/finishReason.ts"; // Helper: stop thinking block if started function stopThinkingBlock(state, results) { @@ -281,7 +282,16 @@ function convertFinishReason(reason) { case "tool_calls": return "tool_use"; default: - return "end_turn"; + // Gemini/Antigravity abort reasons (e.g. MALFORMED_FUNCTION_CALL, + // UNEXPECTED_TOOL_CALL — see isAbortFinishReason) reach here unrecognized + // after the OpenAI hub normalization. Collapsing them to a clean + // "end_turn" presents an aborted tool call to the client as a successful + // completion (9router#2462 sub-bug #2). Surface them as "tool_use" — + // the same non-clean-stop signal already used for real tool_calls above — + // so the client does not treat the turn as done. Genuinely unknown future + // reasons still fall back to "end_turn" so a benign new value does not + // start misreporting every Gemini-family turn as an unfinished tool call. + return isAbortFinishReason(reason) ? "tool_use" : "end_turn"; } } diff --git a/open-sse/utils/finishReason.ts b/open-sse/utils/finishReason.ts index 5d8ab33667..5bb0835adc 100644 --- a/open-sse/utils/finishReason.ts +++ b/open-sse/utils/finishReason.ts @@ -16,6 +16,31 @@ const SAFETY_FINISH_REASONS = new Set([ "malformed_response", ]); +// Gemini/Antigravity finish reasons that mean the model ABORTED the turn before +// completing it — most commonly a tool call the model started narrating but +// Gemini could not parse/execute (MALFORMED_FUNCTION_CALL, UNEXPECTED_TOOL_CALL). +// Distinct from SAFETY_FINISH_REASONS: those are deliberate, deterministic +// content blocks; these are execution failures mid tool-call. Left un-mapped +// here (still passed through raw, e.g. "malformed_function_call") so an +// OpenAI-format client at least sees a non-standard-but-honest value instead of +// a misleading "stop" — downstream Claude translation classifies them via +// isAbortFinishReason() so it does not collapse them to a clean "end_turn" +// (9router#2462 sub-bug #2: an aborted tool call must not present to the client +// as a successful completion). +const ABORT_FINISH_REASONS = new Set([ + "malformed_function_call", + "unexpected_tool_call", + "finish_reason_unspecified", + "other", + "language", + "no_image", +]); + +export function isAbortFinishReason(value: unknown): boolean { + if (typeof value !== "string") return false; + return ABORT_FINISH_REASONS.has(value.toLowerCase()); +} + export function normalizeOpenAICompatibleFinishReason(value: unknown): unknown { if (typeof value !== "string") return value; diff --git a/tests/unit/gemini-malformed-function-call-finish-reason-2462.test.ts b/tests/unit/gemini-malformed-function-call-finish-reason-2462.test.ts new file mode 100644 index 0000000000..4cda9fa761 --- /dev/null +++ b/tests/unit/gemini-malformed-function-call-finish-reason-2462.test.ts @@ -0,0 +1,156 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +// Upstream: decolua/9router#2462 sub-bug #2 (@anhdiepmmk). +// +// Gemini/Antigravity aborts a turn mid tool-call with finishReason +// MALFORMED_FUNCTION_CALL (or a sibling abort reason like UNEXPECTED_TOOL_CALL) +// instead of completing it cleanly. Before this fix: +// - open-sse/utils/finishReason.ts had no notion of these reasons, so they +// passed through the OpenAI hub unchanged (harmless on their own). +// - open-sse/translator/response/openai-to-claude.ts's convertFinishReason() +// collapsed ANY unrecognized OpenAI finish_reason to a clean "end_turn" in +// its default case — presenting an aborted tool call to the Claude client +// as a successful completion. +// This regression guard chains the real Gemini -> OpenAI -> Claude translator +// pipeline (mirroring translateResponse's hub-and-spoke Step 1 + Step 2) and +// asserts the Claude stop_reason is never a silent "end_turn" for these +// abort/error finish reasons, while a genuine clean STOP still maps to +// "end_turn" (no regression). + +const { geminiToOpenAIResponse } = await import( + "../../open-sse/translator/response/gemini-to-openai.ts" +); +const { openaiToClaudeResponse } = await import( + "../../open-sse/translator/response/openai-to-claude.ts" +); +const { geminiToClaudeResponse } = await import( + "../../open-sse/translator/response/gemini-to-claude.ts" +); + +// Direct Gemini -> Claude translator (the path Claude Code hits through an +// antigravity/Gemini-routed model — sourceFormat=CLAUDE, targetFormat=GEMINI — +// which bypasses the OpenAI hub). Its finishReason classifier had the identical +// bug: any unrecognized reason (incl. MALFORMED_FUNCTION_CALL) fell through to +// a clean "end_turn". +function runDirectGeminiToClaude(finishReason: string) { + const state: Record = {}; + const events = + geminiToClaudeResponse( + { + responseId: "resp-direct", + modelVersion: "gemini-2.5-pro", + candidates: [{ content: { parts: [{ text: "partial" }] }, finishReason, index: 0 }], + }, + state + ) || []; + const messageDelta = (events as Array>).find( + (event) => event.type === "message_delta" + ); + return (messageDelta?.delta as { stop_reason?: string } | undefined)?.stop_reason; +} + +function runGeminiToClaude(geminiChunk) { + const geminiState: { toolCalls: Map } = { toolCalls: new Map() }; + const openaiEvents = geminiToOpenAIResponse(geminiChunk, geminiState) || []; + + const claudeState: { toolCalls: Map } = { toolCalls: new Map() }; + const claudeEvents: Array> = []; + for (const chunk of openaiEvents) { + const converted = openaiToClaudeResponse(chunk, claudeState); + if (converted) claudeEvents.push(...converted); + } + return { openaiEvents, claudeEvents }; +} + +test("Gemini MALFORMED_FUNCTION_CALL does not surface as a clean Claude end_turn", () => { + const { openaiEvents, claudeEvents } = runGeminiToClaude({ + responseId: "resp-malformed", + modelVersion: "gemini-2.5-pro", + candidates: [ + { + content: { parts: [{ text: "partial text" }] }, + finishReason: "MALFORMED_FUNCTION_CALL", + index: 0, + }, + ], + }); + + // Sanity: the OpenAI hub must not silently rewrite it to a clean "stop" either. + const openaiFinish = openaiEvents.at(-1)?.choices?.[0]?.finish_reason; + assert.notEqual(openaiFinish, "stop"); + + const messageDelta = claudeEvents.find((event) => event.type === "message_delta"); + assert.ok(messageDelta, "expected a Claude message_delta terminal event"); + const stopReason = (messageDelta.delta as { stop_reason?: string }).stop_reason; + assert.notEqual(stopReason, "end_turn"); +}); + +test("Gemini UNEXPECTED_TOOL_CALL does not surface as a clean Claude end_turn", () => { + const { claudeEvents } = runGeminiToClaude({ + responseId: "resp-unexpected", + modelVersion: "gemini-2.5-pro", + candidates: [ + { + content: { parts: [{ text: "partial text" }] }, + finishReason: "UNEXPECTED_TOOL_CALL", + index: 0, + }, + ], + }); + + const messageDelta = claudeEvents.find((event) => event.type === "message_delta"); + assert.ok(messageDelta, "expected a Claude message_delta terminal event"); + const stopReason = (messageDelta.delta as { stop_reason?: string }).stop_reason; + assert.notEqual(stopReason, "end_turn"); +}); + +test("Gemini clean STOP still maps to Claude end_turn (no regression)", () => { + const { claudeEvents } = runGeminiToClaude({ + responseId: "resp-clean", + modelVersion: "gemini-2.5-pro", + candidates: [ + { + content: { parts: [{ text: "All done." }] }, + finishReason: "STOP", + index: 0, + }, + ], + }); + + const messageDelta = claudeEvents.find((event) => event.type === "message_delta"); + assert.ok(messageDelta, "expected a Claude message_delta terminal event"); + const stopReason = (messageDelta.delta as { stop_reason?: string }).stop_reason; + assert.equal(stopReason, "end_turn"); +}); + +test("direct Gemini->Claude: MALFORMED_FUNCTION_CALL does not surface as a clean end_turn", () => { + assert.notEqual(runDirectGeminiToClaude("MALFORMED_FUNCTION_CALL"), "end_turn"); +}); + +test("direct Gemini->Claude: UNEXPECTED_TOOL_CALL does not surface as a clean end_turn", () => { + assert.notEqual(runDirectGeminiToClaude("UNEXPECTED_TOOL_CALL"), "end_turn"); +}); + +test("direct Gemini->Claude: clean STOP still maps to end_turn (no regression)", () => { + assert.equal(runDirectGeminiToClaude("STOP"), "end_turn"); +}); + +test("Gemini MAX_TOKENS still maps to Claude max_tokens (no regression)", () => { + const { claudeEvents } = runGeminiToClaude({ + responseId: "resp-length", + modelVersion: "gemini-2.5-pro", + candidates: [ + { + content: { parts: [{ text: "Truncated" }] }, + finishReason: "MAX_TOKENS", + index: 0, + }, + ], + }); + + const messageDelta = claudeEvents.find((event) => event.type === "message_delta"); + assert.ok(messageDelta, "expected a Claude message_delta terminal event"); + const stopReason = (messageDelta.delta as { stop_reason?: string }).stop_reason; + assert.equal(stopReason, "max_tokens"); +});