From c55de7ab574fd92af0231a4faa4bbe7f5a1f4e6a Mon Sep 17 00:00:00 2001 From: Adam <835217250@qq.com> Date: Sat, 18 Jul 2026 22:34:27 +0800 Subject: [PATCH] fix(antigravity): collect native part.functionCall into tool calls (#7037) (#7053) * fix(antigravity): collect native part.functionCall into tool calls (#7037) * test(antigravity): add #7037 native functionCall regression coverage * fix(antigravity): do not clobber tool_calls finish reason with candidate STOP (#7037) --- open-sse/executors/antigravity/sseCollect.ts | 16 +++- tests/unit/executor-agy.test.ts | 85 ++++++++++++++++++++ 2 files changed, 100 insertions(+), 1 deletion(-) diff --git a/open-sse/executors/antigravity/sseCollect.ts b/open-sse/executors/antigravity/sseCollect.ts index d42bab72e9..7c6e795787 100644 --- a/open-sse/executors/antigravity/sseCollect.ts +++ b/open-sse/executors/antigravity/sseCollect.ts @@ -96,9 +96,23 @@ export function processAntigravitySSEPayload( collected.textContent += part.text; } } + // Native Gemini function calls. Non-streaming responses (and some + // streaming ones) carry the tool call as `part.functionCall` rather than + // the textual `[Tool call: ...]` markdown. Without this, a tool-only + // response produced empty content and a 502 Provider error (#7037). + if (part.functionCall && typeof part.functionCall.name === "string") { + addAntigravityTextualToolCall(collected, { + name: part.functionCall.name, + args: part.functionCall.args ?? {}, + }); + } } } - if (candidate?.finishReason) { + // Preserve a tool-call finish reason: once a native `part.functionCall` + // (or textual tool call) has populated `toolCalls`, the candidate's own + // finish reason (often STOP) must not clobber it (#7037 — a tool-only + // response would otherwise report STOP and lose its tool-call signal). + if (candidate?.finishReason && collected.toolCalls.length === 0) { collected.finishReason = normalizeOpenAICompatibleFinishReasonString( String(candidate.finishReason).toLowerCase() ); diff --git a/tests/unit/executor-agy.test.ts b/tests/unit/executor-agy.test.ts index 1c30e74129..2bab4fbfe9 100644 --- a/tests/unit/executor-agy.test.ts +++ b/tests/unit/executor-agy.test.ts @@ -75,3 +75,88 @@ test("processAntigravitySSEPayload ignores [DONE] and malformed payloads without processAntigravitySSEPayload("{not json", collected); assert.equal(collected.textContent, ""); }); + +// #7037 — non-streaming (and tool-only) responses carry the tool call as a native +// `part.functionCall` with no `part.text`. It must produce a tool call instead of +// empty content (which previously surfaced as a 502 "Provider returned empty content"). +test("processAntigravitySSEPayload converts native part.functionCall into a tool call (#7037)", () => { + const collected = emptyCollected(); + processAntigravitySSEPayload( + JSON.stringify({ + response: { + candidates: [ + { + content: { + parts: [{ functionCall: { name: "get_weather", args: { city: "Paris" } } }], + }, + finishReason: "STOP", + }, + ], + usageMetadata: { promptTokenCount: 12, candidatesTokenCount: 932, totalTokenCount: 944 }, + }, + }), + collected + ); + + assert.equal(collected.textContent, ""); + assert.equal(collected.toolCalls.length, 1); + assert.equal(collected.toolCalls[0].type, "function"); + assert.equal(collected.toolCalls[0].function.name, "get_weather"); + assert.deepEqual(JSON.parse(collected.toolCalls[0].function.arguments), { city: "Paris" }); + assert.equal(collected.finishReason, "tool_calls"); + assert.ok(collected.usage !== null, "usage metadata should still be collected"); +}); + +test("processAntigravitySSEPayload handles a mixed text + functionCall response (#7037)", () => { + const collected = emptyCollected(); + processAntigravitySSEPayload( + JSON.stringify({ + response: { + candidates: [ + { + content: { + parts: [ + { text: "Let me check." }, + { functionCall: { name: "get_weather", args: { city: "Paris" } } }, + ], + }, + }, + ], + }, + }), + collected + ); + + assert.equal(collected.textContent, "Let me check."); + assert.equal(collected.toolCalls.length, 1); + assert.equal(collected.toolCalls[0].function.name, "get_weather"); +}); + +// #7037 — before the fix, a function-call-only payload yielded no text and no +// tool call, so the non-streaming path returned empty content. Guard that the +// textual-tool-call path is unaffected. +test("processAntigravitySSEPayload still parses textual [Tool call:] when present", () => { + const collected = emptyCollected(); + processAntigravitySSEPayload( + JSON.stringify({ + response: { + candidates: [ + { + content: { + parts: [ + { + text: "[Tool call: get_weather]\nArguments: {\"city\":\"Paris\"}", + }, + ], + }, + }, + ], + }, + }), + collected + ); + + assert.equal(collected.toolCalls.length, 1); + assert.equal(collected.toolCalls[0].function.name, "get_weather"); + assert.deepEqual(JSON.parse(collected.toolCalls[0].function.arguments), { city: "Paris" }); +});