From 447ea69831b524282e87745f53d5faef0112c3d4 Mon Sep 17 00:00:00 2001 From: 014-code <2402143478@qq.com> Date: Sat, 25 Jul 2026 23:11:43 +0800 Subject: [PATCH] fix(jsonrpc): avoid wrapping tasks in list responses Register EventKind and StreamingEventKind adapters only for their declared interface types so List serializes task fields directly while JSON-RPC oneof results and streaming events keep their wrapper shape. Add regression coverage for ListTasksResult/ListTasksResponse serialization and keep push notification streaming payload coverage passing. This fixes #843 --- .../core/EventSerializationTest.java | 18 +++--- .../sdk/jsonrpc/common/json/JsonUtil.java | 49 ++++++++++++++- .../StreamingEventKindSerializationTest.java | 8 +-- .../common/json/TaskSerializationTest.java | 61 +++++++++++++++++++ .../tasks/BasePushNotificationSender.java | 2 +- 5 files changed, 123 insertions(+), 15 deletions(-) diff --git a/extras/queue-manager-replicated/core/src/test/java/org/a2aproject/sdk/extras/queuemanager/replicated/core/EventSerializationTest.java b/extras/queue-manager-replicated/core/src/test/java/org/a2aproject/sdk/extras/queuemanager/replicated/core/EventSerializationTest.java index 66b10694f..ead3a5d7a 100644 --- a/extras/queue-manager-replicated/core/src/test/java/org/a2aproject/sdk/extras/queuemanager/replicated/core/EventSerializationTest.java +++ b/extras/queue-manager-replicated/core/src/test/java/org/a2aproject/sdk/extras/queuemanager/replicated/core/EventSerializationTest.java @@ -51,8 +51,8 @@ public void testTaskSerialization() throws JsonProcessingException { .status(status) .build(); - // Test serialization as Event - String json = JsonUtil.toJson(originalTask); + // Test streaming-event serialization + String json = JsonUtil.toJsonStreamingEvent(originalTask); assertTrue(json.contains("\"task\""), "JSON should contain task wrapper"); assertTrue(json.contains("\"id\":\"test-task-123\""), "JSON should contain task ID"); @@ -82,8 +82,8 @@ public void testMessageSerialization() throws JsonProcessingException { .contextId("test-context-123") .build(); - // Test serialization as Event - String json = JsonUtil.toJson(originalMessage); + // Test streaming-event serialization + String json = JsonUtil.toJsonStreamingEvent(originalMessage); assertTrue(json.contains("\"message\""), "JSON should contain message wrapper"); assertTrue(json.contains("\"taskId\":\"test-task-789\""), "JSON should contain task ID"); @@ -112,8 +112,8 @@ public void testTaskStatusUpdateEventSerialization() throws JsonProcessingExcept .status(status) .build(); - // Test serialization as Event - String json = JsonUtil.toJson((Event) originalEvent); + // Test streaming-event serialization + String json = JsonUtil.toJsonStreamingEvent(originalEvent); assertTrue(json.contains("\"statusUpdate\""), "JSON should contain statusUpdate wrapper"); assertTrue(json.contains("\"taskId\":\"test-task-abc\""), "JSON should contain task ID"); assertFalse(json.contains("\"final\""), "JSON should not contain final field"); @@ -145,8 +145,8 @@ public void testTaskArtifactUpdateEventSerialization() throws JsonProcessingExce .artifact(artifact) .build(); - // Test serialization as Event - String json = JsonUtil.toJson((Event) originalEvent); + // Test streaming-event serialization + String json = JsonUtil.toJsonStreamingEvent(originalEvent); assertTrue(json.contains("\"artifactUpdate\""), "JSON should contain artifactUpdate wrapper"); assertTrue(json.contains("\"taskId\":\"test-task-xyz\""), "JSON should contain task ID"); assertTrue(json.contains("\"test-artifact-123\""), "JSON should contain artifact ID"); @@ -328,4 +328,4 @@ public void testQueueClosedEventSerialization() throws JsonProcessingException { QueueClosedEvent retrievedClosedEvent = (QueueClosedEvent) retrievedEvent; assertEquals(taskId, retrievedClosedEvent.getTaskId(), "Reconstructed event should have correct task ID"); } -} \ No newline at end of file +} diff --git a/jsonrpc-common/src/main/java/org/a2aproject/sdk/jsonrpc/common/json/JsonUtil.java b/jsonrpc-common/src/main/java/org/a2aproject/sdk/jsonrpc/common/json/JsonUtil.java index 8a43260e0..9d3208cd4 100644 --- a/jsonrpc-common/src/main/java/org/a2aproject/sdk/jsonrpc/common/json/JsonUtil.java +++ b/jsonrpc-common/src/main/java/org/a2aproject/sdk/jsonrpc/common/json/JsonUtil.java @@ -34,6 +34,7 @@ import org.a2aproject.sdk.spec.APIKeySecurityScheme; import org.a2aproject.sdk.spec.ContentTypeNotSupportedError; import org.a2aproject.sdk.spec.DataPart; +import org.a2aproject.sdk.spec.EventKind; import org.a2aproject.sdk.spec.ExtendedAgentCardNotConfiguredError; import org.a2aproject.sdk.spec.ExtensionSupportRequiredError; import org.a2aproject.sdk.spec.FileContent; @@ -93,7 +94,8 @@ private static GsonBuilder createBaseGsonBuilder() { */ public static final Gson OBJECT_MAPPER = createBaseGsonBuilder() .registerTypeHierarchyAdapter(Part.class, new PartTypeAdapter()) - .registerTypeHierarchyAdapter(StreamingEventKind.class, new StreamingEventKindTypeAdapter()) + .registerTypeAdapter(EventKind.class, new EventKindTypeAdapter()) + .registerTypeAdapter(StreamingEventKind.class, new StreamingEventKindTypeAdapter()) .registerTypeHierarchyAdapter(SecurityScheme.class, new SecuritySchemeTypeAdapter()) .create(); @@ -149,6 +151,21 @@ public static String toJson(Object data) throws JsonProcessingException { } } + /** + * Serializes a streaming event using the A2A streaming wrapper format. + * + * @param data the streaming event to serialize + * @return JSON string representation of the wrapped streaming event + * @throws JsonProcessingException if conversion fails + */ + public static String toJsonStreamingEvent(StreamingEventKind data) throws JsonProcessingException { + try { + return OBJECT_MAPPER.toJson(data, StreamingEventKind.class); + } catch (JsonSyntaxException e) { + throw new JsonProcessingException("Failed to generate JSON", e); + } + } + /** * Writes a JSON-RPC {@code id} field. Handles null, String, and Number values, * preserving fractional precision for non-integer numeric IDs. @@ -623,6 +640,36 @@ private String stringOrEmpty(com.google.gson.JsonObject obj, String key) { } } + /** + * Gson TypeAdapter for serializing and deserializing {@link EventKind} in oneof wrapper format. + */ + static class EventKindTypeAdapter extends TypeAdapter { + + private final Gson delegateGson = createBaseGsonBuilder() + .registerTypeHierarchyAdapter(Part.class, new PartTypeAdapter()) + .create(); + private final StreamingEventKindTypeAdapter streamingEventKindTypeAdapter = + new StreamingEventKindTypeAdapter(); + + @Override + public void write(JsonWriter out, EventKind value) throws java.io.IOException { + if (value == null) { + out.nullValue(); + return; + } + out.beginObject(); + out.name(value.kind()); + delegateGson.toJson(value, value.getClass(), out); + out.endObject(); + } + + @Override + public @Nullable EventKind read(JsonReader in) throws java.io.IOException { + StreamingEventKind event = streamingEventKindTypeAdapter.read(in); + return event == null ? null : (EventKind) event; + } + } + /** * Gson TypeAdapter for serializing and deserializing {@link StreamingEventKind} and its implementations. *

