diff --git a/spec-grpc/src/main/java/org/a2aproject/sdk/grpc/mapper/A2ACommonFieldMapper.java b/spec-grpc/src/main/java/org/a2aproject/sdk/grpc/mapper/A2ACommonFieldMapper.java index d2170d9c1..9704dd66c 100644 --- a/spec-grpc/src/main/java/org/a2aproject/sdk/grpc/mapper/A2ACommonFieldMapper.java +++ b/spec-grpc/src/main/java/org/a2aproject/sdk/grpc/mapper/A2ACommonFieldMapper.java @@ -4,10 +4,12 @@ import java.time.OffsetDateTime; import java.time.ZoneOffset; import java.util.Collections; +import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.stream.Collectors; +import com.google.protobuf.NullValue; import com.google.protobuf.Struct; import com.google.protobuf.Timestamp; import com.google.protobuf.Value; @@ -166,8 +168,9 @@ default Map structToMap(Struct struct) { if (struct == null || struct.getFieldsCount() == 0) { return null; } - return struct.getFieldsMap().entrySet().stream() - .collect(Collectors.toMap(Map.Entry::getKey, e -> valueToObject(e.getValue()))); + Map map = new HashMap<>(); + struct.getFieldsMap().forEach((key, value) -> map.put(key, valueToObject(value))); + return map; } /** @@ -182,7 +185,9 @@ default Map structToMap(Struct struct) { @SuppressWarnings("unchecked") default Value objectToValue(Object value) { Value.Builder valueBuilder = Value.newBuilder(); - if (value instanceof String) { + if (value == null) { + valueBuilder.setNullValue(NullValue.NULL_VALUE); + } else if (value instanceof String) { valueBuilder.setStringValue((String) value); } else if (value instanceof Number) { valueBuilder.setNumberValue(((Number) value).doubleValue()); diff --git a/spec-grpc/src/test/java/org/a2aproject/sdk/grpc/mapper/A2ACommonFieldMapperTest.java b/spec-grpc/src/test/java/org/a2aproject/sdk/grpc/mapper/A2ACommonFieldMapperTest.java index d77c649a8..40598e2e9 100644 --- a/spec-grpc/src/test/java/org/a2aproject/sdk/grpc/mapper/A2ACommonFieldMapperTest.java +++ b/spec-grpc/src/test/java/org/a2aproject/sdk/grpc/mapper/A2ACommonFieldMapperTest.java @@ -1,12 +1,19 @@ package org.a2aproject.sdk.grpc.mapper; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertInstanceOf; 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 java.util.Arrays; +import java.util.List; import java.util.Map; import com.google.protobuf.InvalidProtocolBufferException; +import com.google.protobuf.Struct; +import com.google.protobuf.Value; import com.google.protobuf.util.JsonFormat; import org.a2aproject.sdk.grpc.utils.ProtoUtils; @@ -15,6 +22,95 @@ public class A2ACommonFieldMapperTest { + @Test + void testStructToMap_WithExplicitNull_PreservesKey() throws InvalidProtocolBufferException { + Struct.Builder builder = Struct.newBuilder(); + JsonFormat.parser().merge("{\"attributeId\":null,\"source\":\"ID\"}", builder); + + Map result = A2ACommonFieldMapper.INSTANCE.structToMap(builder.build()); + + assertNotNull(result); + assertEquals(2, result.size()); + assertTrue(result.containsKey("attributeId")); + assertNull(result.get("attributeId")); + assertEquals("ID", result.get("source")); + } + + @Test + void testObjectToValue_WithExplicitNull_SetsNullKind() { + Value result = A2ACommonFieldMapper.INSTANCE.objectToValue(null); + + assertEquals(Value.KindCase.NULL_VALUE, result.getKindCase()); + } + + @Test + void testStructToMap_WithNestedNulls_RoundTrips() throws InvalidProtocolBufferException { + String json = """ + { + "nested": {"optional": null}, + "items": [null, {"optional": null}, [], {}], + "emptyObject": {}, + "emptyList": [], + "text": "", + "number": 0, + "flag": false + } + """; + Struct.Builder builder = Struct.newBuilder(); + JsonFormat.parser().merge(json, builder); + Struct original = builder.build(); + + Map result = A2ACommonFieldMapper.INSTANCE.structToMap(original); + + Map nested = assertInstanceOf(Map.class, result.get("nested")); + assertTrue(nested.containsKey("optional")); + assertNull(nested.get("optional")); + List items = assertInstanceOf(List.class, result.get("items")); + assertEquals(4, items.size()); + assertNull(items.get(0)); + Map item = assertInstanceOf(Map.class, items.get(1)); + assertTrue(item.containsKey("optional")); + assertNull(item.get("optional")); + assertEquals(List.of(), items.get(2)); + assertEquals(Map.of(), items.get(3)); + assertEquals(Map.of(), result.get("emptyObject")); + assertEquals(List.of(), result.get("emptyList")); + assertEquals("", result.get("text")); + assertEquals(0.0, result.get("number")); + assertEquals(false, result.get("flag")); + assertFalse(result.containsKey("absent")); + assertEquals(original, A2ACommonFieldMapper.INSTANCE.mapToStruct(result)); + assertEquals(result, A2ACommonFieldMapper.INSTANCE.metadataFromProto(original)); + assertEquals(original, A2ACommonFieldMapper.INSTANCE.metadataToProto(result)); + } + + @Test + void testValueToObject_WithNullListElement_RoundTrips() throws InvalidProtocolBufferException { + Value.Builder builder = Value.newBuilder(); + JsonFormat.parser().merge("[null,\"value\",{},[]]", builder); + Value original = builder.build(); + + List result = assertInstanceOf(List.class, A2ACommonFieldMapper.INSTANCE.valueToObject(original)); + + assertEquals(Arrays.asList(null, "value", Map.of(), List.of()), result); + assertEquals(original, A2ACommonFieldMapper.INSTANCE.objectToValue(result)); + } + + @Test + void testStructConversions_WithAbsentOrEmptyValues_PreserveDefaults() { + A2ACommonFieldMapper mapper = A2ACommonFieldMapper.INSTANCE; + Struct empty = Struct.getDefaultInstance(); + + assertNull(mapper.structToMap(null)); + assertNull(mapper.structToMap(empty)); + assertEquals(Map.of(), mapper.metadataFromProto(null)); + assertEquals(Map.of(), mapper.metadataFromProto(empty)); + assertEquals(empty, mapper.mapToStruct(null)); + assertEquals(empty, mapper.mapToStruct(Map.of())); + assertEquals(empty, mapper.metadataToProto(null)); + assertEquals(empty, mapper.metadataToProto(Map.of())); + } + /** * Test that valueToObject handles empty struct correctly without throwing NullPointerException. * diff --git a/spec-grpc/src/test/java/org/a2aproject/sdk/grpc/utils/JSONRPCUtilsTest.java b/spec-grpc/src/test/java/org/a2aproject/sdk/grpc/utils/JSONRPCUtilsTest.java index 5e47bd188..c072c1df9 100644 --- a/spec-grpc/src/test/java/org/a2aproject/sdk/grpc/utils/JSONRPCUtilsTest.java +++ b/spec-grpc/src/test/java/org/a2aproject/sdk/grpc/utils/JSONRPCUtilsTest.java @@ -1,6 +1,7 @@ package org.a2aproject.sdk.grpc.utils; import static org.a2aproject.sdk.grpc.utils.JSONRPCUtils.ERROR_MESSAGE; +import static org.a2aproject.sdk.spec.A2AMethods.GET_TASK_METHOD; import static org.a2aproject.sdk.spec.A2AMethods.GET_TASK_PUSH_NOTIFICATION_CONFIG_METHOD; import static org.a2aproject.sdk.spec.A2AMethods.SEND_MESSAGE_METHOD; import static org.a2aproject.sdk.spec.A2AMethods.SET_TASK_PUSH_NOTIFICATION_CONFIG_METHOD; @@ -8,11 +9,13 @@ import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertInstanceOf; import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assertions.fail; import java.util.Collections; +import java.util.Map; import com.google.gson.JsonArray; import com.google.gson.JsonParser; @@ -28,11 +31,15 @@ import org.a2aproject.sdk.jsonrpc.common.wrappers.GetExtendedAgentCardRequest; import org.a2aproject.sdk.jsonrpc.common.wrappers.GetTaskPushNotificationConfigRequest; import org.a2aproject.sdk.jsonrpc.common.wrappers.GetTaskPushNotificationConfigResponse; +import org.a2aproject.sdk.jsonrpc.common.wrappers.GetTaskResponse; +import org.a2aproject.sdk.jsonrpc.common.wrappers.SendMessageRequest; +import org.a2aproject.sdk.spec.DataPart; import org.a2aproject.sdk.spec.GetExtendedAgentCardParams; import org.a2aproject.sdk.spec.InvalidParamsError; import org.a2aproject.sdk.spec.JSONParseError; import org.a2aproject.sdk.spec.Message; import org.a2aproject.sdk.spec.MessageSendParams; +import org.a2aproject.sdk.spec.Task; import org.a2aproject.sdk.spec.TaskNotFoundError; import org.a2aproject.sdk.spec.TaskPushNotificationConfig; import org.a2aproject.sdk.spec.TextPart; @@ -41,6 +48,92 @@ public class JSONRPCUtilsTest { + @Test + public void testSendMessage_WithExplicitNulls_RoundTrips() throws JsonProcessingException { + String json = """ + { + "jsonrpc": "2.0", + "id": "null-message", + "method": "SendMessage", + "params": { + "message": { + "messageId": "message-null", + "role": "ROLE_USER", + "parts": [{ + "data": {"attributeId": null, "source": "ID"}, + "metadata": {"partOptional": null} + }], + "metadata": {"messageOptional": null} + }, + "metadata": {"requestOptional": null} + } + } + """; + + SendMessageRequest request = assertInstanceOf(SendMessageRequest.class, JSONRPCUtils.parseRequestBody(json, null)); + MessageSendParams params = request.getParams(); + DataPart part = assertInstanceOf(DataPart.class, params.message().parts().get(0)); + Map data = assertInstanceOf(Map.class, part.data()); + assertTrue(data.containsKey("attributeId")); + assertNull(data.get("attributeId")); + assertEquals("ID", data.get("source")); + assertEquals(Collections.singletonMap("partOptional", null), part.metadata()); + assertEquals(Collections.singletonMap("messageOptional", null), params.message().metadata()); + assertEquals(Collections.singletonMap("requestOptional", null), params.metadata()); + + String serialized = JSONRPCUtils.toJsonRPCRequest(assertInstanceOf(String.class, request.getId()), SEND_MESSAGE_METHOD, + ProtoUtils.ToProto.sendMessageRequest(params)); + SendMessageRequest roundTripped = assertInstanceOf(SendMessageRequest.class, + JSONRPCUtils.parseRequestBody(serialized, null)); + assertEquals(params, roundTripped.getParams()); + } + + @Test + public void testGetTask_WithNestedNulls_RoundTrips() throws JsonProcessingException { + String json = """ + { + "jsonrpc": "2.0", + "id": "null-task", + "result": { + "id": "task-null", + "contextId": "context-null", + "status": { + "state": "TASK_STATE_COMPLETED", + "timestamp": "2026-09-21T00:00:00Z" + }, + "history": [{ + "messageId": "message-null", + "role": "ROLE_AGENT", + "parts": [{ + "data": { + "nested": {"optional": null}, + "items": [null, {"optional": null}, {}, []] + }, + "metadata": {"partOptional": null} + }], + "metadata": {"messageOptional": null} + }], + "metadata": {"taskOptional": null} + } + } + """; + + GetTaskResponse response = assertInstanceOf(GetTaskResponse.class, JSONRPCUtils.parseResponseBody(json, GET_TASK_METHOD)); + Task task = response.getResult(); + assertNotNull(task); + assertEquals(Collections.singletonMap("taskOptional", null), task.metadata()); + DataPart part = assertInstanceOf(DataPart.class, task.history().get(0).parts().get(0)); + Map data = assertInstanceOf(Map.class, part.data()); + assertEquals(Collections.singletonMap("optional", null), data.get("nested")); + assertEquals(Collections.singletonMap("partOptional", null), part.metadata()); + assertEquals(Collections.singletonMap("messageOptional", null), task.history().get(0).metadata()); + + String serialized = JSONRPCUtils.toJsonRPCResultResponse(response.getId(), ProtoUtils.ToProto.task(task)); + GetTaskResponse roundTripped = assertInstanceOf(GetTaskResponse.class, + JSONRPCUtils.parseResponseBody(serialized, GET_TASK_METHOD)); + assertEquals(task, roundTripped.getResult()); + } + @Test public void testParseCreateTaskPushNotificationConfigRequest_ValidProtoFormat() throws JsonProcessingException { String validRequest = """ diff --git a/spec-grpc/src/test/java/org/a2aproject/sdk/grpc/utils/ToProtoTest.java b/spec-grpc/src/test/java/org/a2aproject/sdk/grpc/utils/ToProtoTest.java index 009bb8a5d..8c3130b52 100644 --- a/spec-grpc/src/test/java/org/a2aproject/sdk/grpc/utils/ToProtoTest.java +++ b/spec-grpc/src/test/java/org/a2aproject/sdk/grpc/utils/ToProtoTest.java @@ -6,12 +6,18 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; import java.time.OffsetDateTime; +import java.util.Arrays; import java.util.Collections; +import java.util.HashMap; import java.util.List; import java.util.Map; +import com.google.protobuf.Struct; +import com.google.protobuf.Value; + import org.a2aproject.sdk.grpc.SendMessageConfiguration; import org.a2aproject.sdk.spec.AgentCapabilities; import org.a2aproject.sdk.spec.AgentCard; @@ -20,6 +26,7 @@ import org.a2aproject.sdk.spec.SecurityRequirement; import org.a2aproject.sdk.spec.Artifact; import org.a2aproject.sdk.spec.AuthenticationInfo; +import org.a2aproject.sdk.spec.DataPart; import org.a2aproject.sdk.spec.DeleteTaskPushNotificationConfigParams; import org.a2aproject.sdk.spec.HTTPAuthSecurityScheme; import org.a2aproject.sdk.spec.ListTaskPushNotificationConfigsParams; @@ -206,6 +213,72 @@ public void convertMessage() { assertEquals(false, result.getParts(0).hasData()); } + @Test + public void convertMessageWithExplicitNulls() { + Message message = messageWithExplicitNulls(); + + org.a2aproject.sdk.grpc.Message result = ProtoUtils.ToProto.message(message); + + assertEquals(Value.KindCase.NULL_VALUE, + result.getParts(0).getData().getStructValue().getFieldsOrThrow("attributeId").getKindCase()); + assertEquals(Value.KindCase.NULL_VALUE, + result.getParts(0).getMetadata().getFieldsOrThrow("optional").getKindCase()); + assertEquals(Value.KindCase.NULL_VALUE, + result.getMetadata().getFieldsOrThrow("optional").getKindCase()); + assertEquals(message, ProtoUtils.FromProto.message(result)); + } + + @Test + public void convertMessageWithAbsentOrEmptyMetadata() { + org.a2aproject.sdk.grpc.Message.Builder builder = ProtoUtils.ToProto.message(SIMPLE_MESSAGE) + .toBuilder() + .clearMetadata(); + + assertNull(ProtoUtils.FromProto.message(builder).metadata()); + builder.setMetadata(Struct.getDefaultInstance()); + assertEquals(Map.of(), ProtoUtils.FromProto.message(builder).metadata()); + } + + @Test + public void convertTaskWithExplicitNulls() { + Message message = messageWithExplicitNulls(); + Task task = Task.builder() + .id("task-null") + .contextId("context-null") + .status(new TaskStatus(TaskState.TASK_STATE_COMPLETED, message, + OffsetDateTime.parse("2026-09-21T00:00:00Z"))) + .history(List.of(message)) + .artifacts(List.of(Artifact.builder() + .artifactId("artifact-null") + .parts(message.parts()) + .metadata(message.metadata()) + .build())) + .metadata(message.metadata()) + .build(); + + org.a2aproject.sdk.grpc.Task result = ProtoUtils.ToProto.task(task); + + assertEquals(Value.KindCase.NULL_VALUE, + result.getMetadata().getFieldsOrThrow("optional").getKindCase()); + assertEquals(Value.KindCase.NULL_VALUE, + result.getArtifacts(0).getMetadata().getFieldsOrThrow("optional").getKindCase()); + assertEquals(task, ProtoUtils.FromProto.task(result)); + } + + private Message messageWithExplicitNulls() { + Map data = new HashMap<>(); + data.put("attributeId", null); + data.put("items", Arrays.asList(null, Collections.singletonMap("optional", null), Map.of(), List.of())); + Map metadata = Collections.singletonMap("optional", null); + return Message.builder() + .messageId("message-null") + .contextId("context-null") + .role(Message.Role.ROLE_USER) + .parts(List.of(new DataPart(data, metadata))) + .metadata(metadata) + .build(); + } + @Test public void convertTaskPushNotificationConfig() { TaskPushNotificationConfig taskPushConfig = TaskPushNotificationConfig.builder()