diff --git a/CHANGELOG.md b/CHANGELOG.md index 57375278ec..b08d31be40 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,7 +12,7 @@ _TBD_ ### 🔧 Bug Fixes -- **fix(sse):** strip ANSI/VT100 escape codes from gemini-cli stream frames so ANSI-prefixed `data:` lines are no longer silently dropped. (thanks @anki1kr) +- **fix(translator):** antigravity→openai request now emits Anthropic-compliant content blocks — drops empty text blocks and preserves tool calls/text co-located with tool results. (thanks @SahrulRamadhanHardiansyah) ### 📝 Maintenance diff --git a/open-sse/translator/request/antigravity-to-openai.ts b/open-sse/translator/request/antigravity-to-openai.ts index 24d25d4b11..d2ae57fcb2 100644 --- a/open-sse/translator/request/antigravity-to-openai.ts +++ b/open-sse/translator/request/antigravity-to-openai.ts @@ -229,14 +229,17 @@ function convertContent(content) { continue; } - // Text with thoughtSignature = regular text after thinking + // Text with thoughtSignature = regular text after thinking. + // Skip empty text — Anthropic rejects empty content blocks with a 400. if (part.thoughtSignature && part.text !== undefined) { - textParts.push({ type: "text", text: part.text }); + if (part.text) { + textParts.push({ type: "text", text: part.text }); + } continue; } - // Regular text - if (part.text !== undefined) { + // Regular text — skip empty strings (Anthropic rejects empty content blocks). + if (part.text !== undefined && part.text !== "") { textParts.push({ type: "text", text: part.text }); } @@ -274,8 +277,26 @@ function convertContent(content) { } } - // Content with only functionResponses → return array of tool messages + // Function responses may be co-located with function calls / text / reasoning in + // the same content. Emit the tool messages AND the accompanying assistant message so + // nothing is dropped (previously only the tool messages survived). if (toolResults.length > 0) { + if (toolCalls.length > 0 || textParts.length > 0 || reasoningContent) { + const assistantMsg: JsonRecord = { role: "assistant" }; + if (textParts.length > 0) { + assistantMsg.content = + textParts.length === 1 && textParts[0].type === "text" + ? textParts[0].text + : textParts; + } + if (reasoningContent) { + assistantMsg.reasoning_content = reasoningContent; + } + if (toolCalls.length > 0) { + assistantMsg.tool_calls = toolCalls; + } + return [...toolResults, assistantMsg]; + } return toolResults; } diff --git a/tests/unit/translator-antigravity-to-openai.test.ts b/tests/unit/translator-antigravity-to-openai.test.ts index 6012b0bb9a..1cd2de11a4 100644 --- a/tests/unit/translator-antigravity-to-openai.test.ts +++ b/tests/unit/translator-antigravity-to-openai.test.ts @@ -154,6 +154,74 @@ test("Antigravity -> OpenAI returns tool messages when content contains only fun ]); }); +test("Antigravity -> OpenAI keeps co-located function response, function call and text", () => { + const result = antigravityToOpenAIRequest( + "gpt-4o", + { + request: { + contents: [ + { + role: "model", + parts: [ + { text: "Let me look that up." }, + { functionResponse: { id: "call_9", name: "lookup", response: { result: { ok: true } } } }, + { functionCall: { id: "call_10", name: "lookup", args: { q: "weather" } } }, + ], + }, + ], + }, + }, + false + ); + + // Both the tool-result message AND the accompanying assistant message must survive. + const toolMsg = result.messages.find((m) => m.role === "tool"); + const assistantMsg = result.messages.find((m) => m.role === "assistant"); + assert.ok(toolMsg, "expected a role:tool message"); + assert.equal(toolMsg.tool_call_id, "call_9"); + assert.ok(assistantMsg, "expected a role:assistant message"); + assert.equal(assistantMsg.content, "Let me look that up."); + assert.deepEqual(assistantMsg.tool_calls, [ + { + id: "call_10", + type: "function", + function: { name: "lookup", arguments: '{"q":"weather"}' }, + }, + ]); +}); + +test("Antigravity -> OpenAI drops empty thoughtSignature text instead of emitting empty content", () => { + const result = antigravityToOpenAIRequest( + "gpt-4o", + { + request: { + contents: [ + { + role: "model", + parts: [ + { thoughtSignature: "sig", text: "" }, + { functionCall: { id: "call_11", name: "noop", args: {} } }, + ], + }, + ], + }, + }, + false + ); + + const assistantMsg = result.messages.find((m) => m.role === "assistant"); + assert.ok(assistantMsg, "expected a role:assistant message"); + // No empty content block should be emitted (Anthropic rejects it with a 400). + assert.equal("content" in assistantMsg, false); + assert.deepEqual(assistantMsg.tool_calls, [ + { + id: "call_11", + type: "function", + function: { name: "noop", arguments: "{}" }, + }, + ]); +}); + test("Antigravity -> OpenAI lowers schema types recursively", () => { const result = antigravityToOpenAIRequest( "gpt-4o",