diff --git a/jsonrpc-common/src/test/java/org/a2aproject/sdk/jsonrpc/common/json/StreamingEventKindSerializationTest.java b/jsonrpc-common/src/test/java/org/a2aproject/sdk/jsonrpc/common/json/StreamingEventKindSerializationTest.java index 80e178c51..1c2c83ac5 100644 --- a/jsonrpc-common/src/test/java/org/a2aproject/sdk/jsonrpc/common/json/StreamingEventKindSerializationTest.java +++ b/jsonrpc-common/src/test/java/org/a2aproject/sdk/jsonrpc/common/json/StreamingEventKindSerializationTest.java @@ -37,7 +37,7 @@ void testTaskSerialization() throws JsonProcessingException { .build(); // Serialize as StreamingEventKind - String json = JsonUtil.toJson((StreamingEventKind) task); + String json = JsonUtil.toJsonStreamingEvent(task); // Verify JSON contains task wrapper, not "kind" field assertNotNull(json); @@ -69,7 +69,7 @@ void testMessageSerialization() throws JsonProcessingException { .build(); // Serialize as StreamingEventKind - String json = JsonUtil.toJson((StreamingEventKind) message); + String json = JsonUtil.toJsonStreamingEvent(message); // Verify JSON contains message wrapper, not "kind" field assertNotNull(json); @@ -100,7 +100,7 @@ void testTaskStatusUpdateEventSerialization() throws JsonProcessingException { .build(); // Serialize as StreamingEventKind - String json = JsonUtil.toJson((StreamingEventKind) statusEvent); + String json = JsonUtil.toJsonStreamingEvent(statusEvent); // Verify JSON contains statusUpdate wrapper, not "kind" field assertNotNull(json); @@ -137,7 +137,7 @@ void testTaskArtifactUpdateEventSerialization() throws JsonProcessingException { .build(); // Serialize as StreamingEventKind - String json = JsonUtil.toJson((StreamingEventKind) artifactEvent); + String json = JsonUtil.toJsonStreamingEvent(artifactEvent); // Verify JSON contains artifactUpdate wrapper, not "kind" field assertNotNull(json); diff --git a/jsonrpc-common/src/test/java/org/a2aproject/sdk/jsonrpc/common/json/TaskSerializationTest.java b/jsonrpc-common/src/test/java/org/a2aproject/sdk/jsonrpc/common/json/TaskSerializationTest.java index 77fa911f3..73fdb58c1 100644 --- a/jsonrpc-common/src/test/java/org/a2aproject/sdk/jsonrpc/common/json/TaskSerializationTest.java +++ b/jsonrpc-common/src/test/java/org/a2aproject/sdk/jsonrpc/common/json/TaskSerializationTest.java @@ -15,6 +15,11 @@ import java.util.List; import java.util.Map; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import org.a2aproject.sdk.jsonrpc.common.wrappers.ListTasksResponse; +import org.a2aproject.sdk.jsonrpc.common.wrappers.ListTasksResult; +import org.a2aproject.sdk.jsonrpc.common.wrappers.SendMessageResponse; import org.a2aproject.sdk.spec.Artifact; import org.a2aproject.sdk.spec.DataPart; import org.a2aproject.sdk.spec.FileContent; @@ -49,6 +54,8 @@ void testBasicTaskSerialization() throws JsonProcessingException { // Verify JSON contains expected fields assertNotNull(json); + JsonObject jsonObject = JsonParser.parseString(json).getAsJsonObject(); + assertFalse(jsonObject.has(Task.STREAMING_EVENT_ID)); assertTrue(json.contains("\"id\":\"task-123\"")); assertTrue(json.contains("\"state\":\"TASK_STATE_SUBMITTED\"")); @@ -60,6 +67,60 @@ void testBasicTaskSerialization() throws JsonProcessingException { assertEquals(task.status().state(), deserialized.status().state()); } + @Test + void testListTasksSerializationDoesNotWrapTaskItems() throws JsonProcessingException { + Task task = Task.builder() + .id("task-123") + .contextId("context-456") + .status(new TaskStatus(TaskState.TASK_STATE_SUBMITTED)) + .build(); + + ListTasksResult result = new ListTasksResult(List.of(task)); + String resultJson = JsonUtil.toJson(result); + JsonObject resultTaskJson = JsonParser.parseString(resultJson) + .getAsJsonObject() + .getAsJsonArray("tasks") + .get(0) + .getAsJsonObject(); + + assertFalse(resultTaskJson.has(Task.STREAMING_EVENT_ID)); + assertEquals(task.id(), resultTaskJson.get("id").getAsString()); + assertEquals(task.contextId(), resultTaskJson.get("contextId").getAsString()); + + ListTasksResponse response = new ListTasksResponse("request-1", result); + String responseJson = JsonUtil.toJson(response); + JsonObject responseTaskJson = JsonParser.parseString(responseJson) + .getAsJsonObject() + .getAsJsonObject("result") + .getAsJsonArray("tasks") + .get(0) + .getAsJsonObject(); + + assertFalse(responseTaskJson.has(Task.STREAMING_EVENT_ID)); + assertEquals(task.id(), responseTaskJson.get("id").getAsString()); + assertEquals(task.contextId(), responseTaskJson.get("contextId").getAsString()); + } + + @Test + void testSendMessageResponseSerializationKeepsEventKindWrapper() throws JsonProcessingException { + Task task = Task.builder() + .id("task-123") + .contextId("context-456") + .status(new TaskStatus(TaskState.TASK_STATE_SUBMITTED)) + .build(); + + SendMessageResponse response = new SendMessageResponse("request-1", task); + String responseJson = JsonUtil.toJson(response); + JsonObject responseResultJson = JsonParser.parseString(responseJson) + .getAsJsonObject() + .getAsJsonObject("result"); + + assertTrue(responseResultJson.has(Task.STREAMING_EVENT_ID)); + JsonObject taskJson = responseResultJson.getAsJsonObject(Task.STREAMING_EVENT_ID); + assertEquals(task.id(), taskJson.get("id").getAsString()); + assertEquals(task.contextId(), taskJson.get("contextId").getAsString()); + } + @Test void testTaskWithTimestamp() throws JsonProcessingException { OffsetDateTime timestamp = OffsetDateTime.now(); diff --git a/server-common/src/main/java/org/a2aproject/sdk/server/tasks/BasePushNotificationSender.java b/server-common/src/main/java/org/a2aproject/sdk/server/tasks/BasePushNotificationSender.java index 5605c9001..b0f6e38d8 100644 --- a/server-common/src/main/java/org/a2aproject/sdk/server/tasks/BasePushNotificationSender.java +++ b/server-common/src/main/java/org/a2aproject/sdk/server/tasks/BasePushNotificationSender.java @@ -185,7 +185,7 @@ private boolean dispatchNotification(StreamingEventKind event, } } else { try { - body = JsonUtil.toJson(event); + body = JsonUtil.toJsonStreamingEvent(event); } catch (Throwable throwable) { LOGGER.error("Error serializing StreamingEventKind to JSON: {}", throwable.getMessage(), throwable); return false;