From beaa7267b6168acf137ecf9da04159763c865975 Mon Sep 17 00:00:00 2001 From: OpenClaw Date: Mon, 25 May 2026 13:36:41 +0300 Subject: [PATCH] fix(antigravity): preserve textual SSE tool calls --- open-sse/executors/antigravity.ts | 80 ++++++++++++++++++++++++- tests/unit/executor-antigravity.test.ts | 54 +++++++++++++++++ 2 files changed, 131 insertions(+), 3 deletions(-) diff --git a/open-sse/executors/antigravity.ts b/open-sse/executors/antigravity.ts index 93292938e4..9a6db344bc 100644 --- a/open-sse/executors/antigravity.ts +++ b/open-sse/executors/antigravity.ts @@ -123,10 +123,67 @@ function serializeAntigravityRequest( type AntigravityCollectedStream = { textContent: string; finishReason: string; + toolCalls: Array<{ + id: string; + index: number; + type: "function"; + function: { name: string; arguments: string }; + }>; usage: Record | null; remainingCredits: Array<{ creditType: string; creditAmount: string }> | null; }; +function stripZeroWidth(value: unknown): unknown { + if (typeof value === "string") { + return value.replace(/[\u200B-\u200D\uFEFF]/g, ""); + } + if (Array.isArray(value)) { + return value.map((item) => stripZeroWidth(item)); + } + if (value && typeof value === "object") { + return Object.fromEntries( + Object.entries(value as Record).map(([key, item]) => [ + key, + stripZeroWidth(item), + ]) + ); + } + return value; +} + +function parseAntigravityTextualToolCall(text: unknown): { name: string; args: unknown } | null { + if (typeof text !== "string") return null; + const normalized = text.replace(/[\u200B-\u200D\uFEFF]/g, ""); + const match = normalized.match( + /^[\s\S]*?\[Tool call:\s*([^\]\n]+)\]\s*\nArguments:\s*([\s\S]+?)\s*$/ + ); + if (!match) return null; + const name = match[1]?.trim(); + const rawArgs = match[2]?.trim(); + if (!name || !rawArgs) return null; + try { + return { name, args: stripZeroWidth(JSON.parse(rawArgs)) }; + } catch { + return null; + } +} + +function addAntigravityTextualToolCall( + collected: AntigravityCollectedStream, + parsed: { name: string; args: unknown } +): void { + collected.toolCalls.push({ + id: `${parsed.name}-${Date.now()}-${collected.toolCalls.length}`, + index: collected.toolCalls.length, + type: "function", + function: { + name: parsed.name, + arguments: JSON.stringify(parsed.args || {}), + }, + }); + collected.finishReason = "tool_calls"; +} + type AntigravityRequestEnvelope = Record & { project: string; model: string; @@ -233,7 +290,12 @@ function processAntigravitySSEPayload( if (candidate?.content?.parts) { for (const part of candidate.content.parts) { if (typeof part.text === "string" && !part.thought && !part.thoughtSignature) { - collected.textContent += part.text; + const textualToolCall = parseAntigravityTextualToolCall(part.text); + if (textualToolCall) { + addAntigravityTextualToolCall(collected, textualToolCall); + } else { + collected.textContent += part.text; + } } } } @@ -717,6 +779,7 @@ export class AntigravityExecutor extends BaseExecutor { const collected: AntigravityCollectedStream = { textContent: "", finishReason: "stop", + toolCalls: [], usage: null, remainingCredits: null, }; @@ -761,8 +824,19 @@ export class AntigravityExecutor extends BaseExecutor { choices: [ { index: 0, - message: { role: "assistant", content: collected.textContent }, - finish_reason: timedOut ? "length" : collected.finishReason, + message: + collected.toolCalls.length > 0 + ? { + role: "assistant", + content: collected.textContent || null, + tool_calls: collected.toolCalls, + } + : { role: "assistant", content: collected.textContent }, + finish_reason: timedOut + ? "length" + : collected.toolCalls.length > 0 + ? "tool_calls" + : collected.finishReason, }, ], ...(collected.usage && { usage: collected.usage }), diff --git a/tests/unit/executor-antigravity.test.ts b/tests/unit/executor-antigravity.test.ts index e7d067fcaf..76384f6578 100644 --- a/tests/unit/executor-antigravity.test.ts +++ b/tests/unit/executor-antigravity.test.ts @@ -476,6 +476,60 @@ test("AntigravityExecutor.collectStreamToResponse turns SSE Gemini chunks into a }); }); +test("AntigravityExecutor.collectStreamToResponse converts textual tool call SSE to structured tool_calls", async () => { + const executor = new AntigravityExecutor(); + const response = new Response( + [ + `data: ${JSON.stringify({ + response: { + candidates: [ + { + content: { + parts: [ + { + text: '[Tool call: search_files]\nArguments: {"file_glob":"*gemini*","output_mode":"files_only","path":"/opt/O\\u200dmniRoute","target":"files"}', + }, + ], + }, + finishReason: "STOP", + }, + ], + usageMetadata: { + promptTokenCount: 7, + candidatesTokenCount: 4, + totalTokenCount: 11, + }, + }, + })}\n\n`, + ].join(""), + { + status: 200, + headers: { "Content-Type": "text/event-stream" }, + } + ); + + const result = await executor.collectStreamToResponse( + response, + "gemini-3.5-flash-low", + "https://example.com", + { Authorization: "Bearer ag-token" }, + { request: {} } + ); + const payload = await result.response.json(); + const choice = payload.choices[0]; + + assert.equal(choice.message.content, null); + assert.equal(choice.finish_reason, "tool_calls"); + assert.equal(choice.message.tool_calls.length, 1); + assert.equal(choice.message.tool_calls[0].function.name, "search_files"); + assert.deepEqual(JSON.parse(choice.message.tool_calls[0].function.arguments), { + file_glob: "*gemini*", + output_mode: "files_only", + path: "/opt/OmniRoute", + target: "files", + }); +}); + test("AntigravityExecutor.collectStreamToResponse parses fragmented SSE lines incrementally", async () => { const executor = new AntigravityExecutor(); const encoder = new TextEncoder();