From 9392bd55c2b93cccef3a2d5da5229a1fb0f1a9fd Mon Sep 17 00:00:00 2001 From: MSiva <113901375+Siva010@users.noreply.github.com> Date: Mon, 31 Aug 2026 22:40:44 +0530 Subject: [PATCH] fix(translator): preserve falsy primitive values in Gemini and Antigravity function response results (#12191) --- .../request/antigravity-to-openai.ts | 20 +-- .../translator/request/gemini-to-openai.ts | 7 +- .../translator-antigravity-to-openai.test.ts | 126 +++++++++++++++++- .../unit/translator-gemini-to-openai.test.ts | 67 ++++++++++ 4 files changed, 201 insertions(+), 19 deletions(-) diff --git a/open-sse/translator/request/antigravity-to-openai.ts b/open-sse/translator/request/antigravity-to-openai.ts index 922cf0c17b..cf67e5600f 100644 --- a/open-sse/translator/request/antigravity-to-openai.ts +++ b/open-sse/translator/request/antigravity-to-openai.ts @@ -220,12 +220,15 @@ function preserveRequired(obj: unknown): void { return; } const record = obj as JsonRecord; - if (Array.isArray(record.required) && record.properties && typeof record.properties === "object") { + if ( + Array.isArray(record.required) && + record.properties && + typeof record.properties === "object" + ) { const properties = record.properties as JsonRecord; const valid = (record.required as unknown[]).filter( (field) => - typeof field === "string" && - Object.prototype.hasOwnProperty.call(properties, field) + typeof field === "string" && Object.prototype.hasOwnProperty.call(properties, field) ); if (valid.length === 0) { delete record.required; @@ -298,12 +301,13 @@ function convertContent(content) { // Function response → collect all, each becomes a separate tool message if (part.functionResponse) { + const resp = part.functionResponse.response; + const resultPayload = + resp && typeof resp === "object" && "result" in resp ? resp.result : (resp ?? {}); toolResults.push({ role: "tool", tool_call_id: part.functionResponse.id || part.functionResponse.name, - content: JSON.stringify( - part.functionResponse.response?.result || part.functionResponse.response || {} - ), + content: JSON.stringify(resultPayload), }); } } @@ -316,9 +320,7 @@ function convertContent(content) { const assistantMsg: JsonRecord = { role: "assistant" }; if (textParts.length > 0) { assistantMsg.content = - textParts.length === 1 && textParts[0].type === "text" - ? textParts[0].text - : textParts; + textParts.length === 1 && textParts[0].type === "text" ? textParts[0].text : textParts; } if (reasoningContent) { assistantMsg.reasoning_content = reasoningContent; diff --git a/open-sse/translator/request/gemini-to-openai.ts b/open-sse/translator/request/gemini-to-openai.ts index 2206b106f4..873aa22f83 100644 --- a/open-sse/translator/request/gemini-to-openai.ts +++ b/open-sse/translator/request/gemini-to-openai.ts @@ -147,12 +147,13 @@ function convertGeminiContent(content) { } if (part.functionResponse) { + const resp = part.functionResponse.response; + const resultPayload = + resp && typeof resp === "object" && "result" in resp ? resp.result : (resp ?? {}); return { role: "tool", tool_call_id: part.functionResponse.id || part.functionResponse.name, - content: JSON.stringify( - part.functionResponse.response?.result || part.functionResponse.response || {} - ), + content: JSON.stringify(resultPayload), }; } } diff --git a/tests/unit/translator-antigravity-to-openai.test.ts b/tests/unit/translator-antigravity-to-openai.test.ts index 1463538c6d..811c462e73 100644 --- a/tests/unit/translator-antigravity-to-openai.test.ts +++ b/tests/unit/translator-antigravity-to-openai.test.ts @@ -162,7 +162,13 @@ test("Antigravity -> OpenAI keeps co-located function call and text but strips t role: "model", parts: [ { text: "Let me look that up." }, - { functionResponse: { id: "call_9", name: "lookup", response: { result: { ok: true } } } }, + { + functionResponse: { + id: "call_9", + name: "lookup", + response: { result: { ok: true } }, + }, + }, { functionCall: { id: "call_10", name: "lookup", args: { q: "weather" } } }, ], }, @@ -249,7 +255,7 @@ test("Antigravity -> OpenAI lowers schema types recursively", () => { false ); - assert.deepEqual((result.tools[0].function as any).parameters, { + assert.deepEqual((result.tools[0].function as Record).parameters, { type: "object", properties: { items: { @@ -298,12 +304,17 @@ test("Antigravity -> OpenAI strips enumDescriptions from tool schema (top-level false ); - const parameters = (result.tools[0].function as any).parameters; + const parameters = (result.tools[0].function as Record).parameters as Record< + string, + unknown + >; + const props = parameters.properties as Record; + const tags = props.tags as Record; // enumDescriptions must be removed at every level of the schema tree... assert.equal("enumDescriptions" in parameters, false); - assert.equal("enumDescriptions" in parameters.properties.mode, false); - assert.equal("enumDescriptions" in parameters.properties.tags.items, false); + assert.equal("enumDescriptions" in (props.mode as Record), false); + assert.equal("enumDescriptions" in (tags.items as Record), false); // ...while leaving the rest of the schema (incl. enum values) intact. assert.deepEqual(parameters, { @@ -349,7 +360,10 @@ test("Antigravity -> OpenAI preserves the required array on Draft 2020-12 tool s false ); - const params = (result.tools[0].function as any).parameters; + const params = (result.tools[0].function as Record).parameters as Record< + string, + unknown + >; // The required array must survive so the model treats mandatory args as mandatory. assert.deepEqual(params.required, ["path", "contents"]); // Types are still lowered and Draft 2020-12 meta keywords are stripped. @@ -385,6 +399,104 @@ test("Antigravity -> OpenAI drops required entries that no longer exist in prope false ); - const params = (result.tools[0].function as any).parameters; + const params = (result.tools[0].function as Record).parameters as Record< + string, + unknown + >; assert.deepEqual(params.required, ["kept"]); }); + +test("Antigravity -> OpenAI preserves falsy primitive results in function responses (false, 0, empty string, null)", () => { + const cases: Array<[unknown, string]> = [ + [false, "false"], + [0, "0"], + ["", '""'], + [null, "null"], + [true, "true"], + [42, "42"], + ["done", '"done"'], + ]; + + for (const [inputVal, expected] of cases) { + const result = antigravityToOpenAIRequest( + "gpt-4o", + { + request: { + contents: [ + { + role: "model", + parts: [ + { + functionCall: { + id: "call_test", + name: "check_condition", + args: {}, + }, + }, + ], + }, + { + role: "user", + parts: [ + { + functionResponse: { + id: "call_test", + name: "check_condition", + response: { result: inputVal }, + }, + }, + ], + }, + ], + }, + }, + false + ); + + const toolMsg = result.messages.find((m) => m.role === "tool"); + assert.ok(toolMsg, "expected role:tool message"); + assert.equal(toolMsg.tool_call_id, "call_test"); + assert.equal(toolMsg.content, expected); + } +}); + +test("Antigravity -> OpenAI preserves custom response objects without result key", () => { + const result = antigravityToOpenAIRequest( + "gpt-4o", + { + request: { + contents: [ + { + role: "model", + parts: [ + { + functionCall: { + id: "call_custom", + name: "custom_op", + args: {}, + }, + }, + ], + }, + { + role: "user", + parts: [ + { + functionResponse: { + id: "call_custom", + name: "custom_op", + response: { output: "value", success: false }, + }, + }, + ], + }, + ], + }, + }, + false + ); + + const toolMsg = result.messages.find((m) => m.role === "tool"); + assert.ok(toolMsg, "expected role:tool message"); + assert.equal(toolMsg.content, '{"output":"value","success":false}'); +}); diff --git a/tests/unit/translator-gemini-to-openai.test.ts b/tests/unit/translator-gemini-to-openai.test.ts index bac2aeda33..eb4210de96 100644 --- a/tests/unit/translator-gemini-to-openai.test.ts +++ b/tests/unit/translator-gemini-to-openai.test.ts @@ -237,3 +237,70 @@ test("Gemini -> OpenAI maintains matching IDs across multi-turn tool call and re assert.equal(toolResponseCallId, "call_calc_456"); assert.equal(assistantCallId, toolResponseCallId); }); + +test("Gemini -> OpenAI preserves falsy primitive results in function responses (false, 0, empty string, null)", () => { + const cases: Array<[unknown, string]> = [ + [false, "false"], + [0, "0"], + ["", '""'], + [null, "null"], + [true, "true"], + [42, "42"], + ["done", '"done"'], + ]; + + for (const [inputVal, expected] of cases) { + const result = geminiToOpenAIRequest( + "gpt-4o", + { + contents: [ + { + role: "user", + parts: [ + { + functionResponse: { + id: "call_test", + name: "check_condition", + response: { result: inputVal }, + }, + }, + ], + }, + ], + }, + false + ); + + assert.equal(result.messages.length, 1); + assert.equal(result.messages[0].role, "tool"); + assert.equal(result.messages[0].tool_call_id, "call_test"); + assert.equal(result.messages[0].content, expected); + } +}); + +test("Gemini -> OpenAI preserves custom response objects without result key", () => { + const result = geminiToOpenAIRequest( + "gpt-4o", + { + contents: [ + { + role: "user", + parts: [ + { + functionResponse: { + id: "call_custom", + name: "custom_op", + response: { output: "value", success: false }, + }, + }, + ], + }, + ], + }, + false + ); + + assert.equal(result.messages.length, 1); + assert.equal(result.messages[0].role, "tool"); + assert.equal(result.messages[0].content, '{"output":"value","success":false}'); +});