diff --git a/config/quality/file-size-baseline.json b/config/quality/file-size-baseline.json index c25f3ea39f..9fcd6f0aad 100644 --- a/config/quality/file-size-baseline.json +++ b/config/quality/file-size-baseline.json @@ -214,6 +214,8 @@ "open-sse/services/usage.ts": 3454, "open-sse/translator/request/openai-to-gemini.ts": 906, "open-sse/translator/request/openai-to-kiro.ts": 912, + "_rebaseline_2026_07_22_8211_gemini_malformed_tool_choice": "PR #8211 (hartmark, fix/gemini-malformed-function-call-tool-choice) own growth: open-sse/translator/response/gemini-to-openai.ts 771->821 (+50, entirely this PR's diff — no other commit touched this file between the PR's merge-base and the release tip). Adds MALFORMED_FUNCTION_CALL/UNEXPECTED_TOOL_CALL handling inside geminiToOpenAIResponse(): synthesizes a `malformed_tool_call` tool_calls entry so finish_reason normalizes to the standard \"tool_calls\" instead of an unrecognized raw enum value that OpenAI-compatible clients (e.g. OpenClaw) silently ignore, and always synthesizes (rather than skipping when a real tool call already exists) so a malformed attempt alongside a real one in the same turn is not silently discarded. Irreducible cohesive addition at the existing candidate/finishReason translation chokepoint (mirrors the 9router#2462 raw-finish-reason precedent immediately below it in the same function). Covered by the PR's own tests/unit test additions for both the malformed-only and malformed-plus-real-call cases.", + "open-sse/translator/response/gemini-to-openai.ts": 821, "_rebaseline_2026_07_22_7936_namespace_roundtrip": "#7936 (@RCrushMe, Responses-Chat namespace round-trip identity seam) own growth: open-sse/translator/response/openai-responses.ts 1092->1125 (+33) and open-sse/utils/stream.ts 2814->2869 (+55) — threading the namespace-identity seam through the Responses↔Chat translation + stream paths so tool-call namespaces survive the round-trip. Cohesive translation/stream wiring at existing chokepoints, frozen at new size.", "open-sse/translator/response/openai-responses.ts": 1137, "open-sse/utils/cursorAgentProtobuf.ts": 1521, diff --git a/open-sse/translator/request/openai-to-gemini.ts b/open-sse/translator/request/openai-to-gemini.ts index f5c1a3ee04..f2fb354980 100644 --- a/open-sse/translator/request/openai-to-gemini.ts +++ b/open-sse/translator/request/openai-to-gemini.ts @@ -64,6 +64,11 @@ type GeminiFunctionDeclaration = { parameters: unknown; }; +type GeminiFunctionCallingConfig = { + mode: string; + allowedFunctionNames?: string[]; +}; + type GeminiRequest = { model: string; contents?: GeminiContent[]; @@ -75,10 +80,43 @@ type GeminiRequest = { functionDeclarations?: GeminiFunctionDeclaration[]; googleSearch?: Record; }>; + toolConfig?: { functionCallingConfig: GeminiFunctionCallingConfig }; cachedContent?: string; _toolNameMap?: Map; }; +// Convert OpenAI tool_choice into Gemini's functionCallingConfig mode. Mirrors +// convertOpenAIToolChoice in openai-to-claude.ts (same enum shapes from the client). +// Gemini's modes: AUTO (model decides), ANY (must call a function — OpenAI's +// "required"), NONE (never call), VALIDATED (may call a function OR respond with +// plain text, but any call it makes is schema-validated — this was the unconditional +// hardcoded default before tool_choice was wired up at all, so it stays the fallback +// for "auto"/unset to avoid changing existing behavior for the common case). +// +// Live investigation: gemini-3.1-flash-lite frequently narrates an intended tool call +// in plain text ("I'm running python3 now...") instead of actually emitting one +// (dashboard log id 1784591483850-49c408) — VALIDATED mode never forces a call, so +// the model is always free to just talk instead of act. tool_choice: "required" (-> +// ANY) is the lever a caller has to prevent that, but it was silently ignored until +// this fix — body.tool_choice was never read anywhere in this file. +function convertOpenAIToolChoiceToGemini(choice: unknown): GeminiFunctionCallingConfig { + if (!choice) return { mode: "VALIDATED" }; + if (typeof choice === "string") { + if (choice === "none") return { mode: "NONE" }; + if (choice === "required" || choice === "any") return { mode: "ANY" }; + return { mode: "VALIDATED" }; // "auto" or unrecognized string + } + if (typeof choice === "object") { + const c = choice as { type?: string; function?: { name?: string } }; + if (c.type === "function" && c.function?.name) { + return { mode: "ANY", allowedFunctionNames: [c.function.name] }; + } + if (c.type === "none") return { mode: "NONE" }; + if (c.type === "required" || c.type === "any") return { mode: "ANY" }; + } + return { mode: "VALIDATED" }; +} + type CloudCodeEnvelope = { project: string; model?: string; @@ -539,7 +577,9 @@ function openaiToGeminiBase( if (hasGoogleSearch) { result.tools.push({ googleSearch: {} }); } - result.toolConfig = { functionCallingConfig: { mode: "VALIDATED" } }; + result.toolConfig = { + functionCallingConfig: convertOpenAIToolChoiceToGemini(body.tool_choice), + }; } else if (hasGoogleSearch) { result.tools = [{ googleSearch: {} }]; } @@ -700,7 +740,10 @@ function wrapInCloudCodeEnvelope(model, cloudCodeRequest, credentials = null) { (tool) => (tool.functionDeclarations?.length ?? 0) > 0 ); if (hasCustomTools) { - envelope.request.toolConfig = { + // Reuse the toolConfig openaiToGeminiBase already computed from the caller's + // tool_choice (cloudCodeRequest is that function's return value) instead of + // re-deriving a hardcoded default here — see convertOpenAIToolChoiceToGemini. + envelope.request.toolConfig = cloudCodeRequest.toolConfig ?? { functionCallingConfig: { mode: "VALIDATED" }, }; } diff --git a/open-sse/translator/response/gemini-to-openai.ts b/open-sse/translator/response/gemini-to-openai.ts index 464a3feee3..3f915b5ec2 100644 --- a/open-sse/translator/response/gemini-to-openai.ts +++ b/open-sse/translator/response/gemini-to-openai.ts @@ -8,7 +8,10 @@ import { parseTextualToolCallCandidate, containsTextualToolCallMarker, } from "../../utils/textualToolCall.ts"; -import { normalizeOpenAICompatibleFinishReasonString } from "../../utils/finishReason.ts"; +import { + normalizeOpenAICompatibleFinishReasonString, + isMalformedToolCallFinishReason, +} from "../../utils/finishReason.ts"; import { stripAnsiCodes } from "../../utils/streamHelpers.ts"; type GeminiToOpenAIState = { @@ -726,6 +729,48 @@ export function geminiToOpenAIResponse(chunk, state) { } } + // Live incident (dashboard log id 1784489701456-d8c0e9): MALFORMED_FUNCTION_CALL / + // UNEXPECTED_TOOL_CALL mean Gemini's OWN parser rejected an attempted tool call — + // there is no real functionCall part to translate, only a human-readable + // finishMessage. Passing "malformed_function_call" through raw as finish_reason + // (the 9router#2462 fix below) is honest but useless to a real OpenAI-format + // client: it isn't one of the 5 values the spec defines, so a client like OpenClaw + // has no handling for it at all and just silently never notices the turn failed. + // Synthesize a tool_calls entry instead so finish_reason becomes the standard + // "tool_calls" — that routes the failure into the ordinary "tool call arguments + // didn't parse" path every OpenAI-compatible agent loop already handles, instead + // of an unrecognized enum value nothing is watching for. + // + // Follow-up live incident (log id 1784589106014-2a42f8): Gemini can emit a REAL, + // valid functionCall (e.g. "openclaw") AND still finish the SAME candidate with + // MALFORMED_FUNCTION_CALL — the model attempted multiple tool calls in one turn, + // one parsed cleanly and another (e.g. "exec"+"cron") did not. The first version of + // this fix skipped synthesis whenever state.toolCalls.size > 0, on the assumption + // that a real call already existing meant the model was retrying a LATER, separate + // attempt after an earlier one already succeeded — but that's indistinguishable, + // from here, from a real call and a malformed one arriving in the very same turn. + // Skipping silently discarded the malformed attempt's failure entirely: the client + // saw "openclaw" succeed and never learned "exec"/"cron" were attempted and + // rejected. Always synthesize instead — multiple tool_calls in one response is + // normal, well-supported OpenAI behavior (parallel tool calls), so this adds the + // failure as an additional entry rather than replacing or hiding the real one. + const isMalformedToolCall = isMalformedToolCallFinishReason(candidate.finishReason); + if (isMalformedToolCall) { + emitFunctionCallPart( + { + functionCall: { + name: "malformed_tool_call", + args: { + error: candidate.finishReason, + message: typeof candidate.finishMessage === "string" ? candidate.finishMessage : null, + }, + }, + }, + state, + results + ); + } + // 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. @@ -736,7 +781,11 @@ export function geminiToOpenAIResponse(chunk, state) { // 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) { + if ((finishReason === "stop" || isMalformedToolCall) && state.toolCalls.size > 0) { + // Covers (1) a clean stop with tool calls already accumulated (pre-existing + // behavior, unchanged) and (2) any malformed-tool-call abort — always true here + // since the synthesis above guarantees state.toolCalls.size > 0 whenever + // isMalformedToolCall is true. finishReason = "tool_calls"; } diff --git a/open-sse/utils/finishReason.ts b/open-sse/utils/finishReason.ts index 5bb0835adc..54320dde19 100644 --- a/open-sse/utils/finishReason.ts +++ b/open-sse/utils/finishReason.ts @@ -41,6 +41,26 @@ export function isAbortFinishReason(value: unknown): boolean { return ABORT_FINISH_REASONS.has(value.toLowerCase()); } +// Subset of ABORT_FINISH_REASONS that specifically means "the model attempted a +// tool call and Gemini's parser rejected it" (as opposed to language/no_image/ +// other, which aren't about tool calls at all). Live incident (dashboard log id +// 1784489701456-d8c0e9): passing "malformed_function_call" through raw as +// finish_reason left a real OpenAI-format client (OpenClaw) with a value it has +// no handling for at all — no tool_calls array, no recognized terminal state — so +// it silently never noticed the turn failed. gemini-to-openai.ts uses this to +// synthesize a tool_calls entry and finish_reason: "tool_calls" instead, routing +// the failure into the ordinary "tool call arguments didn't parse" path every +// OpenAI-compatible agent loop already has to handle. +const MALFORMED_TOOL_CALL_FINISH_REASONS = new Set([ + "malformed_function_call", + "unexpected_tool_call", +]); + +export function isMalformedToolCallFinishReason(value: unknown): boolean { + if (typeof value !== "string") return false; + return MALFORMED_TOOL_CALL_FINISH_REASONS.has(value.toLowerCase()); +} + export function normalizeOpenAICompatibleFinishReason(value: unknown): unknown { if (typeof value !== "string") return value; diff --git a/scripts/check/check-t11-any-budget.mjs b/scripts/check/check-t11-any-budget.mjs index 3330aa2158..2de446192d 100644 --- a/scripts/check/check-t11-any-budget.mjs +++ b/scripts/check/check-t11-any-budget.mjs @@ -69,7 +69,12 @@ const budget = [ { file: "open-sse/utils/tlsClient.ts", maxAny: 0 }, { file: "open-sse/utils/proxyFetch.ts", maxAny: 0 }, { file: "open-sse/utils/error.ts", maxAny: 0 }, - { file: "open-sse/translator/request/openai-to-gemini.ts", maxAny: 0 }, + // 2 FALSE POSITIVES: convertOpenAIToolChoiceToGemini() compares the OpenAI + // `tool_choice` value against the STRING literal "any" (`choice === "any"` and + // `c.type === "any"`) — same tool_choice-detection pattern as the executors/base.ts + // entry above. The checker strips comments but not strings, and there are zero + // actual TypeScript `any` types in this file. Budget set to the matched count. + { file: "open-sse/translator/request/openai-to-gemini.ts", maxAny: 2 }, { file: "open-sse/translator/request/antigravity-to-openai.ts", maxAny: 0 }, { file: "open-sse/translator/request/claude-to-openai.ts", maxAny: 0 }, { file: "open-sse/handlers/audioTranscription.ts", maxAny: 0 }, diff --git a/tests/fixtures/translation/gemini-malformed-function-call-parallel-real-call-stream.json b/tests/fixtures/translation/gemini-malformed-function-call-parallel-real-call-stream.json new file mode 100644 index 0000000000..ce49b3fa57 --- /dev/null +++ b/tests/fixtures/translation/gemini-malformed-function-call-parallel-real-call-stream.json @@ -0,0 +1,98 @@ +{ + "name": "gemini-malformed-function-call-parallel-real-call-live-incident", + "description": "Real raw Gemini streaming response chunks captured from a live incident (dashboard log id 1784589106014-2a42f8, 2026-07-20) — a follow-up to gemini-malformed-function-call-stream.json. Unlike that fixture (no real functionCall at all), THIS incident shows Gemini emitting a REAL, valid functionCall in the same turn where it ALSO attempts a separate malformed multi-call and finishes the candidate with finishReason MALFORMED_FUNCTION_CALL. An earlier version of the fix skipped synthesizing a failure signal whenever a real tool call already existed, which silently discarded the malformed attempt's information in exactly this case. Content sanitized: personal file paths replaced with generic placeholders. Tool name genericized (real incident used a self-referential 'check status of this agent' tool named after the client itself). Structure (chunk count, field shapes, thoughtSignature split across chunks) preserved exactly as captured.", + "chunks": [ + { + "candidates": [ + { + "content": { + "parts": [ + { + "text": "**Re-evaluating the approach**\n\nI've hit a roadblock with the status request; the previous attempt was unsuccessful and subsequently aborted. I'm now reassessing the current strategy, and considering how to proceed. I'll need to explore alternative methods, or perhaps inform the user of the limitations I'm facing at this time.\n\n\n", + "thought": true + } + ], + "role": "model" + }, + "index": 0 + } + ], + "usageMetadata": { + "promptTokenCount": 34579, + "totalTokenCount": 34579, + "promptTokensDetails": [{ "modality": "TEXT", "tokenCount": 34579 }], + "serviceTier": "standard" + }, + "modelVersion": "gemini-3.1-flash-lite", + "responseId": "resp-fixture-parallel-001" + }, + { + "candidates": [ + { + "content": { + "parts": [ + { + "functionCall": { + "name": "check_status", + "args": { "message": "status" }, + "id": "zgtP7IsV" + }, + "thoughtSignature": "FIXTURE_PLACEHOLDER_THOUGHT_SIGNATURE_OPAQUE_BASE64_BLOB_TRUNCATED_FOR_READABILITY==" + } + ], + "role": "model" + }, + "index": 0 + } + ], + "usageMetadata": { + "promptTokenCount": 34579, + "candidatesTokenCount": 15, + "totalTokenCount": 35035, + "promptTokensDetails": [{ "modality": "TEXT", "tokenCount": 34579 }], + "thoughtsTokenCount": 441, + "serviceTier": "standard" + }, + "modelVersion": "gemini-3.1-flash-lite", + "responseId": "resp-fixture-parallel-001" + }, + { + "candidates": [ + { + "content": { "parts": [{ "text": "" }], "role": "model" }, + "index": 0 + } + ], + "usageMetadata": { + "promptTokenCount": 34579, + "candidatesTokenCount": 58, + "totalTokenCount": 35078, + "promptTokensDetails": [{ "modality": "TEXT", "tokenCount": 34579 }], + "thoughtsTokenCount": 441, + "serviceTier": "standard" + }, + "modelVersion": "gemini-3.1-flash-lite", + "responseId": "resp-fixture-parallel-001" + }, + { + "candidates": [ + { + "content": { "parts": [{ "text": "" }], "role": "model" }, + "finishReason": "MALFORMED_FUNCTION_CALL", + "index": 0, + "finishMessage": "Malformed function call: call:default_api:exec{command: df -h /home/user/workspace }\n call:default_api:cron{action: status } }" + } + ], + "usageMetadata": { + "promptTokenCount": 34579, + "candidatesTokenCount": 58, + "totalTokenCount": 35078, + "promptTokensDetails": [{ "modality": "TEXT", "tokenCount": 34579 }], + "thoughtsTokenCount": 441, + "serviceTier": "standard" + }, + "modelVersion": "gemini-3.1-flash-lite", + "responseId": "resp-fixture-parallel-001" + } + ] +} diff --git a/tests/fixtures/translation/gemini-malformed-function-call-stream.json b/tests/fixtures/translation/gemini-malformed-function-call-stream.json new file mode 100644 index 0000000000..f769e295b3 --- /dev/null +++ b/tests/fixtures/translation/gemini-malformed-function-call-stream.json @@ -0,0 +1,144 @@ +{ + "name": "gemini-malformed-function-call-live-incident", + "description": "Real raw Gemini streaming response chunks captured from a live incident (dashboard log id 1784489701456-d8c0e9, 2026-07-19) where the model attempted to chain multiple tool calls into one malformed invocation and Gemini's own parser rejected it with finishReason MALFORMED_FUNCTION_CALL instead of a real functionCall part. Content has been sanitized: real file paths, personal automation project details, and job-search site URLs were replaced with generic placeholders. Structure (chunk count, field shapes, thought-then-empty-transition-then-malformed-finish sequence) is preserved exactly as captured, including the long opaque thoughtSignature blob (its exact bytes are irrelevant — only its presence/shape matters — so it has been shortened for fixture readability).", + "chunks": [ + { + "candidates": [ + { + "content": { + "parts": [ + { + "text": "**Initiating the Workflow**\n\nI've initiated the workflow, focusing on running `run_search.py`. The preliminary step is complete. Now, I'm fetching the initial target sites, specifically `site-a.example`, `site-b.example`, and `site-c.example`, as specified to begin the search.\n\n\n", + "thought": true + } + ], + "role": "model" + }, + "index": 0 + } + ], + "usageMetadata": { + "promptTokenCount": 24512, + "totalTokenCount": 24512, + "promptTokensDetails": [{ "modality": "TEXT", "tokenCount": 24512 }], + "serviceTier": "standard" + }, + "modelVersion": "gemini-3.1-flash-lite", + "responseId": "resp-fixture-malformed-001" + }, + { + "candidates": [ + { + "content": { + "parts": [ + { + "text": "**Expanding the Search Parameters**\n\nI'm now expanding the search to include `site-d.example` and `site-e.example` in my `web_fetch` operation. My next steps are to filter the results against the state file and score them based on matching criteria. I will then post high matches to the results channel, update the state, and log the results.\n\n\n", + "thought": true + } + ], + "role": "model" + }, + "index": 0 + } + ], + "usageMetadata": { + "promptTokenCount": 24512, + "totalTokenCount": 24512, + "promptTokensDetails": [{ "modality": "TEXT", "tokenCount": 24512 }], + "serviceTier": "standard" + }, + "modelVersion": "gemini-3.1-flash-lite", + "responseId": "resp-fixture-malformed-001" + }, + { + "candidates": [ + { + "content": { + "parts": [ + { + "text": "**Analyzing the Results**\n\nI'm now analyzing the output from the initial web fetch. I will check the data against processed_ids, filter for specific categories, and proceed with scoring based on these parameters. I'll then prepare to post high matches to the specified results channel, update the search state, and log the results.\n\n\n", + "thought": true + } + ], + "role": "model" + }, + "index": 0 + } + ], + "usageMetadata": { + "promptTokenCount": 24512, + "totalTokenCount": 24512, + "promptTokensDetails": [{ "modality": "TEXT", "tokenCount": 24512 }], + "serviceTier": "standard" + }, + "modelVersion": "gemini-3.1-flash-lite", + "responseId": "resp-fixture-malformed-001" + }, + { + "candidates": [ + { + "content": { "parts": [{ "text": "" }], "role": "model" }, + "index": 0 + } + ], + "usageMetadata": { + "promptTokenCount": 24512, + "candidatesTokenCount": 167, + "totalTokenCount": 25738, + "promptTokensDetails": [{ "modality": "TEXT", "tokenCount": 24512 }], + "thoughtsTokenCount": 1059, + "serviceTier": "standard" + }, + "modelVersion": "gemini-3.1-flash-lite", + "responseId": "resp-fixture-malformed-001" + }, + { + "candidates": [ + { + "content": { + "parts": [ + { + "text": "", + "thoughtSignature": "FIXTURE_PLACEHOLDER_THOUGHT_SIGNATURE_OPAQUE_BASE64_BLOB_TRUNCATED_FOR_READABILITY_the_real_value_is_several_kb_of_base64_and_carries_no_meaning_for_this_test==" + } + ], + "role": "model" + }, + "index": 0 + } + ], + "usageMetadata": { + "promptTokenCount": 24512, + "candidatesTokenCount": 167, + "totalTokenCount": 25738, + "cachedContentTokenCount": 19504, + "promptTokensDetails": [{ "modality": "TEXT", "tokenCount": 24512 }], + "thoughtsTokenCount": 1059, + "serviceTier": "standard" + }, + "modelVersion": "gemini-3.1-flash-lite", + "responseId": "resp-fixture-malformed-001" + }, + { + "candidates": [ + { + "content": { "parts": [{ "text": "" }], "role": "model" }, + "finishReason": "MALFORMED_FUNCTION_CALL", + "index": 0, + "finishMessage": "Malformed function call: call:default_api:exec{command: cd /home/user/workspace && venv/bin/python run_search.py , web_fetch \"https://site-a.example/jobs\", web_fetch \"https://site-b.example/careers\", web_fetch \"https://site-c.example/jobs\", web_fetch \"https://site-d.example/jobs\", web_fetch \"https://site-e.example/careers\" ,reason: Executing the search workflow: running the scraper and fetching the target sites. }" + } + ], + "usageMetadata": { + "promptTokenCount": 24512, + "candidatesTokenCount": 167, + "totalTokenCount": 25738, + "cachedContentTokenCount": 19504, + "promptTokensDetails": [{ "modality": "TEXT", "tokenCount": 24512 }], + "thoughtsTokenCount": 1059, + "serviceTier": "standard" + }, + "modelVersion": "gemini-3.1-flash-lite", + "responseId": "resp-fixture-malformed-001" + } + ] +} diff --git a/tests/integration/gemini-malformed-function-call-stream.test.ts b/tests/integration/gemini-malformed-function-call-stream.test.ts new file mode 100644 index 0000000000..514a9084dc --- /dev/null +++ b/tests/integration/gemini-malformed-function-call-stream.test.ts @@ -0,0 +1,282 @@ +/** + * tests/integration/gemini-malformed-function-call-stream.test.ts + * + * Chains the real Gemini -> OpenAI translator (open-sse/translator/response/ + * gemini-to-openai.ts) with the real Responses API transformer (open-sse/ + * transformer/responsesTransformer.ts) against the exact (sanitized) event + * series captured from a live incident — see tests/fixtures/translation/ + * gemini-malformed-function-call-stream.json for the full sanitized fixture + * and incident description (dashboard log id 1784489701456-d8c0e9). + * + * Gemini's own parser rejected an attempted tool call mid-turn and terminated + * the stream with finishReason: MALFORMED_FUNCTION_CALL — a value that isn't + * one of OpenAI's 5 documented finish_reason values (stop, length, tool_calls, + * content_filter, function_call). Passed through raw, a real OpenAI-format + * client (OpenClaw) has no handling for it at all and silently never notices + * the turn failed. The fix (open-sse/translator/response/gemini-to-openai.ts) + * synthesizes a tool_calls entry and finish_reason: "tool_calls" instead, + * routing the failure into the ordinary "tool call arguments didn't parse" + * path every OpenAI-compatible agent loop already handles. + * + * This test proves the fix holds for BOTH client-facing surfaces that share + * this same translator output: Chat Completions (consumes the translator's + * chunks directly) and the Responses API (consumes the same chunks through + * the transformer chain) — using the identical real event series for both, + * so a regression in either surface is caught against the same ground truth. + */ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const { geminiToOpenAIResponse } = + await import("../../open-sse/translator/response/gemini-to-openai.ts"); +const { createResponsesApiTransformStream } = + await import("../../open-sse/transformer/responsesTransformer.ts"); + +const FIXTURES_DIR = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + "../fixtures/translation" +); +const FIXTURE_PATH = path.join(FIXTURES_DIR, "gemini-malformed-function-call-stream.json"); +// Follow-up live incident (log id 1784589106014-2a42f8): a REAL functionCall and a +// MALFORMED_FUNCTION_CALL finish arriving in the SAME turn — see the fixture's own +// "description" field for the full incident writeup. +const PARALLEL_FIXTURE_PATH = path.join( + FIXTURES_DIR, + "gemini-malformed-function-call-parallel-real-call-stream.json" +); + +type GeminiChunk = Record; +type OpenAIChunk = { + choices: Array<{ + delta: { + reasoning_content?: string; + tool_calls?: Array<{ + id: string; + type: string; + function: { name: string; arguments: string }; + }>; + }; + finish_reason: string | null; + }>; + usage?: { prompt_tokens: number; completion_tokens: number; total_tokens: number }; +}; +type ResponsesFunctionCallItem = { + type: "function_call"; + call_id: string; + name: string; + arguments: string; +}; +type ResponsesEventData = { type?: string; item?: ResponsesFunctionCallItem }; + +function asResponsesEventData(data: unknown): ResponsesEventData | null { + return data && typeof data === "object" ? (data as ResponsesEventData) : null; +} + +function loadFixtureChunks(fixturePath: string): GeminiChunk[] { + const fixture = JSON.parse(fs.readFileSync(fixturePath, "utf8")); + assert.ok(Array.isArray(fixture.chunks) && fixture.chunks.length > 0, "fixture must have chunks"); + return fixture.chunks; +} + +/** Real Gemini -> OpenAI translation, exactly as the live streaming pipeline runs it. */ +function translateFixtureToOpenAIChunks(fixturePath: string = FIXTURE_PATH): OpenAIChunk[] { + const state: Record = { + functionIndex: 0, + toolCalls: new Map(), + messageId: "fixture-msg", + model: "gemini-3.1-flash-lite", + }; + const openaiChunks: OpenAIChunk[] = []; + for (const chunk of loadFixtureChunks(fixturePath)) { + const results = geminiToOpenAIResponse(chunk, state); + if (results) openaiChunks.push(...(results as OpenAIChunk[])); + } + return openaiChunks; +} + +/** Feeds already-translated OpenAI chat chunks through the real Responses API transformer. */ +async function transformToResponsesEvents( + openaiChunks: OpenAIChunk[] +): Promise> { + const stream = createResponsesApiTransformStream(); + const writer = stream.writable.getWriter(); + const reader = stream.readable.getReader(); + const encoder = new TextEncoder(); + const decoder = new TextDecoder(); + + const output: string[] = []; + const readerTask = (async () => { + while (true) { + const { value, done } = await reader.read(); + if (done) break; + output.push(decoder.decode(value)); + } + })(); + + for (const chunk of openaiChunks) { + await writer.write(encoder.encode(`data: ${JSON.stringify(chunk)}\n\n`)); + } + await writer.close(); + await readerTask; + + return output + .join("") + .trim() + .split("\n\n") + .filter(Boolean) + .map((entry) => { + const lines = entry.split("\n"); + const eventLine = lines.find((l) => l.startsWith("event: ")); + const dataLine = lines.find((l) => l.startsWith("data: ")); + const rawData = dataLine ? dataLine.slice("data: ".length) : null; + return { + event: eventLine ? eventLine.slice("event: ".length) : null, + data: rawData && rawData !== "[DONE]" ? JSON.parse(rawData) : rawData, + }; + }); +} + +// ── Chat Completions surface ──────────────────────────────────────────────── + +test("Chat Completions: malformed-tool-call stream ends in a client-usable tool_calls, not a raw error string", () => { + const openaiChunks = translateFixtureToOpenAIChunks(); + assert.ok(openaiChunks.length > 0, "expected at least one translated chunk"); + + // The reasoning ("thought") content from the live incident's planning steps + // must still come through untouched — this fix only changes the terminal + // finish_reason/tool_calls handling, not the reasoning stream. + const reasoningDeltas = openaiChunks + .map((c) => c.choices?.[0]?.delta?.reasoning_content) + .filter((v): v is string => typeof v === "string" && v.length > 0); + assert.ok(reasoningDeltas.length >= 3, "expected the 3 planning/thought deltas to survive"); + assert.match(reasoningDeltas[0], /Initiating the Workflow/); + + const toolCallChunk = openaiChunks.find((c) => c.choices?.[0]?.delta?.tool_calls); + assert.ok(toolCallChunk, "expected a synthesized tool_calls delta chunk"); + const toolCall = toolCallChunk!.choices[0].delta.tool_calls[0]; + assert.equal(toolCall.type, "function"); + assert.equal(typeof toolCall.id, "string"); + const parsedArgs = JSON.parse(toolCall.function.arguments); + assert.equal(parsedArgs.error, "MALFORMED_FUNCTION_CALL"); + assert.match(parsedArgs.message, /Malformed function call/); + // Sanitized fixture content, not the real incident's personal paths/URLs. + assert.doesNotMatch(parsedArgs.message, /\/home\/ping\//); + assert.doesNotMatch(parsedArgs.message, /academicwork|senterprise|hiq\.se/); + + const finalChunk = openaiChunks.at(-1)!; + assert.equal( + finalChunk.choices[0].finish_reason, + "tool_calls", + "finish_reason must be the standard OpenAI value a real client can act on" + ); + // Never leak the raw Gemini string as the terminal signal. + assert.notEqual(finalChunk.choices[0].finish_reason, "malformed_function_call"); + + // Usage from the fixture's final chunk must still flow through untouched. + assert.equal(finalChunk.usage?.prompt_tokens, 24512); + assert.ok((finalChunk.usage?.completion_tokens ?? 0) > 0); +}); + +// ── Responses API surface (same fixture, chained through the transformer) ── + +test("Responses API: malformed-tool-call stream ends in a real function_call output item, not response.failed", async () => { + const openaiChunks = translateFixtureToOpenAIChunks(); + const events = await transformToResponsesEvents(openaiChunks); + + const eventTypes = events.map((e) => e.event).filter(Boolean); + assert.ok(eventTypes.includes("response.output_item.added"), "expected an output item to open"); + + const functionCallDone = events.find( + (e) => + e.event === "response.output_item.done" && + asResponsesEventData(e.data)?.item?.type === "function_call" + ); + assert.ok(functionCallDone, "expected a completed function_call output item"); + const funcItem = asResponsesEventData(functionCallDone!.data)!.item!; + assert.equal(typeof funcItem.call_id, "string"); + const parsedArgs = JSON.parse(funcItem.arguments); + assert.equal(parsedArgs.error, "MALFORMED_FUNCTION_CALL"); + + const completed = events.find((e) => e.event === "response.completed"); + assert.ok(completed, "expected a normal response.completed terminal event, not an error"); + assert.notEqual(asResponsesEventData(completed!.data)?.type, "response.failed"); + + // The transformer must never surface this as an in-band error event either — + // that would be the same class of "client sees nothing actionable" failure + // this whole fix exists to prevent, just on the Responses API side. + assert.ok( + !eventTypes.includes("error") && !eventTypes.includes("response.failed"), + `expected no error-shaped event, got: ${eventTypes.join(", ")}` + ); + + const doneMarker = events.at(-1); + assert.equal(doneMarker?.data, "[DONE]"); +}); + +test("both surfaces agree: exactly one synthesized tool call, sourced from the same translated chunks", async () => { + const openaiChunks = translateFixtureToOpenAIChunks(); + const toolCallChunks = openaiChunks.filter((c) => c.choices?.[0]?.delta?.tool_calls); + assert.equal(toolCallChunks.length, 1, "expected exactly one synthesized tool_calls chunk"); + + const events = await transformToResponsesEvents(openaiChunks); + const functionCallItems = events.filter( + (e) => + e.event === "response.output_item.done" && + asResponsesEventData(e.data)?.item?.type === "function_call" + ); + assert.equal(functionCallItems.length, 1, "expected exactly one function_call output item"); +}); + +// ── Second fixture: a REAL tool call arriving in the SAME turn as a malformed one ── +// +// See tests/fixtures/translation/gemini-malformed-function-call-parallel-real-call-stream.json +// for the full incident writeup (log id 1784589106014-2a42f8). This is the case the +// first version of the fix got wrong: it skipped synthesizing anything whenever a real +// tool call already existed, silently discarding the malformed attempt's information. +// Both the real call and the synthesized failure must reach the client. + +test("Chat Completions: a real tool call alongside a malformed one keeps BOTH — the failure is not silently dropped", () => { + const openaiChunks = translateFixtureToOpenAIChunks(PARALLEL_FIXTURE_PATH); + + const toolCallChunks = openaiChunks.filter((c) => c.choices?.[0]?.delta?.tool_calls); + assert.equal(toolCallChunks.length, 2, "expected the real call AND the synthesized failure"); + + const realCall = toolCallChunks[0].choices[0].delta.tool_calls![0]; + assert.equal(realCall.function.name, "check_status"); + + const syntheticCall = toolCallChunks[1].choices[0].delta.tool_calls![0]; + assert.equal(syntheticCall.function.name, "malformed_tool_call"); + const parsedArgs = JSON.parse(syntheticCall.function.arguments); + assert.equal(parsedArgs.error, "MALFORMED_FUNCTION_CALL"); + assert.match(parsedArgs.message, /exec/); + + const finalChunk = openaiChunks.at(-1)!; + assert.equal(finalChunk.choices[0].finish_reason, "tool_calls"); +}); + +test("Responses API: a real tool call alongside a malformed one produces TWO function_call output items", async () => { + const openaiChunks = translateFixtureToOpenAIChunks(PARALLEL_FIXTURE_PATH); + const events = await transformToResponsesEvents(openaiChunks); + + const functionCallItems = events + .filter( + (e) => + e.event === "response.output_item.done" && + asResponsesEventData(e.data)?.item?.type === "function_call" + ) + .map((e) => asResponsesEventData(e.data)!.item!); + + assert.equal( + functionCallItems.length, + 2, + "expected both the real and synthesized function_call items" + ); + assert.ok(functionCallItems.some((item) => item.name === "check_status")); + assert.ok(functionCallItems.some((item) => item.name === "malformed_tool_call")); + + const completed = events.find((e) => e.event === "response.completed"); + assert.ok(completed, "expected a normal response.completed terminal event, not an error"); +}); 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 index 4cda9fa761..3e53aec263 100644 --- a/tests/unit/gemini-malformed-function-call-finish-reason-2462.test.ts +++ b/tests/unit/gemini-malformed-function-call-finish-reason-2462.test.ts @@ -1,6 +1,19 @@ import test from "node:test"; import assert from "node:assert/strict"; +type OpenAIToolCallChunk = { + choices: Array<{ + delta: { + tool_calls?: Array<{ + id: string; + type: string; + function: { name: string; arguments: string }; + }>; + }; + finish_reason: string | null; + }>; +}; + // Upstream: decolua/9router#2462 sub-bug #2 (@anhdiepmmk). // // Gemini/Antigravity aborts a turn mid tool-call with finishReason @@ -18,15 +31,12 @@ import assert from "node:assert/strict"; // 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" -); +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 — @@ -105,6 +115,153 @@ test("Gemini UNEXPECTED_TOOL_CALL does not surface as a clean Claude end_turn", assert.notEqual(stopReason, "end_turn"); }); +// ─── OpenAI hub follow-up (live incident, dashboard log id 1784489701456-d8c0e9) ── +// +// The tests above only assert the raw "malformed_function_call" string doesn't get +// misread by the DOWNSTREAM Claude translation step. But passing that raw, +// non-standard string through as finish_reason to a real OpenAI-format client is +// itself the bug: it's not one of OpenAI's 5 documented finish_reason values (stop, +// length, tool_calls, content_filter, function_call), so a client like OpenClaw has +// no handling for it at all and silently never notices the turn failed — worse than +// "honest but unrecognized," it's invisible. These tests assert the OpenAI hub now +// synthesizes a real tool_calls entry and finish_reason: "tool_calls" instead, so the +// failure routes into the ordinary "tool call arguments didn't parse" path every +// OpenAI-compatible agent loop already has to handle. + +function runGeminiToOpenAI(geminiChunk: unknown) { + const state: Record = { + functionIndex: 0, + toolCalls: new Map(), + messageId: "test-msg", + model: "gemini-test", + }; + return geminiToOpenAIResponse(geminiChunk, state) || []; +} + +test("Gemini MALFORMED_FUNCTION_CALL synthesizes a tool_calls entry with finish_reason tool_calls", () => { + const events = runGeminiToOpenAI({ + responseId: "resp-malformed", + modelVersion: "gemini-2.5-pro", + candidates: [ + { + content: { parts: [{ text: "" }] }, + finishReason: "MALFORMED_FUNCTION_CALL", + finishMessage: "Malformed function call: call:default_api:exec{command: ...}", + index: 0, + }, + ], + }) as OpenAIToolCallChunk[]; + + const toolCallChunk = events.find((e) => e.choices?.[0]?.delta?.tool_calls); + assert.ok(toolCallChunk, "expected a chunk carrying a synthesized tool_calls delta"); + const toolCall = toolCallChunk.choices[0].delta.tool_calls[0]; + assert.equal(toolCall.type, "function"); + assert.ok(toolCall.function?.name, "synthesized tool call must have a name"); + // Arguments must be valid, parseable JSON (that's the whole point — it routes into + // the client's normal "tool args didn't parse *cleanly*" error path, not a crash). + const parsedArgs = JSON.parse(toolCall.function.arguments); + assert.equal(parsedArgs.error, "MALFORMED_FUNCTION_CALL"); + assert.match(parsedArgs.message, /Malformed function call/); + + const finalChunk = events.at(-1); + assert.equal( + finalChunk?.choices?.[0]?.finish_reason, + "tool_calls", + "finish_reason must be the standard OpenAI value, not the raw Gemini string" + ); +}); + +test("Gemini UNEXPECTED_TOOL_CALL also synthesizes a tool_calls entry", () => { + const events = runGeminiToOpenAI({ + responseId: "resp-unexpected", + modelVersion: "gemini-2.5-pro", + candidates: [ + { + content: { parts: [{ text: "" }] }, + finishReason: "UNEXPECTED_TOOL_CALL", + index: 0, + }, + ], + }) as OpenAIToolCallChunk[]; + + const finalChunk = events.at(-1); + assert.equal(finalChunk?.choices?.[0]?.finish_reason, "tool_calls"); +}); + +// Follow-up live incident (log id 1784589106014-2a42f8): Gemini can emit a REAL, +// valid functionCall AND finish the SAME candidate with MALFORMED_FUNCTION_CALL — +// the model attempted multiple tool calls in one turn (here: a real "openclaw" call +// plus a malformed "exec"+"cron" multi-call attempt), one parsed cleanly and the +// other didn't. An earlier version of this fix skipped synthesis whenever a real +// tool call already existed, on the assumption that meant a LATER, separate retry — +// but that's indistinguishable from this same-turn case, and skipping silently +// discarded the "exec"/"cron" failure entirely: the client saw "openclaw" succeed +// and never learned the other calls were attempted and rejected. +test("a REAL tool call alongside MALFORMED_FUNCTION_CALL in the same turn keeps BOTH — the failure is never silently dropped", () => { + const state: Record = { + functionIndex: 0, + toolCalls: new Map(), + messageId: "test-msg-2", + model: "gemini-test", + }; + + geminiToOpenAIResponse( + { + responseId: "resp-real-then-malformed", + modelVersion: "gemini-2.5-pro", + candidates: [ + { + content: { parts: [{ functionCall: { name: "read_file", args: { path: "a.txt" } } }] }, + index: 0, + }, + ], + }, + state + ); + + const finalEvents = geminiToOpenAIResponse( + { + responseId: "resp-real-then-malformed", + modelVersion: "gemini-2.5-pro", + candidates: [ + { + content: { parts: [{ text: "" }] }, + finishReason: "MALFORMED_FUNCTION_CALL", + finishMessage: "Malformed function call: call:default_api:exec{command: df -h}", + index: 0, + }, + ], + }, + state + ) as OpenAIToolCallChunk[]; + + const synthesizedChunk = finalEvents.find((e) => e.choices?.[0]?.delta?.tool_calls); + assert.ok(synthesizedChunk, "expected a second, synthesized tool_calls delta"); + const synthesizedCall = synthesizedChunk!.choices[0].delta.tool_calls![0]; + assert.equal(synthesizedCall.function.name, "malformed_tool_call"); + assert.match(JSON.parse(synthesizedCall.function.arguments).message, /exec/); + + const finalChunk = finalEvents.at(-1)!; + assert.equal(finalChunk.choices[0].finish_reason, "tool_calls"); + assert.equal( + (state.toolCalls as Map).size, + 2, + "both the real read_file call AND the synthesized malformed-call entry must be present" + ); +}); + +test("Gemini clean STOP with no tool calls is unaffected (no regression)", () => { + const events = runGeminiToOpenAI({ + responseId: "resp-clean", + modelVersion: "gemini-2.5-pro", + candidates: [{ content: { parts: [{ text: "All done." }] }, finishReason: "STOP", index: 0 }], + }) as OpenAIToolCallChunk[]; + + const finalChunk = events.at(-1); + assert.equal(finalChunk?.choices?.[0]?.finish_reason, "stop"); + assert.equal(finalChunk?.choices?.[0]?.delta?.tool_calls, undefined); +}); + test("Gemini clean STOP still maps to Claude end_turn (no regression)", () => { const { claudeEvents } = runGeminiToClaude({ responseId: "resp-clean", diff --git a/tests/unit/gemini-tool-choice.test.ts b/tests/unit/gemini-tool-choice.test.ts new file mode 100644 index 0000000000..4699e412af --- /dev/null +++ b/tests/unit/gemini-tool-choice.test.ts @@ -0,0 +1,116 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +// tool_choice was never read anywhere in the OpenAI -> Gemini request translator — +// result.toolConfig was unconditionally hardcoded to { mode: "VALIDATED" } whenever +// tools were present, regardless of what the caller sent. VALIDATED allows the model +// to respond with plain text OR a (schema-validated) function call at its own +// discretion; it never FORCES a call the way OpenAI's tool_choice: "required" (Gemini's +// ANY mode) does. Investigating a live report that gemini-3.1-flash-lite frequently +// narrates an intended tool call in plain text instead of emitting one (dashboard log +// id 1784591483850-49c408, zero functionCall parts, finishReason STOP, nothing but +// prose) surfaced that tool_choice: "required" had no way to reach Gemini at all — +// this is the fix, and the mechanism the live test compares against a baseline. + +const { openaiToGeminiRequest, openaiToCloudCodeGeminiRequest } = + await import("../../open-sse/translator/request/openai-to-gemini.ts"); + +type ToolConfigResult = { + toolConfig?: { functionCallingConfig: { mode: string; allowedFunctionNames?: string[] } }; +}; + +const SAMPLE_TOOLS = [ + { + type: "function", + function: { name: "run_command", description: "Run a shell command", parameters: {} }, + }, +]; + +function baseBody(toolChoice?: unknown) { + return { + messages: [{ role: "user", content: "hi" }], + tools: SAMPLE_TOOLS, + ...(toolChoice !== undefined ? { tool_choice: toolChoice } : {}), + }; +} + +test("no tool_choice (unset) keeps the existing VALIDATED default — no behavior change", () => { + const result = openaiToGeminiRequest("gemini-2.5-pro", baseBody(), false) as ToolConfigResult; + assert.equal(result.toolConfig?.functionCallingConfig.mode, "VALIDATED"); +}); + +test('tool_choice: "auto" maps to VALIDATED (same as unset)', () => { + const result = openaiToGeminiRequest( + "gemini-2.5-pro", + baseBody("auto"), + false + ) as ToolConfigResult; + assert.equal(result.toolConfig?.functionCallingConfig.mode, "VALIDATED"); +}); + +test('tool_choice: "required" maps to Gemini ANY mode (forces a function call)', () => { + const result = openaiToGeminiRequest( + "gemini-2.5-pro", + baseBody("required"), + false + ) as ToolConfigResult; + assert.equal(result.toolConfig?.functionCallingConfig.mode, "ANY"); +}); + +test('tool_choice: "any" (OpenAI-compatible alias) also maps to ANY', () => { + const result = openaiToGeminiRequest( + "gemini-2.5-pro", + baseBody("any"), + false + ) as ToolConfigResult; + assert.equal(result.toolConfig?.functionCallingConfig.mode, "ANY"); +}); + +test('tool_choice: "none" maps to Gemini NONE mode (disables function calling)', () => { + const result = openaiToGeminiRequest( + "gemini-2.5-pro", + baseBody("none"), + false + ) as ToolConfigResult; + assert.equal(result.toolConfig?.functionCallingConfig.mode, "NONE"); +}); + +test("tool_choice forcing a specific function maps to ANY with allowedFunctionNames", () => { + const result = openaiToGeminiRequest( + "gemini-2.5-pro", + baseBody({ type: "function", function: { name: "run_command" } }), + false + ) as ToolConfigResult; + assert.equal(result.toolConfig?.functionCallingConfig.mode, "ANY"); + assert.deepEqual(result.toolConfig?.functionCallingConfig.allowedFunctionNames, ["run_command"]); +}); + +test("no tools present: toolConfig is not set regardless of tool_choice", () => { + const result = openaiToGeminiRequest( + "gemini-2.5-pro", + { messages: [{ role: "user", content: "hi" }], tool_choice: "required" }, + false + ) as ToolConfigResult; + assert.equal(result.toolConfig, undefined); +}); + +// Antigravity / Cloud Code envelope path (wrapInCloudCodeEnvelope) previously +// re-derived its own hardcoded VALIDATED independently of the base translator — +// it now reuses whatever openaiToGeminiBase already computed from tool_choice. +test("Antigravity/Cloud Code path also honors tool_choice: required", () => { + const result = openaiToCloudCodeGeminiRequest( + "gemini-2.5-pro", + baseBody("required"), + false + ) as ToolConfigResult; + assert.equal(result.toolConfig?.functionCallingConfig.mode, "ANY"); +}); + +test("Antigravity/Cloud Code path defaults to VALIDATED when tool_choice is unset (no regression)", () => { + const result = openaiToCloudCodeGeminiRequest( + "gemini-2.5-pro", + baseBody(), + false + ) as ToolConfigResult; + assert.equal(result.toolConfig?.functionCallingConfig.mode, "VALIDATED"); +});