From 9f769da4db70d56e1723310d4f9c56b28c79af76 Mon Sep 17 00:00:00 2001 From: Yuqi Guo Date: Tue, 28 Apr 2026 21:57:20 -0600 Subject: [PATCH] fix: strip 'id' field from FunctionCall/FunctionResponse before sending to Gemini API The Gemini API does not accept an 'id' field in contents[].parts[].function_call or contents[].parts[].function_response, returning HTTP 400 'Unknown name id' when the field is present. The SDK was forwarding the 'id' field from the FunctionCall and FunctionResponse type objects directly into the outbound JSON payload via the converter methods in Models, Batches, Caches, LiveConverters, and TokensConverters. Fix: remove the 'id' copy-block from functionCallToMldev() and strip 'id' from the functionResponse node in partToMldev() across all five converter files. The 'id' field is intentionally retained in the type definitions so it can still be deserialized from inbound API responses. Fixes #694 --- src/main/java/com/google/genai/Batches.java | 16 +- src/main/java/com/google/genai/Caches.java | 27 +- .../java/com/google/genai/LiveConverters.java | 27 +- src/main/java/com/google/genai/Models.java | 16 +- .../com/google/genai/TokensConverters.java | 16 +- .../genai/FunctionCallIdStrippingTest.java | 414 ++++++++++++++++++ 6 files changed, 463 insertions(+), 53 deletions(-) create mode 100644 src/test/java/com/google/genai/FunctionCallIdStrippingTest.java diff --git a/src/main/java/com/google/genai/Batches.java b/src/main/java/com/google/genai/Batches.java index a621661bd6c..f6d51abb801 100644 --- a/src/main/java/com/google/genai/Batches.java +++ b/src/main/java/com/google/genai/Batches.java @@ -1095,11 +1095,6 @@ ObjectNode fileDataToMldev(JsonNode fromObject, ObjectNode parentObject) { @ExcludeFromGeneratedCoverageReport ObjectNode functionCallToMldev(JsonNode fromObject, ObjectNode parentObject) { ObjectNode toObject = JsonSerializable.objectMapper().createObjectNode(); - if (Common.getValueByPath(fromObject, new String[] {"id"}) != null) { - Common.setValueByPath( - toObject, new String[] {"id"}, Common.getValueByPath(fromObject, new String[] {"id"})); - } - if (Common.getValueByPath(fromObject, new String[] {"args"}) != null) { Common.setValueByPath( toObject, @@ -1846,10 +1841,13 @@ ObjectNode partToMldev(JsonNode fromObject, ObjectNode parentObject) { } if (Common.getValueByPath(fromObject, new String[] {"functionResponse"}) != null) { - Common.setValueByPath( - toObject, - new String[] {"functionResponse"}, - Common.getValueByPath(fromObject, new String[] {"functionResponse"})); + JsonNode functionResponseNode = + JsonSerializable.toJsonNode( + Common.getValueByPath(fromObject, new String[] {"functionResponse"})); + if (functionResponseNode instanceof ObjectNode) { + ((ObjectNode) functionResponseNode).remove("id"); + } + Common.setValueByPath(toObject, new String[] {"functionResponse"}, functionResponseNode); } if (Common.getValueByPath(fromObject, new String[] {"inlineData"}) != null) { diff --git a/src/main/java/com/google/genai/Caches.java b/src/main/java/com/google/genai/Caches.java index 3d5a4203525..a8cc0839b25 100644 --- a/src/main/java/com/google/genai/Caches.java +++ b/src/main/java/com/google/genai/Caches.java @@ -457,11 +457,6 @@ ObjectNode fileDataToMldev(JsonNode fromObject, ObjectNode parentObject) { @ExcludeFromGeneratedCoverageReport ObjectNode functionCallToMldev(JsonNode fromObject, ObjectNode parentObject) { ObjectNode toObject = JsonSerializable.objectMapper().createObjectNode(); - if (Common.getValueByPath(fromObject, new String[] {"id"}) != null) { - Common.setValueByPath( - toObject, new String[] {"id"}, Common.getValueByPath(fromObject, new String[] {"id"})); - } - if (Common.getValueByPath(fromObject, new String[] {"args"}) != null) { Common.setValueByPath( toObject, @@ -818,10 +813,13 @@ ObjectNode partToMldev(JsonNode fromObject, ObjectNode parentObject) { } if (Common.getValueByPath(fromObject, new String[] {"functionResponse"}) != null) { - Common.setValueByPath( - toObject, - new String[] {"functionResponse"}, - Common.getValueByPath(fromObject, new String[] {"functionResponse"})); + JsonNode functionResponseNode = + JsonSerializable.toJsonNode( + Common.getValueByPath(fromObject, new String[] {"functionResponse"})); + if (functionResponseNode instanceof ObjectNode) { + ((ObjectNode) functionResponseNode).remove("id"); + } + Common.setValueByPath(toObject, new String[] {"functionResponse"}, functionResponseNode); } if (Common.getValueByPath(fromObject, new String[] {"inlineData"}) != null) { @@ -925,10 +923,13 @@ ObjectNode partToVertex(JsonNode fromObject, ObjectNode parentObject) { } if (Common.getValueByPath(fromObject, new String[] {"functionResponse"}) != null) { - Common.setValueByPath( - toObject, - new String[] {"functionResponse"}, - Common.getValueByPath(fromObject, new String[] {"functionResponse"})); + JsonNode functionResponseNode = + JsonSerializable.toJsonNode( + Common.getValueByPath(fromObject, new String[] {"functionResponse"})); + if (functionResponseNode instanceof ObjectNode) { + ((ObjectNode) functionResponseNode).remove("id"); + } + Common.setValueByPath(toObject, new String[] {"functionResponse"}, functionResponseNode); } if (Common.getValueByPath(fromObject, new String[] {"inlineData"}) != null) { diff --git a/src/main/java/com/google/genai/LiveConverters.java b/src/main/java/com/google/genai/LiveConverters.java index febb094e1e5..5ff9b3f125d 100644 --- a/src/main/java/com/google/genai/LiveConverters.java +++ b/src/main/java/com/google/genai/LiveConverters.java @@ -180,11 +180,6 @@ ObjectNode fileDataToMldev(JsonNode fromObject, ObjectNode parentObject) { @ExcludeFromGeneratedCoverageReport ObjectNode functionCallToMldev(JsonNode fromObject, ObjectNode parentObject) { ObjectNode toObject = JsonSerializable.objectMapper().createObjectNode(); - if (Common.getValueByPath(fromObject, new String[] {"id"}) != null) { - Common.setValueByPath( - toObject, new String[] {"id"}, Common.getValueByPath(fromObject, new String[] {"id"})); - } - if (Common.getValueByPath(fromObject, new String[] {"args"}) != null) { Common.setValueByPath( toObject, @@ -1693,10 +1688,13 @@ ObjectNode partToMldev(JsonNode fromObject, ObjectNode parentObject) { } if (Common.getValueByPath(fromObject, new String[] {"functionResponse"}) != null) { - Common.setValueByPath( - toObject, - new String[] {"functionResponse"}, - Common.getValueByPath(fromObject, new String[] {"functionResponse"})); + JsonNode functionResponseNode = + JsonSerializable.toJsonNode( + Common.getValueByPath(fromObject, new String[] {"functionResponse"})); + if (functionResponseNode instanceof ObjectNode) { + ((ObjectNode) functionResponseNode).remove("id"); + } + Common.setValueByPath(toObject, new String[] {"functionResponse"}, functionResponseNode); } if (Common.getValueByPath(fromObject, new String[] {"inlineData"}) != null) { @@ -1800,10 +1798,13 @@ ObjectNode partToVertex(JsonNode fromObject, ObjectNode parentObject) { } if (Common.getValueByPath(fromObject, new String[] {"functionResponse"}) != null) { - Common.setValueByPath( - toObject, - new String[] {"functionResponse"}, - Common.getValueByPath(fromObject, new String[] {"functionResponse"})); + JsonNode functionResponseNode = + JsonSerializable.toJsonNode( + Common.getValueByPath(fromObject, new String[] {"functionResponse"})); + if (functionResponseNode instanceof ObjectNode) { + ((ObjectNode) functionResponseNode).remove("id"); + } + Common.setValueByPath(toObject, new String[] {"functionResponse"}, functionResponseNode); } if (Common.getValueByPath(fromObject, new String[] {"inlineData"}) != null) { diff --git a/src/main/java/com/google/genai/Models.java b/src/main/java/com/google/genai/Models.java index a6fb051345d..afebc2944d2 100644 --- a/src/main/java/com/google/genai/Models.java +++ b/src/main/java/com/google/genai/Models.java @@ -1316,11 +1316,6 @@ ObjectNode fileDataToMldev(JsonNode fromObject, ObjectNode parentObject, JsonNod ObjectNode functionCallToMldev( JsonNode fromObject, ObjectNode parentObject, JsonNode rootObject) { ObjectNode toObject = JsonSerializable.objectMapper().createObjectNode(); - if (Common.getValueByPath(fromObject, new String[] {"id"}) != null) { - Common.setValueByPath( - toObject, new String[] {"id"}, Common.getValueByPath(fromObject, new String[] {"id"})); - } - if (Common.getValueByPath(fromObject, new String[] {"args"}) != null) { Common.setValueByPath( toObject, @@ -4156,10 +4151,13 @@ ObjectNode partToMldev(JsonNode fromObject, ObjectNode parentObject, JsonNode ro } if (Common.getValueByPath(fromObject, new String[] {"functionResponse"}) != null) { - Common.setValueByPath( - toObject, - new String[] {"functionResponse"}, - Common.getValueByPath(fromObject, new String[] {"functionResponse"})); + JsonNode functionResponseNode = + JsonSerializable.toJsonNode( + Common.getValueByPath(fromObject, new String[] {"functionResponse"})); + if (functionResponseNode instanceof ObjectNode) { + ((ObjectNode) functionResponseNode).remove("id"); + } + Common.setValueByPath(toObject, new String[] {"functionResponse"}, functionResponseNode); } if (Common.getValueByPath(fromObject, new String[] {"inlineData"}) != null) { diff --git a/src/main/java/com/google/genai/TokensConverters.java b/src/main/java/com/google/genai/TokensConverters.java index 8a5a46fc7c8..c5c9d0b9e41 100644 --- a/src/main/java/com/google/genai/TokensConverters.java +++ b/src/main/java/com/google/genai/TokensConverters.java @@ -232,11 +232,6 @@ ObjectNode fileDataToMldev(JsonNode fromObject, ObjectNode parentObject) { @ExcludeFromGeneratedCoverageReport ObjectNode functionCallToMldev(JsonNode fromObject, ObjectNode parentObject) { ObjectNode toObject = JsonSerializable.objectMapper().createObjectNode(); - if (Common.getValueByPath(fromObject, new String[] {"id"}) != null) { - Common.setValueByPath( - toObject, new String[] {"id"}, Common.getValueByPath(fromObject, new String[] {"id"})); - } - if (Common.getValueByPath(fromObject, new String[] {"args"}) != null) { Common.setValueByPath( toObject, @@ -562,10 +557,13 @@ ObjectNode partToMldev(JsonNode fromObject, ObjectNode parentObject) { } if (Common.getValueByPath(fromObject, new String[] {"functionResponse"}) != null) { - Common.setValueByPath( - toObject, - new String[] {"functionResponse"}, - Common.getValueByPath(fromObject, new String[] {"functionResponse"})); + JsonNode functionResponseNode = + JsonSerializable.toJsonNode( + Common.getValueByPath(fromObject, new String[] {"functionResponse"})); + if (functionResponseNode instanceof ObjectNode) { + ((ObjectNode) functionResponseNode).remove("id"); + } + Common.setValueByPath(toObject, new String[] {"functionResponse"}, functionResponseNode); } if (Common.getValueByPath(fromObject, new String[] {"inlineData"}) != null) { diff --git a/src/test/java/com/google/genai/FunctionCallIdStrippingTest.java b/src/test/java/com/google/genai/FunctionCallIdStrippingTest.java new file mode 100644 index 00000000000..22ab8c0fc9e --- /dev/null +++ b/src/test/java/com/google/genai/FunctionCallIdStrippingTest.java @@ -0,0 +1,414 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.genai; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.google.common.collect.ImmutableMap; +import com.google.genai.types.FunctionCall; +import com.google.genai.types.FunctionResponse; +import com.google.genai.types.Part; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +/** + * Tests that the {@code id} field is stripped from {@code FunctionCall} and {@code + * FunctionResponse} objects before they are sent to the Gemini API (Mldev format), while remaining + * available for deserialization and client-side use. + */ +public class FunctionCallIdStrippingTest { + + private ApiClient apiClient; + private Models models; + private Batches batches; + private LiveConverters liveConverters; + private TokensConverters tokensConverters; + private Caches caches; + + @BeforeEach + public void setUp() { + apiClient = mock(ApiClient.class); + models = new Models(apiClient); + batches = new Batches(apiClient); + liveConverters = new LiveConverters(apiClient); + tokensConverters = new TokensConverters(apiClient); + caches = new Caches(apiClient); + } + + // --------------------------------------------------------------------------- + // functionCallToMldev — id stripping + // --------------------------------------------------------------------------- + + @Test + public void testFunctionCallToMldev_withId_idIsStripped_Models() { + FunctionCall fc = + FunctionCall.builder() + .id("call_123") + .name("getWeather") + .args(ImmutableMap.of("city", "Seattle")) + .build(); + JsonNode fromObject = JsonSerializable.toJsonNode(fc); + ObjectNode parentObject = JsonSerializable.objectMapper().createObjectNode(); + JsonNode rootObject = JsonSerializable.objectMapper().createObjectNode(); + + ObjectNode result = models.functionCallToMldev(fromObject, parentObject, rootObject); + + assertNull(result.get("id"), "id should be stripped from functionCall"); + assertEquals("getWeather", result.get("name").asText()); + assertNotNull(result.get("args")); + } + + @Test + public void testFunctionCallToMldev_withId_idIsStripped_Batches() { + FunctionCall fc = + FunctionCall.builder() + .id("call_456") + .name("search") + .args(ImmutableMap.of("q", "test")) + .build(); + JsonNode fromObject = JsonSerializable.toJsonNode(fc); + ObjectNode parentObject = JsonSerializable.objectMapper().createObjectNode(); + + ObjectNode result = batches.functionCallToMldev(fromObject, parentObject); + + assertNull(result.get("id"), "id should be stripped from functionCall"); + assertEquals("search", result.get("name").asText()); + } + + @Test + public void testFunctionCallToMldev_withId_idIsStripped_LiveConverters() { + FunctionCall fc = + FunctionCall.builder() + .id("call_live") + .name("stream") + .args(ImmutableMap.of("ch", "main")) + .build(); + JsonNode fromObject = JsonSerializable.toJsonNode(fc); + ObjectNode parentObject = JsonSerializable.objectMapper().createObjectNode(); + + ObjectNode result = liveConverters.functionCallToMldev(fromObject, parentObject); + + assertNull(result.get("id"), "id should be stripped from functionCall"); + assertEquals("stream", result.get("name").asText()); + } + + @Test + public void testFunctionCallToMldev_withId_idIsStripped_TokensConverters() { + FunctionCall fc = + FunctionCall.builder() + .id("call_tok") + .name("count") + .args(ImmutableMap.of("t", "hi")) + .build(); + JsonNode fromObject = JsonSerializable.toJsonNode(fc); + ObjectNode parentObject = JsonSerializable.objectMapper().createObjectNode(); + + ObjectNode result = tokensConverters.functionCallToMldev(fromObject, parentObject); + + assertNull(result.get("id"), "id should be stripped from functionCall"); + assertEquals("count", result.get("name").asText()); + } + + @Test + public void testFunctionCallToMldev_withId_idIsStripped_Caches() { + FunctionCall fc = + FunctionCall.builder() + .id("call_cache") + .name("lookup") + .args(ImmutableMap.of("key", "abc")) + .build(); + JsonNode fromObject = JsonSerializable.toJsonNode(fc); + ObjectNode parentObject = JsonSerializable.objectMapper().createObjectNode(); + + ObjectNode result = caches.functionCallToMldev(fromObject, parentObject); + + assertNull(result.get("id"), "id should be stripped from functionCall"); + assertEquals("lookup", result.get("name").asText()); + } + + // --------------------------------------------------------------------------- + // functionCallToMldev — regression: no id present + // --------------------------------------------------------------------------- + + @Test + public void testFunctionCallToMldev_withoutId_worksNormally() { + FunctionCall fc = + FunctionCall.builder() + .name("getWeather") + .args(ImmutableMap.of("city", "Portland")) + .build(); + JsonNode fromObject = JsonSerializable.toJsonNode(fc); + ObjectNode parentObject = JsonSerializable.objectMapper().createObjectNode(); + JsonNode rootObject = JsonSerializable.objectMapper().createObjectNode(); + + ObjectNode result = models.functionCallToMldev(fromObject, parentObject, rootObject); + + assertNull(result.get("id")); + assertEquals("getWeather", result.get("name").asText()); + assertNotNull(result.get("args")); + } + + // --------------------------------------------------------------------------- + // functionCallToMldev — name and args preserved + // --------------------------------------------------------------------------- + + @Test + public void testFunctionCallToMldev_nameOnly() { + FunctionCall fc = FunctionCall.builder().name("noArgs").build(); + JsonNode fromObject = JsonSerializable.toJsonNode(fc); + ObjectNode parentObject = JsonSerializable.objectMapper().createObjectNode(); + JsonNode rootObject = JsonSerializable.objectMapper().createObjectNode(); + + ObjectNode result = models.functionCallToMldev(fromObject, parentObject, rootObject); + + assertNull(result.get("id")); + assertEquals("noArgs", result.get("name").asText()); + assertNull(result.get("args")); + } + + // --------------------------------------------------------------------------- + // partToMldev — functionResponse id stripping + // --------------------------------------------------------------------------- + + @Test + public void testPartToMldev_functionResponseWithId_idIsStripped_Models() { + FunctionResponse fr = + FunctionResponse.builder() + .id("call_123") + .name("getWeather") + .response(ImmutableMap.of("output", "sunny")) + .build(); + Part part = Part.builder().functionResponse(fr).build(); + JsonNode fromObject = JsonSerializable.toJsonNode(part); + ObjectNode parentObject = JsonSerializable.objectMapper().createObjectNode(); + JsonNode rootObject = JsonSerializable.objectMapper().createObjectNode(); + + ObjectNode result = models.partToMldev(fromObject, parentObject, rootObject); + + JsonNode frNode = result.get("functionResponse"); + assertNotNull(frNode, "functionResponse should exist in output"); + assertNull(frNode.get("id"), "id should be stripped from functionResponse"); + assertEquals("getWeather", frNode.get("name").asText()); + } + + @Test + public void testPartToMldev_functionResponseWithId_idIsStripped_Batches() { + FunctionResponse fr = + FunctionResponse.builder() + .id("call_456") + .name("search") + .response(ImmutableMap.of("output", "results")) + .build(); + Part part = Part.builder().functionResponse(fr).build(); + JsonNode fromObject = JsonSerializable.toJsonNode(part); + ObjectNode parentObject = JsonSerializable.objectMapper().createObjectNode(); + + ObjectNode result = batches.partToMldev(fromObject, parentObject); + + JsonNode frNode = result.get("functionResponse"); + assertNotNull(frNode); + assertNull(frNode.get("id"), "id should be stripped from functionResponse"); + assertEquals("search", frNode.get("name").asText()); + } + + @Test + public void testPartToMldev_functionResponseWithId_idIsStripped_LiveConverters() { + FunctionResponse fr = + FunctionResponse.builder() + .id("call_live") + .name("stream") + .response(ImmutableMap.of("output", "data")) + .build(); + Part part = Part.builder().functionResponse(fr).build(); + JsonNode fromObject = JsonSerializable.toJsonNode(part); + ObjectNode parentObject = JsonSerializable.objectMapper().createObjectNode(); + + ObjectNode result = liveConverters.partToMldev(fromObject, parentObject); + + JsonNode frNode = result.get("functionResponse"); + assertNotNull(frNode); + assertNull(frNode.get("id"), "id should be stripped from functionResponse"); + assertEquals("stream", frNode.get("name").asText()); + } + + @Test + public void testPartToMldev_functionResponseWithId_idIsStripped_TokensConverters() { + FunctionResponse fr = + FunctionResponse.builder() + .id("call_tok") + .name("count") + .response(ImmutableMap.of("output", "42")) + .build(); + Part part = Part.builder().functionResponse(fr).build(); + JsonNode fromObject = JsonSerializable.toJsonNode(part); + ObjectNode parentObject = JsonSerializable.objectMapper().createObjectNode(); + + ObjectNode result = tokensConverters.partToMldev(fromObject, parentObject); + + JsonNode frNode = result.get("functionResponse"); + assertNotNull(frNode); + assertNull(frNode.get("id"), "id should be stripped from functionResponse"); + assertEquals("count", frNode.get("name").asText()); + } + + @Test + public void testPartToMldev_functionResponseWithId_idIsStripped_Caches() { + FunctionResponse fr = + FunctionResponse.builder() + .id("call_cache") + .name("lookup") + .response(ImmutableMap.of("output", "hit")) + .build(); + Part part = Part.builder().functionResponse(fr).build(); + JsonNode fromObject = JsonSerializable.toJsonNode(part); + ObjectNode parentObject = JsonSerializable.objectMapper().createObjectNode(); + + ObjectNode result = caches.partToMldev(fromObject, parentObject); + + JsonNode frNode = result.get("functionResponse"); + assertNotNull(frNode); + assertNull(frNode.get("id"), "id should be stripped from functionResponse"); + assertEquals("lookup", frNode.get("name").asText()); + } + + // --------------------------------------------------------------------------- + // partToMldev — regression: functionResponse without id + // --------------------------------------------------------------------------- + + @Test + public void testPartToMldev_functionResponseWithoutId_worksNormally() { + FunctionResponse fr = + FunctionResponse.builder() + .name("getWeather") + .response(ImmutableMap.of("output", "rainy")) + .build(); + Part part = Part.builder().functionResponse(fr).build(); + JsonNode fromObject = JsonSerializable.toJsonNode(part); + ObjectNode parentObject = JsonSerializable.objectMapper().createObjectNode(); + JsonNode rootObject = JsonSerializable.objectMapper().createObjectNode(); + + ObjectNode result = models.partToMldev(fromObject, parentObject, rootObject); + + JsonNode frNode = result.get("functionResponse"); + assertNotNull(frNode); + assertNull(frNode.get("id")); + assertEquals("getWeather", frNode.get("name").asText()); + } + + // --------------------------------------------------------------------------- + // partToMldev — all fields except id are preserved in functionResponse + // --------------------------------------------------------------------------- + + @Test + public void testPartToMldev_functionResponse_preservesAllFieldsExceptId() { + FunctionResponse fr = + FunctionResponse.builder() + .id("strip_me") + .name("importantFunc") + .response(ImmutableMap.of("result", "success", "data", "payload")) + .build(); + Part part = Part.builder().functionResponse(fr).build(); + JsonNode fromObject = JsonSerializable.toJsonNode(part); + ObjectNode parentObject = JsonSerializable.objectMapper().createObjectNode(); + JsonNode rootObject = JsonSerializable.objectMapper().createObjectNode(); + + ObjectNode result = models.partToMldev(fromObject, parentObject, rootObject); + + JsonNode frNode = result.get("functionResponse"); + assertNull(frNode.get("id"), "id should be stripped"); + assertEquals("importantFunc", frNode.get("name").asText()); + assertEquals("success", frNode.get("response").get("result").asText()); + assertEquals("payload", frNode.get("response").get("data").asText()); + } + + // --------------------------------------------------------------------------- + // partToMldev — functionCall inside Part has id stripped + // --------------------------------------------------------------------------- + + @Test + public void testPartToMldev_functionCallWithId_idIsStripped() { + FunctionCall fc = + FunctionCall.builder() + .id("fc_e2e") + .name("lookupUser") + .args(ImmutableMap.of("userId", "u123")) + .build(); + Part part = Part.builder().functionCall(fc).build(); + JsonNode fromObject = JsonSerializable.toJsonNode(part); + ObjectNode parentObject = JsonSerializable.objectMapper().createObjectNode(); + JsonNode rootObject = JsonSerializable.objectMapper().createObjectNode(); + + ObjectNode result = models.partToMldev(fromObject, parentObject, rootObject); + + JsonNode fcNode = result.get("functionCall"); + assertNotNull(fcNode, "functionCall should exist in output"); + assertNull(fcNode.get("id"), "id should be stripped from functionCall"); + assertEquals("lookupUser", fcNode.get("name").asText()); + } + + // --------------------------------------------------------------------------- + // Deserialization: id field is still readable from API responses + // --------------------------------------------------------------------------- + + @Test + public void testFunctionCall_idStillDeserializable() { + String json = "{\"id\":\"call_999\",\"name\":\"myFunc\",\"args\":{\"key\":\"value\"}}"; + FunctionCall fc = JsonSerializable.fromJsonString(json, FunctionCall.class); + + assertTrue(fc.id().isPresent()); + assertEquals("call_999", fc.id().get()); + assertEquals("myFunc", fc.name().get()); + } + + @Test + public void testFunctionResponse_idStillDeserializable() { + String json = "{\"id\":\"call_999\",\"name\":\"myFunc\",\"response\":{\"out\":\"done\"}}"; + FunctionResponse fr = JsonSerializable.fromJsonString(json, FunctionResponse.class); + + assertTrue(fr.id().isPresent()); + assertEquals("call_999", fr.id().get()); + assertEquals("myFunc", fr.name().get()); + } + + // --------------------------------------------------------------------------- + // Client-side serialization: id still appears in toJson() output + // --------------------------------------------------------------------------- + + @Test + public void testFunctionCall_clientSideSerializationStillIncludesId() { + FunctionCall fc = FunctionCall.builder().id("serialize_me").name("testFunc").build(); + String json = fc.toJson(); + + assertTrue(json.contains("\"id\":\"serialize_me\""), + "id should still appear in client-side toJson() output"); + } + + @Test + public void testFunctionResponse_clientSideSerializationStillIncludesId() { + FunctionResponse fr = FunctionResponse.builder().id("serialize_me").name("testFunc").build(); + String json = fr.toJson(); + + assertTrue(json.contains("\"id\":\"serialize_me\""), + "id should still appear in client-side toJson() output"); + } +}