From d7c593fde392a0b17b737bd4af95a51722b6e0d6 Mon Sep 17 00:00:00 2001 From: misselvexu Date: Fri, 11 Jul 2025 16:45:17 +0800 Subject: [PATCH 01/31] feat: Add A2AClient test variants for server-side testing --- .../apps/common/AbstractA2AServerTest.java | 769 +++++++----------- 1 file changed, 308 insertions(+), 461 deletions(-) diff --git a/tests/server-common/src/test/java/io/a2a/server/apps/common/AbstractA2AServerTest.java b/tests/server-common/src/test/java/io/a2a/server/apps/common/AbstractA2AServerTest.java index 481767a2e..783318c43 100644 --- a/tests/server-common/src/test/java/io/a2a/server/apps/common/AbstractA2AServerTest.java +++ b/tests/server-common/src/test/java/io/a2a/server/apps/common/AbstractA2AServerTest.java @@ -9,7 +9,6 @@ import static org.wildfly.common.Assert.assertNotNull; import static org.wildfly.common.Assert.assertTrue; -import java.io.EOFException; import java.io.IOException; import java.net.URI; import java.net.http.HttpClient; @@ -24,27 +23,21 @@ import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicReference; -import java.util.stream.Stream; import jakarta.ws.rs.core.MediaType; -import com.fasterxml.jackson.core.JsonProcessingException; - import io.a2a.client.A2AClient; import io.a2a.spec.A2AServerException; import io.a2a.spec.AgentCard; import io.a2a.spec.Artifact; import io.a2a.spec.AuthenticatedExtendedCardNotConfiguredError; -import io.a2a.spec.CancelTaskRequest; import io.a2a.spec.CancelTaskResponse; import io.a2a.spec.DeleteTaskPushNotificationConfigResponse; import io.a2a.spec.Event; import io.a2a.spec.GetAuthenticatedExtendedCardRequest; import io.a2a.spec.GetAuthenticatedExtendedCardResponse; import io.a2a.spec.GetTaskPushNotificationConfigParams; -import io.a2a.spec.GetTaskPushNotificationConfigRequest; import io.a2a.spec.GetTaskPushNotificationConfigResponse; -import io.a2a.spec.GetTaskRequest; import io.a2a.spec.GetTaskResponse; import io.a2a.spec.InvalidParamsError; import io.a2a.spec.InvalidRequestError; @@ -57,28 +50,20 @@ import io.a2a.spec.MethodNotFoundError; import io.a2a.spec.Part; import io.a2a.spec.PushNotificationConfig; -import io.a2a.spec.SendMessageRequest; import io.a2a.spec.SendMessageResponse; -import io.a2a.spec.SendStreamingMessageRequest; -import io.a2a.spec.SendStreamingMessageResponse; -import io.a2a.spec.SetTaskPushNotificationConfigRequest; import io.a2a.spec.SetTaskPushNotificationConfigResponse; -import io.a2a.spec.StreamingJSONRPCRequest; import io.a2a.spec.Task; import io.a2a.spec.TaskArtifactUpdateEvent; import io.a2a.spec.TaskIdParams; import io.a2a.spec.TaskNotFoundError; import io.a2a.spec.TaskPushNotificationConfig; import io.a2a.spec.TaskQueryParams; -import io.a2a.spec.TaskResubscriptionRequest; import io.a2a.spec.TaskState; import io.a2a.spec.TaskStatus; import io.a2a.spec.TaskStatusUpdateEvent; import io.a2a.spec.TextPart; import io.a2a.spec.UnsupportedOperationError; import io.a2a.util.Utils; -import io.restassured.RestAssured; -import io.restassured.specification.RequestSpecification; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; @@ -89,46 +74,45 @@ * which delegates to {@link TestUtilsBean}. */ public abstract class AbstractA2AServerTest { - private static final Task MINIMAL_TASK = new Task.Builder() .id("task-123") .contextId("session-xyz") .status(new TaskStatus(TaskState.SUBMITTED)) .build(); - + private static final Task CANCEL_TASK = new Task.Builder() .id("cancel-task-123") .contextId("session-xyz") .status(new TaskStatus(TaskState.SUBMITTED)) .build(); - + private static final Task CANCEL_TASK_NOT_SUPPORTED = new Task.Builder() .id("cancel-task-not-supported-123") .contextId("session-xyz") .status(new TaskStatus(TaskState.SUBMITTED)) .build(); - + private static final Task SEND_MESSAGE_NOT_SUPPORTED = new Task.Builder() .id("task-not-supported-123") .contextId("session-xyz") .status(new TaskStatus(TaskState.SUBMITTED)) .build(); - + private static final Message MESSAGE = new Message.Builder() .messageId("111") .role(Message.Role.AGENT) .parts(new TextPart("test message")) .build(); public static final String APPLICATION_JSON = "application/json"; - + private final int serverPort; private A2AClient client; - + protected AbstractA2AServerTest(int serverPort) { this.serverPort = serverPort; this.client = new A2AClient("http://localhost:" + serverPort); } - + @Test public void testTaskStoreMethodsSanityTest() throws Exception { Task task = new Task.Builder(MINIMAL_TASK).id("abcde").build(); @@ -137,139 +121,89 @@ public void testTaskStoreMethodsSanityTest() throws Exception { assertEquals(task.getId(), saved.getId()); assertEquals(task.getContextId(), saved.getContextId()); assertEquals(task.getStatus().state(), saved.getStatus().state()); - + deleteTaskInTaskStore(task.getId()); Task saved2 = getTaskFromTaskStore(task.getId()); assertNull(saved2); } - + @Test public void testGetTaskSuccess() throws Exception { testGetTask(); } - + private void testGetTask() throws Exception { testGetTask(null); } - + private void testGetTask(String mediaType) throws Exception { saveTaskInTaskStore(MINIMAL_TASK); try { - GetTaskRequest request = new GetTaskRequest("1", new TaskQueryParams(MINIMAL_TASK.getId())); - RequestSpecification requestSpecification = RestAssured.given() - .contentType(MediaType.APPLICATION_JSON) - .body(request); - if (mediaType != null) { - requestSpecification = requestSpecification.accept(mediaType); - } - GetTaskResponse response = requestSpecification - .when() - .post("/") - .then() - .statusCode(200) - .extract() - .as(GetTaskResponse.class); + GetTaskResponse response = client.getTask("1", new TaskQueryParams(MINIMAL_TASK.getId())); assertEquals("1", response.getId()); assertEquals("task-123", response.getResult().getId()); assertEquals("session-xyz", response.getResult().getContextId()); assertEquals(TaskState.SUBMITTED, response.getResult().getStatus().state()); assertNull(response.getError()); + } catch (A2AServerException e) { + fail("Unexpected exception during getTask: " + e.getMessage(), e); } finally { deleteTaskInTaskStore(MINIMAL_TASK.getId()); } } - + @Test public void testGetTaskNotFound() throws Exception { assertTrue(getTaskFromTaskStore("non-existent-task") == null); - GetTaskRequest request = new GetTaskRequest("1", new TaskQueryParams("non-existent-task")); - GetTaskResponse response = given() - .contentType(MediaType.APPLICATION_JSON) - .body(request) - .when() - .post("/") - .then() - .statusCode(200) - .extract() - .as(GetTaskResponse.class); - assertEquals("1", response.getId()); - // this should be an instance of TaskNotFoundError, see https://github.com/a2aproject/a2a-java/issues/23 - assertInstanceOf(JSONRPCError.class, response.getError()); - assertEquals(new TaskNotFoundError().getCode(), response.getError().getCode()); - assertNull(response.getResult()); + try { + client.getTask("1", new TaskQueryParams("non-existent-task")); + fail("Expected A2AServerException to be thrown"); + } catch (A2AServerException e) { + assertInstanceOf(TaskNotFoundError.class, e.getCause()); + } } - + @Test public void testCancelTaskSuccess() throws Exception { saveTaskInTaskStore(CANCEL_TASK); try { - CancelTaskRequest request = new CancelTaskRequest("1", new TaskIdParams(CANCEL_TASK.getId())); - CancelTaskResponse response = given() - .contentType(MediaType.APPLICATION_JSON) - .body(request) - .when() - .post("/") - .then() - .statusCode(200) - .extract() - .as(CancelTaskResponse.class); + CancelTaskResponse response = client.cancelTask("1", new TaskIdParams(CANCEL_TASK.getId())); + assertEquals("1", response.getId()); assertNull(response.getError()); - assertEquals(request.getId(), response.getId()); Task task = response.getResult(); assertEquals(CANCEL_TASK.getId(), task.getId()); assertEquals(CANCEL_TASK.getContextId(), task.getContextId()); assertEquals(TaskState.CANCELED, task.getStatus().state()); - } catch (Exception e) { + } catch (A2AServerException e) { + fail("Unexpected exception during cancel task success test: " + e.getMessage(), e); } finally { deleteTaskInTaskStore(CANCEL_TASK.getId()); } } - + @Test public void testCancelTaskNotSupported() throws Exception { saveTaskInTaskStore(CANCEL_TASK_NOT_SUPPORTED); try { - CancelTaskRequest request = new CancelTaskRequest("1", new TaskIdParams(CANCEL_TASK_NOT_SUPPORTED.getId())); - CancelTaskResponse response = given() - .contentType(MediaType.APPLICATION_JSON) - .body(request) - .when() - .post("/") - .then() - .statusCode(200) - .extract() - .as(CancelTaskResponse.class); - assertEquals(request.getId(), response.getId()); - assertNull(response.getResult()); - // this should be an instance of UnsupportedOperationError, see https://github.com/a2aproject/a2a-java/issues/23 - assertInstanceOf(JSONRPCError.class, response.getError()); - assertEquals(new UnsupportedOperationError().getCode(), response.getError().getCode()); - } catch (Exception e) { + client.cancelTask("1", new TaskIdParams(CANCEL_TASK_NOT_SUPPORTED.getId())); + fail("Expected A2AServerException to be thrown"); + } catch (A2AServerException e) { + assertInstanceOf(UnsupportedOperationError.class, e.getCause()); } finally { deleteTaskInTaskStore(CANCEL_TASK_NOT_SUPPORTED.getId()); } } - + @Test public void testCancelTaskNotFound() { - CancelTaskRequest request = new CancelTaskRequest("1", new TaskIdParams("non-existent-task")); - CancelTaskResponse response = given() - .contentType(MediaType.APPLICATION_JSON) - .body(request) - .when() - .post("/") - .then() - .statusCode(200) - .extract() - .as(CancelTaskResponse.class) - ; - assertEquals(request.getId(), response.getId()); - assertNull(response.getResult()); - // this should be an instance of UnsupportedOperationError, see https://github.com/a2aproject/a2a-java/issues/23 - assertInstanceOf(JSONRPCError.class, response.getError()); - assertEquals(new TaskNotFoundError().getCode(), response.getError().getCode()); + try { + client.cancelTask("1", new TaskIdParams("non-existent-task")); + fail("Expected A2AServerException to be thrown"); + } catch (A2AServerException e) { + assertInstanceOf(TaskNotFoundError.class, e.getCause()); + } } - + @Test public void testSendMessageNewMessageSuccess() throws Exception { assertTrue(getTaskFromTaskStore(MINIMAL_TASK.getId()) == null); @@ -277,25 +211,23 @@ public void testSendMessageNewMessageSuccess() throws Exception { .taskId(MINIMAL_TASK.getId()) .contextId(MINIMAL_TASK.getContextId()) .build(); - SendMessageRequest request = new SendMessageRequest("1", new MessageSendParams(message, null, null)); - SendMessageResponse response = given() - .contentType(MediaType.APPLICATION_JSON) - .body(request) - .when() - .post("/") - .then() - .statusCode(200) - .extract() - .as(SendMessageResponse.class); - assertNull(response.getError()); - Message messageResponse = (Message) response.getResult(); - assertEquals(MESSAGE.getMessageId(), messageResponse.getMessageId()); - assertEquals(MESSAGE.getRole(), messageResponse.getRole()); - Part part = messageResponse.getParts().get(0); - assertEquals(Part.Kind.TEXT, part.getKind()); - assertEquals("test message", ((TextPart) part).getText()); + MessageSendParams messageSendParams = new MessageSendParams(message, null, null); + + try { + SendMessageResponse response = client.sendMessage("1", messageSendParams); + assertEquals("1", response.getId()); + assertNull(response.getError()); + Message messageResponse = (Message) response.getResult(); + assertEquals(MESSAGE.getMessageId(), messageResponse.getMessageId()); + assertEquals(MESSAGE.getRole(), messageResponse.getRole()); + Part part = messageResponse.getParts().get(0); + assertEquals(Part.Kind.TEXT, part.getKind()); + assertEquals("test message", ((TextPart) part).getText()); + } catch (A2AServerException e) { + fail("Unexpected exception during send new message test: " + e.getMessage(), e); + } } - + @Test public void testSendMessageExistingTaskSuccess() throws Exception { saveTaskInTaskStore(MINIMAL_TASK); @@ -304,16 +236,10 @@ public void testSendMessageExistingTaskSuccess() throws Exception { .taskId(MINIMAL_TASK.getId()) .contextId(MINIMAL_TASK.getContextId()) .build(); - SendMessageRequest request = new SendMessageRequest("1", new MessageSendParams(message, null, null)); - SendMessageResponse response = given() - .contentType(MediaType.APPLICATION_JSON) - .body(request) - .when() - .post("/") - .then() - .statusCode(200) - .extract() - .as(SendMessageResponse.class); + MessageSendParams messageSendParams = new MessageSendParams(message, null, null); + + SendMessageResponse response = client.sendMessage("1", messageSendParams); + assertEquals("1", response.getId()); assertNull(response.getError()); Message messageResponse = (Message) response.getResult(); assertEquals(MESSAGE.getMessageId(), messageResponse.getMessageId()); @@ -321,108 +247,78 @@ public void testSendMessageExistingTaskSuccess() throws Exception { Part part = messageResponse.getParts().get(0); assertEquals(Part.Kind.TEXT, part.getKind()); assertEquals("test message", ((TextPart) part).getText()); - } catch (Exception e) { + } catch (A2AServerException e) { + fail("Unexpected exception during send message to existing task test: " + e.getMessage(), e); } finally { deleteTaskInTaskStore(MINIMAL_TASK.getId()); } } - + @Test public void testSetPushNotificationSuccess() throws Exception { saveTaskInTaskStore(MINIMAL_TASK); try { - TaskPushNotificationConfig taskPushConfig = - new TaskPushNotificationConfig( - MINIMAL_TASK.getId(), new PushNotificationConfig.Builder().url("http://example.com").build()); - SetTaskPushNotificationConfigRequest request = new SetTaskPushNotificationConfigRequest("1", taskPushConfig); - SetTaskPushNotificationConfigResponse response = given() - .contentType(MediaType.APPLICATION_JSON) - .body(request) - .when() - .post("/") - .then() - .statusCode(200) - .extract() - .as(SetTaskPushNotificationConfigResponse.class); + PushNotificationConfig pushNotificationConfig = new PushNotificationConfig.Builder() + .url("http://example.com") + .build(); + SetTaskPushNotificationConfigResponse response = client.setTaskPushNotificationConfig("1", + MINIMAL_TASK.getId(), pushNotificationConfig); + assertEquals("1", response.getId()); assertNull(response.getError()); - assertEquals(request.getId(), response.getId()); TaskPushNotificationConfig config = response.getResult(); assertEquals(MINIMAL_TASK.getId(), config.taskId()); assertEquals("http://example.com", config.pushNotificationConfig().url()); - } catch (Exception e) { + } catch (A2AServerException e) { + fail("Unexpected exception during set push notification test: " + e.getMessage(), e); } finally { deletePushNotificationConfigInStore(MINIMAL_TASK.getId(), MINIMAL_TASK.getId()); deleteTaskInTaskStore(MINIMAL_TASK.getId()); } } - + @Test public void testGetPushNotificationSuccess() throws Exception { saveTaskInTaskStore(MINIMAL_TASK); try { - TaskPushNotificationConfig taskPushConfig = - new TaskPushNotificationConfig( - MINIMAL_TASK.getId(), new PushNotificationConfig.Builder().url("http://example.com").build()); - - SetTaskPushNotificationConfigRequest setTaskPushNotificationRequest = new SetTaskPushNotificationConfigRequest("1", taskPushConfig); - SetTaskPushNotificationConfigResponse setTaskPushNotificationResponse = given() - .contentType(MediaType.APPLICATION_JSON) - .body(setTaskPushNotificationRequest) - .when() - .post("/") - .then() - .statusCode(200) - .extract() - .as(SetTaskPushNotificationConfigResponse.class); - assertNotNull(setTaskPushNotificationResponse); - - GetTaskPushNotificationConfigRequest request = - new GetTaskPushNotificationConfigRequest("111", new GetTaskPushNotificationConfigParams(MINIMAL_TASK.getId())); - GetTaskPushNotificationConfigResponse response = given() - .contentType(MediaType.APPLICATION_JSON) - .body(request) - .when() - .post("/") - .then() - .statusCode(200) - .extract() - .as(GetTaskPushNotificationConfigResponse.class); + PushNotificationConfig pushNotificationConfig = new PushNotificationConfig.Builder() + .url("http://example.com") + .build(); + + // First set the push notification config + SetTaskPushNotificationConfigResponse setResponse = client.setTaskPushNotificationConfig("1", + MINIMAL_TASK.getId(), pushNotificationConfig); + assertNotNull(setResponse); + + // Then get the push notification config + GetTaskPushNotificationConfigResponse response = client.getTaskPushNotificationConfig("2", new GetTaskPushNotificationConfigParams(MINIMAL_TASK.getId())); + assertEquals("2", response.getId()); assertNull(response.getError()); - assertEquals(request.getId(), response.getId()); TaskPushNotificationConfig config = response.getResult(); assertEquals(MINIMAL_TASK.getId(), config.taskId()); assertEquals("http://example.com", config.pushNotificationConfig().url()); - } catch (Exception e) { + } catch (A2AServerException e) { + fail("Unexpected exception during get push notification test: " + e.getMessage(), e); } finally { deletePushNotificationConfigInStore(MINIMAL_TASK.getId(), MINIMAL_TASK.getId()); - deleteTaskInTaskStore(MINIMAL_TASK.getId()); } } - + @Test public void testError() { Message message = new Message.Builder(MESSAGE) .taskId(SEND_MESSAGE_NOT_SUPPORTED.getId()) .contextId(SEND_MESSAGE_NOT_SUPPORTED.getContextId()) .build(); - SendMessageRequest request = new SendMessageRequest( - "1", new MessageSendParams(message, null, null)); - SendMessageResponse response = given() - .contentType(MediaType.APPLICATION_JSON) - .body(request) - .when() - .post("/") - .then() - .statusCode(200) - .extract() - .as(SendMessageResponse.class); - assertEquals(request.getId(), response.getId()); - assertNull(response.getResult()); - // this should be an instance of UnsupportedOperationError, see https://github.com/a2aproject/a2a-java/issues/23 - assertInstanceOf(JSONRPCError.class, response.getError()); - assertEquals(new UnsupportedOperationError().getCode(), response.getError().getCode()); - } + MessageSendParams messageSendParams = new MessageSendParams(message, null, null); + try { + client.sendMessage("1", messageSendParams); + fail("Expected A2AServerException to be thrown"); + } catch (A2AServerException e) { + assertInstanceOf(UnsupportedOperationError.class, e.getCause()); + } + } + @Test public void testGetAgentCard() { AgentCard agentCard = given() @@ -444,7 +340,7 @@ public void testGetAgentCard() { assertTrue(agentCard.capabilities().stateTransitionHistory()); assertTrue(agentCard.skills().isEmpty()); } - + @Test public void testGetExtendAgentCardNotSupported() { GetAuthenticatedExtendedCardRequest request = new GetAuthenticatedExtendedCardRequest("1"); @@ -462,7 +358,7 @@ public void testGetExtendAgentCardNotSupported() { assertEquals(new AuthenticatedExtendedCardNotConfiguredError().getCode(), response.getError().getCode()); assertNull(response.getResult()); } - + @Test public void testMalformedJSONRPCRequest() { // missing closing bracket @@ -479,20 +375,20 @@ public void testMalformedJSONRPCRequest() { assertNotNull(response.getError()); assertEquals(new JSONParseError().getCode(), response.getError().getCode()); } - + @Test public void testInvalidParamsJSONRPCRequest() { String invalidParamsRequest = """ {"jsonrpc": "2.0", "method": "message/send", "params": "not_a_dict", "id": "1"} """; testInvalidParams(invalidParamsRequest); - + invalidParamsRequest = """ {"jsonrpc": "2.0", "method": "message/send", "params": {"message": {"parts": "invalid"}}, "id": "1"} """; testInvalidParams(invalidParamsRequest); } - + private void testInvalidParams(String invalidParamsRequest) { JSONRPCErrorResponse response = given() .contentType(MediaType.APPLICATION_JSON) @@ -507,7 +403,7 @@ private void testInvalidParams(String invalidParamsRequest) { assertEquals(new InvalidParamsError().getCode(), response.getError().getCode()); assertEquals("1", response.getId()); } - + @Test public void testInvalidJSONRPCRequestMissingJsonrpc() { String invalidRequest = """ @@ -528,7 +424,7 @@ public void testInvalidJSONRPCRequestMissingJsonrpc() { assertNotNull(response.getError()); assertEquals(new InvalidRequestError().getCode(), response.getError().getCode()); } - + @Test public void testInvalidJSONRPCRequestMissingMethod() { String invalidRequest = """ @@ -546,7 +442,7 @@ public void testInvalidJSONRPCRequestMissingMethod() { assertNotNull(response.getError()); assertEquals(new InvalidRequestError().getCode(), response.getError().getCode()); } - + @Test public void testInvalidJSONRPCRequestInvalidId() { String invalidRequest = """ @@ -564,7 +460,7 @@ public void testInvalidJSONRPCRequestInvalidId() { assertNotNull(response.getError()); assertEquals(new InvalidRequestError().getCode(), response.getError().getCode()); } - + @Test public void testInvalidJSONRPCRequestNonExistentMethod() { String invalidRequest = """ @@ -582,13 +478,12 @@ public void testInvalidJSONRPCRequestNonExistentMethod() { assertNotNull(response.getError()); assertEquals(new MethodNotFoundError().getCode(), response.getError().getCode()); } - + @Test public void testNonStreamingMethodWithAcceptHeader() throws Exception { testGetTask(MediaType.APPLICATION_JSON); } - - + @Test public void testSendMessageStreamExistingTaskSuccess() throws Exception { saveTaskInTaskStore(MINIMAL_TASK); @@ -597,115 +492,121 @@ public void testSendMessageStreamExistingTaskSuccess() throws Exception { .taskId(MINIMAL_TASK.getId()) .contextId(MINIMAL_TASK.getContextId()) .build(); - SendStreamingMessageRequest request = new SendStreamingMessageRequest( - "1", new MessageSendParams(message, null, null)); - - CompletableFuture>> responseFuture = initialiseStreamingRequest(request, null); - + MessageSendParams messageSendParams = new MessageSendParams(message, null, null); + CountDownLatch latch = new CountDownLatch(1); AtomicReference errorRef = new AtomicReference<>(); - - responseFuture.thenAccept(response -> { - if (response.statusCode() != 200) { - //errorRef.set(new IllegalStateException("Status code was " + response.statusCode())); - throw new IllegalStateException("Status code was " + response.statusCode()); - } - response.body().forEach(line -> { - try { - SendStreamingMessageResponse jsonResponse = extractJsonResponseFromSseLine(line); - if (jsonResponse != null) { - assertNull(jsonResponse.getError()); - Message messageResponse = (Message) jsonResponse.getResult(); - assertEquals(MESSAGE.getMessageId(), messageResponse.getMessageId()); - assertEquals(MESSAGE.getRole(), messageResponse.getRole()); - Part part = messageResponse.getParts().get(0); - assertEquals(Part.Kind.TEXT, part.getKind()); - assertEquals("test message", ((TextPart) part).getText()); + AtomicReference messageResponseRef = new AtomicReference<>(); + + // Replace the native HttpClient with A2AClient's sendStreamingMessage method. + client.sendStreamingMessage( + "1", + messageSendParams, + // eventHandler + (streamingEvent) -> { + try { + if (streamingEvent instanceof Message) { + messageResponseRef.set((Message) streamingEvent); + latch.countDown(); + } + } catch (Exception e) { + errorRef.set(e); latch.countDown(); } - } catch (JsonProcessingException e) { - throw new RuntimeException(e); + }, + // errorHandler + (jsonRpcError) -> { + errorRef.set(new RuntimeException("JSON-RPC Error: " + jsonRpcError.getMessage())); + latch.countDown(); + }, + // failureHandler + () -> { + if (errorRef.get() == null) { + errorRef.set(new RuntimeException("Stream processing failed")); + } + latch.countDown(); } - }); - }).exceptionally(t -> { - if (!isStreamClosedError(t)) { - errorRef.set(t); - } - latch.countDown(); - return null; - }); - + ); + boolean dataRead = latch.await(20, TimeUnit.SECONDS); Assertions.assertTrue(dataRead); Assertions.assertNull(errorRef.get()); + + Message messageResponse = messageResponseRef.get(); + Assertions.assertNotNull(messageResponse); + assertEquals(MESSAGE.getMessageId(), messageResponse.getMessageId()); + assertEquals(MESSAGE.getRole(), messageResponse.getRole()); + Part part = messageResponse.getParts().get(0); + assertEquals(Part.Kind.TEXT, part.getKind()); + assertEquals("test message", ((TextPart) part).getText()); } catch (Exception e) { + fail("Unexpected exception during send message stream to existing task test: " + e.getMessage(), e); } finally { deleteTaskInTaskStore(MINIMAL_TASK.getId()); } } - + @Test @Timeout(value = 3, unit = TimeUnit.MINUTES) public void testResubscribeExistingTaskSuccess() throws Exception { ExecutorService executorService = Executors.newSingleThreadExecutor(); saveTaskInTaskStore(MINIMAL_TASK); - + try { // attempting to send a streaming message instead of explicitly calling queueManager#createOrTap // does not work because after the message is sent, the queue becomes null but task resubscription // requires the queue to still be active ensureQueueForTask(MINIMAL_TASK.getId()); - + CountDownLatch taskResubscriptionRequestSent = new CountDownLatch(1); CountDownLatch taskResubscriptionResponseReceived = new CountDownLatch(2); - AtomicReference firstResponse = new AtomicReference<>(); - AtomicReference secondResponse = new AtomicReference<>(); - + AtomicReference firstResponse = new AtomicReference<>(); + AtomicReference secondResponse = new AtomicReference<>(); + AtomicReference errorRef = new AtomicReference<>(); + // resubscribe to the task, requires the task and its queue to still be active - TaskResubscriptionRequest taskResubscriptionRequest = new TaskResubscriptionRequest("1", new TaskIdParams(MINIMAL_TASK.getId())); - + TaskIdParams taskIdParams = new TaskIdParams(MINIMAL_TASK.getId()); + // Count down the latch when the MultiSseSupport on the server has started subscribing awaitStreamingSubscription() .whenComplete((unused, throwable) -> taskResubscriptionRequestSent.countDown()); - - CompletableFuture>> responseFuture = initialiseStreamingRequest(taskResubscriptionRequest, null); - - AtomicReference errorRef = new AtomicReference<>(); - - responseFuture.thenAccept(response -> { - - if (response.statusCode() != 200) { - throw new IllegalStateException("Status code was " + response.statusCode()); - } - try { - response.body().forEach(line -> { + + // Use A2AClient-like resubscribeToTask Method + client.resubscribeToTask( + "1", // requestId + taskIdParams, + // eventHandler + (streamingEvent) -> { try { - SendStreamingMessageResponse jsonResponse = extractJsonResponseFromSseLine(line); - if (jsonResponse != null) { - SendStreamingMessageResponse sendStreamingMessageResponse = Utils.OBJECT_MAPPER.readValue(line.substring("data: ".length()).trim(), SendStreamingMessageResponse.class); - if (taskResubscriptionResponseReceived.getCount() == 2) { - firstResponse.set(sendStreamingMessageResponse); - } else { - secondResponse.set(sendStreamingMessageResponse); - } + if (streamingEvent instanceof TaskArtifactUpdateEvent artifactUpdateEvent) { + firstResponse.set(artifactUpdateEvent); + taskResubscriptionResponseReceived.countDown(); + } else if (streamingEvent instanceof TaskStatusUpdateEvent statusUpdateEvent) { + secondResponse.set(statusUpdateEvent); taskResubscriptionResponseReceived.countDown(); - if (taskResubscriptionResponseReceived.getCount() == 0) { - throw new BreakException(); - } } - } catch (JsonProcessingException e) { - throw new RuntimeException(e); + } catch (Exception e) { + errorRef.set(e); + taskResubscriptionResponseReceived.countDown(); + taskResubscriptionResponseReceived.countDown(); // Make sure the counter is zeroed } - }); - } catch (BreakException e) { - } - }).exceptionally(t -> { - if (!isStreamClosedError(t)) { - errorRef.set(t); - } - return null; - }); - + }, + // errorHandler + (jsonRpcError) -> { + errorRef.set(new RuntimeException("JSON-RPC Error: " + jsonRpcError.getMessage())); + taskResubscriptionResponseReceived.countDown(); + taskResubscriptionResponseReceived.countDown(); // Make sure the counter is zeroed + }, + // failureHandler + () -> { + if (errorRef.get() == null) { + errorRef.set(new RuntimeException("Stream processing failed")); + } + taskResubscriptionResponseReceived.countDown(); + taskResubscriptionResponseReceived.countDown(); // Make sure the counter is zeroed + } + ); + try { taskResubscriptionRequestSent.await(); List events = List.of( @@ -723,37 +624,34 @@ public void testResubscribeExistingTaskSuccess() throws Exception { .status(new TaskStatus(TaskState.COMPLETED)) .isFinal(true) .build()); - + for (Event event : events) { enqueueEventOnServer(event); } } catch (InterruptedException e) { Thread.currentThread().interrupt(); } - + // wait for the client to receive the responses taskResubscriptionResponseReceived.await(); - + + Assertions.assertNull(errorRef.get()); + assertNotNull(firstResponse.get()); - SendStreamingMessageResponse sendStreamingMessageResponse = firstResponse.get(); - assertNull(sendStreamingMessageResponse.getError()); - TaskArtifactUpdateEvent taskArtifactUpdateEvent = (TaskArtifactUpdateEvent) sendStreamingMessageResponse.getResult(); + TaskArtifactUpdateEvent taskArtifactUpdateEvent = firstResponse.get(); assertEquals(MINIMAL_TASK.getId(), taskArtifactUpdateEvent.getTaskId()); assertEquals(MINIMAL_TASK.getContextId(), taskArtifactUpdateEvent.getContextId()); Part part = taskArtifactUpdateEvent.getArtifact().parts().get(0); assertEquals(Part.Kind.TEXT, part.getKind()); assertEquals("text", ((TextPart) part).getText()); - + assertNotNull(secondResponse.get()); - sendStreamingMessageResponse = secondResponse.get(); - assertNull(sendStreamingMessageResponse.getError()); - TaskStatusUpdateEvent taskStatusUpdateEvent = (TaskStatusUpdateEvent) sendStreamingMessageResponse.getResult(); + TaskStatusUpdateEvent taskStatusUpdateEvent = secondResponse.get(); assertEquals(MINIMAL_TASK.getId(), taskStatusUpdateEvent.getTaskId()); assertEquals(MINIMAL_TASK.getContextId(), taskStatusUpdateEvent.getContextId()); assertEquals(TaskState.COMPLETED, taskStatusUpdateEvent.getStatus().state()); assertNotNull(taskStatusUpdateEvent.getStatus().timestamp()); } finally { - //setStreamingSubscribedRunnable(null); deleteTaskInTaskStore(MINIMAL_TASK.getId()); executorService.shutdown(); if (!executorService.awaitTermination(10, TimeUnit.SECONDS)) { @@ -761,109 +659,114 @@ public void testResubscribeExistingTaskSuccess() throws Exception { } } } - + @Test public void testResubscribeNoExistingTaskError() throws Exception { - TaskResubscriptionRequest request = new TaskResubscriptionRequest("1", new TaskIdParams("non-existent-task")); - - CompletableFuture>> responseFuture = initialiseStreamingRequest(request, null); - + TaskIdParams taskIdParams = new TaskIdParams("non-existent-task"); + CountDownLatch latch = new CountDownLatch(1); AtomicReference errorRef = new AtomicReference<>(); - - responseFuture.thenAccept(response -> { - if (response.statusCode() != 200) { - //errorRef.set(new IllegalStateException("Status code was " + response.statusCode())); - throw new IllegalStateException("Status code was " + response.statusCode()); - } - response.body().forEach(line -> { - try { - SendStreamingMessageResponse jsonResponse = extractJsonResponseFromSseLine(line); - if (jsonResponse != null) { - assertEquals(request.getId(), jsonResponse.getId()); - assertNull(jsonResponse.getResult()); - // this should be an instance of TaskNotFoundError, see https://github.com/a2aproject/a2a-java/issues/23 - assertInstanceOf(JSONRPCError.class, jsonResponse.getError()); - assertEquals(new TaskNotFoundError().getCode(), jsonResponse.getError().getCode()); - latch.countDown(); + AtomicReference jsonRpcErrorRef = new AtomicReference<>(); + + // Use A2AClient-like resubscribeToTask Method + client.resubscribeToTask( + "1", // requestId + taskIdParams, + // eventHandler + (streamingEvent) -> { + // Do not expect to receive any success events, as the task does not exist + errorRef.set(new RuntimeException("Unexpected event received for non-existent task")); + latch.countDown(); + }, + // errorHandler + (jsonRpcError) -> { + jsonRpcErrorRef.set(jsonRpcError); + latch.countDown(); + }, + // failureHandler + () -> { + if (errorRef.get() == null && jsonRpcErrorRef.get() == null) { + errorRef.set(new RuntimeException("Expected error for non-existent task")); } - } catch (JsonProcessingException e) { - throw new RuntimeException(e); + latch.countDown(); } - }); - }).exceptionally(t -> { - if (!isStreamClosedError(t)) { - errorRef.set(t); - } - latch.countDown(); - return null; - }); - + ); + boolean dataRead = latch.await(20, TimeUnit.SECONDS); Assertions.assertTrue(dataRead); Assertions.assertNull(errorRef.get()); + + // Validation returns the expected TaskNotFoundError + JSONRPCError jsonRpcError = jsonRpcErrorRef.get(); + Assertions.assertNotNull(jsonRpcError); + assertEquals(new TaskNotFoundError().getCode(), jsonRpcError.getCode()); } - + @Test public void testStreamingMethodWithAcceptHeader() throws Exception { testSendStreamingMessage(MediaType.SERVER_SENT_EVENTS); } - + @Test public void testSendMessageStreamNewMessageSuccess() throws Exception { testSendStreamingMessage(null); } - + private void testSendStreamingMessage(String mediaType) throws Exception { Message message = new Message.Builder(MESSAGE) .taskId(MINIMAL_TASK.getId()) .contextId(MINIMAL_TASK.getContextId()) .build(); - SendStreamingMessageRequest request = new SendStreamingMessageRequest( - "1", new MessageSendParams(message, null, null)); - - CompletableFuture>> responseFuture = initialiseStreamingRequest(request, mediaType); - + MessageSendParams messageSendParams = new MessageSendParams(message, null, null); + CountDownLatch latch = new CountDownLatch(1); AtomicReference errorRef = new AtomicReference<>(); - - responseFuture.thenAccept(response -> { - if (response.statusCode() != 200) { - //errorRef.set(new IllegalStateException("Status code was " + response.statusCode())); - throw new IllegalStateException("Status code was " + response.statusCode()); - } - response.body().forEach(line -> { - try { - SendStreamingMessageResponse jsonResponse = extractJsonResponseFromSseLine(line); - if (jsonResponse != null) { - assertNull(jsonResponse.getError()); - Message messageResponse = (Message) jsonResponse.getResult(); - assertEquals(MESSAGE.getMessageId(), messageResponse.getMessageId()); - assertEquals(MESSAGE.getRole(), messageResponse.getRole()); - Part part = messageResponse.getParts().get(0); - assertEquals(Part.Kind.TEXT, part.getKind()); - assertEquals("test message", ((TextPart) part).getText()); + AtomicReference messageResponseRef = new AtomicReference<>(); + + // Using A2AClient's sendStreamingMessage method + client.sendStreamingMessage( + "1", // requestId + messageSendParams, + // eventHandler + (streamingEvent) -> { + try { + if (streamingEvent instanceof Message) { + messageResponseRef.set((Message) streamingEvent); + latch.countDown(); + } + } catch (Exception e) { + errorRef.set(e); latch.countDown(); } - } catch (JsonProcessingException e) { - throw new RuntimeException(e); + }, + // errorHandler + (jsonRpcError) -> { + errorRef.set(new RuntimeException("JSON-RPC Error: " + jsonRpcError.getMessage())); + latch.countDown(); + }, + // failureHandler + () -> { + if (errorRef.get() == null) { + errorRef.set(new RuntimeException("Stream processing failed")); + } + latch.countDown(); } - }); - }).exceptionally(t -> { - if (!isStreamClosedError(t)) { - errorRef.set(t); - } - latch.countDown(); - return null; - }); - - + ); + boolean dataRead = latch.await(20, TimeUnit.SECONDS); Assertions.assertTrue(dataRead); Assertions.assertNull(errorRef.get()); - + + Message messageResponse = messageResponseRef.get(); + Assertions.assertNotNull(messageResponse); + assertEquals(MESSAGE.getMessageId(), messageResponse.getMessageId()); + assertEquals(MESSAGE.getRole(), messageResponse.getRole()); + Part part = messageResponse.getParts().get(0); + assertEquals(Part.Kind.TEXT, part.getKind()); + assertEquals("test message", ((TextPart) part).getText()); + } - + @Test public void testListPushNotificationConfigWithConfigId() throws Exception { saveTaskInTaskStore(MINIMAL_TASK); @@ -879,7 +782,7 @@ public void testListPushNotificationConfigWithConfigId() throws Exception { .build(); savePushNotificationConfigInStore(MINIMAL_TASK.getId(), notificationConfig1); savePushNotificationConfigInStore(MINIMAL_TASK.getId(), notificationConfig2); - + try { ListTaskPushNotificationConfigResponse listResponse = client.listTaskPushNotificationConfig("111", MINIMAL_TASK.getId()); assertEquals("111", listResponse.getId()); @@ -894,7 +797,7 @@ public void testListPushNotificationConfigWithConfigId() throws Exception { deleteTaskInTaskStore(MINIMAL_TASK.getId()); } } - + @Test public void testListPushNotificationConfigWithoutConfigId() throws Exception { saveTaskInTaskStore(MINIMAL_TASK); @@ -907,14 +810,14 @@ public void testListPushNotificationConfigWithoutConfigId() throws Exception { .url("http://2.example.com") .build(); savePushNotificationConfigInStore(MINIMAL_TASK.getId(), notificationConfig1); - + // will overwrite the previous one savePushNotificationConfigInStore(MINIMAL_TASK.getId(), notificationConfig2); try { ListTaskPushNotificationConfigResponse listResponse = client.listTaskPushNotificationConfig("111", MINIMAL_TASK.getId()); assertEquals("111", listResponse.getId()); assertEquals(1, listResponse.getResult().size()); - + PushNotificationConfig expectedNotificationConfig = new PushNotificationConfig.Builder() .url("http://2.example.com") .id(MINIMAL_TASK.getId()) @@ -928,7 +831,7 @@ public void testListPushNotificationConfigWithoutConfigId() throws Exception { deleteTaskInTaskStore(MINIMAL_TASK.getId()); } } - + @Test public void testListPushNotificationConfigTaskNotFound() { try { @@ -938,7 +841,7 @@ public void testListPushNotificationConfigTaskNotFound() { assertInstanceOf(TaskNotFoundError.class, e.getCause()); } } - + @Test public void testListPushNotificationConfigEmptyList() throws Exception { saveTaskInTaskStore(MINIMAL_TASK); @@ -952,7 +855,7 @@ public void testListPushNotificationConfigEmptyList() throws Exception { deleteTaskInTaskStore(MINIMAL_TASK.getId()); } } - + @Test public void testDeletePushNotificationConfigWithValidConfigId() throws Exception { saveTaskInTaskStore(MINIMAL_TASK); @@ -961,7 +864,7 @@ public void testDeletePushNotificationConfigWithValidConfigId() throws Exception .contextId("session-xyz") .status(new TaskStatus(TaskState.SUBMITTED)) .build()); - + PushNotificationConfig notificationConfig1 = new PushNotificationConfig.Builder() .url("http://example.com") @@ -975,18 +878,18 @@ public void testDeletePushNotificationConfigWithValidConfigId() throws Exception savePushNotificationConfigInStore(MINIMAL_TASK.getId(), notificationConfig1); savePushNotificationConfigInStore(MINIMAL_TASK.getId(), notificationConfig2); savePushNotificationConfigInStore("task-456", notificationConfig1); - + try { // specify the config ID to delete DeleteTaskPushNotificationConfigResponse deleteResponse = client.deleteTaskPushNotificationConfig(MINIMAL_TASK.getId(), "config1"); assertNull(deleteResponse.getError()); assertNull(deleteResponse.getResult()); - + // should now be 1 left ListTaskPushNotificationConfigResponse listResponse = client.listTaskPushNotificationConfig(MINIMAL_TASK.getId()); assertEquals(1, listResponse.getResult().size()); - + // should remain unchanged, this is a different task listResponse = client.listTaskPushNotificationConfig("task-456"); assertEquals(1, listResponse.getResult().size()); @@ -1000,7 +903,7 @@ public void testDeletePushNotificationConfigWithValidConfigId() throws Exception deleteTaskInTaskStore("task-456"); } } - + @Test public void testDeletePushNotificationConfigWithNonExistingConfigId() throws Exception { saveTaskInTaskStore(MINIMAL_TASK); @@ -1016,13 +919,13 @@ public void testDeletePushNotificationConfigWithNonExistingConfigId() throws Exc .build(); savePushNotificationConfigInStore(MINIMAL_TASK.getId(), notificationConfig1); savePushNotificationConfigInStore(MINIMAL_TASK.getId(), notificationConfig2); - + try { DeleteTaskPushNotificationConfigResponse deleteResponse = client.deleteTaskPushNotificationConfig(MINIMAL_TASK.getId(), "non-existent-config-id"); assertNull(deleteResponse.getError()); assertNull(deleteResponse.getResult()); - + // should remain unchanged ListTaskPushNotificationConfigResponse listResponse = client.listTaskPushNotificationConfig(MINIMAL_TASK.getId()); assertEquals(2, listResponse.getResult().size()); @@ -1034,7 +937,7 @@ public void testDeletePushNotificationConfigWithNonExistingConfigId() throws Exc deleteTaskInTaskStore(MINIMAL_TASK.getId()); } } - + @Test public void testDeletePushNotificationConfigTaskNotFound() { try { @@ -1044,7 +947,7 @@ public void testDeletePushNotificationConfigTaskNotFound() { assertInstanceOf(TaskNotFoundError.class, e.getCause()); } } - + @Test public void testDeletePushNotificationConfigSetWithoutConfigId() throws Exception { saveTaskInTaskStore(MINIMAL_TASK); @@ -1057,16 +960,16 @@ public void testDeletePushNotificationConfigSetWithoutConfigId() throws Exceptio .url("http://2.example.com") .build(); savePushNotificationConfigInStore(MINIMAL_TASK.getId(), notificationConfig1); - + // this one will overwrite the previous one savePushNotificationConfigInStore(MINIMAL_TASK.getId(), notificationConfig2); - + try { DeleteTaskPushNotificationConfigResponse deleteResponse = client.deleteTaskPushNotificationConfig(MINIMAL_TASK.getId(), MINIMAL_TASK.getId()); assertNull(deleteResponse.getError()); assertNull(deleteResponse.getResult()); - + // should now be 0 ListTaskPushNotificationConfigResponse listResponse = client.listTaskPushNotificationConfig(MINIMAL_TASK.getId()); assertEquals(0, listResponse.getResult().size()); @@ -1077,59 +980,7 @@ public void testDeletePushNotificationConfigSetWithoutConfigId() throws Exceptio deleteTaskInTaskStore(MINIMAL_TASK.getId()); } } - - private SendStreamingMessageResponse extractJsonResponseFromSseLine(String line) throws JsonProcessingException { - line = extractSseData(line); - if (line != null) { - return Utils.OBJECT_MAPPER.readValue(line, SendStreamingMessageResponse.class); - } - return null; - } - - private static String extractSseData(String line) { - if (line.startsWith("data:")) { - line = line.substring(5).trim(); - return line; - } - return null; - } - - private boolean isStreamClosedError(Throwable throwable) { - // Unwrap the CompletionException - Throwable cause = throwable; - - while (cause != null) { - if (cause instanceof EOFException) { - return true; - } - cause = cause.getCause(); - } - return false; - } - - private CompletableFuture>> initialiseStreamingRequest( - StreamingJSONRPCRequest request, String mediaType) throws Exception { - - // Create the client - HttpClient client = HttpClient.newBuilder() - .version(HttpClient.Version.HTTP_2) - .build(); - - // Create the request - HttpRequest.Builder builder = HttpRequest.newBuilder() - .uri(URI.create("http://localhost:" + serverPort + "/")) - .POST(HttpRequest.BodyPublishers.ofString(Utils.OBJECT_MAPPER.writeValueAsString(request))) - .header("Content-Type", APPLICATION_JSON); - if (mediaType != null) { - builder.header("Accept", mediaType); - } - HttpRequest httpRequest = builder.build(); - - - // Send request async and return the CompletableFuture - return client.sendAsync(httpRequest, HttpResponse.BodyHandlers.ofLines()); - } - + protected void saveTaskInTaskStore(Task task) throws Exception { HttpClient client = HttpClient.newBuilder() .version(HttpClient.Version.HTTP_2) @@ -1139,13 +990,13 @@ protected void saveTaskInTaskStore(Task task) throws Exception { .POST(HttpRequest.BodyPublishers.ofString(Utils.OBJECT_MAPPER.writeValueAsString(task))) .header("Content-Type", APPLICATION_JSON) .build(); - + HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); if (response.statusCode() != 200) { -throw new RuntimeException(String.format("Saving task failed! Status: %d, Body: %s", response.statusCode(), response.body())); + throw new RuntimeException(String.format("Saving task failed! Status: %d, Body: %s", response.statusCode(), response.body())); } } - + protected Task getTaskFromTaskStore(String taskId) throws Exception { HttpClient client = HttpClient.newBuilder() .version(HttpClient.Version.HTTP_2) @@ -1154,17 +1005,17 @@ protected Task getTaskFromTaskStore(String taskId) throws Exception { .uri(URI.create("http://localhost:" + serverPort + "/test/task/" + taskId)) .GET() .build(); - + HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); if (response.statusCode() == 404) { return null; } if (response.statusCode() != 200) { -throw new RuntimeException(String.format("Getting task failed! Status: %d, Body: %s", response.statusCode(), response.body())); + throw new RuntimeException(String.format("Getting task failed! Status: %d, Body: %s", response.statusCode(), response.body())); } return Utils.OBJECT_MAPPER.readValue(response.body(), Task.TYPE_REFERENCE); } - + protected void deleteTaskInTaskStore(String taskId) throws Exception { HttpClient client = HttpClient.newBuilder() .version(HttpClient.Version.HTTP_2) @@ -1178,7 +1029,7 @@ protected void deleteTaskInTaskStore(String taskId) throws Exception { throw new RuntimeException(response.statusCode() + ": Deleting task failed!" + response.body()); } } - + protected void ensureQueueForTask(String taskId) throws Exception { HttpClient client = HttpClient.newBuilder() .version(HttpClient.Version.HTTP_2) @@ -1189,10 +1040,10 @@ protected void ensureQueueForTask(String taskId) throws Exception { .build(); HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); if (response.statusCode() != 200) { -throw new RuntimeException(String.format("Ensuring queue failed! Status: %d, Body: %s", response.statusCode(), response.body())); + throw new RuntimeException(String.format("Ensuring queue failed! Status: %d, Body: %s", response.statusCode(), response.body())); } } - + protected void enqueueEventOnServer(Event event) throws Exception { String path; if (event instanceof TaskArtifactUpdateEvent e) { @@ -1211,17 +1062,17 @@ protected void enqueueEventOnServer(Event event) throws Exception { .header("Content-Type", APPLICATION_JSON) .POST(HttpRequest.BodyPublishers.ofString(Utils.OBJECT_MAPPER.writeValueAsString(event))) .build(); - + HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); if (response.statusCode() != 200) { throw new RuntimeException(response.statusCode() + ": Queueing event failed!" + response.body()); } } - + private CompletableFuture awaitStreamingSubscription() { int cnt = getStreamingSubscribedCount(); AtomicInteger initialCount = new AtomicInteger(cnt); - + return CompletableFuture.runAsync(() -> { try { boolean done = false; @@ -1243,7 +1094,7 @@ private CompletableFuture awaitStreamingSubscription() { } }); } - + private int getStreamingSubscribedCount() { HttpClient client = HttpClient.newBuilder() .version(HttpClient.Version.HTTP_2) @@ -1260,7 +1111,7 @@ private int getStreamingSubscribedCount() { throw new RuntimeException(e); } } - + protected void deletePushNotificationConfigInStore(String taskId, String configId) throws Exception { HttpClient client = HttpClient.newBuilder() .version(HttpClient.Version.HTTP_2) @@ -1274,7 +1125,7 @@ protected void deletePushNotificationConfigInStore(String taskId, String configI throw new RuntimeException(response.statusCode() + ": Deleting task failed!" + response.body()); } } - + protected void savePushNotificationConfigInStore(String taskId, PushNotificationConfig notificationConfig) throws Exception { HttpClient client = HttpClient.newBuilder() .version(HttpClient.Version.HTTP_2) @@ -1284,14 +1135,10 @@ protected void savePushNotificationConfigInStore(String taskId, PushNotification .POST(HttpRequest.BodyPublishers.ofString(Utils.OBJECT_MAPPER.writeValueAsString(notificationConfig))) .header("Content-Type", APPLICATION_JSON) .build(); - + HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); if (response.statusCode() != 200) { throw new RuntimeException(response.statusCode() + ": Creating task push notification config failed! " + response.body()); } } - - private static class BreakException extends RuntimeException { - - } } From e68931aa2feb511bf0c893bcc5e4961b9df89053 Mon Sep 17 00:00:00 2001 From: David Brassely Date: Thu, 14 Aug 2025 15:30:12 +0200 Subject: [PATCH 02/31] feat: Make A2AClient agnostic to the transport protocol --- client/pom.xml | 2 +- client/src/main/java/io/a2a/A2A.java | 4 +- .../java/io/a2a/client/A2ACardResolver.java | 4 +- .../main/java/io/a2a/client/A2AClient.java | 716 ------------------ .../java/io/a2a/client/A2AGrpcClient.java | 200 ----- .../io/a2a/client/A2ACardResolverTest.java | 4 +- pom.xml | 1 + .../grpc/quarkus/QuarkusGrpcHandler.java | 2 +- .../server/grpc/quarkus/A2ATestResource.java | 2 +- .../server/apps/quarkus/A2AServerRoutes.java | 2 +- .../tasks/BasePushNotificationSender.java | 4 +- .../AbstractA2ARequestHandlerTest.java | 4 +- .../server/apps/common/TestHttpClient.java | 4 +- transport/grpc/pom.xml | 5 + .../grpc/client/EventStreamObserver.java | 16 +- .../transport/grpc/client/GrpcTransport.java | 164 ++++ .../grpc/server}/handler/GrpcHandler.java | 2 +- .../io/a2a/grpc/handler/GrpcHandlerTest.java | 1 + transport/jsonrpc/pom.xml | 5 + .../jsonrpc/client}/A2AHttpClient.java | 2 +- .../jsonrpc/client}/A2AHttpResponse.java | 2 +- .../jsonrpc/client/JSONRPCTransport.java | 301 ++++++++ .../jsonrpc/client}/JdkA2AHttpClient.java | 2 +- .../jsonrpc}/client/sse/SSEEventListener.java | 14 +- .../server}/handler/JSONRPCHandler.java | 2 +- .../jsonrpc/client/JsonStreamingMessages.java | 148 ++++ .../client/sse/SSEEventListenerTest.java | 25 +- .../server}/handler/JSONRPCHandlerTest.java | 3 +- transport/spi/pom.xml | 29 + .../a2a/transport/spi/client/Transport.java | 109 +++ 30 files changed, 812 insertions(+), 967 deletions(-) delete mode 100644 client/src/main/java/io/a2a/client/A2AClient.java delete mode 100644 client/src/main/java/io/a2a/client/A2AGrpcClient.java rename client/src/main/java/io/a2a/client/sse/SSEStreamObserver.java => transport/grpc/src/main/java/io/a2a/transport/grpc/client/EventStreamObserver.java (83%) create mode 100644 transport/grpc/src/main/java/io/a2a/transport/grpc/client/GrpcTransport.java rename transport/grpc/src/main/java/io/a2a/{grpc => transport/grpc/server}/handler/GrpcHandler.java (99%) rename {client/src/main/java/io/a2a/http => transport/jsonrpc/src/main/java/io/a2a/transport/jsonrpc/client}/A2AHttpClient.java (96%) rename {client/src/main/java/io/a2a/http => transport/jsonrpc/src/main/java/io/a2a/transport/jsonrpc/client}/A2AHttpResponse.java (70%) create mode 100644 transport/jsonrpc/src/main/java/io/a2a/transport/jsonrpc/client/JSONRPCTransport.java rename {client/src/main/java/io/a2a/http => transport/jsonrpc/src/main/java/io/a2a/transport/jsonrpc/client}/JdkA2AHttpClient.java (99%) rename {client/src/main/java/io/a2a => transport/jsonrpc/src/main/java/io/a2a/transport/jsonrpc}/client/sse/SSEEventListener.java (98%) rename transport/jsonrpc/src/main/java/io/a2a/{jsonrpc => transport/jsonrpc/server}/handler/JSONRPCHandler.java (99%) create mode 100644 transport/jsonrpc/src/test/java/io/a2a/transport/jsonrpc/client/JsonStreamingMessages.java rename {client/src/test/java/io/a2a => transport/jsonrpc/src/test/java/io/a2a/transport/jsonrpc}/client/sse/SSEEventListenerTest.java (97%) rename transport/jsonrpc/src/test/java/io/a2a/{jsonrpc => transport/jsonrpc/server}/handler/JSONRPCHandlerTest.java (99%) create mode 100644 transport/spi/pom.xml create mode 100644 transport/spi/src/main/java/io/a2a/transport/spi/client/Transport.java diff --git a/client/pom.xml b/client/pom.xml index fa381e65e..9f8b37779 100644 --- a/client/pom.xml +++ b/client/pom.xml @@ -29,7 +29,7 @@ ${project.groupId} - a2a-java-sdk-spec-grpc + a2a-java-sdk-transport-jsonrpc ${project.version} diff --git a/client/src/main/java/io/a2a/A2A.java b/client/src/main/java/io/a2a/A2A.java index 1d1f6260e..5fb536df8 100644 --- a/client/src/main/java/io/a2a/A2A.java +++ b/client/src/main/java/io/a2a/A2A.java @@ -4,8 +4,8 @@ import java.util.Map; import io.a2a.client.A2ACardResolver; -import io.a2a.http.A2AHttpClient; -import io.a2a.http.JdkA2AHttpClient; +import io.a2a.transport.jsonrpc.client.A2AHttpClient; +import io.a2a.transport.jsonrpc.client.JdkA2AHttpClient; import io.a2a.spec.A2AClientError; import io.a2a.spec.A2AClientJSONError; import io.a2a.spec.AgentCard; diff --git a/client/src/main/java/io/a2a/client/A2ACardResolver.java b/client/src/main/java/io/a2a/client/A2ACardResolver.java index 8ec181647..c5bb0b7c0 100644 --- a/client/src/main/java/io/a2a/client/A2ACardResolver.java +++ b/client/src/main/java/io/a2a/client/A2ACardResolver.java @@ -9,8 +9,8 @@ import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.core.type.TypeReference; -import io.a2a.http.A2AHttpClient; -import io.a2a.http.A2AHttpResponse; +import io.a2a.transport.jsonrpc.client.A2AHttpClient; +import io.a2a.transport.jsonrpc.client.A2AHttpResponse; import io.a2a.spec.A2AClientError; import io.a2a.spec.A2AClientJSONError; import io.a2a.spec.AgentCard; diff --git a/client/src/main/java/io/a2a/client/A2AClient.java b/client/src/main/java/io/a2a/client/A2AClient.java deleted file mode 100644 index 146e1bc58..000000000 --- a/client/src/main/java/io/a2a/client/A2AClient.java +++ /dev/null @@ -1,716 +0,0 @@ -package io.a2a.client; - -import static io.a2a.util.Assert.checkNotNullParam; - -import java.io.IOException; -import java.util.Map; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.atomic.AtomicReference; -import java.util.function.Consumer; - -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.core.type.TypeReference; -import io.a2a.client.sse.SSEEventListener; -import io.a2a.http.A2AHttpClient; -import io.a2a.http.A2AHttpResponse; -import io.a2a.http.JdkA2AHttpClient; -import io.a2a.A2A; -import io.a2a.spec.A2AClientError; -import io.a2a.spec.A2AClientJSONError; -import io.a2a.spec.A2AServerException; -import io.a2a.spec.AgentCard; -import io.a2a.spec.CancelTaskRequest; -import io.a2a.spec.CancelTaskResponse; -import io.a2a.spec.DeleteTaskPushNotificationConfigParams; -import io.a2a.spec.DeleteTaskPushNotificationConfigRequest; -import io.a2a.spec.DeleteTaskPushNotificationConfigResponse; -import io.a2a.spec.GetAuthenticatedExtendedCardRequest; -import io.a2a.spec.GetAuthenticatedExtendedCardResponse; -import io.a2a.spec.GetTaskPushNotificationConfigParams; -import io.a2a.spec.GetTaskPushNotificationConfigRequest; -import io.a2a.spec.GetTaskPushNotificationConfigResponse; -import io.a2a.spec.GetTaskRequest; -import io.a2a.spec.GetTaskResponse; -import io.a2a.spec.JSONRPCError; -import io.a2a.spec.JSONRPCMessage; -import io.a2a.spec.JSONRPCResponse; -import io.a2a.spec.ListTaskPushNotificationConfigParams; -import io.a2a.spec.ListTaskPushNotificationConfigRequest; -import io.a2a.spec.ListTaskPushNotificationConfigResponse; -import io.a2a.spec.MessageSendParams; -import io.a2a.spec.PushNotificationConfig; -import io.a2a.spec.SendMessageRequest; -import io.a2a.spec.SendMessageResponse; -import io.a2a.spec.SendStreamingMessageRequest; -import io.a2a.spec.SetTaskPushNotificationConfigRequest; -import io.a2a.spec.SetTaskPushNotificationConfigResponse; -import io.a2a.spec.StreamingEventKind; -import io.a2a.spec.TaskIdParams; -import io.a2a.spec.TaskPushNotificationConfig; -import io.a2a.spec.TaskQueryParams; -import io.a2a.spec.TaskResubscriptionRequest; -import io.a2a.util.Utils; - -/** - * An A2A client. - */ -public class A2AClient { - - private static final TypeReference SEND_MESSAGE_RESPONSE_REFERENCE = new TypeReference<>() {}; - private static final TypeReference GET_TASK_RESPONSE_REFERENCE = new TypeReference<>() {}; - private static final TypeReference CANCEL_TASK_RESPONSE_REFERENCE = new TypeReference<>() {}; - private static final TypeReference GET_TASK_PUSH_NOTIFICATION_CONFIG_RESPONSE_REFERENCE = new TypeReference<>() {}; - private static final TypeReference SET_TASK_PUSH_NOTIFICATION_CONFIG_RESPONSE_REFERENCE = new TypeReference<>() {}; - private static final TypeReference LIST_TASK_PUSH_NOTIFICATION_CONFIG_RESPONSE_REFERENCE = new TypeReference<>() {}; - private static final TypeReference DELETE_TASK_PUSH_NOTIFICATION_CONFIG_RESPONSE_REFERENCE = new TypeReference<>() {}; - private static final TypeReference GET_AUTHENTICATED_EXTENDED_CARD_RESPONSE_REFERENCE = new TypeReference<>() {}; - private final A2AHttpClient httpClient; - private final String agentUrl; - private AgentCard agentCard; - - - /** - * Create a new A2AClient. - * - * @param agentCard the agent card for the A2A server this client will be communicating with - */ - public A2AClient(AgentCard agentCard) { - checkNotNullParam("agentCard", agentCard); - this.agentCard = agentCard; - this.agentUrl = agentCard.url(); - this.httpClient = new JdkA2AHttpClient(); - } - - /** - * Create a new A2AClient. - * - * @param agentUrl the URL for the A2A server this client will be communicating with - */ - public A2AClient(String agentUrl) { - checkNotNullParam("agentUrl", agentUrl); - this.agentUrl = agentUrl; - this.httpClient = new JdkA2AHttpClient(); - } - - /** - * Fetches the agent card and initialises an A2A client. - * - * @param httpClient the {@link A2AHttpClient} to use - * @param baseUrl the base URL of the agent's host - * @param agentCardPath the path to the agent card endpoint, relative to the {@code baseUrl}. If {@code null}, the - * value {@link A2ACardResolver#DEFAULT_AGENT_CARD_PATH} will be used - * @return an initialised {@code A2AClient} instance - * @throws A2AClientError If an HTTP error occurs fetching the card - * @throws A2AClientJSONError if the agent card response is invalid - */ - public static A2AClient getClientFromAgentCardUrl(A2AHttpClient httpClient, String baseUrl, - String agentCardPath) throws A2AClientError, A2AClientJSONError { - A2ACardResolver resolver = new A2ACardResolver(httpClient, baseUrl, agentCardPath); - AgentCard card = resolver.getAgentCard(); - return new A2AClient(card); - } - - /** - * Get the agent card for the A2A server this client will be communicating with from - * the default public agent card endpoint. - * - * @return the agent card for the A2A server - * @throws A2AClientError If an HTTP error occurs fetching the card - * @throws A2AClientJSONError f the response body cannot be decoded as JSON or validated against the AgentCard schema - */ - public AgentCard getAgentCard() throws A2AClientError, A2AClientJSONError { - if (this.agentCard == null) { - this.agentCard = A2A.getAgentCard(this.httpClient, this.agentUrl); - } - return this.agentCard; - } - - /** - * Get the agent card for the A2A server this client will be communicating with. - * - * @param relativeCardPath the path to the agent card endpoint relative to the base URL of the A2A server - * @param authHeaders the HTTP authentication headers to use - * @return the agent card for the A2A server - * @throws A2AClientError If an HTTP error occurs fetching the card - * @throws A2AClientJSONError f the response body cannot be decoded as JSON or validated against the AgentCard schema - */ - public AgentCard getAgentCard(String relativeCardPath, Map authHeaders) throws A2AClientError, A2AClientJSONError { - if (this.agentCard == null) { - this.agentCard = A2A.getAgentCard(this.httpClient, this.agentUrl, relativeCardPath, authHeaders); - } - return this.agentCard; - } - - /** - * Send a message to the remote agent. - * - * @param messageSendParams the parameters for the message to be sent - * @return the response, may contain a message or a task - * @throws A2AServerException if sending the message fails for any reason - */ - public SendMessageResponse sendMessage(MessageSendParams messageSendParams) throws A2AServerException { - return sendMessage(null, messageSendParams); - } - - /** - * Send a message to the remote agent. - * - * @param requestId the request ID to use - * @param messageSendParams the parameters for the message to be sent - * @return the response, may contain a message or a task - * @throws A2AServerException if sending the message fails for any reason - */ - public SendMessageResponse sendMessage(String requestId, MessageSendParams messageSendParams) throws A2AServerException { - SendMessageRequest.Builder sendMessageRequestBuilder = new SendMessageRequest.Builder() - .jsonrpc(JSONRPCMessage.JSONRPC_VERSION) - .method(SendMessageRequest.METHOD) - .params(messageSendParams); - - if (requestId != null) { - sendMessageRequestBuilder.id(requestId); - } - - SendMessageRequest sendMessageRequest = sendMessageRequestBuilder.build(); - - try { - String httpResponseBody = sendPostRequest(sendMessageRequest); - return unmarshalResponse(httpResponseBody, SEND_MESSAGE_RESPONSE_REFERENCE); - } catch (IOException | InterruptedException e) { - throw new A2AServerException("Failed to send message: " + e, e.getCause()); - } - } - - /** - * Retrieve a task from the A2A server. This method can be used to retrieve the generated - * artifacts for a task. - * - * @param id the task ID - * @return the response containing the task - * @throws A2AServerException if retrieving the task fails for any reason - */ - public GetTaskResponse getTask(String id) throws A2AServerException { - return getTask(null, new TaskQueryParams(id)); - } - - /** - * Retrieve a task from the A2A server. This method can be used to retrieve the generated - * artifacts for a task. - * - * @param taskQueryParams the params for the task to be queried - * @return the response containing the task - * @throws A2AServerException if retrieving the task fails for any reason - */ - public GetTaskResponse getTask(TaskQueryParams taskQueryParams) throws A2AServerException { - return getTask(null, taskQueryParams); - } - - /** - * Retrieve the generated artifacts for a task. - * - * @param requestId the request ID to use - * @param taskQueryParams the params for the task to be queried - * @return the response containing the task - * @throws A2AServerException if retrieving the task fails for any reason - */ - public GetTaskResponse getTask(String requestId, TaskQueryParams taskQueryParams) throws A2AServerException { - GetTaskRequest.Builder getTaskRequestBuilder = new GetTaskRequest.Builder() - .jsonrpc(JSONRPCMessage.JSONRPC_VERSION) - .method(GetTaskRequest.METHOD) - .params(taskQueryParams); - - if (requestId != null) { - getTaskRequestBuilder.id(requestId); - } - - GetTaskRequest getTaskRequest = getTaskRequestBuilder.build(); - - try { - String httpResponseBody = sendPostRequest(getTaskRequest); - return unmarshalResponse(httpResponseBody, GET_TASK_RESPONSE_REFERENCE); - } catch (IOException | InterruptedException e) { - throw new A2AServerException("Failed to get task: " + e, e.getCause()); - } - } - - /** - * Cancel a task that was previously submitted to the A2A server. - * - * @param id the task ID - * @return the response indicating if the task was cancelled - * @throws A2AServerException if cancelling the task fails for any reason - */ - public CancelTaskResponse cancelTask(String id) throws A2AServerException { - return cancelTask(null, new TaskIdParams(id)); - } - - /** - * Cancel a task that was previously submitted to the A2A server. - * - * @param taskIdParams the params for the task to be cancelled - * @return the response indicating if the task was cancelled - * @throws A2AServerException if cancelling the task fails for any reason - */ - public CancelTaskResponse cancelTask(TaskIdParams taskIdParams) throws A2AServerException { - return cancelTask(null, taskIdParams); - } - - /** - * Cancel a task that was previously submitted to the A2A server. - * - * @param requestId the request ID to use - * @param taskIdParams the params for the task to be cancelled - * @return the response indicating if the task was cancelled - * @throws A2AServerException if retrieving the task fails for any reason - */ - public CancelTaskResponse cancelTask(String requestId, TaskIdParams taskIdParams) throws A2AServerException { - CancelTaskRequest.Builder cancelTaskRequestBuilder = new CancelTaskRequest.Builder() - .jsonrpc(JSONRPCMessage.JSONRPC_VERSION) - .method(CancelTaskRequest.METHOD) - .params(taskIdParams); - - if (requestId != null) { - cancelTaskRequestBuilder.id(requestId); - } - - CancelTaskRequest cancelTaskRequest = cancelTaskRequestBuilder.build(); - - try { - String httpResponseBody = sendPostRequest(cancelTaskRequest); - return unmarshalResponse(httpResponseBody, CANCEL_TASK_RESPONSE_REFERENCE); - } catch (IOException | InterruptedException e) { - throw new A2AServerException("Failed to cancel task: " + e, e.getCause()); - } - } - - /** - * Get the push notification configuration for a task. - * - * @param taskId the task ID - * @return the response containing the push notification configuration - * @throws A2AServerException if getting the push notification configuration fails for any reason - */ - public GetTaskPushNotificationConfigResponse getTaskPushNotificationConfig(String taskId) throws A2AServerException { - return getTaskPushNotificationConfig(null, new GetTaskPushNotificationConfigParams(taskId)); - } - - /** - * Get the push notification configuration for a task. - * - * @param taskId the task ID - * @param pushNotificationConfigId the push notification configuration ID - * @return the response containing the push notification configuration - * @throws A2AServerException if getting the push notification configuration fails for any reason - */ - public GetTaskPushNotificationConfigResponse getTaskPushNotificationConfig(String taskId, String pushNotificationConfigId) throws A2AServerException { - return getTaskPushNotificationConfig(null, new GetTaskPushNotificationConfigParams(taskId, pushNotificationConfigId)); - } - - /** - * Get the push notification configuration for a task. - * - * @param getTaskPushNotificationConfigParams the params for the task - * @return the response containing the push notification configuration - * @throws A2AServerException if getting the push notification configuration fails for any reason - */ - public GetTaskPushNotificationConfigResponse getTaskPushNotificationConfig(GetTaskPushNotificationConfigParams getTaskPushNotificationConfigParams) throws A2AServerException { - return getTaskPushNotificationConfig(null, getTaskPushNotificationConfigParams); - } - - /** - * Get the push notification configuration for a task. - * - * @param requestId the request ID to use - * @param getTaskPushNotificationConfigParams the params for the task - * @return the response containing the push notification configuration - * @throws A2AServerException if getting the push notification configuration fails for any reason - */ - public GetTaskPushNotificationConfigResponse getTaskPushNotificationConfig(String requestId, GetTaskPushNotificationConfigParams getTaskPushNotificationConfigParams) throws A2AServerException { - GetTaskPushNotificationConfigRequest.Builder getTaskPushNotificationRequestBuilder = new GetTaskPushNotificationConfigRequest.Builder() - .jsonrpc(JSONRPCMessage.JSONRPC_VERSION) - .method(GetTaskPushNotificationConfigRequest.METHOD) - .params(getTaskPushNotificationConfigParams); - - if (requestId != null) { - getTaskPushNotificationRequestBuilder.id(requestId); - } - - GetTaskPushNotificationConfigRequest getTaskPushNotificationRequest = getTaskPushNotificationRequestBuilder.build(); - - try { - String httpResponseBody = sendPostRequest(getTaskPushNotificationRequest); - return unmarshalResponse(httpResponseBody, GET_TASK_PUSH_NOTIFICATION_CONFIG_RESPONSE_REFERENCE); - } catch (IOException | InterruptedException e) { - throw new A2AServerException("Failed to get task push notification config: " + e, e.getCause()); - } - } - - /** - * Set push notification configuration for a task. - * - * @param taskId the task ID - * @param pushNotificationConfig the push notification configuration - * @return the response indicating whether setting the task push notification configuration succeeded - * @throws A2AServerException if setting the push notification configuration fails for any reason - */ - public SetTaskPushNotificationConfigResponse setTaskPushNotificationConfig(String taskId, - PushNotificationConfig pushNotificationConfig) throws A2AServerException { - return setTaskPushNotificationConfig(null, taskId, pushNotificationConfig); - } - - /** - * Set push notification configuration for a task. - * - * @param requestId the request ID to use - * @param taskId the task ID - * @param pushNotificationConfig the push notification configuration - * @return the response indicating whether setting the task push notification configuration succeeded - * @throws A2AServerException if setting the push notification configuration fails for any reason - */ - public SetTaskPushNotificationConfigResponse setTaskPushNotificationConfig(String requestId, String taskId, - PushNotificationConfig pushNotificationConfig) throws A2AServerException { - SetTaskPushNotificationConfigRequest.Builder setTaskPushNotificationRequestBuilder = new SetTaskPushNotificationConfigRequest.Builder() - .jsonrpc(JSONRPCMessage.JSONRPC_VERSION) - .method(SetTaskPushNotificationConfigRequest.METHOD) - .params(new TaskPushNotificationConfig(taskId, pushNotificationConfig)); - - if (requestId != null) { - setTaskPushNotificationRequestBuilder.id(requestId); - } - - SetTaskPushNotificationConfigRequest setTaskPushNotificationRequest = setTaskPushNotificationRequestBuilder.build(); - - try { - String httpResponseBody = sendPostRequest(setTaskPushNotificationRequest); - return unmarshalResponse(httpResponseBody, SET_TASK_PUSH_NOTIFICATION_CONFIG_RESPONSE_REFERENCE); - } catch (IOException | InterruptedException e) { - throw new A2AServerException("Failed to set task push notification config: " + e, e.getCause()); - } - } - - /** - * Retrieves the push notification configurations for a specified task. - * - * @param requestId the request ID to use - * @param taskId the task ID to use - * @return the response containing the push notification configuration - * @throws A2AServerException if getting the push notification configuration fails for any reason - */ - public ListTaskPushNotificationConfigResponse listTaskPushNotificationConfig(String requestId, String taskId) throws A2AServerException { - return listTaskPushNotificationConfig(requestId, new ListTaskPushNotificationConfigParams(taskId)); - } - - /** - * Retrieves the push notification configurations for a specified task. - * - * @param taskId the task ID to use - * @return the response containing the push notification configuration - * @throws A2AServerException if getting the push notification configuration fails for any reason - */ - public ListTaskPushNotificationConfigResponse listTaskPushNotificationConfig(String taskId) throws A2AServerException { - return listTaskPushNotificationConfig(null, new ListTaskPushNotificationConfigParams(taskId)); - } - - /** - * Retrieves the push notification configurations for a specified task. - * - * @param listTaskPushNotificationConfigParams the params for retrieving the push notification configuration - * @return the response containing the push notification configuration - * @throws A2AServerException if getting the push notification configuration fails for any reason - */ - public ListTaskPushNotificationConfigResponse listTaskPushNotificationConfig(ListTaskPushNotificationConfigParams listTaskPushNotificationConfigParams) throws A2AServerException { - return listTaskPushNotificationConfig(null, listTaskPushNotificationConfigParams); - } - - /** - * Retrieves the push notification configurations for a specified task. - * - * @param requestId the request ID to use - * @param listTaskPushNotificationConfigParams the params for retrieving the push notification configuration - * @return the response containing the push notification configuration - * @throws A2AServerException if getting the push notification configuration fails for any reason - */ - public ListTaskPushNotificationConfigResponse listTaskPushNotificationConfig(String requestId, - ListTaskPushNotificationConfigParams listTaskPushNotificationConfigParams) throws A2AServerException { - ListTaskPushNotificationConfigRequest.Builder listTaskPushNotificationRequestBuilder = new ListTaskPushNotificationConfigRequest.Builder() - .jsonrpc(JSONRPCMessage.JSONRPC_VERSION) - .method(ListTaskPushNotificationConfigRequest.METHOD) - .params(listTaskPushNotificationConfigParams); - - if (requestId != null) { - listTaskPushNotificationRequestBuilder.id(requestId); - } - - ListTaskPushNotificationConfigRequest listTaskPushNotificationRequest = listTaskPushNotificationRequestBuilder.build(); - - try { - String httpResponseBody = sendPostRequest(listTaskPushNotificationRequest); - return unmarshalResponse(httpResponseBody, LIST_TASK_PUSH_NOTIFICATION_CONFIG_RESPONSE_REFERENCE); - } catch (IOException | InterruptedException e) { - throw new A2AServerException("Failed to list task push notification config: " + e, e.getCause()); - } - } - - /** - * Delete the push notification configuration for a specified task. - * - * @param requestId the request ID to use - * @param taskId the task ID - * @param pushNotificationConfigId the push notification config ID - * @return the response - * @throws A2AServerException if deleting the push notification configuration fails for any reason - */ - public DeleteTaskPushNotificationConfigResponse deleteTaskPushNotificationConfig(String requestId, String taskId, - String pushNotificationConfigId) throws A2AServerException { - return deleteTaskPushNotificationConfig(requestId, new DeleteTaskPushNotificationConfigParams(taskId, pushNotificationConfigId)); - } - - /** - * Delete the push notification configuration for a specified task. - * - * @param taskId the task ID - * @param pushNotificationConfigId the push notification config ID - * @return the response - * @throws A2AServerException if deleting the push notification configuration fails for any reason - */ - public DeleteTaskPushNotificationConfigResponse deleteTaskPushNotificationConfig(String taskId, - String pushNotificationConfigId) throws A2AServerException { - return deleteTaskPushNotificationConfig(null, new DeleteTaskPushNotificationConfigParams(taskId, pushNotificationConfigId)); - } - - /** - * Delete the push notification configuration for a specified task. - * - * @param deleteTaskPushNotificationConfigParams the params for deleting the push notification configuration - * @return the response - * @throws A2AServerException if deleting the push notification configuration fails for any reason - */ - public DeleteTaskPushNotificationConfigResponse deleteTaskPushNotificationConfig(DeleteTaskPushNotificationConfigParams deleteTaskPushNotificationConfigParams) throws A2AServerException { - return deleteTaskPushNotificationConfig(null, deleteTaskPushNotificationConfigParams); - } - - /** - * Delete the push notification configuration for a specified task. - * - * @param requestId the request ID to use - * @param deleteTaskPushNotificationConfigParams the params for deleting the push notification configuration - * @return the response - * @throws A2AServerException if deleting the push notification configuration fails for any reason - */ - public DeleteTaskPushNotificationConfigResponse deleteTaskPushNotificationConfig(String requestId, - DeleteTaskPushNotificationConfigParams deleteTaskPushNotificationConfigParams) throws A2AServerException { - DeleteTaskPushNotificationConfigRequest.Builder deleteTaskPushNotificationRequestBuilder = new DeleteTaskPushNotificationConfigRequest.Builder() - .jsonrpc(JSONRPCMessage.JSONRPC_VERSION) - .method(DeleteTaskPushNotificationConfigRequest.METHOD) - .params(deleteTaskPushNotificationConfigParams); - - if (requestId != null) { - deleteTaskPushNotificationRequestBuilder.id(requestId); - } - - DeleteTaskPushNotificationConfigRequest deleteTaskPushNotificationRequest = deleteTaskPushNotificationRequestBuilder.build(); - - try { - String httpResponseBody = sendPostRequest(deleteTaskPushNotificationRequest); - return unmarshalResponse(httpResponseBody, DELETE_TASK_PUSH_NOTIFICATION_CONFIG_RESPONSE_REFERENCE); - } catch (IOException | InterruptedException e) { - throw new A2AServerException("Failed to delete task push notification config: " + e, e.getCause()); - } - } - - /** - * Send a streaming message to the remote agent. - * - * @param messageSendParams the parameters for the message to be sent - * @param eventHandler a consumer that will be invoked for each event received from the remote agent - * @param errorHandler a consumer that will be invoked if the remote agent returns an error - * @param failureHandler a consumer that will be invoked if a failure occurs when processing events - * @throws A2AServerException if sending the streaming message fails for any reason - */ - public void sendStreamingMessage(MessageSendParams messageSendParams, Consumer eventHandler, - Consumer errorHandler, Runnable failureHandler) throws A2AServerException { - sendStreamingMessage(null, messageSendParams, eventHandler, errorHandler, failureHandler); - } - - /** - * Send a streaming message to the remote agent. - * - * @param requestId the request ID to use - * @param messageSendParams the parameters for the message to be sent - * @param eventHandler a consumer that will be invoked for each event received from the remote agent - * @param errorHandler a consumer that will be invoked if the remote agent returns an error - * @param failureHandler a consumer that will be invoked if a failure occurs when processing events - * @throws A2AServerException if sending the streaming message fails for any reason - */ - public void sendStreamingMessage(String requestId, MessageSendParams messageSendParams, Consumer eventHandler, - Consumer errorHandler, Runnable failureHandler) throws A2AServerException { - checkNotNullParam("messageSendParams", messageSendParams); - checkNotNullParam("eventHandler", eventHandler); - checkNotNullParam("errorHandler", errorHandler); - checkNotNullParam("failureHandler", failureHandler); - - SendStreamingMessageRequest.Builder sendStreamingMessageRequestBuilder = new SendStreamingMessageRequest.Builder() - .jsonrpc(JSONRPCMessage.JSONRPC_VERSION) - .method(SendStreamingMessageRequest.METHOD) - .params(messageSendParams); - - if (requestId != null) { - sendStreamingMessageRequestBuilder.id(requestId); - } - - AtomicReference> ref = new AtomicReference<>(); - SSEEventListener sseEventListener = new SSEEventListener(eventHandler, errorHandler, failureHandler); - SendStreamingMessageRequest sendStreamingMessageRequest = sendStreamingMessageRequestBuilder.build(); - try { - A2AHttpClient.PostBuilder builder = createPostBuilder(sendStreamingMessageRequest); - ref.set(builder.postAsyncSSE( - msg -> sseEventListener.onMessage(msg, ref.get()), - throwable -> sseEventListener.onError(throwable, ref.get()), - () -> { - // We don't need to do anything special on completion - })); - - } catch (IOException e) { - throw new A2AServerException("Failed to send streaming message request: " + e, e.getCause()); - } catch (InterruptedException e) { - throw new A2AServerException("Send streaming message request timed out: " + e, e.getCause()); - } - } - - /** - * Resubscribe to an ongoing task. - * - * @param taskIdParams the params for the task to resubscribe to - * @param eventHandler a consumer that will be invoked for each event received from the remote agent - * @param errorHandler a consumer that will be invoked if the remote agent returns an error - * @param failureHandler a consumer that will be invoked if a failure occurs when processing events - * @throws A2AServerException if resubscribing to the task fails for any reason - */ - public void resubscribeToTask(TaskIdParams taskIdParams, Consumer eventHandler, - Consumer errorHandler, Runnable failureHandler) throws A2AServerException { - resubscribeToTask(null, taskIdParams, eventHandler, errorHandler, failureHandler); - } - - /** - * Resubscribe to an ongoing task. - * - * @param requestId the request ID to use - * @param taskIdParams the params for the task to resubscribe to - * @param eventHandler a consumer that will be invoked for each event received from the remote agent - * @param errorHandler a consumer that will be invoked if the remote agent returns an error - * @param failureHandler a consumer that will be invoked if a failure occurs when processing events - * @throws A2AServerException if resubscribing to the task fails for any reason - */ - public void resubscribeToTask(String requestId, TaskIdParams taskIdParams, Consumer eventHandler, - Consumer errorHandler, Runnable failureHandler) throws A2AServerException { - checkNotNullParam("taskIdParams", taskIdParams); - checkNotNullParam("eventHandler", eventHandler); - checkNotNullParam("errorHandler", errorHandler); - checkNotNullParam("failureHandler", failureHandler); - - TaskResubscriptionRequest.Builder taskResubscriptionRequestBuilder = new TaskResubscriptionRequest.Builder() - .jsonrpc(JSONRPCMessage.JSONRPC_VERSION) - .method(TaskResubscriptionRequest.METHOD) - .params(taskIdParams); - - if (requestId != null) { - taskResubscriptionRequestBuilder.id(requestId); - } - - AtomicReference> ref = new AtomicReference<>(); - SSEEventListener sseEventListener = new SSEEventListener(eventHandler, errorHandler, failureHandler); - TaskResubscriptionRequest taskResubscriptionRequest = taskResubscriptionRequestBuilder.build(); - try { - A2AHttpClient.PostBuilder builder = createPostBuilder(taskResubscriptionRequest); - ref.set(builder.postAsyncSSE( - msg -> sseEventListener.onMessage(msg, ref.get()), - throwable -> sseEventListener.onError(throwable, ref.get()), - () -> { - // We don't need to do anything special on completion - })); - - } catch (IOException e) { - throw new A2AServerException("Failed to send task resubscription request: " + e, e.getCause()); - } catch (InterruptedException e) { - throw new A2AServerException("Task resubscription request timed out: " + e, e.getCause()); - } - } - - /** - * Retrieve the authenticated extended agent card. - * - * @param authHeaders the HTTP authentication headers to use - * @return the response - * @throws A2AServerException if retrieving the authenticated extended agent card fails for any reason - */ - public GetAuthenticatedExtendedCardResponse getAuthenticatedExtendedCard(Map authHeaders) throws A2AServerException { - return getAuthenticatedExtendedCard(null, authHeaders); - } - - /** - * Retrieve the authenticated extended agent card. - * - * @param requestId the request ID to use - * @param authHeaders the HTTP authentication headers to use - * @return the response - * @throws A2AServerException if retrieving the authenticated extended agent card fails for any reason - */ - public GetAuthenticatedExtendedCardResponse getAuthenticatedExtendedCard(String requestId, - Map authHeaders) throws A2AServerException { - GetAuthenticatedExtendedCardRequest.Builder requestBuilder = new GetAuthenticatedExtendedCardRequest.Builder() - .jsonrpc(JSONRPCMessage.JSONRPC_VERSION) - .method(GetAuthenticatedExtendedCardRequest.METHOD); - - if (requestId != null) { - requestBuilder.id(requestId); - } - - GetAuthenticatedExtendedCardRequest request = requestBuilder.build(); - - try { - String httpResponseBody = sendPostRequest(request, authHeaders); - return unmarshalResponse(httpResponseBody, GET_AUTHENTICATED_EXTENDED_CARD_RESPONSE_REFERENCE); - } catch (IOException | InterruptedException e) { - throw new A2AServerException("Failed to get authenticated extended agent card: " + e, e); - } - } - - private String sendPostRequest(Object value) throws IOException, InterruptedException { - return sendPostRequest(value, null); - } - - private String sendPostRequest(Object value, Map authHeaders) throws IOException, InterruptedException { - A2AHttpClient.PostBuilder builder = createPostBuilder(value, authHeaders); - A2AHttpResponse response = builder.post(); - if (!response.success()) { - throw new IOException("Request failed " + response.status()); - } - return response.body(); - } - - private A2AHttpClient.PostBuilder createPostBuilder(Object value) throws JsonProcessingException { - return createPostBuilder(value, null); - } - - private A2AHttpClient.PostBuilder createPostBuilder(Object value, Map authHeaders) throws JsonProcessingException { - A2AHttpClient.PostBuilder builder = httpClient.createPost() - .url(agentUrl) - .addHeader("Content-Type", "application/json") - .body(Utils.OBJECT_MAPPER.writeValueAsString(value)); - if (authHeaders != null) { - for (Map.Entry entry : authHeaders.entrySet()) { - builder.addHeader(entry.getKey(), entry.getValue()); - } - } - return builder; - } - - private T unmarshalResponse(String response, TypeReference typeReference) - throws A2AServerException, JsonProcessingException { - T value = Utils.unmarshalFrom(response, typeReference); - JSONRPCError error = value.getError(); - if (error != null) { - throw new A2AServerException(error.getMessage() + (error.getData() != null ? ": " + error.getData() : ""), error); - } - return value; - } -} diff --git a/client/src/main/java/io/a2a/client/A2AGrpcClient.java b/client/src/main/java/io/a2a/client/A2AGrpcClient.java deleted file mode 100644 index 661adc228..000000000 --- a/client/src/main/java/io/a2a/client/A2AGrpcClient.java +++ /dev/null @@ -1,200 +0,0 @@ -package io.a2a.client; - -import static io.a2a.grpc.A2AServiceGrpc.A2AServiceBlockingV2Stub; -import static io.a2a.grpc.A2AServiceGrpc.A2AServiceStub; -import static io.a2a.grpc.utils.ProtoUtils.FromProto; -import static io.a2a.grpc.utils.ProtoUtils.ToProto; -import static io.a2a.util.Assert.checkNotNullParam; - -import java.util.function.Consumer; - -import io.a2a.client.sse.SSEStreamObserver; -import io.a2a.grpc.A2AServiceGrpc; -import io.a2a.grpc.CancelTaskRequest; -import io.a2a.grpc.CreateTaskPushNotificationConfigRequest; -import io.a2a.grpc.GetTaskPushNotificationConfigRequest; -import io.a2a.grpc.GetTaskRequest; -import io.a2a.grpc.SendMessageRequest; -import io.a2a.grpc.SendMessageResponse; -import io.a2a.grpc.StreamResponse; -import io.a2a.grpc.utils.ProtoUtils; -import io.a2a.spec.A2AServerException; -import io.a2a.spec.AgentCard; -import io.a2a.spec.EventKind; -import io.a2a.spec.GetTaskPushNotificationConfigParams; -import io.a2a.spec.MessageSendParams; -import io.a2a.spec.StreamingEventKind; -import io.a2a.spec.Task; -import io.a2a.spec.TaskIdParams; -import io.a2a.spec.TaskPushNotificationConfig; -import io.a2a.spec.TaskQueryParams; -import io.grpc.Channel; -import io.grpc.StatusRuntimeException; -import io.grpc.stub.StreamObserver; - -/** - * A2A Client for interacting with an A2A agent via gRPC. - */ -public class A2AGrpcClient { - - private A2AServiceBlockingV2Stub blockingStub; - private A2AServiceStub asyncStub; - private AgentCard agentCard; - - /** - * Create an A2A client for interacting with an A2A agent via gRPC. - * - * @param channel the gRPC channel - * @param agentCard the agent card for the A2A server this client will be communicating with - */ - public A2AGrpcClient(Channel channel, AgentCard agentCard) { - checkNotNullParam("channel", channel); - checkNotNullParam("agentCard", agentCard); - this.asyncStub = A2AServiceGrpc.newStub(channel); - this.blockingStub = A2AServiceGrpc.newBlockingV2Stub(channel); - this.agentCard = agentCard; - } - - /** - * Send a message to the remote agent. - * - * @param messageSendParams the parameters for the message to be sent - * @return the response, may be a message or a task - * @throws A2AServerException if sending the message fails for any reason - */ - public EventKind sendMessage(MessageSendParams messageSendParams) throws A2AServerException { - SendMessageRequest request = createGrpcSendMessageRequestFromMessageSendParams(messageSendParams); - try { - SendMessageResponse response = blockingStub.sendMessage(request); - if (response.hasMsg()) { - return FromProto.message(response.getMsg()); - } else if (response.hasTask()) { - return FromProto.task(response.getTask()); - } else { - throw new A2AServerException("Server response did not contain a message or task"); - } - } catch (StatusRuntimeException e) { - throw new A2AServerException("Failed to send message: " + e, e); - } - } - - /** - * Retrieves the current state and history of a specific task. - * - * @param taskQueryParams the params for the task to be queried - * @return the task - * @throws A2AServerException if retrieving the task fails for any reason - */ - public Task getTask(TaskQueryParams taskQueryParams) throws A2AServerException { - GetTaskRequest.Builder requestBuilder = GetTaskRequest.newBuilder(); - requestBuilder.setName("tasks/" + taskQueryParams.id()); - if (taskQueryParams.historyLength() != null) { - requestBuilder.setHistoryLength(taskQueryParams.historyLength()); - } - GetTaskRequest getTaskRequest = requestBuilder.build(); - try { - return FromProto.task(blockingStub.getTask(getTaskRequest)); - } catch (StatusRuntimeException e) { - throw new A2AServerException("Failed to get task: " + e, e); - } - } - - /** - * Cancel a task that was previously submitted to the A2A server. - * - * @param taskIdParams the params for the task to be cancelled - * @return the updated task - * @throws A2AServerException if cancelling the task fails for any reason - */ - public Task cancelTask(TaskIdParams taskIdParams) throws A2AServerException { - CancelTaskRequest cancelTaskRequest = CancelTaskRequest.newBuilder() - .setName("tasks/" + taskIdParams.id()) - .build(); - try { - return FromProto.task(blockingStub.cancelTask(cancelTaskRequest)); - } catch (StatusRuntimeException e) { - throw new A2AServerException("Failed to cancel task: " + e, e); - } - } - - /** - * Set push notification configuration for a task. - * - * @param taskPushNotificationConfig the task push notification configuration - * @return the task push notification config - * @throws A2AServerException if setting the push notification configuration fails for any reason - */ - public TaskPushNotificationConfig setTaskPushNotificationConfig(TaskPushNotificationConfig taskPushNotificationConfig) throws A2AServerException { - String configId = taskPushNotificationConfig.pushNotificationConfig().id(); - CreateTaskPushNotificationConfigRequest request = CreateTaskPushNotificationConfigRequest.newBuilder() - .setParent("tasks/" + taskPushNotificationConfig.taskId()) - .setConfig(ToProto.taskPushNotificationConfig(taskPushNotificationConfig)) - .setConfigId(configId == null ? "" : configId) - .build(); - try { - return FromProto.taskPushNotificationConfig(blockingStub.createTaskPushNotificationConfig(request)); - } catch (StatusRuntimeException e) { - throw new A2AServerException("Failed to set the task push notification config: " + e, e); - } - } - - /** - * Get the push notification configuration for a task. - * - * @param getTaskPushNotificationConfigParams the params for the task - * @return the push notification configuration - * @throws A2AServerException if getting the push notification configuration fails for any reason - */ - public TaskPushNotificationConfig getTaskPushNotificationConfig(GetTaskPushNotificationConfigParams getTaskPushNotificationConfigParams) throws A2AServerException { - GetTaskPushNotificationConfigRequest getTaskPushNotificationConfigRequest = GetTaskPushNotificationConfigRequest.newBuilder() - .setName(getTaskPushNotificationConfigName(getTaskPushNotificationConfigParams)) - .build(); - try { - return FromProto.taskPushNotificationConfig(blockingStub.getTaskPushNotificationConfig(getTaskPushNotificationConfigRequest)); - } catch (StatusRuntimeException e) { - throw new A2AServerException("Failed to get the task push notification config: " + e, e); - } - } - - /** - * Send a streaming message request to the remote agent. - * - * @param messageSendParams the parameters for the message to be sent - * @param eventHandler a consumer that will be invoked for each event received from the remote agent - * @param errorHandler a consumer that will be invoked if an error occurs - * @throws A2AServerException if sending the streaming message fails for any reason - */ - public void sendMessageStreaming(MessageSendParams messageSendParams, Consumer eventHandler, - Consumer errorHandler) throws A2AServerException { - SendMessageRequest request = createGrpcSendMessageRequestFromMessageSendParams(messageSendParams); - StreamObserver streamObserver = new SSEStreamObserver(eventHandler, errorHandler); - try { - asyncStub.sendStreamingMessage(request, streamObserver); - } catch (StatusRuntimeException e) { - throw new A2AServerException("Failed to send streaming message: " + e, e); - } - } - - private SendMessageRequest createGrpcSendMessageRequestFromMessageSendParams(MessageSendParams messageSendParams) { - SendMessageRequest.Builder builder = SendMessageRequest.newBuilder(); - builder.setRequest(ToProto.message(messageSendParams.message())); - if (messageSendParams.configuration() != null) { - builder.setConfiguration(ToProto.messageSendConfiguration(messageSendParams.configuration())); - } - if (messageSendParams.metadata() != null) { - builder.setMetadata(ToProto.struct(messageSendParams.metadata())); - } - return builder.build(); - } - - private String getTaskPushNotificationConfigName(GetTaskPushNotificationConfigParams getTaskPushNotificationConfigParams) { - StringBuilder name = new StringBuilder(); - name.append("tasks/"); - name.append(getTaskPushNotificationConfigParams.id()); - if (getTaskPushNotificationConfigParams.pushNotificationConfigId() != null) { - name.append("/pushNotificationConfigs/"); - name.append(getTaskPushNotificationConfigParams.pushNotificationConfigId()); - } - return name.toString(); - } -} diff --git a/client/src/test/java/io/a2a/client/A2ACardResolverTest.java b/client/src/test/java/io/a2a/client/A2ACardResolverTest.java index c9ce509d3..d85e341f4 100644 --- a/client/src/test/java/io/a2a/client/A2ACardResolverTest.java +++ b/client/src/test/java/io/a2a/client/A2ACardResolverTest.java @@ -11,8 +11,8 @@ import java.util.function.Consumer; import com.fasterxml.jackson.core.type.TypeReference; -import io.a2a.http.A2AHttpClient; -import io.a2a.http.A2AHttpResponse; +import io.a2a.transport.jsonrpc.client.A2AHttpClient; +import io.a2a.transport.jsonrpc.client.A2AHttpResponse; import io.a2a.spec.A2AClientError; import io.a2a.spec.A2AClientJSONError; import io.a2a.spec.AgentCard; diff --git a/pom.xml b/pom.xml index 22e44da39..d454e284a 100644 --- a/pom.xml +++ b/pom.xml @@ -290,6 +290,7 @@ spec-grpc tck tests/server-common + transport/spi transport/jsonrpc transport/grpc diff --git a/reference/grpc/src/main/java/io/a2a/server/grpc/quarkus/QuarkusGrpcHandler.java b/reference/grpc/src/main/java/io/a2a/server/grpc/quarkus/QuarkusGrpcHandler.java index 4a98ace48..816ed6f79 100644 --- a/reference/grpc/src/main/java/io/a2a/server/grpc/quarkus/QuarkusGrpcHandler.java +++ b/reference/grpc/src/main/java/io/a2a/server/grpc/quarkus/QuarkusGrpcHandler.java @@ -3,7 +3,7 @@ import jakarta.enterprise.inject.Instance; import jakarta.inject.Inject; -import io.a2a.grpc.handler.GrpcHandler; +import io.a2a.transport.grpc.server.handler.GrpcHandler; import io.a2a.server.PublicAgentCard; import io.a2a.server.requesthandlers.CallContextFactory; import io.a2a.server.requesthandlers.RequestHandler; diff --git a/reference/grpc/src/test/java/io/a2a/server/grpc/quarkus/A2ATestResource.java b/reference/grpc/src/test/java/io/a2a/server/grpc/quarkus/A2ATestResource.java index 25758da9c..9fb0074ac 100644 --- a/reference/grpc/src/test/java/io/a2a/server/grpc/quarkus/A2ATestResource.java +++ b/reference/grpc/src/test/java/io/a2a/server/grpc/quarkus/A2ATestResource.java @@ -19,7 +19,7 @@ import jakarta.ws.rs.core.Response; import io.a2a.server.apps.common.TestUtilsBean; -import io.a2a.grpc.handler.GrpcHandler; +import io.a2a.transport.grpc.server.handler.GrpcHandler; import io.a2a.spec.PushNotificationConfig; import io.a2a.spec.Task; import io.a2a.spec.TaskArtifactUpdateEvent; diff --git a/reference/jsonrpc/src/main/java/io/a2a/server/apps/quarkus/A2AServerRoutes.java b/reference/jsonrpc/src/main/java/io/a2a/server/apps/quarkus/A2AServerRoutes.java index 2cd411409..4e8cbcbb9 100644 --- a/reference/jsonrpc/src/main/java/io/a2a/server/apps/quarkus/A2AServerRoutes.java +++ b/reference/jsonrpc/src/main/java/io/a2a/server/apps/quarkus/A2AServerRoutes.java @@ -20,7 +20,7 @@ import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.core.io.JsonEOFException; import com.fasterxml.jackson.databind.JsonNode; -import io.a2a.jsonrpc.handler.JSONRPCHandler; +import io.a2a.transport.jsonrpc.server.handler.JSONRPCHandler; import io.a2a.server.ExtendedAgentCard; import io.a2a.server.ServerCallContext; import io.a2a.server.auth.UnauthenticatedUser; diff --git a/server-common/src/main/java/io/a2a/server/tasks/BasePushNotificationSender.java b/server-common/src/main/java/io/a2a/server/tasks/BasePushNotificationSender.java index 33ac4445c..ac04216f3 100644 --- a/server-common/src/main/java/io/a2a/server/tasks/BasePushNotificationSender.java +++ b/server-common/src/main/java/io/a2a/server/tasks/BasePushNotificationSender.java @@ -10,8 +10,8 @@ import com.fasterxml.jackson.core.JsonProcessingException; -import io.a2a.http.A2AHttpClient; -import io.a2a.http.JdkA2AHttpClient; +import io.a2a.transport.jsonrpc.client.A2AHttpClient; +import io.a2a.transport.jsonrpc.client.JdkA2AHttpClient; import io.a2a.spec.PushNotificationConfig; import io.a2a.spec.Task; import io.a2a.util.Utils; diff --git a/server-common/src/test/java/io/a2a/server/requesthandlers/AbstractA2ARequestHandlerTest.java b/server-common/src/test/java/io/a2a/server/requesthandlers/AbstractA2ARequestHandlerTest.java index 7232d1a0c..7c62562f8 100644 --- a/server-common/src/test/java/io/a2a/server/requesthandlers/AbstractA2ARequestHandlerTest.java +++ b/server-common/src/test/java/io/a2a/server/requesthandlers/AbstractA2ARequestHandlerTest.java @@ -12,8 +12,8 @@ import java.util.concurrent.Executors; import java.util.function.Consumer; -import io.a2a.http.A2AHttpClient; -import io.a2a.http.A2AHttpResponse; +import io.a2a.transport.jsonrpc.client.A2AHttpClient; +import io.a2a.transport.jsonrpc.client.A2AHttpResponse; import io.a2a.server.agentexecution.AgentExecutor; import io.a2a.server.agentexecution.RequestContext; import io.a2a.server.events.EventQueue; diff --git a/tests/server-common/src/test/java/io/a2a/server/apps/common/TestHttpClient.java b/tests/server-common/src/test/java/io/a2a/server/apps/common/TestHttpClient.java index c5deef68f..046e0f8bb 100644 --- a/tests/server-common/src/test/java/io/a2a/server/apps/common/TestHttpClient.java +++ b/tests/server-common/src/test/java/io/a2a/server/apps/common/TestHttpClient.java @@ -11,8 +11,8 @@ import jakarta.enterprise.context.Dependent; import jakarta.enterprise.inject.Alternative; -import io.a2a.http.A2AHttpClient; -import io.a2a.http.A2AHttpResponse; +import io.a2a.transport.jsonrpc.client.A2AHttpClient; +import io.a2a.transport.jsonrpc.client.A2AHttpResponse; import io.a2a.spec.Task; import io.a2a.util.Utils; diff --git a/transport/grpc/pom.xml b/transport/grpc/pom.xml index 93ec2a095..7bcc7149d 100644 --- a/transport/grpc/pom.xml +++ b/transport/grpc/pom.xml @@ -18,6 +18,11 @@ Java SDK for the Agent2Agent Protocol (A2A) - gRPC + + io.github.a2asdk + a2a-java-sdk-transport-spi + ${project.version} + io.github.a2asdk a2a-java-sdk-server-common diff --git a/client/src/main/java/io/a2a/client/sse/SSEStreamObserver.java b/transport/grpc/src/main/java/io/a2a/transport/grpc/client/EventStreamObserver.java similarity index 83% rename from client/src/main/java/io/a2a/client/sse/SSEStreamObserver.java rename to transport/grpc/src/main/java/io/a2a/transport/grpc/client/EventStreamObserver.java index adc721f42..ae6d705fc 100644 --- a/client/src/main/java/io/a2a/client/sse/SSEStreamObserver.java +++ b/transport/grpc/src/main/java/io/a2a/transport/grpc/client/EventStreamObserver.java @@ -1,22 +1,22 @@ -package io.a2a.client.sse; +package io.a2a.transport.grpc.client; -import static io.a2a.grpc.utils.ProtoUtils.FromProto; +import io.a2a.grpc.StreamResponse; +import io.a2a.spec.StreamingEventKind; +import io.grpc.stub.StreamObserver; import java.util.function.Consumer; import java.util.logging.Logger; -import io.a2a.grpc.StreamResponse; -import io.a2a.spec.StreamingEventKind; -import io.grpc.stub.StreamObserver; +import static io.a2a.grpc.utils.ProtoUtils.FromProto; -public class SSEStreamObserver implements StreamObserver { +public class EventStreamObserver implements StreamObserver { - private static final Logger log = Logger.getLogger(SSEStreamObserver.class.getName()); + private static final Logger log = Logger.getLogger(EventStreamObserver.class.getName()); private final Consumer eventHandler; private final Consumer errorHandler; - public SSEStreamObserver(Consumer eventHandler, Consumer errorHandler) { + public EventStreamObserver(Consumer eventHandler, Consumer errorHandler) { this.eventHandler = eventHandler; this.errorHandler = errorHandler; } diff --git a/transport/grpc/src/main/java/io/a2a/transport/grpc/client/GrpcTransport.java b/transport/grpc/src/main/java/io/a2a/transport/grpc/client/GrpcTransport.java new file mode 100644 index 000000000..2975948c5 --- /dev/null +++ b/transport/grpc/src/main/java/io/a2a/transport/grpc/client/GrpcTransport.java @@ -0,0 +1,164 @@ +package io.a2a.transport.grpc.client; + +import io.a2a.grpc.*; +import io.a2a.grpc.SendMessageRequest; +import io.a2a.grpc.SendMessageResponse; +import io.a2a.grpc.utils.ProtoUtils; +import io.a2a.spec.*; +import io.a2a.spec.AgentCard; +import io.a2a.spec.Task; +import io.a2a.spec.TaskPushNotificationConfig; +import io.a2a.transport.spi.client.Transport; +import io.grpc.Channel; +import io.grpc.StatusRuntimeException; +import io.grpc.stub.StreamObserver; + +import java.util.List; +import java.util.function.Consumer; + +import static io.a2a.util.Assert.checkNotNullParam; + +/** + * @author David BRASSELY (david.brassely at graviteesource.com) + * @author GraviteeSource Team + */ +public class GrpcTransport implements Transport { + + private A2AServiceGrpc.A2AServiceBlockingV2Stub blockingStub; + private A2AServiceGrpc.A2AServiceStub asyncStub; + private AgentCard agentCard; + + /** + * Create an A2A client for interacting with an A2A agent via gRPC. + * + * @param channel the gRPC channel + * @param agentCard the agent card for the A2A server this client will be communicating with + */ + public GrpcTransport(Channel channel, AgentCard agentCard) { + checkNotNullParam("channel", channel); + checkNotNullParam("agentCard", agentCard); + this.asyncStub = A2AServiceGrpc.newStub(channel); + this.blockingStub = A2AServiceGrpc.newBlockingV2Stub(channel); + this.agentCard = agentCard; + } + + @Override + public EventKind sendMessage(String requestId, MessageSendParams messageSendParams) throws A2AServerException { + SendMessageRequest request = createGrpcSendMessageRequestFromMessageSendParams(messageSendParams); + try { + SendMessageResponse response = blockingStub.sendMessage(request); + if (response.hasMsg()) { + return ProtoUtils.FromProto.message(response.getMsg()); + } else if (response.hasTask()) { + return ProtoUtils.FromProto.task(response.getTask()); + } else { + throw new A2AServerException("Server response did not contain a message or task"); + } + } catch (StatusRuntimeException e) { + throw new A2AServerException("Failed to send message: " + e, e); + } + } + + @Override + public Task getTask(String requestId, TaskQueryParams taskQueryParams) throws A2AServerException { + io.a2a.grpc.GetTaskRequest.Builder requestBuilder = io.a2a.grpc.GetTaskRequest.newBuilder(); + requestBuilder.setName("tasks/" + taskQueryParams.id()); + if (taskQueryParams.historyLength() != null) { + requestBuilder.setHistoryLength(taskQueryParams.historyLength()); + } + io.a2a.grpc.GetTaskRequest getTaskRequest = requestBuilder.build(); + try { + return ProtoUtils.FromProto.task(blockingStub.getTask(getTaskRequest)); + } catch (StatusRuntimeException e) { + throw new A2AServerException("Failed to get task: " + e, e); + } + } + + @Override + public Task cancelTask(String requestId, TaskIdParams taskIdParams) throws A2AServerException { + io.a2a.grpc.CancelTaskRequest cancelTaskRequest = io.a2a.grpc.CancelTaskRequest.newBuilder() + .setName("tasks/" + taskIdParams.id()) + .build(); + try { + return ProtoUtils.FromProto.task(blockingStub.cancelTask(cancelTaskRequest)); + } catch (StatusRuntimeException e) { + throw new A2AServerException("Failed to cancel task: " + e, e); + } + } + + @Override + public TaskPushNotificationConfig getTaskPushNotificationConfig(String requestId, GetTaskPushNotificationConfigParams getTaskPushNotificationConfigParams) throws A2AServerException { + io.a2a.grpc.GetTaskPushNotificationConfigRequest getTaskPushNotificationConfigRequest = io.a2a.grpc.GetTaskPushNotificationConfigRequest.newBuilder() + .setName(getTaskPushNotificationConfigName(getTaskPushNotificationConfigParams)) + .build(); + try { + return ProtoUtils.FromProto.taskPushNotificationConfig(blockingStub.getTaskPushNotificationConfig(getTaskPushNotificationConfigRequest)); + } catch (StatusRuntimeException e) { + throw new A2AServerException("Failed to get the task push notification config: " + e, e); + } + } + + @Override + public TaskPushNotificationConfig setTaskPushNotificationConfig(String requestId, String taskId, TaskPushNotificationConfig taskPushNotificationConfig) throws A2AServerException { + String configId = taskPushNotificationConfig.pushNotificationConfig().id(); + CreateTaskPushNotificationConfigRequest request = CreateTaskPushNotificationConfigRequest.newBuilder() + .setParent("tasks/" + taskPushNotificationConfig.taskId()) + .setConfig(ProtoUtils.ToProto.taskPushNotificationConfig(taskPushNotificationConfig)) + .setConfigId(configId == null ? "" : configId) + .build(); + try { + return ProtoUtils.FromProto.taskPushNotificationConfig(blockingStub.createTaskPushNotificationConfig(request)); + } catch (StatusRuntimeException e) { + throw new A2AServerException("Failed to set the task push notification config: " + e, e); + } + } + + @Override + public List listTaskPushNotificationConfig(String requestId, ListTaskPushNotificationConfigParams listTaskPushNotificationConfigParams) throws A2AServerException { + return List.of(); + } + + @Override + public void deleteTaskPushNotificationConfig(String requestId, DeleteTaskPushNotificationConfigParams deleteTaskPushNotificationConfigParams) throws A2AServerException { + + } + + @Override + public void sendStreamingMessage(String requestId, MessageSendParams messageSendParams, Consumer eventHandler, Consumer errorHandler, Runnable failureHandler) throws A2AServerException { + SendMessageRequest request = createGrpcSendMessageRequestFromMessageSendParams(messageSendParams); + StreamObserver streamObserver = new EventStreamObserver(eventHandler, errorHandler); + try { + asyncStub.sendStreamingMessage(request, streamObserver); + } catch (StatusRuntimeException e) { + throw new A2AServerException("Failed to send streaming message: " + e, e); + } + } + + @Override + public void resubscribeToTask(String requestId, TaskIdParams taskIdParams, Consumer eventHandler, Consumer errorHandler, Runnable failureHandler) throws A2AServerException { + + } + + private SendMessageRequest createGrpcSendMessageRequestFromMessageSendParams(MessageSendParams messageSendParams) { + SendMessageRequest.Builder builder = SendMessageRequest.newBuilder(); + builder.setRequest(ProtoUtils.ToProto.message(messageSendParams.message())); + if (messageSendParams.configuration() != null) { + builder.setConfiguration(ProtoUtils.ToProto.messageSendConfiguration(messageSendParams.configuration())); + } + if (messageSendParams.metadata() != null) { + builder.setMetadata(ProtoUtils.ToProto.struct(messageSendParams.metadata())); + } + return builder.build(); + } + + private String getTaskPushNotificationConfigName(GetTaskPushNotificationConfigParams getTaskPushNotificationConfigParams) { + StringBuilder name = new StringBuilder(); + name.append("tasks/"); + name.append(getTaskPushNotificationConfigParams.id()); + if (getTaskPushNotificationConfigParams.pushNotificationConfigId() != null) { + name.append("/pushNotificationConfigs/"); + name.append(getTaskPushNotificationConfigParams.pushNotificationConfigId()); + } + return name.toString(); + } +} diff --git a/transport/grpc/src/main/java/io/a2a/grpc/handler/GrpcHandler.java b/transport/grpc/src/main/java/io/a2a/transport/grpc/server/handler/GrpcHandler.java similarity index 99% rename from transport/grpc/src/main/java/io/a2a/grpc/handler/GrpcHandler.java rename to transport/grpc/src/main/java/io/a2a/transport/grpc/server/handler/GrpcHandler.java index 69f9d2c1d..4ecdbc9ab 100644 --- a/transport/grpc/src/main/java/io/a2a/grpc/handler/GrpcHandler.java +++ b/transport/grpc/src/main/java/io/a2a/transport/grpc/server/handler/GrpcHandler.java @@ -1,4 +1,4 @@ -package io.a2a.grpc.handler; +package io.a2a.transport.grpc.server.handler; import static io.a2a.grpc.utils.ProtoUtils.FromProto; import static io.a2a.grpc.utils.ProtoUtils.ToProto; diff --git a/transport/grpc/src/test/java/io/a2a/grpc/handler/GrpcHandlerTest.java b/transport/grpc/src/test/java/io/a2a/grpc/handler/GrpcHandlerTest.java index 1cfeb6626..76cdb6f6e 100644 --- a/transport/grpc/src/test/java/io/a2a/grpc/handler/GrpcHandlerTest.java +++ b/transport/grpc/src/test/java/io/a2a/grpc/handler/GrpcHandlerTest.java @@ -45,6 +45,7 @@ import io.a2a.spec.TaskStatusUpdateEvent; import io.a2a.spec.TextPart; import io.a2a.spec.UnsupportedOperationError; +import io.a2a.transport.grpc.server.handler.GrpcHandler; import io.grpc.Status; import io.grpc.StatusRuntimeException; import io.grpc.internal.testing.StreamRecorder; diff --git a/transport/jsonrpc/pom.xml b/transport/jsonrpc/pom.xml index 15eecd06e..d0f3304fe 100644 --- a/transport/jsonrpc/pom.xml +++ b/transport/jsonrpc/pom.xml @@ -18,6 +18,11 @@ Java SDK for the Agent2Agent Protocol (A2A) - JSONRPC + + io.github.a2asdk + a2a-java-sdk-transport-spi + ${project.version} + io.github.a2asdk a2a-java-sdk-server-common diff --git a/client/src/main/java/io/a2a/http/A2AHttpClient.java b/transport/jsonrpc/src/main/java/io/a2a/transport/jsonrpc/client/A2AHttpClient.java similarity index 96% rename from client/src/main/java/io/a2a/http/A2AHttpClient.java rename to transport/jsonrpc/src/main/java/io/a2a/transport/jsonrpc/client/A2AHttpClient.java index 7a246843a..3ebd6e8b7 100644 --- a/client/src/main/java/io/a2a/http/A2AHttpClient.java +++ b/transport/jsonrpc/src/main/java/io/a2a/transport/jsonrpc/client/A2AHttpClient.java @@ -1,4 +1,4 @@ -package io.a2a.http; +package io.a2a.transport.jsonrpc.client; import java.io.IOException; import java.util.concurrent.CompletableFuture; diff --git a/client/src/main/java/io/a2a/http/A2AHttpResponse.java b/transport/jsonrpc/src/main/java/io/a2a/transport/jsonrpc/client/A2AHttpResponse.java similarity index 70% rename from client/src/main/java/io/a2a/http/A2AHttpResponse.java rename to transport/jsonrpc/src/main/java/io/a2a/transport/jsonrpc/client/A2AHttpResponse.java index d6973a5dc..0e7074b8b 100644 --- a/client/src/main/java/io/a2a/http/A2AHttpResponse.java +++ b/transport/jsonrpc/src/main/java/io/a2a/transport/jsonrpc/client/A2AHttpResponse.java @@ -1,4 +1,4 @@ -package io.a2a.http; +package io.a2a.transport.jsonrpc.client; public interface A2AHttpResponse { int status(); diff --git a/transport/jsonrpc/src/main/java/io/a2a/transport/jsonrpc/client/JSONRPCTransport.java b/transport/jsonrpc/src/main/java/io/a2a/transport/jsonrpc/client/JSONRPCTransport.java new file mode 100644 index 000000000..dce6ab668 --- /dev/null +++ b/transport/jsonrpc/src/main/java/io/a2a/transport/jsonrpc/client/JSONRPCTransport.java @@ -0,0 +1,301 @@ +package io.a2a.transport.jsonrpc.client; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; +import io.a2a.spec.A2AServerException; +import io.a2a.spec.CancelTaskRequest; +import io.a2a.spec.CancelTaskResponse; +import io.a2a.spec.DeleteTaskPushNotificationConfigParams; +import io.a2a.spec.DeleteTaskPushNotificationConfigRequest; +import io.a2a.spec.DeleteTaskPushNotificationConfigResponse; +import io.a2a.spec.EventKind; +import io.a2a.spec.GetTaskPushNotificationConfigParams; +import io.a2a.spec.GetTaskPushNotificationConfigRequest; +import io.a2a.spec.GetTaskPushNotificationConfigResponse; +import io.a2a.spec.GetTaskRequest; +import io.a2a.spec.GetTaskResponse; +import io.a2a.spec.JSONRPCError; +import io.a2a.spec.JSONRPCMessage; +import io.a2a.spec.JSONRPCResponse; +import io.a2a.spec.ListTaskPushNotificationConfigParams; +import io.a2a.spec.ListTaskPushNotificationConfigRequest; +import io.a2a.spec.ListTaskPushNotificationConfigResponse; +import io.a2a.spec.MessageSendParams; +import io.a2a.spec.PushNotificationConfig; +import io.a2a.spec.SendMessageRequest; +import io.a2a.spec.SendMessageResponse; +import io.a2a.spec.SendStreamingMessageRequest; +import io.a2a.spec.SetTaskPushNotificationConfigRequest; +import io.a2a.spec.SetTaskPushNotificationConfigResponse; +import io.a2a.spec.StreamingEventKind; +import io.a2a.spec.Task; +import io.a2a.spec.TaskIdParams; +import io.a2a.spec.TaskPushNotificationConfig; +import io.a2a.spec.TaskQueryParams; +import io.a2a.spec.TaskResubscriptionRequest; +import io.a2a.transport.jsonrpc.client.sse.SSEEventListener; +import io.a2a.transport.spi.client.Transport; +import io.a2a.util.Utils; + +import java.io.IOException; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Consumer; + +public class JSONRPCTransport implements Transport { + + private static final TypeReference SEND_MESSAGE_RESPONSE_REFERENCE = new TypeReference<>() {}; + private static final TypeReference GET_TASK_RESPONSE_REFERENCE = new TypeReference<>() {}; + private static final TypeReference CANCEL_TASK_RESPONSE_REFERENCE = new TypeReference<>() {}; + private static final TypeReference GET_TASK_PUSH_NOTIFICATION_CONFIG_RESPONSE_REFERENCE = new TypeReference<>() {}; + private static final TypeReference SET_TASK_PUSH_NOTIFICATION_CONFIG_RESPONSE_REFERENCE = new TypeReference<>() {}; + private static final TypeReference LIST_TASK_PUSH_NOTIFICATION_CONFIG_RESPONSE_REFERENCE = new TypeReference<>() {}; + private static final TypeReference DELETE_TASK_PUSH_NOTIFICATION_CONFIG_RESPONSE_REFERENCE = new TypeReference<>() {}; + + private final String agentUrl; + private final A2AHttpClient httpClient; + + public JSONRPCTransport(String agentUrl) { + this(agentUrl, new JdkA2AHttpClient()); + } + + public JSONRPCTransport(String agentUrl, A2AHttpClient httpClient) { + this.agentUrl = agentUrl; + this.httpClient = httpClient; + } + + @Override + public EventKind sendMessage(String requestId, MessageSendParams messageSendParams) throws A2AServerException { + SendMessageRequest.Builder sendMessageRequestBuilder = new SendMessageRequest.Builder() + .jsonrpc(JSONRPCMessage.JSONRPC_VERSION) + .method(SendMessageRequest.METHOD) + .params(messageSendParams); + + if (requestId != null) { + sendMessageRequestBuilder.id(requestId); + } + + SendMessageRequest sendMessageRequest = sendMessageRequestBuilder.build(); + + try { + String httpResponseBody = sendPostRequest(sendMessageRequest); + return unmarshalResponse(httpResponseBody, SEND_MESSAGE_RESPONSE_REFERENCE).getResult(); + } catch (IOException | InterruptedException e) { + throw new A2AServerException("Failed to send message: " + e, e.getCause()); + } + } + + @Override + public Task getTask(String requestId, TaskQueryParams taskQueryParams) throws A2AServerException { + GetTaskRequest.Builder getTaskRequestBuilder = new GetTaskRequest.Builder() + .jsonrpc(JSONRPCMessage.JSONRPC_VERSION) + .method(GetTaskRequest.METHOD) + .params(taskQueryParams); + + if (requestId != null) { + getTaskRequestBuilder.id(requestId); + } + + GetTaskRequest getTaskRequest = getTaskRequestBuilder.build(); + + try { + String httpResponseBody = sendPostRequest(getTaskRequest); + return unmarshalResponse(httpResponseBody, GET_TASK_RESPONSE_REFERENCE).getResult(); + } catch (IOException | InterruptedException e) { + throw new A2AServerException("Failed to get task: " + e, e.getCause()); + } + } + + @Override + public Task cancelTask(String requestId, TaskIdParams taskIdParams) throws A2AServerException { + CancelTaskRequest.Builder cancelTaskRequestBuilder = new CancelTaskRequest.Builder() + .jsonrpc(JSONRPCMessage.JSONRPC_VERSION) + .method(CancelTaskRequest.METHOD) + .params(taskIdParams); + + if (requestId != null) { + cancelTaskRequestBuilder.id(requestId); + } + + CancelTaskRequest cancelTaskRequest = cancelTaskRequestBuilder.build(); + + try { + String httpResponseBody = sendPostRequest(cancelTaskRequest); + return unmarshalResponse(httpResponseBody, CANCEL_TASK_RESPONSE_REFERENCE).getResult(); + } catch (IOException | InterruptedException e) { + throw new A2AServerException("Failed to cancel task: " + e, e.getCause()); + } + } + + @Override + public TaskPushNotificationConfig getTaskPushNotificationConfig(String requestId, GetTaskPushNotificationConfigParams getTaskPushNotificationConfigParams) throws A2AServerException { + GetTaskPushNotificationConfigRequest.Builder getTaskPushNotificationRequestBuilder = new GetTaskPushNotificationConfigRequest.Builder() + .jsonrpc(JSONRPCMessage.JSONRPC_VERSION) + .method(GetTaskPushNotificationConfigRequest.METHOD) + .params(getTaskPushNotificationConfigParams); + + if (requestId != null) { + getTaskPushNotificationRequestBuilder.id(requestId); + } + + GetTaskPushNotificationConfigRequest getTaskPushNotificationRequest = getTaskPushNotificationRequestBuilder.build(); + + try { + String httpResponseBody = sendPostRequest(getTaskPushNotificationRequest); + return unmarshalResponse(httpResponseBody, GET_TASK_PUSH_NOTIFICATION_CONFIG_RESPONSE_REFERENCE).getResult(); + } catch (IOException | InterruptedException e) { + throw new A2AServerException("Failed to get task push notification config: " + e, e.getCause()); + } + } + + @Override + public TaskPushNotificationConfig setTaskPushNotificationConfig(String requestId, String taskId, PushNotificationConfig pushNotificationConfig) throws A2AServerException { + SetTaskPushNotificationConfigRequest.Builder setTaskPushNotificationRequestBuilder = new SetTaskPushNotificationConfigRequest.Builder() + .jsonrpc(JSONRPCMessage.JSONRPC_VERSION) + .method(SetTaskPushNotificationConfigRequest.METHOD) + .params(new TaskPushNotificationConfig(taskId, pushNotificationConfig)); + + if (requestId != null) { + setTaskPushNotificationRequestBuilder.id(requestId); + } + + SetTaskPushNotificationConfigRequest setTaskPushNotificationRequest = setTaskPushNotificationRequestBuilder.build(); + + try { + String httpResponseBody = sendPostRequest(setTaskPushNotificationRequest); + return unmarshalResponse(httpResponseBody, SET_TASK_PUSH_NOTIFICATION_CONFIG_RESPONSE_REFERENCE).getResult(); + } catch (IOException | InterruptedException e) { + throw new A2AServerException("Failed to set task push notification config: " + e, e.getCause()); + } + } + + @Override + public List listTaskPushNotificationConfig(String requestId, ListTaskPushNotificationConfigParams listTaskPushNotificationConfigParams) throws A2AServerException { + ListTaskPushNotificationConfigRequest.Builder listTaskPushNotificationRequestBuilder = new ListTaskPushNotificationConfigRequest.Builder() + .jsonrpc(JSONRPCMessage.JSONRPC_VERSION) + .method(ListTaskPushNotificationConfigRequest.METHOD) + .params(listTaskPushNotificationConfigParams); + + if (requestId != null) { + listTaskPushNotificationRequestBuilder.id(requestId); + } + + ListTaskPushNotificationConfigRequest listTaskPushNotificationRequest = listTaskPushNotificationRequestBuilder.build(); + + try { + String httpResponseBody = sendPostRequest(listTaskPushNotificationRequest); + return unmarshalResponse(httpResponseBody, LIST_TASK_PUSH_NOTIFICATION_CONFIG_RESPONSE_REFERENCE).getResult(); + } catch (IOException | InterruptedException e) { + throw new A2AServerException("Failed to list task push notification config: " + e, e.getCause()); + } + } + + @Override + public void deleteTaskPushNotificationConfig(String requestId, DeleteTaskPushNotificationConfigParams deleteTaskPushNotificationConfigParams) throws A2AServerException { + DeleteTaskPushNotificationConfigRequest.Builder deleteTaskPushNotificationRequestBuilder = new DeleteTaskPushNotificationConfigRequest.Builder() + .jsonrpc(JSONRPCMessage.JSONRPC_VERSION) + .method(DeleteTaskPushNotificationConfigRequest.METHOD) + .params(deleteTaskPushNotificationConfigParams); + + if (requestId != null) { + deleteTaskPushNotificationRequestBuilder.id(requestId); + } + + DeleteTaskPushNotificationConfigRequest deleteTaskPushNotificationRequest = deleteTaskPushNotificationRequestBuilder.build(); + + try { + String httpResponseBody = sendPostRequest(deleteTaskPushNotificationRequest); + unmarshalResponse(httpResponseBody, DELETE_TASK_PUSH_NOTIFICATION_CONFIG_RESPONSE_REFERENCE); + } catch (IOException | InterruptedException e) { + throw new A2AServerException("Failed to delete task push notification config: " + e, e.getCause()); + } + } + + @Override + public void sendStreamingMessage(String requestId, MessageSendParams messageSendParams, Consumer eventHandler, Consumer errorHandler, Runnable failureHandler) throws A2AServerException { + SendStreamingMessageRequest.Builder sendStreamingMessageRequestBuilder = new SendStreamingMessageRequest.Builder() + .jsonrpc(JSONRPCMessage.JSONRPC_VERSION) + .method(SendStreamingMessageRequest.METHOD) + .params(messageSendParams); + + if (requestId != null) { + sendStreamingMessageRequestBuilder.id(requestId); + } + + AtomicReference> ref = new AtomicReference<>(); + SSEEventListener sseEventListener = new SSEEventListener(eventHandler, errorHandler, failureHandler); + SendStreamingMessageRequest sendStreamingMessageRequest = sendStreamingMessageRequestBuilder.build(); + try { + A2AHttpClient.PostBuilder builder = createPostBuilder(sendStreamingMessageRequest); + ref.set(builder.postAsyncSSE( + msg -> sseEventListener.onMessage(msg, ref.get()), + throwable -> sseEventListener.onError(throwable, ref.get()), + () -> { + // We don't need to do anything special on completion + })); + + } catch (IOException e) { + throw new A2AServerException("Failed to send streaming message request: " + e, e.getCause()); + } catch (InterruptedException e) { + throw new A2AServerException("Send streaming message request timed out: " + e, e.getCause()); + } + } + + @Override + public void resubscribeToTask(String requestId, TaskIdParams taskIdParams, Consumer eventHandler, Consumer errorHandler, Runnable failureHandler) throws A2AServerException { + TaskResubscriptionRequest.Builder taskResubscriptionRequestBuilder = new TaskResubscriptionRequest.Builder() + .jsonrpc(JSONRPCMessage.JSONRPC_VERSION) + .method(TaskResubscriptionRequest.METHOD) + .params(taskIdParams); + + if (requestId != null) { + taskResubscriptionRequestBuilder.id(requestId); + } + + AtomicReference> ref = new AtomicReference<>(); + SSEEventListener sseEventListener = new SSEEventListener(eventHandler, errorHandler, failureHandler); + TaskResubscriptionRequest taskResubscriptionRequest = taskResubscriptionRequestBuilder.build(); + try { + A2AHttpClient.PostBuilder builder = createPostBuilder(taskResubscriptionRequest); + ref.set(builder.postAsyncSSE( + msg -> sseEventListener.onMessage(msg, ref.get()), + throwable -> sseEventListener.onError(throwable, ref.get()), + () -> { + // We don't need to do anything special on completion + })); + + } catch (IOException e) { + throw new A2AServerException("Failed to send task resubscription request: " + e, e.getCause()); + } catch (InterruptedException e) { + throw new A2AServerException("Task resubscription request timed out: " + e, e.getCause()); + } + } + + private String sendPostRequest(Object value) throws IOException, InterruptedException { + A2AHttpClient.PostBuilder builder = createPostBuilder(value); + A2AHttpResponse response = builder.post(); + if (!response.success()) { + throw new IOException("Request failed " + response.status()); + } + return response.body(); + } + + private A2AHttpClient.PostBuilder createPostBuilder(Object value) throws JsonProcessingException { + return httpClient.createPost() + .url(agentUrl) + .addHeader("Content-Type", "application/json") + .body(Utils.OBJECT_MAPPER.writeValueAsString(value)); + + } + + private T unmarshalResponse(String response, TypeReference typeReference) + throws A2AServerException, JsonProcessingException { + T value = Utils.unmarshalFrom(response, typeReference); + JSONRPCError error = value.getError(); + if (error != null) { + throw new A2AServerException(error.getMessage() + (error.getData() != null ? ": " + error.getData() : ""), error); + } + return value; + } +} diff --git a/client/src/main/java/io/a2a/http/JdkA2AHttpClient.java b/transport/jsonrpc/src/main/java/io/a2a/transport/jsonrpc/client/JdkA2AHttpClient.java similarity index 99% rename from client/src/main/java/io/a2a/http/JdkA2AHttpClient.java rename to transport/jsonrpc/src/main/java/io/a2a/transport/jsonrpc/client/JdkA2AHttpClient.java index c3d5907a2..e61d7fab3 100644 --- a/client/src/main/java/io/a2a/http/JdkA2AHttpClient.java +++ b/transport/jsonrpc/src/main/java/io/a2a/transport/jsonrpc/client/JdkA2AHttpClient.java @@ -1,4 +1,4 @@ -package io.a2a.http; +package io.a2a.transport.jsonrpc.client; import java.io.IOException; import java.net.URI; diff --git a/client/src/main/java/io/a2a/client/sse/SSEEventListener.java b/transport/jsonrpc/src/main/java/io/a2a/transport/jsonrpc/client/sse/SSEEventListener.java similarity index 98% rename from client/src/main/java/io/a2a/client/sse/SSEEventListener.java rename to transport/jsonrpc/src/main/java/io/a2a/transport/jsonrpc/client/sse/SSEEventListener.java index 8ed0e9aa3..7f4ff19fc 100644 --- a/client/src/main/java/io/a2a/client/sse/SSEEventListener.java +++ b/transport/jsonrpc/src/main/java/io/a2a/transport/jsonrpc/client/sse/SSEEventListener.java @@ -1,10 +1,4 @@ -package io.a2a.client.sse; - -import static io.a2a.util.Utils.OBJECT_MAPPER; - -import java.util.concurrent.Future; -import java.util.function.Consumer; -import java.util.logging.Logger; +package io.a2a.transport.jsonrpc.client.sse; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.JsonNode; @@ -12,6 +6,12 @@ import io.a2a.spec.StreamingEventKind; import io.a2a.spec.TaskStatusUpdateEvent; +import java.util.concurrent.Future; +import java.util.function.Consumer; +import java.util.logging.Logger; + +import static io.a2a.util.Utils.OBJECT_MAPPER; + public class SSEEventListener { private static final Logger log = Logger.getLogger(SSEEventListener.class.getName()); private final Consumer eventHandler; diff --git a/transport/jsonrpc/src/main/java/io/a2a/jsonrpc/handler/JSONRPCHandler.java b/transport/jsonrpc/src/main/java/io/a2a/transport/jsonrpc/server/handler/JSONRPCHandler.java similarity index 99% rename from transport/jsonrpc/src/main/java/io/a2a/jsonrpc/handler/JSONRPCHandler.java rename to transport/jsonrpc/src/main/java/io/a2a/transport/jsonrpc/server/handler/JSONRPCHandler.java index e4e11f294..463c2aab3 100644 --- a/transport/jsonrpc/src/main/java/io/a2a/jsonrpc/handler/JSONRPCHandler.java +++ b/transport/jsonrpc/src/main/java/io/a2a/transport/jsonrpc/server/handler/JSONRPCHandler.java @@ -1,4 +1,4 @@ -package io.a2a.jsonrpc.handler; +package io.a2a.transport.jsonrpc.server.handler; import static io.a2a.server.util.async.AsyncUtils.createTubeConfig; import jakarta.enterprise.context.ApplicationScoped; diff --git a/transport/jsonrpc/src/test/java/io/a2a/transport/jsonrpc/client/JsonStreamingMessages.java b/transport/jsonrpc/src/test/java/io/a2a/transport/jsonrpc/client/JsonStreamingMessages.java new file mode 100644 index 000000000..7bf46054d --- /dev/null +++ b/transport/jsonrpc/src/test/java/io/a2a/transport/jsonrpc/client/JsonStreamingMessages.java @@ -0,0 +1,148 @@ +package io.a2a.transport.jsonrpc.client; + +/** + * Contains JSON strings for testing SSE streaming. + */ +public class JsonStreamingMessages { + + public static final String STREAMING_TASK_EVENT = """ + data: { + "jsonrpc": "2.0", + "id": "1234", + "result": { + "kind": "task", + "id": "task-123", + "contextId": "context-456", + "status": { + "state": "working" + } + } + } + """; + + + public static final String STREAMING_MESSAGE_EVENT = """ + data: { + "jsonrpc": "2.0", + "id": "1234", + "result": { + "kind": "message", + "role": "agent", + "messageId": "msg-123", + "contextId": "context-456", + "parts": [ + { + "kind": "text", + "text": "Hello, world!" + } + ] + } + }"""; + + public static final String STREAMING_STATUS_UPDATE_EVENT = """ + data: { + "jsonrpc": "2.0", + "id": "1234", + "result": { + "taskId": "1", + "contextId": "2", + "status": { + "state": "submitted" + }, + "final": false, + "kind": "status-update" + } + }"""; + + public static final String STREAMING_STATUS_UPDATE_EVENT_FINAL = """ + data: { + "jsonrpc": "2.0", + "id": "1234", + "result": { + "taskId": "1", + "contextId": "2", + "status": { + "state": "completed" + }, + "final": true, + "kind": "status-update" + } + }"""; + + public static final String STREAMING_ARTIFACT_UPDATE_EVENT = """ + data: { + "jsonrpc": "2.0", + "id": "1234", + "result": { + "kind": "artifact-update", + "taskId": "1", + "contextId": "2", + "append": false, + "lastChunk": true, + "artifact": { + "artifactId": "artifact-1", + "parts": [ + { + "kind": "text", + "text": "Why did the chicken cross the road? To get to the other side!" + } + ] + } + } + } + }"""; + + public static final String STREAMING_ERROR_EVENT = """ + data: { + "jsonrpc": "2.0", + "id": "1234", + "error": { + "code": -32602, + "message": "Invalid parameters", + "data": "Missing required field" + } + }"""; + + public static final String SEND_MESSAGE_STREAMING_TEST_REQUEST = """ + { + "jsonrpc": "2.0", + "id": "request-1234", + "method": "message/stream", + "params": { + "message": { + "role": "user", + "parts": [ + { + "kind": "text", + "text": "tell me some jokes" + } + ], + "messageId": "message-1234", + "contextId": "context-1234", + "kind": "message" + }, + "configuration": { + "acceptedOutputModes": ["text"], + "blocking": false + }, + } + }"""; + + static final String SEND_MESSAGE_STREAMING_TEST_RESPONSE = + "event: message\n" + + "data: {\"jsonrpc\":\"2.0\",\"id\":1,\"result\":{\"id\":\"2\",\"contextId\":\"context-1234\",\"status\":{\"state\":\"completed\"},\"artifacts\":[{\"artifactId\":\"artifact-1\",\"name\":\"joke\",\"parts\":[{\"kind\":\"text\",\"text\":\"Why did the chicken cross the road? To get to the other side!\"}]}],\"metadata\":{},\"kind\":\"task\"}}\n\n"; + + static final String TASK_RESUBSCRIPTION_REQUEST_TEST_RESPONSE = + "event: message\n" + + "data: {\"jsonrpc\":\"2.0\",\"id\":1,\"result\":{\"id\":\"2\",\"contextId\":\"context-1234\",\"status\":{\"state\":\"completed\"},\"artifacts\":[{\"artifactId\":\"artifact-1\",\"name\":\"joke\",\"parts\":[{\"kind\":\"text\",\"text\":\"Why did the chicken cross the road? To get to the other side!\"}]}],\"metadata\":{},\"kind\":\"task\"}}\n\n"; + + public static final String TASK_RESUBSCRIPTION_TEST_REQUEST = """ + { + "jsonrpc": "2.0", + "id": "request-1234", + "method": "tasks/resubscribe", + "params": { + "id": "task-1234" + } + }"""; +} \ No newline at end of file diff --git a/client/src/test/java/io/a2a/client/sse/SSEEventListenerTest.java b/transport/jsonrpc/src/test/java/io/a2a/transport/jsonrpc/client/sse/SSEEventListenerTest.java similarity index 97% rename from client/src/test/java/io/a2a/client/sse/SSEEventListenerTest.java rename to transport/jsonrpc/src/test/java/io/a2a/transport/jsonrpc/client/sse/SSEEventListenerTest.java index 1fca0ff9c..5e4fc357c 100644 --- a/client/src/test/java/io/a2a/client/sse/SSEEventListenerTest.java +++ b/transport/jsonrpc/src/test/java/io/a2a/transport/jsonrpc/client/sse/SSEEventListenerTest.java @@ -1,18 +1,5 @@ -package io.a2a.client.sse; +package io.a2a.transport.jsonrpc.client.sse; -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.assertTrue; - -import java.util.concurrent.ExecutionException; -import java.util.concurrent.Future; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.TimeoutException; -import java.util.concurrent.atomic.AtomicBoolean; -import java.util.concurrent.atomic.AtomicReference; - -import io.a2a.client.JsonStreamingMessages; import io.a2a.spec.Artifact; import io.a2a.spec.JSONRPCError; import io.a2a.spec.Message; @@ -24,8 +11,18 @@ import io.a2a.spec.TaskStatus; import io.a2a.spec.TaskStatusUpdateEvent; import io.a2a.spec.TextPart; +import io.a2a.transport.jsonrpc.client.JsonStreamingMessages; import org.junit.jupiter.api.Test; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.jupiter.api.Assertions.*; + public class SSEEventListenerTest { @Test diff --git a/transport/jsonrpc/src/test/java/io/a2a/jsonrpc/handler/JSONRPCHandlerTest.java b/transport/jsonrpc/src/test/java/io/a2a/transport/jsonrpc/server/handler/JSONRPCHandlerTest.java similarity index 99% rename from transport/jsonrpc/src/test/java/io/a2a/jsonrpc/handler/JSONRPCHandlerTest.java rename to transport/jsonrpc/src/test/java/io/a2a/transport/jsonrpc/server/handler/JSONRPCHandlerTest.java index a645c9b37..931f4fdc0 100644 --- a/transport/jsonrpc/src/test/java/io/a2a/jsonrpc/handler/JSONRPCHandlerTest.java +++ b/transport/jsonrpc/src/test/java/io/a2a/transport/jsonrpc/server/handler/JSONRPCHandlerTest.java @@ -1,4 +1,4 @@ -package io.a2a.jsonrpc.handler; +package io.a2a.transport.jsonrpc.server.handler; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertInstanceOf; @@ -65,6 +65,7 @@ import io.a2a.spec.TaskStatusUpdateEvent; import io.a2a.spec.TextPart; import io.a2a.spec.UnsupportedOperationError; +import io.a2a.transport.jsonrpc.server.handler.JSONRPCHandler; import mutiny.zero.ZeroPublisher; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Disabled; diff --git a/transport/spi/pom.xml b/transport/spi/pom.xml new file mode 100644 index 000000000..1e284687f --- /dev/null +++ b/transport/spi/pom.xml @@ -0,0 +1,29 @@ + + + 4.0.0 + + + io.github.a2asdk + a2a-java-sdk-parent + 0.2.6.Beta1-SNAPSHOT + ../../pom.xml + + a2a-java-sdk-transport-spi + + jar + + Java SDK A2A Transport: SPI + Java SDK for the Agent2Agent Protocol (A2A) - SPI + + + + io.github.a2asdk + a2a-java-sdk-spec + ${project.version} + + + + + diff --git a/transport/spi/src/main/java/io/a2a/transport/spi/client/Transport.java b/transport/spi/src/main/java/io/a2a/transport/spi/client/Transport.java new file mode 100644 index 000000000..9ab643b56 --- /dev/null +++ b/transport/spi/src/main/java/io/a2a/transport/spi/client/Transport.java @@ -0,0 +1,109 @@ +package io.a2a.transport.spi.client; + +import io.a2a.spec.*; + +import java.util.List; +import java.util.function.Consumer; + +public interface Transport { + + /** + * Send a message to the remote agent. + * + * @param requestId the request ID to use + * @param messageSendParams the parameters for the message to be sent + * @return the response, may contain a message or a task + * @throws A2AServerException if sending the message fails for any reason + */ + EventKind sendMessage(String requestId, MessageSendParams messageSendParams) throws A2AServerException; + + /** + * Retrieve the generated artifacts for a task. + * + * @param requestId the request ID to use + * @param taskQueryParams the params for the task to be queried + * @return the response containing the task + * @throws A2AServerException if retrieving the task fails for any reason + */ + Task getTask(String requestId, TaskQueryParams taskQueryParams) throws A2AServerException; + + /** + * Cancel a task that was previously submitted to the A2A server. + * + * @param requestId the request ID to use + * @param taskIdParams the params for the task to be cancelled + * @return the response indicating if the task was cancelled + * @throws A2AServerException if retrieving the task fails for any reason + */ + Task cancelTask(String requestId, TaskIdParams taskIdParams) throws A2AServerException; + + /** + * Get the push notification configuration for a task. + * + * @param requestId the request ID to use + * @param getTaskPushNotificationConfigParams the params for the task + * @return the response containing the push notification configuration + * @throws A2AServerException if getting the push notification configuration fails for any reason + */ + TaskPushNotificationConfig getTaskPushNotificationConfig(String requestId, GetTaskPushNotificationConfigParams getTaskPushNotificationConfigParams) throws A2AServerException; + + /** + * Set push notification configuration for a task. + * + * @param requestId the request ID to use + * @param taskId the task ID + * @param pushNotificationConfig the push notification configuration + * @return the response indicating whether setting the task push notification configuration succeeded + * @throws A2AServerException if setting the push notification configuration fails for any reason + */ + TaskPushNotificationConfig setTaskPushNotificationConfig(String requestId, String taskId, + PushNotificationConfig pushNotificationConfig) throws A2AServerException; + + /** + * Retrieves the push notification configurations for a specified task. + * + * @param requestId the request ID to use + * @param listTaskPushNotificationConfigParams the params for retrieving the push notification configuration + * @return the response containing the push notification configuration + * @throws A2AServerException if getting the push notification configuration fails for any reason + */ + List listTaskPushNotificationConfig(String requestId, + ListTaskPushNotificationConfigParams listTaskPushNotificationConfigParams) throws A2AServerException; + + /** + * Delete the push notification configuration for a specified task. + * + * @param requestId the request ID to use + * @param deleteTaskPushNotificationConfigParams the params for deleting the push notification configuration + * @throws A2AServerException if deleting the push notification configuration fails for any reason + */ + void deleteTaskPushNotificationConfig(String requestId, + DeleteTaskPushNotificationConfigParams deleteTaskPushNotificationConfigParams) throws A2AServerException; + + /** + * Send a streaming message to the remote agent. + * + * @param requestId the request ID to use + * @param messageSendParams the parameters for the message to be sent + * @param eventHandler a consumer that will be invoked for each event received from the remote agent + * @param errorHandler a consumer that will be invoked if the remote agent returns an error + * @param failureHandler a consumer that will be invoked if a failure occurs when processing events + * @throws A2AServerException if sending the streaming message fails for any reason + */ + void sendStreamingMessage(String requestId, MessageSendParams messageSendParams, Consumer eventHandler, + Consumer errorHandler, Runnable failureHandler) throws A2AServerException; + + /** + * Resubscribe to an ongoing task. + * + * @param requestId the request ID to use + * @param taskIdParams the params for the task to resubscribe to + * @param eventHandler a consumer that will be invoked for each event received from the remote agent + * @param errorHandler a consumer that will be invoked if the remote agent returns an error + * @param failureHandler a consumer that will be invoked if a failure occurs when processing events + * @throws A2AServerException if resubscribing to the task fails for any reason + */ + void resubscribeToTask(String requestId, TaskIdParams taskIdParams, Consumer eventHandler, + Consumer errorHandler, Runnable failureHandler) throws A2AServerException; + +} From 18db0ed3a089516936a32217e6d6c2c36eba66ce Mon Sep 17 00:00:00 2001 From: Farah Juma Date: Thu, 14 Aug 2025 13:15:38 -0400 Subject: [PATCH 03/31] fix: Split out client and server transport code into separate modules and also split out the http client code into a separate module --- client-http/pom.xml | 33 ++++++++++++ .../io/a2a/client/http}/A2AHttpClient.java | 2 +- .../io/a2a/client/http}/A2AHttpResponse.java | 2 +- .../io/a2a/client/http}/JdkA2AHttpClient.java | 2 +- client-transport/grpc/pom.xml | 53 +++++++++++++++++++ .../transport/grpc}/EventStreamObserver.java | 2 +- .../client/transport/grpc}/GrpcTransport.java | 6 +-- client-transport/jsonrpc/pom.xml | 53 +++++++++++++++++++ .../transport/jsonrpc}/JSONRPCTransport.java | 10 ++-- .../jsonrpc}/sse/SSEEventListener.java | 2 +- .../jsonrpc/sse}/JsonStreamingMessages.java | 2 +- .../jsonrpc}/sse/SSEEventListenerTest.java | 3 +- {transport => client-transport}/spi/pom.xml | 7 ++- .../a2a/client/transport/spi}/Transport.java | 2 +- client/pom.xml | 7 ++- client/src/main/java/io/a2a/A2A.java | 4 +- .../java/io/a2a/client/A2ACardResolver.java | 4 +- .../io/a2a/client/A2ACardResolverTest.java | 4 +- examples/helloworld/server/pom.xml | 4 ++ pom.xml | 5 +- .../grpc/quarkus/QuarkusGrpcHandler.java | 4 +- .../server/grpc/quarkus/A2ATestResource.java | 2 +- .../server/apps/quarkus/A2AServerRoutes.java | 2 +- server-common/pom.xml | 2 +- .../tasks/BasePushNotificationSender.java | 4 +- .../AbstractA2ARequestHandlerTest.java | 4 +- .../server/apps/common/TestHttpClient.java | 4 +- .../grpc/handler}/CallContextFactory.java | 4 +- .../{server => }/handler/GrpcHandler.java | 3 +- .../grpc/handler/GrpcHandlerTest.java | 4 +- .../{server => }/handler/JSONRPCHandler.java | 2 +- .../handler/JSONRPCHandlerTest.java | 4 +- 32 files changed, 198 insertions(+), 48 deletions(-) create mode 100644 client-http/pom.xml rename {transport/jsonrpc/src/main/java/io/a2a/transport/jsonrpc/client => client-http/src/main/java/io/a2a/client/http}/A2AHttpClient.java (96%) rename {transport/jsonrpc/src/main/java/io/a2a/transport/jsonrpc/client => client-http/src/main/java/io/a2a/client/http}/A2AHttpResponse.java (70%) rename {transport/jsonrpc/src/main/java/io/a2a/transport/jsonrpc/client => client-http/src/main/java/io/a2a/client/http}/JdkA2AHttpClient.java (99%) create mode 100644 client-transport/grpc/pom.xml rename {transport/grpc/src/main/java/io/a2a/transport/grpc/client => client-transport/grpc/src/main/java/io/a2a/client/transport/grpc}/EventStreamObserver.java (97%) rename {transport/grpc/src/main/java/io/a2a/transport/grpc/client => client-transport/grpc/src/main/java/io/a2a/client/transport/grpc}/GrpcTransport.java (98%) create mode 100644 client-transport/jsonrpc/pom.xml rename {transport/jsonrpc/src/main/java/io/a2a/transport/jsonrpc/client => client-transport/jsonrpc/src/main/java/io/a2a/client/transport/jsonrpc}/JSONRPCTransport.java (98%) rename {transport/jsonrpc/src/main/java/io/a2a/transport/jsonrpc/client => client-transport/jsonrpc/src/main/java/io/a2a/client/transport/jsonrpc}/sse/SSEEventListener.java (98%) rename {transport/jsonrpc/src/test/java/io/a2a/transport/jsonrpc/client => client-transport/jsonrpc/src/test/java/io/a2a/client/transport/jsonrpc/sse}/JsonStreamingMessages.java (99%) rename {transport/jsonrpc/src/test/java/io/a2a/transport/jsonrpc/client => client-transport/jsonrpc/src/test/java/io/a2a/client/transport/jsonrpc}/sse/SSEEventListenerTest.java (98%) rename {transport => client-transport}/spi/pom.xml (78%) rename {transport/spi/src/main/java/io/a2a/transport/spi/client => client-transport/spi/src/main/java/io/a2a/client/transport/spi}/Transport.java (99%) rename {server-common/src/main/java/io/a2a/server/requesthandlers => transport/grpc/src/main/java/io/a2a/transport/grpc/handler}/CallContextFactory.java (81%) rename transport/grpc/src/main/java/io/a2a/transport/grpc/{server => }/handler/GrpcHandler.java (99%) rename transport/grpc/src/test/java/io/a2a/{ => transport}/grpc/handler/GrpcHandlerTest.java (99%) rename transport/jsonrpc/src/main/java/io/a2a/transport/jsonrpc/{server => }/handler/JSONRPCHandler.java (99%) rename transport/jsonrpc/src/test/java/io/a2a/transport/jsonrpc/{server => }/handler/JSONRPCHandlerTest.java (99%) diff --git a/client-http/pom.xml b/client-http/pom.xml new file mode 100644 index 000000000..ceff12762 --- /dev/null +++ b/client-http/pom.xml @@ -0,0 +1,33 @@ + + + 4.0.0 + + + io.github.a2asdk + a2a-java-sdk-parent + 0.2.6.Beta1-SNAPSHOT + + a2a-java-sdk-client-http + + jar + + Java SDK A2A HTTP Client + Java SDK for the Agent2Agent Protocol (A2A) - HTTP Client + + + + org.junit.jupiter + junit-jupiter-api + test + + + + org.mock-server + mockserver-netty + test + + + + \ No newline at end of file diff --git a/transport/jsonrpc/src/main/java/io/a2a/transport/jsonrpc/client/A2AHttpClient.java b/client-http/src/main/java/io/a2a/client/http/A2AHttpClient.java similarity index 96% rename from transport/jsonrpc/src/main/java/io/a2a/transport/jsonrpc/client/A2AHttpClient.java rename to client-http/src/main/java/io/a2a/client/http/A2AHttpClient.java index 3ebd6e8b7..f59e079f2 100644 --- a/transport/jsonrpc/src/main/java/io/a2a/transport/jsonrpc/client/A2AHttpClient.java +++ b/client-http/src/main/java/io/a2a/client/http/A2AHttpClient.java @@ -1,4 +1,4 @@ -package io.a2a.transport.jsonrpc.client; +package io.a2a.client.http; import java.io.IOException; import java.util.concurrent.CompletableFuture; diff --git a/transport/jsonrpc/src/main/java/io/a2a/transport/jsonrpc/client/A2AHttpResponse.java b/client-http/src/main/java/io/a2a/client/http/A2AHttpResponse.java similarity index 70% rename from transport/jsonrpc/src/main/java/io/a2a/transport/jsonrpc/client/A2AHttpResponse.java rename to client-http/src/main/java/io/a2a/client/http/A2AHttpResponse.java index 0e7074b8b..171fceebd 100644 --- a/transport/jsonrpc/src/main/java/io/a2a/transport/jsonrpc/client/A2AHttpResponse.java +++ b/client-http/src/main/java/io/a2a/client/http/A2AHttpResponse.java @@ -1,4 +1,4 @@ -package io.a2a.transport.jsonrpc.client; +package io.a2a.client.http; public interface A2AHttpResponse { int status(); diff --git a/transport/jsonrpc/src/main/java/io/a2a/transport/jsonrpc/client/JdkA2AHttpClient.java b/client-http/src/main/java/io/a2a/client/http/JdkA2AHttpClient.java similarity index 99% rename from transport/jsonrpc/src/main/java/io/a2a/transport/jsonrpc/client/JdkA2AHttpClient.java rename to client-http/src/main/java/io/a2a/client/http/JdkA2AHttpClient.java index e61d7fab3..2cdbb2d37 100644 --- a/transport/jsonrpc/src/main/java/io/a2a/transport/jsonrpc/client/JdkA2AHttpClient.java +++ b/client-http/src/main/java/io/a2a/client/http/JdkA2AHttpClient.java @@ -1,4 +1,4 @@ -package io.a2a.transport.jsonrpc.client; +package io.a2a.client.http; import java.io.IOException; import java.net.URI; diff --git a/client-transport/grpc/pom.xml b/client-transport/grpc/pom.xml new file mode 100644 index 000000000..87e873a4d --- /dev/null +++ b/client-transport/grpc/pom.xml @@ -0,0 +1,53 @@ + + + 4.0.0 + + + io.github.a2asdk + a2a-java-sdk-parent + 0.2.6.Beta1-SNAPSHOT + ../../pom.xml + + a2a-java-sdk-client-transport-grpc + jar + + Java SDK A2A Client Transport: gRPC + Java SDK for the Agent2Agent Protocol (A2A) - gRPC Client Transport + + + + ${project.groupId} + a2a-java-sdk-common + ${project.version} + + + ${project.groupId} + a2a-java-sdk-spec + ${project.version} + + + ${project.groupId} + a2a-java-sdk-spec-grpc + ${project.version} + + + ${project.groupId} + a2a-java-sdk-client-transport-spi + ${project.version} + + + org.junit.jupiter + junit-jupiter-api + test + + + + org.mock-server + mockserver-netty + test + + + + \ No newline at end of file diff --git a/transport/grpc/src/main/java/io/a2a/transport/grpc/client/EventStreamObserver.java b/client-transport/grpc/src/main/java/io/a2a/client/transport/grpc/EventStreamObserver.java similarity index 97% rename from transport/grpc/src/main/java/io/a2a/transport/grpc/client/EventStreamObserver.java rename to client-transport/grpc/src/main/java/io/a2a/client/transport/grpc/EventStreamObserver.java index ae6d705fc..48bfa6e76 100644 --- a/transport/grpc/src/main/java/io/a2a/transport/grpc/client/EventStreamObserver.java +++ b/client-transport/grpc/src/main/java/io/a2a/client/transport/grpc/EventStreamObserver.java @@ -1,4 +1,4 @@ -package io.a2a.transport.grpc.client; +package io.a2a.client.transport.grpc; import io.a2a.grpc.StreamResponse; diff --git a/transport/grpc/src/main/java/io/a2a/transport/grpc/client/GrpcTransport.java b/client-transport/grpc/src/main/java/io/a2a/client/transport/grpc/GrpcTransport.java similarity index 98% rename from transport/grpc/src/main/java/io/a2a/transport/grpc/client/GrpcTransport.java rename to client-transport/grpc/src/main/java/io/a2a/client/transport/grpc/GrpcTransport.java index 2975948c5..f008a8b10 100644 --- a/transport/grpc/src/main/java/io/a2a/transport/grpc/client/GrpcTransport.java +++ b/client-transport/grpc/src/main/java/io/a2a/client/transport/grpc/GrpcTransport.java @@ -1,4 +1,4 @@ -package io.a2a.transport.grpc.client; +package io.a2a.client.transport.grpc; import io.a2a.grpc.*; import io.a2a.grpc.SendMessageRequest; @@ -6,9 +6,10 @@ import io.a2a.grpc.utils.ProtoUtils; import io.a2a.spec.*; import io.a2a.spec.AgentCard; +import io.a2a.spec.PushNotificationConfig; import io.a2a.spec.Task; import io.a2a.spec.TaskPushNotificationConfig; -import io.a2a.transport.spi.client.Transport; +import io.a2a.client.transport.spi.Transport; import io.grpc.Channel; import io.grpc.StatusRuntimeException; import io.grpc.stub.StreamObserver; @@ -98,7 +99,6 @@ public TaskPushNotificationConfig getTaskPushNotificationConfig(String requestId } } - @Override public TaskPushNotificationConfig setTaskPushNotificationConfig(String requestId, String taskId, TaskPushNotificationConfig taskPushNotificationConfig) throws A2AServerException { String configId = taskPushNotificationConfig.pushNotificationConfig().id(); CreateTaskPushNotificationConfigRequest request = CreateTaskPushNotificationConfigRequest.newBuilder() diff --git a/client-transport/jsonrpc/pom.xml b/client-transport/jsonrpc/pom.xml new file mode 100644 index 000000000..7afc0c769 --- /dev/null +++ b/client-transport/jsonrpc/pom.xml @@ -0,0 +1,53 @@ + + + 4.0.0 + + + io.github.a2asdk + a2a-java-sdk-parent + 0.2.6.Beta1-SNAPSHOT + ../../pom.xml + + a2a-java-sdk-client-transport-jsonrpc + jar + + Java SDK A2A Client Transport: JSONRPC + Java SDK for the Agent2Agent Protocol (A2A) - JSONRPC Client Transport + + + + ${project.groupId} + a2a-java-sdk-client-http + ${project.version} + + + ${project.groupId} + a2a-java-sdk-client-transport-spi + ${project.version} + + + ${project.groupId} + a2a-java-sdk-common + ${project.version} + + + ${project.groupId} + a2a-java-sdk-spec + ${project.version} + + + org.junit.jupiter + junit-jupiter-api + test + + + + org.mock-server + mockserver-netty + test + + + + \ No newline at end of file diff --git a/transport/jsonrpc/src/main/java/io/a2a/transport/jsonrpc/client/JSONRPCTransport.java b/client-transport/jsonrpc/src/main/java/io/a2a/client/transport/jsonrpc/JSONRPCTransport.java similarity index 98% rename from transport/jsonrpc/src/main/java/io/a2a/transport/jsonrpc/client/JSONRPCTransport.java rename to client-transport/jsonrpc/src/main/java/io/a2a/client/transport/jsonrpc/JSONRPCTransport.java index dce6ab668..20986993a 100644 --- a/transport/jsonrpc/src/main/java/io/a2a/transport/jsonrpc/client/JSONRPCTransport.java +++ b/client-transport/jsonrpc/src/main/java/io/a2a/client/transport/jsonrpc/JSONRPCTransport.java @@ -1,7 +1,13 @@ -package io.a2a.transport.jsonrpc.client; +package io.a2a.client.transport.jsonrpc; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.core.type.TypeReference; + +import io.a2a.client.http.A2AHttpClient; +import io.a2a.client.http.A2AHttpResponse; +import io.a2a.client.http.JdkA2AHttpClient; +import io.a2a.client.transport.jsonrpc.sse.SSEEventListener; +import io.a2a.client.transport.spi.Transport; import io.a2a.spec.A2AServerException; import io.a2a.spec.CancelTaskRequest; import io.a2a.spec.CancelTaskResponse; @@ -33,8 +39,6 @@ import io.a2a.spec.TaskPushNotificationConfig; import io.a2a.spec.TaskQueryParams; import io.a2a.spec.TaskResubscriptionRequest; -import io.a2a.transport.jsonrpc.client.sse.SSEEventListener; -import io.a2a.transport.spi.client.Transport; import io.a2a.util.Utils; import java.io.IOException; diff --git a/transport/jsonrpc/src/main/java/io/a2a/transport/jsonrpc/client/sse/SSEEventListener.java b/client-transport/jsonrpc/src/main/java/io/a2a/client/transport/jsonrpc/sse/SSEEventListener.java similarity index 98% rename from transport/jsonrpc/src/main/java/io/a2a/transport/jsonrpc/client/sse/SSEEventListener.java rename to client-transport/jsonrpc/src/main/java/io/a2a/client/transport/jsonrpc/sse/SSEEventListener.java index 7f4ff19fc..0312e9873 100644 --- a/transport/jsonrpc/src/main/java/io/a2a/transport/jsonrpc/client/sse/SSEEventListener.java +++ b/client-transport/jsonrpc/src/main/java/io/a2a/client/transport/jsonrpc/sse/SSEEventListener.java @@ -1,4 +1,4 @@ -package io.a2a.transport.jsonrpc.client.sse; +package io.a2a.client.transport.jsonrpc.sse; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.JsonNode; diff --git a/transport/jsonrpc/src/test/java/io/a2a/transport/jsonrpc/client/JsonStreamingMessages.java b/client-transport/jsonrpc/src/test/java/io/a2a/client/transport/jsonrpc/sse/JsonStreamingMessages.java similarity index 99% rename from transport/jsonrpc/src/test/java/io/a2a/transport/jsonrpc/client/JsonStreamingMessages.java rename to client-transport/jsonrpc/src/test/java/io/a2a/client/transport/jsonrpc/sse/JsonStreamingMessages.java index 7bf46054d..4b79a57cb 100644 --- a/transport/jsonrpc/src/test/java/io/a2a/transport/jsonrpc/client/JsonStreamingMessages.java +++ b/client-transport/jsonrpc/src/test/java/io/a2a/client/transport/jsonrpc/sse/JsonStreamingMessages.java @@ -1,4 +1,4 @@ -package io.a2a.transport.jsonrpc.client; +package io.a2a.client.transport.jsonrpc.sse; /** * Contains JSON strings for testing SSE streaming. diff --git a/transport/jsonrpc/src/test/java/io/a2a/transport/jsonrpc/client/sse/SSEEventListenerTest.java b/client-transport/jsonrpc/src/test/java/io/a2a/client/transport/jsonrpc/sse/SSEEventListenerTest.java similarity index 98% rename from transport/jsonrpc/src/test/java/io/a2a/transport/jsonrpc/client/sse/SSEEventListenerTest.java rename to client-transport/jsonrpc/src/test/java/io/a2a/client/transport/jsonrpc/sse/SSEEventListenerTest.java index 5e4fc357c..021b810dd 100644 --- a/transport/jsonrpc/src/test/java/io/a2a/transport/jsonrpc/client/sse/SSEEventListenerTest.java +++ b/client-transport/jsonrpc/src/test/java/io/a2a/client/transport/jsonrpc/sse/SSEEventListenerTest.java @@ -1,4 +1,4 @@ -package io.a2a.transport.jsonrpc.client.sse; +package io.a2a.client.transport.jsonrpc.sse; import io.a2a.spec.Artifact; import io.a2a.spec.JSONRPCError; @@ -11,7 +11,6 @@ import io.a2a.spec.TaskStatus; import io.a2a.spec.TaskStatusUpdateEvent; import io.a2a.spec.TextPart; -import io.a2a.transport.jsonrpc.client.JsonStreamingMessages; import org.junit.jupiter.api.Test; import java.util.concurrent.ExecutionException; diff --git a/transport/spi/pom.xml b/client-transport/spi/pom.xml similarity index 78% rename from transport/spi/pom.xml rename to client-transport/spi/pom.xml index 1e284687f..76a3002f8 100644 --- a/transport/spi/pom.xml +++ b/client-transport/spi/pom.xml @@ -10,12 +10,11 @@ 0.2.6.Beta1-SNAPSHOT ../../pom.xml - a2a-java-sdk-transport-spi - + a2a-java-sdk-client-transport-spi jar - Java SDK A2A Transport: SPI - Java SDK for the Agent2Agent Protocol (A2A) - SPI + Java SDK A2A Client Transport: SPI + Java SDK for the Agent2Agent Protocol (A2A) - Client Transport SPI diff --git a/transport/spi/src/main/java/io/a2a/transport/spi/client/Transport.java b/client-transport/spi/src/main/java/io/a2a/client/transport/spi/Transport.java similarity index 99% rename from transport/spi/src/main/java/io/a2a/transport/spi/client/Transport.java rename to client-transport/spi/src/main/java/io/a2a/client/transport/spi/Transport.java index 9ab643b56..ce473a321 100644 --- a/transport/spi/src/main/java/io/a2a/transport/spi/client/Transport.java +++ b/client-transport/spi/src/main/java/io/a2a/client/transport/spi/Transport.java @@ -1,4 +1,4 @@ -package io.a2a.transport.spi.client; +package io.a2a.client.transport.spi; import io.a2a.spec.*; diff --git a/client/pom.xml b/client/pom.xml index 9f8b37779..b3a09f64f 100644 --- a/client/pom.xml +++ b/client/pom.xml @@ -17,6 +17,11 @@ Java SDK for the Agent2Agent Protocol (A2A) - Client + + ${project.groupId} + a2a-java-sdk-client-http + ${project.version} + ${project.groupId} a2a-java-sdk-common @@ -29,7 +34,7 @@ ${project.groupId} - a2a-java-sdk-transport-jsonrpc + a2a-java-sdk-client-transport-jsonrpc ${project.version} diff --git a/client/src/main/java/io/a2a/A2A.java b/client/src/main/java/io/a2a/A2A.java index 5fb536df8..46b9c11ad 100644 --- a/client/src/main/java/io/a2a/A2A.java +++ b/client/src/main/java/io/a2a/A2A.java @@ -4,8 +4,8 @@ import java.util.Map; import io.a2a.client.A2ACardResolver; -import io.a2a.transport.jsonrpc.client.A2AHttpClient; -import io.a2a.transport.jsonrpc.client.JdkA2AHttpClient; +import io.a2a.client.http.A2AHttpClient; +import io.a2a.client.http.JdkA2AHttpClient; import io.a2a.spec.A2AClientError; import io.a2a.spec.A2AClientJSONError; import io.a2a.spec.AgentCard; diff --git a/client/src/main/java/io/a2a/client/A2ACardResolver.java b/client/src/main/java/io/a2a/client/A2ACardResolver.java index c5bb0b7c0..bbbc6caf0 100644 --- a/client/src/main/java/io/a2a/client/A2ACardResolver.java +++ b/client/src/main/java/io/a2a/client/A2ACardResolver.java @@ -9,8 +9,8 @@ import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.core.type.TypeReference; -import io.a2a.transport.jsonrpc.client.A2AHttpClient; -import io.a2a.transport.jsonrpc.client.A2AHttpResponse; +import io.a2a.client.http.A2AHttpClient; +import io.a2a.client.http.A2AHttpResponse; import io.a2a.spec.A2AClientError; import io.a2a.spec.A2AClientJSONError; import io.a2a.spec.AgentCard; diff --git a/client/src/test/java/io/a2a/client/A2ACardResolverTest.java b/client/src/test/java/io/a2a/client/A2ACardResolverTest.java index d85e341f4..aef8cca9c 100644 --- a/client/src/test/java/io/a2a/client/A2ACardResolverTest.java +++ b/client/src/test/java/io/a2a/client/A2ACardResolverTest.java @@ -11,8 +11,8 @@ import java.util.function.Consumer; import com.fasterxml.jackson.core.type.TypeReference; -import io.a2a.transport.jsonrpc.client.A2AHttpClient; -import io.a2a.transport.jsonrpc.client.A2AHttpResponse; +import io.a2a.client.http.A2AHttpClient; +import io.a2a.client.http.A2AHttpResponse; import io.a2a.spec.A2AClientError; import io.a2a.spec.A2AClientJSONError; import io.a2a.spec.AgentCard; diff --git a/examples/helloworld/server/pom.xml b/examples/helloworld/server/pom.xml index 2b0b15652..e23537cee 100644 --- a/examples/helloworld/server/pom.xml +++ b/examples/helloworld/server/pom.xml @@ -34,6 +34,10 @@ jakarta.ws.rs jakarta.ws.rs-api + + io.github.a2asdk + a2a-java-sdk-client + diff --git a/pom.xml b/pom.xml index d454e284a..0e1e4542d 100644 --- a/pom.xml +++ b/pom.xml @@ -280,6 +280,10 @@ client + client-http + client-transport/grpc + client-transport/jsonrpc + client-transport/spi common examples/helloworld reference/common @@ -290,7 +294,6 @@ spec-grpc tck tests/server-common - transport/spi transport/jsonrpc transport/grpc diff --git a/reference/grpc/src/main/java/io/a2a/server/grpc/quarkus/QuarkusGrpcHandler.java b/reference/grpc/src/main/java/io/a2a/server/grpc/quarkus/QuarkusGrpcHandler.java index 816ed6f79..5b30d1b15 100644 --- a/reference/grpc/src/main/java/io/a2a/server/grpc/quarkus/QuarkusGrpcHandler.java +++ b/reference/grpc/src/main/java/io/a2a/server/grpc/quarkus/QuarkusGrpcHandler.java @@ -3,9 +3,9 @@ import jakarta.enterprise.inject.Instance; import jakarta.inject.Inject; -import io.a2a.transport.grpc.server.handler.GrpcHandler; +import io.a2a.transport.grpc.handler.CallContextFactory; +import io.a2a.transport.grpc.handler.GrpcHandler; import io.a2a.server.PublicAgentCard; -import io.a2a.server.requesthandlers.CallContextFactory; import io.a2a.server.requesthandlers.RequestHandler; import io.a2a.spec.AgentCard; import io.quarkus.grpc.GrpcService; diff --git a/reference/grpc/src/test/java/io/a2a/server/grpc/quarkus/A2ATestResource.java b/reference/grpc/src/test/java/io/a2a/server/grpc/quarkus/A2ATestResource.java index 9fb0074ac..bf9acfb1e 100644 --- a/reference/grpc/src/test/java/io/a2a/server/grpc/quarkus/A2ATestResource.java +++ b/reference/grpc/src/test/java/io/a2a/server/grpc/quarkus/A2ATestResource.java @@ -19,11 +19,11 @@ import jakarta.ws.rs.core.Response; import io.a2a.server.apps.common.TestUtilsBean; -import io.a2a.transport.grpc.server.handler.GrpcHandler; import io.a2a.spec.PushNotificationConfig; import io.a2a.spec.Task; import io.a2a.spec.TaskArtifactUpdateEvent; import io.a2a.spec.TaskStatusUpdateEvent; +import io.a2a.transport.grpc.handler.GrpcHandler; import io.a2a.util.Utils; @Path("/test") diff --git a/reference/jsonrpc/src/main/java/io/a2a/server/apps/quarkus/A2AServerRoutes.java b/reference/jsonrpc/src/main/java/io/a2a/server/apps/quarkus/A2AServerRoutes.java index 4e8cbcbb9..900ce15ca 100644 --- a/reference/jsonrpc/src/main/java/io/a2a/server/apps/quarkus/A2AServerRoutes.java +++ b/reference/jsonrpc/src/main/java/io/a2a/server/apps/quarkus/A2AServerRoutes.java @@ -20,7 +20,7 @@ import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.core.io.JsonEOFException; import com.fasterxml.jackson.databind.JsonNode; -import io.a2a.transport.jsonrpc.server.handler.JSONRPCHandler; +import io.a2a.transport.jsonrpc.handler.JSONRPCHandler; import io.a2a.server.ExtendedAgentCard; import io.a2a.server.ServerCallContext; import io.a2a.server.auth.UnauthenticatedUser; diff --git a/server-common/pom.xml b/server-common/pom.xml index 5677d4d02..29e7f6b76 100644 --- a/server-common/pom.xml +++ b/server-common/pom.xml @@ -24,7 +24,7 @@ ${project.groupId} - a2a-java-sdk-client + a2a-java-sdk-client-http ${project.version} diff --git a/server-common/src/main/java/io/a2a/server/tasks/BasePushNotificationSender.java b/server-common/src/main/java/io/a2a/server/tasks/BasePushNotificationSender.java index ac04216f3..aec3f3cc3 100644 --- a/server-common/src/main/java/io/a2a/server/tasks/BasePushNotificationSender.java +++ b/server-common/src/main/java/io/a2a/server/tasks/BasePushNotificationSender.java @@ -10,8 +10,8 @@ import com.fasterxml.jackson.core.JsonProcessingException; -import io.a2a.transport.jsonrpc.client.A2AHttpClient; -import io.a2a.transport.jsonrpc.client.JdkA2AHttpClient; +import io.a2a.client.http.A2AHttpClient; +import io.a2a.client.http.JdkA2AHttpClient; import io.a2a.spec.PushNotificationConfig; import io.a2a.spec.Task; import io.a2a.util.Utils; diff --git a/server-common/src/test/java/io/a2a/server/requesthandlers/AbstractA2ARequestHandlerTest.java b/server-common/src/test/java/io/a2a/server/requesthandlers/AbstractA2ARequestHandlerTest.java index 7c62562f8..1e3a0af5f 100644 --- a/server-common/src/test/java/io/a2a/server/requesthandlers/AbstractA2ARequestHandlerTest.java +++ b/server-common/src/test/java/io/a2a/server/requesthandlers/AbstractA2ARequestHandlerTest.java @@ -12,8 +12,8 @@ import java.util.concurrent.Executors; import java.util.function.Consumer; -import io.a2a.transport.jsonrpc.client.A2AHttpClient; -import io.a2a.transport.jsonrpc.client.A2AHttpResponse; +import io.a2a.client.http.A2AHttpClient; +import io.a2a.client.http.A2AHttpResponse; import io.a2a.server.agentexecution.AgentExecutor; import io.a2a.server.agentexecution.RequestContext; import io.a2a.server.events.EventQueue; diff --git a/tests/server-common/src/test/java/io/a2a/server/apps/common/TestHttpClient.java b/tests/server-common/src/test/java/io/a2a/server/apps/common/TestHttpClient.java index 046e0f8bb..87d2e536b 100644 --- a/tests/server-common/src/test/java/io/a2a/server/apps/common/TestHttpClient.java +++ b/tests/server-common/src/test/java/io/a2a/server/apps/common/TestHttpClient.java @@ -11,8 +11,8 @@ import jakarta.enterprise.context.Dependent; import jakarta.enterprise.inject.Alternative; -import io.a2a.transport.jsonrpc.client.A2AHttpClient; -import io.a2a.transport.jsonrpc.client.A2AHttpResponse; +import io.a2a.client.http.A2AHttpClient; +import io.a2a.client.http.A2AHttpResponse; import io.a2a.spec.Task; import io.a2a.util.Utils; diff --git a/server-common/src/main/java/io/a2a/server/requesthandlers/CallContextFactory.java b/transport/grpc/src/main/java/io/a2a/transport/grpc/handler/CallContextFactory.java similarity index 81% rename from server-common/src/main/java/io/a2a/server/requesthandlers/CallContextFactory.java rename to transport/grpc/src/main/java/io/a2a/transport/grpc/handler/CallContextFactory.java index ef173ba0d..f214a51e5 100644 --- a/server-common/src/main/java/io/a2a/server/requesthandlers/CallContextFactory.java +++ b/transport/grpc/src/main/java/io/a2a/transport/grpc/handler/CallContextFactory.java @@ -1,8 +1,8 @@ -package io.a2a.server.requesthandlers; +package io.a2a.transport.grpc.handler; import io.a2a.server.ServerCallContext; import io.grpc.stub.StreamObserver; public interface CallContextFactory { ServerCallContext create(StreamObserver responseObserver); -} +} \ No newline at end of file diff --git a/transport/grpc/src/main/java/io/a2a/transport/grpc/server/handler/GrpcHandler.java b/transport/grpc/src/main/java/io/a2a/transport/grpc/handler/GrpcHandler.java similarity index 99% rename from transport/grpc/src/main/java/io/a2a/transport/grpc/server/handler/GrpcHandler.java rename to transport/grpc/src/main/java/io/a2a/transport/grpc/handler/GrpcHandler.java index 4ecdbc9ab..b32ccc23f 100644 --- a/transport/grpc/src/main/java/io/a2a/transport/grpc/server/handler/GrpcHandler.java +++ b/transport/grpc/src/main/java/io/a2a/transport/grpc/handler/GrpcHandler.java @@ -1,4 +1,4 @@ -package io.a2a.transport.grpc.server.handler; +package io.a2a.transport.grpc.handler; import static io.a2a.grpc.utils.ProtoUtils.FromProto; import static io.a2a.grpc.utils.ProtoUtils.ToProto; @@ -17,7 +17,6 @@ import io.a2a.server.ServerCallContext; import io.a2a.server.auth.UnauthenticatedUser; import io.a2a.server.auth.User; -import io.a2a.server.requesthandlers.CallContextFactory; import io.a2a.server.requesthandlers.RequestHandler; import io.a2a.spec.AgentCard; import io.a2a.spec.ContentTypeNotSupportedError; diff --git a/transport/grpc/src/test/java/io/a2a/grpc/handler/GrpcHandlerTest.java b/transport/grpc/src/test/java/io/a2a/transport/grpc/handler/GrpcHandlerTest.java similarity index 99% rename from transport/grpc/src/test/java/io/a2a/grpc/handler/GrpcHandlerTest.java rename to transport/grpc/src/test/java/io/a2a/transport/grpc/handler/GrpcHandlerTest.java index 76cdb6f6e..c49f91d84 100644 --- a/transport/grpc/src/test/java/io/a2a/grpc/handler/GrpcHandlerTest.java +++ b/transport/grpc/src/test/java/io/a2a/transport/grpc/handler/GrpcHandlerTest.java @@ -1,4 +1,4 @@ -package io.a2a.grpc.handler; +package io.a2a.transport.grpc.handler; import static org.junit.jupiter.api.Assertions.assertEquals; @@ -32,7 +32,6 @@ import io.a2a.server.ServerCallContext; import io.a2a.server.events.EventConsumer; import io.a2a.server.requesthandlers.AbstractA2ARequestHandlerTest; -import io.a2a.server.requesthandlers.CallContextFactory; import io.a2a.server.requesthandlers.DefaultRequestHandler; import io.a2a.server.requesthandlers.RequestHandler; import io.a2a.server.tasks.TaskUpdater; @@ -45,7 +44,6 @@ import io.a2a.spec.TaskStatusUpdateEvent; import io.a2a.spec.TextPart; import io.a2a.spec.UnsupportedOperationError; -import io.a2a.transport.grpc.server.handler.GrpcHandler; import io.grpc.Status; import io.grpc.StatusRuntimeException; import io.grpc.internal.testing.StreamRecorder; diff --git a/transport/jsonrpc/src/main/java/io/a2a/transport/jsonrpc/server/handler/JSONRPCHandler.java b/transport/jsonrpc/src/main/java/io/a2a/transport/jsonrpc/handler/JSONRPCHandler.java similarity index 99% rename from transport/jsonrpc/src/main/java/io/a2a/transport/jsonrpc/server/handler/JSONRPCHandler.java rename to transport/jsonrpc/src/main/java/io/a2a/transport/jsonrpc/handler/JSONRPCHandler.java index 463c2aab3..e75f6f2d3 100644 --- a/transport/jsonrpc/src/main/java/io/a2a/transport/jsonrpc/server/handler/JSONRPCHandler.java +++ b/transport/jsonrpc/src/main/java/io/a2a/transport/jsonrpc/handler/JSONRPCHandler.java @@ -1,4 +1,4 @@ -package io.a2a.transport.jsonrpc.server.handler; +package io.a2a.transport.jsonrpc.handler; import static io.a2a.server.util.async.AsyncUtils.createTubeConfig; import jakarta.enterprise.context.ApplicationScoped; diff --git a/transport/jsonrpc/src/test/java/io/a2a/transport/jsonrpc/server/handler/JSONRPCHandlerTest.java b/transport/jsonrpc/src/test/java/io/a2a/transport/jsonrpc/handler/JSONRPCHandlerTest.java similarity index 99% rename from transport/jsonrpc/src/test/java/io/a2a/transport/jsonrpc/server/handler/JSONRPCHandlerTest.java rename to transport/jsonrpc/src/test/java/io/a2a/transport/jsonrpc/handler/JSONRPCHandlerTest.java index 931f4fdc0..728a26d9d 100644 --- a/transport/jsonrpc/src/test/java/io/a2a/transport/jsonrpc/server/handler/JSONRPCHandlerTest.java +++ b/transport/jsonrpc/src/test/java/io/a2a/transport/jsonrpc/handler/JSONRPCHandlerTest.java @@ -1,4 +1,4 @@ -package io.a2a.transport.jsonrpc.server.handler; +package io.a2a.transport.jsonrpc.handler; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertInstanceOf; @@ -65,7 +65,7 @@ import io.a2a.spec.TaskStatusUpdateEvent; import io.a2a.spec.TextPart; import io.a2a.spec.UnsupportedOperationError; -import io.a2a.transport.jsonrpc.server.handler.JSONRPCHandler; +import io.a2a.transport.jsonrpc.handler.JSONRPCHandler; import mutiny.zero.ZeroPublisher; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Disabled; From e7078878b75d31e90b8170759f7035200b666dbe Mon Sep 17 00:00:00 2001 From: Farah Juma Date: Thu, 14 Aug 2025 13:40:57 -0400 Subject: [PATCH 04/31] fix: Temporarily comment out grpc transport code to get the build to succeed --- .../io/a2a/client/transport/grpc/GrpcTransport.java | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/client-transport/grpc/src/main/java/io/a2a/client/transport/grpc/GrpcTransport.java b/client-transport/grpc/src/main/java/io/a2a/client/transport/grpc/GrpcTransport.java index f008a8b10..d272262e3 100644 --- a/client-transport/grpc/src/main/java/io/a2a/client/transport/grpc/GrpcTransport.java +++ b/client-transport/grpc/src/main/java/io/a2a/client/transport/grpc/GrpcTransport.java @@ -1,3 +1,4 @@ +/* package io.a2a.client.transport.grpc; import io.a2a.grpc.*; @@ -19,22 +20,26 @@ import static io.a2a.util.Assert.checkNotNullParam; +*/ /** * @author David BRASSELY (david.brassely at graviteesource.com) * @author GraviteeSource Team - */ + *//* + public class GrpcTransport implements Transport { private A2AServiceGrpc.A2AServiceBlockingV2Stub blockingStub; private A2AServiceGrpc.A2AServiceStub asyncStub; private AgentCard agentCard; - /** + */ +/** * Create an A2A client for interacting with an A2A agent via gRPC. * * @param channel the gRPC channel * @param agentCard the agent card for the A2A server this client will be communicating with - */ + *//* + public GrpcTransport(Channel channel, AgentCard agentCard) { checkNotNullParam("channel", channel); checkNotNullParam("agentCard", agentCard); @@ -162,3 +167,4 @@ private String getTaskPushNotificationConfigName(GetTaskPushNotificationConfigPa return name.toString(); } } +*/ From 5d2475a69e817e6397c375af8824f4516ca185b1 Mon Sep 17 00:00:00 2001 From: Farah Juma Date: Thu, 14 Aug 2025 13:54:52 -0400 Subject: [PATCH 05/31] fix: Rename Transport to ClientTransport --- .../io/a2a/client/transport/jsonrpc/JSONRPCTransport.java | 4 ++-- .../transport/spi/{Transport.java => ClientTransport.java} | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) rename client-transport/spi/src/main/java/io/a2a/client/transport/spi/{Transport.java => ClientTransport.java} (99%) diff --git a/client-transport/jsonrpc/src/main/java/io/a2a/client/transport/jsonrpc/JSONRPCTransport.java b/client-transport/jsonrpc/src/main/java/io/a2a/client/transport/jsonrpc/JSONRPCTransport.java index 20986993a..e9c270b02 100644 --- a/client-transport/jsonrpc/src/main/java/io/a2a/client/transport/jsonrpc/JSONRPCTransport.java +++ b/client-transport/jsonrpc/src/main/java/io/a2a/client/transport/jsonrpc/JSONRPCTransport.java @@ -7,7 +7,7 @@ import io.a2a.client.http.A2AHttpResponse; import io.a2a.client.http.JdkA2AHttpClient; import io.a2a.client.transport.jsonrpc.sse.SSEEventListener; -import io.a2a.client.transport.spi.Transport; +import io.a2a.client.transport.spi.ClientTransport; import io.a2a.spec.A2AServerException; import io.a2a.spec.CancelTaskRequest; import io.a2a.spec.CancelTaskResponse; @@ -47,7 +47,7 @@ import java.util.concurrent.atomic.AtomicReference; import java.util.function.Consumer; -public class JSONRPCTransport implements Transport { +public class JSONRPCTransport implements ClientTransport { private static final TypeReference SEND_MESSAGE_RESPONSE_REFERENCE = new TypeReference<>() {}; private static final TypeReference GET_TASK_RESPONSE_REFERENCE = new TypeReference<>() {}; diff --git a/client-transport/spi/src/main/java/io/a2a/client/transport/spi/Transport.java b/client-transport/spi/src/main/java/io/a2a/client/transport/spi/ClientTransport.java similarity index 99% rename from client-transport/spi/src/main/java/io/a2a/client/transport/spi/Transport.java rename to client-transport/spi/src/main/java/io/a2a/client/transport/spi/ClientTransport.java index ce473a321..39512fced 100644 --- a/client-transport/spi/src/main/java/io/a2a/client/transport/spi/Transport.java +++ b/client-transport/spi/src/main/java/io/a2a/client/transport/spi/ClientTransport.java @@ -5,7 +5,7 @@ import java.util.List; import java.util.function.Consumer; -public interface Transport { +public interface ClientTransport { /** * Send a message to the remote agent. From e0997fb0d446bf93e21347b41ddf2f45f954c02c Mon Sep 17 00:00:00 2001 From: Farah Juma Date: Fri, 8 Aug 2025 09:51:51 -0400 Subject: [PATCH 06/31] feat: Update the ClientTransport interface, introducing ClientCallContext, ClientConfig, and ClientCallInterceptor similar to the Python SDK. Introduce a ClientTransportProvider and update the JSONRPC and gRPC transport implementations. Introduce a new Client and ClientFactory implementations. --- client-config/pom.xml | 47 + .../a2a/client/config/ClientCallContext.java | 27 + .../client/config/ClientCallInterceptor.java | 26 + .../io/a2a/client/config/ClientConfig.java | 150 +++ .../a2a/client/config/PayloadAndHeaders.java | 22 + client-transport/grpc/pom.xml | 5 + .../transport/grpc/EventStreamObserver.java | 4 +- .../client/transport/grpc/GrpcTransport.java | 244 +++-- .../transport/grpc/GrpcTransportProvider.java | 29 + ...ient.transport.spi.ClientTransportProvider | 1 + client-transport/jsonrpc/pom.xml | 10 + .../transport/jsonrpc/JSONRPCTransport.java | 386 +++++--- .../jsonrpc/JSONRPCTransportProvider.java | 24 + .../jsonrpc/sse/SSEEventListener.java | 15 +- ...ient.transport.spi.ClientTransportProvider | 1 + .../JSONRPCTransportStreamingTest.java | 32 +- .../jsonrpc/JSONRPCTransportTest.java | 245 ++--- .../transport/jsonrpc/JsonMessages.java | 666 ++++++++++++++ .../jsonrpc}/JsonStreamingMessages.java | 4 +- .../jsonrpc/sse/JsonStreamingMessages.java | 148 --- .../jsonrpc/sse/SSEEventListenerTest.java | 65 +- client-transport/spi/pom.xml | 6 +- .../client/transport/spi/ClientTransport.java | 161 ++-- .../spi/ClientTransportProvider.java | 32 + client/pom.xml | 16 +- .../java/io/a2a/client/AbstractClient.java | 185 ++++ .../src/main/java/io/a2a/client/Client.java | 186 ++++ .../main/java/io/a2a/client/ClientEvent.java | 4 + .../java/io/a2a/client/ClientFactory.java | 127 +++ .../java/io/a2a/client/ClientTaskManager.java | 141 +++ .../main/java/io/a2a/client/MessageEvent.java | 26 + .../main/java/io/a2a/client/TaskEvent.java | 27 + .../java/io/a2a/client/TaskUpdateEvent.java | 37 + .../test/java/io/a2a/client/JsonMessages.java | 491 +--------- examples/helloworld/client/pom.xml | 2 +- .../examples/helloworld/HelloWorldClient.java | 9 +- examples/helloworld/pom.xml | 2 +- examples/helloworld/server/pom.xml | 2 +- pom.xml | 1 + server-common/pom.xml | 5 + .../java/io/a2a/server/tasks/TaskManager.java | 56 +- .../java/io/a2a/spec/A2AClientException.java | 23 + .../a2a/spec/A2AClientInvalidArgsError.java | 15 + .../a2a/spec/A2AClientInvalidStateError.java | 15 + .../io/a2a/spec/TaskArtifactUpdateEvent.java | 2 +- .../io/a2a/spec/TaskStatusUpdateEvent.java | 2 +- .../java/io/a2a/spec/TransportProtocol.java | 2 +- .../main/java/io/a2a/spec/UpdateEvent.java | 4 + spec/src/main/java/io/a2a/util/Utils.java | 66 ++ tests/server-common/pom.xml | 6 + .../apps/common/AbstractA2AServerTest.java | 852 +++++++++++------- 51 files changed, 3112 insertions(+), 1542 deletions(-) create mode 100644 client-config/pom.xml create mode 100644 client-config/src/main/java/io/a2a/client/config/ClientCallContext.java create mode 100644 client-config/src/main/java/io/a2a/client/config/ClientCallInterceptor.java create mode 100644 client-config/src/main/java/io/a2a/client/config/ClientConfig.java create mode 100644 client-config/src/main/java/io/a2a/client/config/PayloadAndHeaders.java create mode 100644 client-transport/grpc/src/main/java/io/a2a/client/transport/grpc/GrpcTransportProvider.java create mode 100644 client-transport/grpc/src/main/resources/META-INF/services/io.a2a.client.transport.spi.ClientTransportProvider create mode 100644 client-transport/jsonrpc/src/main/java/io/a2a/client/transport/jsonrpc/JSONRPCTransportProvider.java create mode 100644 client-transport/jsonrpc/src/main/resources/META-INF/services/io.a2a.client.transport.spi.ClientTransportProvider rename client/src/test/java/io/a2a/client/A2AClientStreamingTest.java => client-transport/jsonrpc/src/test/java/io/a2a/client/transport/jsonrpc/JSONRPCTransportStreamingTest.java (84%) rename client/src/test/java/io/a2a/client/A2AClientTest.java => client-transport/jsonrpc/src/test/java/io/a2a/client/transport/jsonrpc/JSONRPCTransportTest.java (76%) create mode 100644 client-transport/jsonrpc/src/test/java/io/a2a/client/transport/jsonrpc/JsonMessages.java rename {client/src/test/java/io/a2a/client => client-transport/jsonrpc/src/test/java/io/a2a/client/transport/jsonrpc}/JsonStreamingMessages.java (98%) delete mode 100644 client-transport/jsonrpc/src/test/java/io/a2a/client/transport/jsonrpc/sse/JsonStreamingMessages.java create mode 100644 client-transport/spi/src/main/java/io/a2a/client/transport/spi/ClientTransportProvider.java create mode 100644 client/src/main/java/io/a2a/client/AbstractClient.java create mode 100644 client/src/main/java/io/a2a/client/Client.java create mode 100644 client/src/main/java/io/a2a/client/ClientEvent.java create mode 100644 client/src/main/java/io/a2a/client/ClientFactory.java create mode 100644 client/src/main/java/io/a2a/client/ClientTaskManager.java create mode 100644 client/src/main/java/io/a2a/client/MessageEvent.java create mode 100644 client/src/main/java/io/a2a/client/TaskEvent.java create mode 100644 client/src/main/java/io/a2a/client/TaskUpdateEvent.java create mode 100644 spec/src/main/java/io/a2a/spec/A2AClientException.java create mode 100644 spec/src/main/java/io/a2a/spec/A2AClientInvalidArgsError.java create mode 100644 spec/src/main/java/io/a2a/spec/A2AClientInvalidStateError.java create mode 100644 spec/src/main/java/io/a2a/spec/UpdateEvent.java diff --git a/client-config/pom.xml b/client-config/pom.xml new file mode 100644 index 000000000..3d85221fb --- /dev/null +++ b/client-config/pom.xml @@ -0,0 +1,47 @@ + + + 4.0.0 + + + io.github.a2asdk + a2a-java-sdk-parent + 0.2.6.Beta1-SNAPSHOT + + a2a-java-sdk-client-config + + jar + + Java SDK A2A Client Configuration + Java SDK for the Agent2Agent Protocol (A2A) - Client Configuration + + + + ${project.groupId} + a2a-java-sdk-client-http + ${project.version} + + + ${project.groupId} + a2a-java-sdk-spec + ${project.version} + + + org.junit.jupiter + junit-jupiter-api + test + + + + org.mock-server + mockserver-netty + test + + + io.grpc + grpc-api + + + + \ No newline at end of file diff --git a/client-config/src/main/java/io/a2a/client/config/ClientCallContext.java b/client-config/src/main/java/io/a2a/client/config/ClientCallContext.java new file mode 100644 index 000000000..0cfff4d65 --- /dev/null +++ b/client-config/src/main/java/io/a2a/client/config/ClientCallContext.java @@ -0,0 +1,27 @@ +package io.a2a.client.config; + +import java.util.Map; + +/** + * A context passed with each client call, allowing for call-specific. + * configuration and data passing. Such as authentication details or + * request deadlines. + */ +public class ClientCallContext { + + private final Map state; + private final Map headers; + + public ClientCallContext(Map state, Map headers) { + this.state = state; + this.headers = headers; + } + + public Map getState() { + return state; + } + + public Map getHeaders() { + return headers; + } +} diff --git a/client-config/src/main/java/io/a2a/client/config/ClientCallInterceptor.java b/client-config/src/main/java/io/a2a/client/config/ClientCallInterceptor.java new file mode 100644 index 000000000..631cd8353 --- /dev/null +++ b/client-config/src/main/java/io/a2a/client/config/ClientCallInterceptor.java @@ -0,0 +1,26 @@ +package io.a2a.client.config; + +import java.util.Map; + +import io.a2a.spec.AgentCard; + +/** + * An abstract base class for client-side call interceptors. + * Interceptors can inspect and modify requests before they are sent, + * which is ideal for concerns like authentication, logging, or tracing. + */ +public abstract class ClientCallInterceptor { + + /** + * Intercept a client call before the request is sent. + * + * @param methodName the name of the protocol method (e.g., 'message/send') + * @param payload the request payload + * @param headers the headers to use + * @param agentCard the agent card (may be {@code null}) + * @param clientCallContext the {@code ClientCallContext} for this call (may be {@code null}) + * @return the potentially modified payload and headers + */ + public abstract PayloadAndHeaders intercept(String methodName, Object payload, Map headers, + AgentCard agentCard, ClientCallContext clientCallContext); +} diff --git a/client-config/src/main/java/io/a2a/client/config/ClientConfig.java b/client-config/src/main/java/io/a2a/client/config/ClientConfig.java new file mode 100644 index 000000000..ef8cc948d --- /dev/null +++ b/client-config/src/main/java/io/a2a/client/config/ClientConfig.java @@ -0,0 +1,150 @@ +package io.a2a.client.config; + +import java.util.List; +import java.util.Map; + +import io.a2a.client.http.A2AHttpClient; +import io.a2a.spec.PushNotificationConfig; +import io.grpc.Channel; + +/** + * Configuration for the A2A client factory. + */ +public class ClientConfig { + + private final Boolean streaming; + private final Boolean polling; + private final A2AHttpClient httpClient; + private final Channel channel; + private final List supportedTransports; + private final Boolean useClientPreference; + private final List acceptedOutputModes; + private final PushNotificationConfig pushNotificationConfig; + private final Integer historyLength; + private final Map metadata; + + public ClientConfig(Boolean streaming, Boolean polling, A2AHttpClient httpClient, Channel channel, + List supportedTransports, Boolean useClientPreference, + List acceptedOutputModes, PushNotificationConfig pushNotificationConfig, + Integer historyLength, Map metadata) { + this.streaming = streaming == null ? true : streaming; + this.polling = polling == null ? false : polling; + this.httpClient = httpClient; + this.channel = channel; + this.supportedTransports = supportedTransports; + this.useClientPreference = useClientPreference == null ? false : useClientPreference; + this.acceptedOutputModes = acceptedOutputModes; + this.pushNotificationConfig = pushNotificationConfig; + this.historyLength = historyLength; + this.metadata = metadata; + } + + public boolean isStreaming() { + return streaming; + } + + public boolean isPolling() { + return polling; + } + + public A2AHttpClient getHttpClient() { + return httpClient; + } + + public Channel getChannel() { + return channel; + } + + public List getSupportedTransports() { + return supportedTransports; + } + + public boolean isUseClientPreference() { + return useClientPreference; + } + + public List getAcceptedOutputModes() { + return acceptedOutputModes; + } + + public PushNotificationConfig getPushNotificationConfig() { + return pushNotificationConfig; + } + + public Integer getHistoryLength() { + return historyLength; + } + + public Map getMetadata() { + return metadata; + } + + public static class Builder { + private Boolean streaming; + private Boolean polling; + private A2AHttpClient httpClient; + private Channel channel; + private List supportedTransports; + private Boolean useClientPreference; + private List acceptedOutputModes; + private PushNotificationConfig pushNotificationConfig; + private Integer historyLength; + private Map metadata; + + public Builder setStreaming(Boolean streaming) { + this.streaming = streaming; + return this; + } + + public Builder setPolling(Boolean polling) { + this.polling = polling; + return this; + } + + public Builder setHttpClient(A2AHttpClient httpClient) { + this.httpClient = httpClient; + return this; + } + + public Builder setChannel(Channel channel) { + this.channel = channel; + return this; + } + + public Builder setSupportedTransports(List supportedTransports) { + this.supportedTransports = supportedTransports; + return this; + } + + public Builder setUseClientPreference(Boolean useClientPreference) { + this.useClientPreference = useClientPreference; + return this; + } + + public Builder setAcceptedOutputModes(List acceptedOutputModes) { + this.acceptedOutputModes = acceptedOutputModes; + return this; + } + + public Builder setPushNotificationConfig(PushNotificationConfig pushNotificationConfig) { + this.pushNotificationConfig = pushNotificationConfig; + return this; + } + + public Builder setHistoryLength(Integer historyLength) { + this.historyLength = historyLength; + return this; + } + + public Builder setMetadata(Map metadata) { + this.metadata = metadata; + return this; + } + + public ClientConfig build() { + return new ClientConfig(streaming, polling, httpClient, channel, + supportedTransports, useClientPreference, acceptedOutputModes, + pushNotificationConfig, historyLength, metadata); + } + } +} \ No newline at end of file diff --git a/client-config/src/main/java/io/a2a/client/config/PayloadAndHeaders.java b/client-config/src/main/java/io/a2a/client/config/PayloadAndHeaders.java new file mode 100644 index 000000000..2146a5547 --- /dev/null +++ b/client-config/src/main/java/io/a2a/client/config/PayloadAndHeaders.java @@ -0,0 +1,22 @@ +package io.a2a.client.config; + +import java.util.Map; + +public class PayloadAndHeaders { + + private final Object payload; + private final Map headers; + + public PayloadAndHeaders(Object payload, Map headers) { + this.payload = payload; + this.headers = headers; + } + + public Object getPayload() { + return payload; + } + + public Map getHeaders() { + return headers; + } +} diff --git a/client-transport/grpc/pom.xml b/client-transport/grpc/pom.xml index 87e873a4d..aaf5f734b 100644 --- a/client-transport/grpc/pom.xml +++ b/client-transport/grpc/pom.xml @@ -17,6 +17,11 @@ Java SDK for the Agent2Agent Protocol (A2A) - gRPC Client Transport + + ${project.groupId} + a2a-java-sdk-client-config + ${project.version} + ${project.groupId} a2a-java-sdk-common diff --git a/client-transport/grpc/src/main/java/io/a2a/client/transport/grpc/EventStreamObserver.java b/client-transport/grpc/src/main/java/io/a2a/client/transport/grpc/EventStreamObserver.java index 48bfa6e76..4edc4a3f5 100644 --- a/client-transport/grpc/src/main/java/io/a2a/client/transport/grpc/EventStreamObserver.java +++ b/client-transport/grpc/src/main/java/io/a2a/client/transport/grpc/EventStreamObserver.java @@ -47,7 +47,9 @@ public void onNext(StreamResponse response) { @Override public void onError(Throwable t) { - errorHandler.accept(t); + if (errorHandler != null) { + errorHandler.accept(t); + } } @Override diff --git a/client-transport/grpc/src/main/java/io/a2a/client/transport/grpc/GrpcTransport.java b/client-transport/grpc/src/main/java/io/a2a/client/transport/grpc/GrpcTransport.java index d272262e3..903eda965 100644 --- a/client-transport/grpc/src/main/java/io/a2a/client/transport/grpc/GrpcTransport.java +++ b/client-transport/grpc/src/main/java/io/a2a/client/transport/grpc/GrpcTransport.java @@ -1,170 +1,252 @@ -/* package io.a2a.client.transport.grpc; -import io.a2a.grpc.*; +import static io.a2a.grpc.A2AServiceGrpc.A2AServiceBlockingV2Stub; +import static io.a2a.grpc.A2AServiceGrpc.A2AServiceStub; +import static io.a2a.grpc.utils.ProtoUtils.FromProto; +import static io.a2a.grpc.utils.ProtoUtils.ToProto; +import static io.a2a.util.Assert.checkNotNullParam; + +import java.util.List; +import java.util.function.Consumer; +import java.util.stream.Collectors; + +import io.a2a.client.config.ClientCallContext; +import io.a2a.client.transport.spi.ClientTransport; +import io.a2a.grpc.A2AServiceGrpc; +import io.a2a.grpc.CancelTaskRequest; +import io.a2a.grpc.CreateTaskPushNotificationConfigRequest; +import io.a2a.grpc.DeleteTaskPushNotificationConfigRequest; +import io.a2a.grpc.GetTaskPushNotificationConfigRequest; +import io.a2a.grpc.GetTaskRequest; +import io.a2a.grpc.ListTaskPushNotificationConfigRequest; import io.a2a.grpc.SendMessageRequest; import io.a2a.grpc.SendMessageResponse; -import io.a2a.grpc.utils.ProtoUtils; -import io.a2a.spec.*; +import io.a2a.grpc.StreamResponse; +import io.a2a.grpc.TaskSubscriptionRequest; + +import io.a2a.spec.A2AClientException; import io.a2a.spec.AgentCard; -import io.a2a.spec.PushNotificationConfig; +import io.a2a.spec.DeleteTaskPushNotificationConfigParams; +import io.a2a.spec.EventKind; +import io.a2a.spec.GetTaskPushNotificationConfigParams; +import io.a2a.spec.ListTaskPushNotificationConfigParams; +import io.a2a.spec.MessageSendParams; +import io.a2a.spec.StreamingEventKind; import io.a2a.spec.Task; +import io.a2a.spec.TaskIdParams; import io.a2a.spec.TaskPushNotificationConfig; -import io.a2a.client.transport.spi.Transport; +import io.a2a.spec.TaskQueryParams; import io.grpc.Channel; + import io.grpc.StatusRuntimeException; import io.grpc.stub.StreamObserver; -import java.util.List; -import java.util.function.Consumer; - -import static io.a2a.util.Assert.checkNotNullParam; +public class GrpcTransport implements ClientTransport { -*/ -/** - * @author David BRASSELY (david.brassely at graviteesource.com) - * @author GraviteeSource Team - *//* - -public class GrpcTransport implements Transport { - - private A2AServiceGrpc.A2AServiceBlockingV2Stub blockingStub; - private A2AServiceGrpc.A2AServiceStub asyncStub; + private final A2AServiceBlockingV2Stub blockingStub; + private final A2AServiceStub asyncStub; private AgentCard agentCard; - */ -/** - * Create an A2A client for interacting with an A2A agent via gRPC. - * - * @param channel the gRPC channel - * @param agentCard the agent card for the A2A server this client will be communicating with - *//* - public GrpcTransport(Channel channel, AgentCard agentCard) { checkNotNullParam("channel", channel); - checkNotNullParam("agentCard", agentCard); this.asyncStub = A2AServiceGrpc.newStub(channel); this.blockingStub = A2AServiceGrpc.newBlockingV2Stub(channel); this.agentCard = agentCard; } @Override - public EventKind sendMessage(String requestId, MessageSendParams messageSendParams) throws A2AServerException { - SendMessageRequest request = createGrpcSendMessageRequestFromMessageSendParams(messageSendParams); + public EventKind sendMessage(MessageSendParams request, ClientCallContext context) throws A2AClientException { + checkNotNullParam("request", request); + + SendMessageRequest sendMessageRequest = createGrpcSendMessageRequest(request, context); + try { - SendMessageResponse response = blockingStub.sendMessage(request); + SendMessageResponse response = blockingStub.sendMessage(sendMessageRequest); if (response.hasMsg()) { - return ProtoUtils.FromProto.message(response.getMsg()); + return FromProto.message(response.getMsg()); } else if (response.hasTask()) { - return ProtoUtils.FromProto.task(response.getTask()); + return FromProto.task(response.getTask()); } else { - throw new A2AServerException("Server response did not contain a message or task"); + throw new A2AClientException("Server response did not contain a message or task"); } } catch (StatusRuntimeException e) { - throw new A2AServerException("Failed to send message: " + e, e); + throw new A2AClientException("Failed to send message: " + e.getMessage(), e); } } @Override - public Task getTask(String requestId, TaskQueryParams taskQueryParams) throws A2AServerException { - io.a2a.grpc.GetTaskRequest.Builder requestBuilder = io.a2a.grpc.GetTaskRequest.newBuilder(); - requestBuilder.setName("tasks/" + taskQueryParams.id()); - if (taskQueryParams.historyLength() != null) { - requestBuilder.setHistoryLength(taskQueryParams.historyLength()); + public void sendMessageStreaming(MessageSendParams request, Consumer eventConsumer, + Consumer errorConsumer, ClientCallContext context) throws A2AClientException { + checkNotNullParam("request", request); + checkNotNullParam("eventConsumer", eventConsumer); + SendMessageRequest grpcRequest = createGrpcSendMessageRequest(request, context); + StreamObserver streamObserver = new EventStreamObserver(eventConsumer, errorConsumer); + + try { + asyncStub.sendStreamingMessage(grpcRequest, streamObserver); + } catch (StatusRuntimeException e) { + throw new A2AClientException("Failed to send streaming message: " + e.getMessage(), e); } - io.a2a.grpc.GetTaskRequest getTaskRequest = requestBuilder.build(); + } + + @Override + public Task getTask(TaskQueryParams request, ClientCallContext context) throws A2AClientException { + checkNotNullParam("request", request); + + GetTaskRequest.Builder requestBuilder = GetTaskRequest.newBuilder(); + requestBuilder.setName("tasks/" + request.id()); + if (request.historyLength() != null) { + requestBuilder.setHistoryLength(request.historyLength()); + } + GetTaskRequest getTaskRequest = requestBuilder.build(); + try { - return ProtoUtils.FromProto.task(blockingStub.getTask(getTaskRequest)); + return FromProto.task(blockingStub.getTask(getTaskRequest)); } catch (StatusRuntimeException e) { - throw new A2AServerException("Failed to get task: " + e, e); + throw new A2AClientException("Failed to get task: " + e.getMessage(), e); } } @Override - public Task cancelTask(String requestId, TaskIdParams taskIdParams) throws A2AServerException { - io.a2a.grpc.CancelTaskRequest cancelTaskRequest = io.a2a.grpc.CancelTaskRequest.newBuilder() - .setName("tasks/" + taskIdParams.id()) + public Task cancelTask(TaskIdParams request, ClientCallContext context) throws A2AClientException { + checkNotNullParam("request", request); + + CancelTaskRequest cancelTaskRequest = CancelTaskRequest.newBuilder() + .setName("tasks/" + request.id()) .build(); + try { - return ProtoUtils.FromProto.task(blockingStub.cancelTask(cancelTaskRequest)); + return FromProto.task(blockingStub.cancelTask(cancelTaskRequest)); } catch (StatusRuntimeException e) { - throw new A2AServerException("Failed to cancel task: " + e, e); + throw new A2AClientException("Failed to cancel task: " + e.getMessage(), e); } } @Override - public TaskPushNotificationConfig getTaskPushNotificationConfig(String requestId, GetTaskPushNotificationConfigParams getTaskPushNotificationConfigParams) throws A2AServerException { - io.a2a.grpc.GetTaskPushNotificationConfigRequest getTaskPushNotificationConfigRequest = io.a2a.grpc.GetTaskPushNotificationConfigRequest.newBuilder() - .setName(getTaskPushNotificationConfigName(getTaskPushNotificationConfigParams)) + public TaskPushNotificationConfig setTaskPushNotificationConfiguration(TaskPushNotificationConfig request, + ClientCallContext context) throws A2AClientException { + checkNotNullParam("request", request); + + String configId = request.pushNotificationConfig().id(); + CreateTaskPushNotificationConfigRequest grpcRequest = CreateTaskPushNotificationConfigRequest.newBuilder() + .setParent("tasks/" + request.taskId()) + .setConfig(ToProto.taskPushNotificationConfig(request)) + .setConfigId(configId == null ? "" : configId) .build(); + try { - return ProtoUtils.FromProto.taskPushNotificationConfig(blockingStub.getTaskPushNotificationConfig(getTaskPushNotificationConfigRequest)); + return FromProto.taskPushNotificationConfig(blockingStub.createTaskPushNotificationConfig(grpcRequest)); } catch (StatusRuntimeException e) { - throw new A2AServerException("Failed to get the task push notification config: " + e, e); + throw new A2AClientException("Failed to set task push notification config: " + e.getMessage(), e); } } - public TaskPushNotificationConfig setTaskPushNotificationConfig(String requestId, String taskId, TaskPushNotificationConfig taskPushNotificationConfig) throws A2AServerException { - String configId = taskPushNotificationConfig.pushNotificationConfig().id(); - CreateTaskPushNotificationConfigRequest request = CreateTaskPushNotificationConfigRequest.newBuilder() - .setParent("tasks/" + taskPushNotificationConfig.taskId()) - .setConfig(ProtoUtils.ToProto.taskPushNotificationConfig(taskPushNotificationConfig)) - .setConfigId(configId == null ? "" : configId) + @Override + public TaskPushNotificationConfig getTaskPushNotificationConfiguration( + GetTaskPushNotificationConfigParams request, + ClientCallContext context) throws A2AClientException { + checkNotNullParam("request", request); + + GetTaskPushNotificationConfigRequest grpcRequest = GetTaskPushNotificationConfigRequest.newBuilder() + .setName(getTaskPushNotificationConfigName(request)) .build(); + try { - return ProtoUtils.FromProto.taskPushNotificationConfig(blockingStub.createTaskPushNotificationConfig(request)); + return FromProto.taskPushNotificationConfig(blockingStub.getTaskPushNotificationConfig(grpcRequest)); } catch (StatusRuntimeException e) { - throw new A2AServerException("Failed to set the task push notification config: " + e, e); + throw new A2AClientException("Failed to get task push notification config: " + e.getMessage(), e); } } @Override - public List listTaskPushNotificationConfig(String requestId, ListTaskPushNotificationConfigParams listTaskPushNotificationConfigParams) throws A2AServerException { - return List.of(); + public List listTaskPushNotificationConfigurations( + ListTaskPushNotificationConfigParams request, + ClientCallContext context) throws A2AClientException { + checkNotNullParam("request", request); + + ListTaskPushNotificationConfigRequest grpcRequest = ListTaskPushNotificationConfigRequest.newBuilder() + .setParent("tasks/" + request.id()) + .build(); + + try { + return blockingStub.listTaskPushNotificationConfig(grpcRequest).getConfigsList().stream() + .map(FromProto::taskPushNotificationConfig) + .collect(Collectors.toList()); + } catch (StatusRuntimeException e) { + throw new A2AClientException("Failed to list task push notification configs: " + e.getMessage(), e); + } } @Override - public void deleteTaskPushNotificationConfig(String requestId, DeleteTaskPushNotificationConfigParams deleteTaskPushNotificationConfigParams) throws A2AServerException { + public void deleteTaskPushNotificationConfigurations(DeleteTaskPushNotificationConfigParams request, + ClientCallContext context) throws A2AClientException { + checkNotNullParam("request", request); + + DeleteTaskPushNotificationConfigRequest grpcRequest = DeleteTaskPushNotificationConfigRequest.newBuilder() + .setName(getTaskPushNotificationConfigName(request.id(), request.pushNotificationConfigId())) + .build(); + try { + blockingStub.deleteTaskPushNotificationConfig(grpcRequest); + } catch (StatusRuntimeException e) { + throw new A2AClientException("Failed to delete task push notification configs: " + e.getMessage(), e); + } } @Override - public void sendStreamingMessage(String requestId, MessageSendParams messageSendParams, Consumer eventHandler, Consumer errorHandler, Runnable failureHandler) throws A2AServerException { - SendMessageRequest request = createGrpcSendMessageRequestFromMessageSendParams(messageSendParams); - StreamObserver streamObserver = new EventStreamObserver(eventHandler, errorHandler); + public void resubscribe(TaskIdParams request, Consumer eventConsumer, + Consumer errorConsumer, ClientCallContext context) throws A2AClientException { + checkNotNullParam("request", request); + checkNotNullParam("eventConsumer", eventConsumer); + + TaskSubscriptionRequest grpcRequest = TaskSubscriptionRequest.newBuilder() + .setName("tasks/" + request.id()) + .build(); + + StreamObserver streamObserver = new EventStreamObserver(eventConsumer, errorConsumer); + try { - asyncStub.sendStreamingMessage(request, streamObserver); + asyncStub.taskSubscription(grpcRequest, streamObserver); } catch (StatusRuntimeException e) { - throw new A2AServerException("Failed to send streaming message: " + e, e); + throw new A2AClientException("Failed to resubscribe to task: " + e.getMessage(), e); } } @Override - public void resubscribeToTask(String requestId, TaskIdParams taskIdParams, Consumer eventHandler, Consumer errorHandler, Runnable failureHandler) throws A2AServerException { + public AgentCard getAgentCard(ClientCallContext context) throws A2AClientException { + // TODO: Determine how to handle retrieving the authenticated extended agent card + return agentCard; + } + @Override + public void close() { } - private SendMessageRequest createGrpcSendMessageRequestFromMessageSendParams(MessageSendParams messageSendParams) { + private SendMessageRequest createGrpcSendMessageRequest(MessageSendParams messageSendParams, ClientCallContext context) { SendMessageRequest.Builder builder = SendMessageRequest.newBuilder(); - builder.setRequest(ProtoUtils.ToProto.message(messageSendParams.message())); + builder.setRequest(ToProto.message(messageSendParams.message())); if (messageSendParams.configuration() != null) { - builder.setConfiguration(ProtoUtils.ToProto.messageSendConfiguration(messageSendParams.configuration())); + builder.setConfiguration(ToProto.messageSendConfiguration(messageSendParams.configuration())); } if (messageSendParams.metadata() != null) { - builder.setMetadata(ProtoUtils.ToProto.struct(messageSendParams.metadata())); + builder.setMetadata(ToProto.struct(messageSendParams.metadata())); } return builder.build(); } - private String getTaskPushNotificationConfigName(GetTaskPushNotificationConfigParams getTaskPushNotificationConfigParams) { + private String getTaskPushNotificationConfigName(GetTaskPushNotificationConfigParams params) { + return getTaskPushNotificationConfigName(params.id(), params.pushNotificationConfigId()); + } + + private String getTaskPushNotificationConfigName(String taskId, String pushNotificationConfigId) { StringBuilder name = new StringBuilder(); name.append("tasks/"); - name.append(getTaskPushNotificationConfigParams.id()); - if (getTaskPushNotificationConfigParams.pushNotificationConfigId() != null) { + name.append(taskId); + if (pushNotificationConfigId != null) { name.append("/pushNotificationConfigs/"); - name.append(getTaskPushNotificationConfigParams.pushNotificationConfigId()); + name.append(pushNotificationConfigId); } return name.toString(); } -} -*/ + +} \ No newline at end of file diff --git a/client-transport/grpc/src/main/java/io/a2a/client/transport/grpc/GrpcTransportProvider.java b/client-transport/grpc/src/main/java/io/a2a/client/transport/grpc/GrpcTransportProvider.java new file mode 100644 index 000000000..cdaffed88 --- /dev/null +++ b/client-transport/grpc/src/main/java/io/a2a/client/transport/grpc/GrpcTransportProvider.java @@ -0,0 +1,29 @@ +package io.a2a.client.transport.grpc; + +import java.util.List; + +import io.a2a.client.config.ClientCallInterceptor; +import io.a2a.client.config.ClientConfig; +import io.a2a.client.transport.spi.ClientTransport; +import io.a2a.client.transport.spi.ClientTransportProvider; +import io.a2a.spec.AgentCard; +import io.a2a.spec.TransportProtocol; + +/** + * Provider for gRPC transport implementation. + */ +public class GrpcTransportProvider implements ClientTransportProvider { + + @Override + public ClientTransport create(ClientConfig clientConfig, AgentCard agentCard, + String agentUrl, List interceptors) { + // not making use of the interceptors for gRPC for now + return new GrpcTransport(clientConfig.getChannel(), agentCard); + } + + @Override + public String getTransportProtocol() { + return TransportProtocol.GRPC.asString(); + } + +} diff --git a/client-transport/grpc/src/main/resources/META-INF/services/io.a2a.client.transport.spi.ClientTransportProvider b/client-transport/grpc/src/main/resources/META-INF/services/io.a2a.client.transport.spi.ClientTransportProvider new file mode 100644 index 000000000..86d4fa7e5 --- /dev/null +++ b/client-transport/grpc/src/main/resources/META-INF/services/io.a2a.client.transport.spi.ClientTransportProvider @@ -0,0 +1 @@ +io.a2a.client.transport.grpc.GrpcTransportProvider \ No newline at end of file diff --git a/client-transport/jsonrpc/pom.xml b/client-transport/jsonrpc/pom.xml index 7afc0c769..dd9a74c22 100644 --- a/client-transport/jsonrpc/pom.xml +++ b/client-transport/jsonrpc/pom.xml @@ -17,6 +17,16 @@ Java SDK for the Agent2Agent Protocol (A2A) - JSONRPC Client Transport + + ${project.groupId} + a2a-java-sdk-client + ${project.version} + + + ${project.groupId} + a2a-java-sdk-client-config + ${project.version} + ${project.groupId} a2a-java-sdk-client-http diff --git a/client-transport/jsonrpc/src/main/java/io/a2a/client/transport/jsonrpc/JSONRPCTransport.java b/client-transport/jsonrpc/src/main/java/io/a2a/client/transport/jsonrpc/JSONRPCTransport.java index e9c270b02..034584cec 100644 --- a/client-transport/jsonrpc/src/main/java/io/a2a/client/transport/jsonrpc/JSONRPCTransport.java +++ b/client-transport/jsonrpc/src/main/java/io/a2a/client/transport/jsonrpc/JSONRPCTransport.java @@ -1,19 +1,30 @@ package io.a2a.client.transport.jsonrpc; +import static io.a2a.util.Assert.checkNotNullParam; + +import java.io.IOException; +import java.util.List; +import java.util.Map; +import java.util.function.Consumer; + import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.core.type.TypeReference; +import io.a2a.client.A2ACardResolver; +import io.a2a.client.config.ClientCallContext; +import io.a2a.client.config.ClientCallInterceptor; +import io.a2a.client.config.PayloadAndHeaders; import io.a2a.client.http.A2AHttpClient; import io.a2a.client.http.A2AHttpResponse; import io.a2a.client.http.JdkA2AHttpClient; -import io.a2a.client.transport.jsonrpc.sse.SSEEventListener; import io.a2a.client.transport.spi.ClientTransport; -import io.a2a.spec.A2AServerException; +import io.a2a.spec.A2AClientError; +import io.a2a.spec.A2AClientException; +import io.a2a.spec.AgentCard; import io.a2a.spec.CancelTaskRequest; import io.a2a.spec.CancelTaskResponse; + import io.a2a.spec.DeleteTaskPushNotificationConfigParams; -import io.a2a.spec.DeleteTaskPushNotificationConfigRequest; -import io.a2a.spec.DeleteTaskPushNotificationConfigResponse; import io.a2a.spec.EventKind; import io.a2a.spec.GetTaskPushNotificationConfigParams; import io.a2a.spec.GetTaskPushNotificationConfigRequest; @@ -23,11 +34,13 @@ import io.a2a.spec.JSONRPCError; import io.a2a.spec.JSONRPCMessage; import io.a2a.spec.JSONRPCResponse; + import io.a2a.spec.ListTaskPushNotificationConfigParams; import io.a2a.spec.ListTaskPushNotificationConfigRequest; import io.a2a.spec.ListTaskPushNotificationConfigResponse; +import io.a2a.spec.DeleteTaskPushNotificationConfigRequest; +import io.a2a.spec.DeleteTaskPushNotificationConfigResponse; import io.a2a.spec.MessageSendParams; -import io.a2a.spec.PushNotificationConfig; import io.a2a.spec.SendMessageRequest; import io.a2a.spec.SendMessageResponse; import io.a2a.spec.SendStreamingMessageRequest; @@ -39,13 +52,11 @@ import io.a2a.spec.TaskPushNotificationConfig; import io.a2a.spec.TaskQueryParams; import io.a2a.spec.TaskResubscriptionRequest; -import io.a2a.util.Utils; - -import java.io.IOException; -import java.util.List; +import io.a2a.client.transport.jsonrpc.sse.SSEEventListener; import java.util.concurrent.CompletableFuture; import java.util.concurrent.atomic.AtomicReference; -import java.util.function.Consumer; + +import io.a2a.util.Utils; public class JSONRPCTransport implements ClientTransport { @@ -56,228 +67,320 @@ public class JSONRPCTransport implements ClientTransport { private static final TypeReference SET_TASK_PUSH_NOTIFICATION_CONFIG_RESPONSE_REFERENCE = new TypeReference<>() {}; private static final TypeReference LIST_TASK_PUSH_NOTIFICATION_CONFIG_RESPONSE_REFERENCE = new TypeReference<>() {}; private static final TypeReference DELETE_TASK_PUSH_NOTIFICATION_CONFIG_RESPONSE_REFERENCE = new TypeReference<>() {}; + // TODO: Uncomment once support for v0.3.0 has been merged + //private static final TypeReference GET_AUTHENTICATED_EXTENDED_CARD_RESPONSE_REFERENCE = new TypeReference<>() {}; - private final String agentUrl; private final A2AHttpClient httpClient; + private final String agentUrl; + private final List interceptors; + private AgentCard agentCard; public JSONRPCTransport(String agentUrl) { - this(agentUrl, new JdkA2AHttpClient()); + this(null, null, agentUrl, null); } - public JSONRPCTransport(String agentUrl, A2AHttpClient httpClient) { + public JSONRPCTransport(AgentCard agentCard) { + this(null, agentCard, agentCard.url(), null); + } + + public JSONRPCTransport(A2AHttpClient httpClient, AgentCard agentCard, + String agentUrl, List interceptors) { + this.httpClient = httpClient == null ? new JdkA2AHttpClient() : httpClient; + this.agentCard = agentCard; this.agentUrl = agentUrl; - this.httpClient = httpClient; + this.interceptors = interceptors; } @Override - public EventKind sendMessage(String requestId, MessageSendParams messageSendParams) throws A2AServerException { - SendMessageRequest.Builder sendMessageRequestBuilder = new SendMessageRequest.Builder() + public EventKind sendMessage(MessageSendParams request, ClientCallContext context) throws A2AClientException { + checkNotNullParam("request", request); + SendMessageRequest sendMessageRequest = new SendMessageRequest.Builder() .jsonrpc(JSONRPCMessage.JSONRPC_VERSION) .method(SendMessageRequest.METHOD) - .params(messageSendParams); - - if (requestId != null) { - sendMessageRequestBuilder.id(requestId); - } + .params(request) + .build(); // id will be randomly generated - SendMessageRequest sendMessageRequest = sendMessageRequestBuilder.build(); + PayloadAndHeaders payloadAndHeaders = applyInterceptors(SendMessageRequest.METHOD, sendMessageRequest, + agentCard, context); try { - String httpResponseBody = sendPostRequest(sendMessageRequest); - return unmarshalResponse(httpResponseBody, SEND_MESSAGE_RESPONSE_REFERENCE).getResult(); + String httpResponseBody = sendPostRequest(payloadAndHeaders); + SendMessageResponse response = unmarshalResponse(httpResponseBody, SEND_MESSAGE_RESPONSE_REFERENCE); + return response.getResult(); + } catch (A2AClientException e) { + throw e; } catch (IOException | InterruptedException e) { - throw new A2AServerException("Failed to send message: " + e, e.getCause()); + throw new A2AClientException("Failed to send message: " + e, e); } } @Override - public Task getTask(String requestId, TaskQueryParams taskQueryParams) throws A2AServerException { - GetTaskRequest.Builder getTaskRequestBuilder = new GetTaskRequest.Builder() + public void sendMessageStreaming(MessageSendParams request, Consumer eventConsumer, + Consumer errorConsumer, ClientCallContext context) throws A2AClientException { + checkNotNullParam("request", request); + checkNotNullParam("eventConsumer", eventConsumer); + SendStreamingMessageRequest sendStreamingMessageRequest = new SendStreamingMessageRequest.Builder() .jsonrpc(JSONRPCMessage.JSONRPC_VERSION) - .method(GetTaskRequest.METHOD) - .params(taskQueryParams); + .method(SendStreamingMessageRequest.METHOD) + .params(request) + .build(); // id will be randomly generated - if (requestId != null) { - getTaskRequestBuilder.id(requestId); - } + PayloadAndHeaders payloadAndHeaders = applyInterceptors(SendStreamingMessageRequest.METHOD, + sendStreamingMessageRequest, agentCard, context); - GetTaskRequest getTaskRequest = getTaskRequestBuilder.build(); + AtomicReference> ref = new AtomicReference<>(); + SSEEventListener sseEventListener = new SSEEventListener(eventConsumer, errorConsumer); try { - String httpResponseBody = sendPostRequest(getTaskRequest); - return unmarshalResponse(httpResponseBody, GET_TASK_RESPONSE_REFERENCE).getResult(); - } catch (IOException | InterruptedException e) { - throw new A2AServerException("Failed to get task: " + e, e.getCause()); + A2AHttpClient.PostBuilder builder = createPostBuilder(payloadAndHeaders); + ref.set(builder.postAsyncSSE( + msg -> sseEventListener.onMessage(msg, ref.get()), + throwable -> sseEventListener.onError(throwable, ref.get()), + () -> { + // We don't need to do anything special on completion + })); + } catch (IOException e) { + throw new A2AClientException("Failed to send streaming message request: " + e, e); + } catch (InterruptedException e) { + throw new A2AClientException("Send streaming message request timed out: " + e, e); } } @Override - public Task cancelTask(String requestId, TaskIdParams taskIdParams) throws A2AServerException { - CancelTaskRequest.Builder cancelTaskRequestBuilder = new CancelTaskRequest.Builder() + public Task getTask(TaskQueryParams request, ClientCallContext context) throws A2AClientException { + checkNotNullParam("request", request); + GetTaskRequest getTaskRequest = new GetTaskRequest.Builder() .jsonrpc(JSONRPCMessage.JSONRPC_VERSION) - .method(CancelTaskRequest.METHOD) - .params(taskIdParams); - - if (requestId != null) { - cancelTaskRequestBuilder.id(requestId); - } + .method(GetTaskRequest.METHOD) + .params(request) + .build(); // id will be randomly generated - CancelTaskRequest cancelTaskRequest = cancelTaskRequestBuilder.build(); + PayloadAndHeaders payloadAndHeaders = applyInterceptors(GetTaskRequest.METHOD, getTaskRequest, + agentCard, context); try { - String httpResponseBody = sendPostRequest(cancelTaskRequest); - return unmarshalResponse(httpResponseBody, CANCEL_TASK_RESPONSE_REFERENCE).getResult(); + String httpResponseBody = sendPostRequest(payloadAndHeaders); + GetTaskResponse response = unmarshalResponse(httpResponseBody, GET_TASK_RESPONSE_REFERENCE); + return response.getResult(); + } catch (A2AClientException e) { + throw e; } catch (IOException | InterruptedException e) { - throw new A2AServerException("Failed to cancel task: " + e, e.getCause()); + throw new A2AClientException("Failed to get task: " + e, e); } } @Override - public TaskPushNotificationConfig getTaskPushNotificationConfig(String requestId, GetTaskPushNotificationConfigParams getTaskPushNotificationConfigParams) throws A2AServerException { - GetTaskPushNotificationConfigRequest.Builder getTaskPushNotificationRequestBuilder = new GetTaskPushNotificationConfigRequest.Builder() + public Task cancelTask(TaskIdParams request, ClientCallContext context) throws A2AClientException { + checkNotNullParam("request", request); + CancelTaskRequest cancelTaskRequest = new CancelTaskRequest.Builder() .jsonrpc(JSONRPCMessage.JSONRPC_VERSION) - .method(GetTaskPushNotificationConfigRequest.METHOD) - .params(getTaskPushNotificationConfigParams); - - if (requestId != null) { - getTaskPushNotificationRequestBuilder.id(requestId); - } + .method(CancelTaskRequest.METHOD) + .params(request) + .build(); // id will be randomly generated - GetTaskPushNotificationConfigRequest getTaskPushNotificationRequest = getTaskPushNotificationRequestBuilder.build(); + PayloadAndHeaders payloadAndHeaders = applyInterceptors(CancelTaskRequest.METHOD, cancelTaskRequest, + agentCard, context); try { - String httpResponseBody = sendPostRequest(getTaskPushNotificationRequest); - return unmarshalResponse(httpResponseBody, GET_TASK_PUSH_NOTIFICATION_CONFIG_RESPONSE_REFERENCE).getResult(); + String httpResponseBody = sendPostRequest(payloadAndHeaders); + CancelTaskResponse response = unmarshalResponse(httpResponseBody, CANCEL_TASK_RESPONSE_REFERENCE); + return response.getResult(); + } catch (A2AClientException e) { + throw e; } catch (IOException | InterruptedException e) { - throw new A2AServerException("Failed to get task push notification config: " + e, e.getCause()); + throw new A2AClientException("Failed to cancel task: " + e, e); } } @Override - public TaskPushNotificationConfig setTaskPushNotificationConfig(String requestId, String taskId, PushNotificationConfig pushNotificationConfig) throws A2AServerException { - SetTaskPushNotificationConfigRequest.Builder setTaskPushNotificationRequestBuilder = new SetTaskPushNotificationConfigRequest.Builder() + public TaskPushNotificationConfig setTaskPushNotificationConfiguration(TaskPushNotificationConfig request, + ClientCallContext context) throws A2AClientException { + checkNotNullParam("request", request); + SetTaskPushNotificationConfigRequest setTaskPushNotificationRequest = new SetTaskPushNotificationConfigRequest.Builder() .jsonrpc(JSONRPCMessage.JSONRPC_VERSION) .method(SetTaskPushNotificationConfigRequest.METHOD) - .params(new TaskPushNotificationConfig(taskId, pushNotificationConfig)); + .params(request) + .build(); // id will be randomly generated + + PayloadAndHeaders payloadAndHeaders = applyInterceptors(SetTaskPushNotificationConfigRequest.METHOD, + setTaskPushNotificationRequest, agentCard, context); - if (requestId != null) { - setTaskPushNotificationRequestBuilder.id(requestId); + try { + String httpResponseBody = sendPostRequest(payloadAndHeaders); + SetTaskPushNotificationConfigResponse response = unmarshalResponse(httpResponseBody, + SET_TASK_PUSH_NOTIFICATION_CONFIG_RESPONSE_REFERENCE); + return response.getResult(); + } catch (A2AClientException e) { + throw e; + } catch (IOException | InterruptedException e) { + throw new A2AClientException("Failed to set task push notification config: " + e, e); } + } - SetTaskPushNotificationConfigRequest setTaskPushNotificationRequest = setTaskPushNotificationRequestBuilder.build(); + @Override + public TaskPushNotificationConfig getTaskPushNotificationConfiguration(GetTaskPushNotificationConfigParams request, + ClientCallContext context) throws A2AClientException { + checkNotNullParam("request", request); + GetTaskPushNotificationConfigRequest getTaskPushNotificationRequest = new GetTaskPushNotificationConfigRequest.Builder() + .jsonrpc(JSONRPCMessage.JSONRPC_VERSION) + .method(GetTaskPushNotificationConfigRequest.METHOD) + .params(request) + .build(); // id will be randomly generated + + PayloadAndHeaders payloadAndHeaders = applyInterceptors(GetTaskPushNotificationConfigRequest.METHOD, + getTaskPushNotificationRequest, agentCard, context); try { - String httpResponseBody = sendPostRequest(setTaskPushNotificationRequest); - return unmarshalResponse(httpResponseBody, SET_TASK_PUSH_NOTIFICATION_CONFIG_RESPONSE_REFERENCE).getResult(); + String httpResponseBody = sendPostRequest(payloadAndHeaders); + GetTaskPushNotificationConfigResponse response = unmarshalResponse(httpResponseBody, + GET_TASK_PUSH_NOTIFICATION_CONFIG_RESPONSE_REFERENCE); + return response.getResult(); + } catch (A2AClientException e) { + throw e; } catch (IOException | InterruptedException e) { - throw new A2AServerException("Failed to set task push notification config: " + e, e.getCause()); + throw new A2AClientException("Failed to get task push notification config: " + e, e); } } @Override - public List listTaskPushNotificationConfig(String requestId, ListTaskPushNotificationConfigParams listTaskPushNotificationConfigParams) throws A2AServerException { - ListTaskPushNotificationConfigRequest.Builder listTaskPushNotificationRequestBuilder = new ListTaskPushNotificationConfigRequest.Builder() + public List listTaskPushNotificationConfigurations( + ListTaskPushNotificationConfigParams request, + ClientCallContext context) throws A2AClientException { + checkNotNullParam("request", request); + ListTaskPushNotificationConfigRequest listTaskPushNotificationRequest = new ListTaskPushNotificationConfigRequest.Builder() .jsonrpc(JSONRPCMessage.JSONRPC_VERSION) .method(ListTaskPushNotificationConfigRequest.METHOD) - .params(listTaskPushNotificationConfigParams); - - if (requestId != null) { - listTaskPushNotificationRequestBuilder.id(requestId); - } + .params(request) + .build(); // id will be randomly generated - ListTaskPushNotificationConfigRequest listTaskPushNotificationRequest = listTaskPushNotificationRequestBuilder.build(); + PayloadAndHeaders payloadAndHeaders = applyInterceptors(ListTaskPushNotificationConfigRequest.METHOD, + listTaskPushNotificationRequest, agentCard, context); try { - String httpResponseBody = sendPostRequest(listTaskPushNotificationRequest); - return unmarshalResponse(httpResponseBody, LIST_TASK_PUSH_NOTIFICATION_CONFIG_RESPONSE_REFERENCE).getResult(); + String httpResponseBody = sendPostRequest(payloadAndHeaders); + ListTaskPushNotificationConfigResponse response = unmarshalResponse(httpResponseBody, + LIST_TASK_PUSH_NOTIFICATION_CONFIG_RESPONSE_REFERENCE); + return response.getResult(); + } catch (A2AClientException e) { + throw e; } catch (IOException | InterruptedException e) { - throw new A2AServerException("Failed to list task push notification config: " + e, e.getCause()); + throw new A2AClientException("Failed to list task push notification configs: " + e, e); } } @Override - public void deleteTaskPushNotificationConfig(String requestId, DeleteTaskPushNotificationConfigParams deleteTaskPushNotificationConfigParams) throws A2AServerException { - DeleteTaskPushNotificationConfigRequest.Builder deleteTaskPushNotificationRequestBuilder = new DeleteTaskPushNotificationConfigRequest.Builder() + public void deleteTaskPushNotificationConfigurations(DeleteTaskPushNotificationConfigParams request, + ClientCallContext context) throws A2AClientException { + checkNotNullParam("request", request); + DeleteTaskPushNotificationConfigRequest deleteTaskPushNotificationRequest = new DeleteTaskPushNotificationConfigRequest.Builder() .jsonrpc(JSONRPCMessage.JSONRPC_VERSION) .method(DeleteTaskPushNotificationConfigRequest.METHOD) - .params(deleteTaskPushNotificationConfigParams); - - if (requestId != null) { - deleteTaskPushNotificationRequestBuilder.id(requestId); - } + .params(request) + .build(); // id will be randomly generated - DeleteTaskPushNotificationConfigRequest deleteTaskPushNotificationRequest = deleteTaskPushNotificationRequestBuilder.build(); + PayloadAndHeaders payloadAndHeaders = applyInterceptors(DeleteTaskPushNotificationConfigRequest.METHOD, + deleteTaskPushNotificationRequest, agentCard, context); try { - String httpResponseBody = sendPostRequest(deleteTaskPushNotificationRequest); + String httpResponseBody = sendPostRequest(payloadAndHeaders); unmarshalResponse(httpResponseBody, DELETE_TASK_PUSH_NOTIFICATION_CONFIG_RESPONSE_REFERENCE); + } catch (A2AClientException e) { + throw e; } catch (IOException | InterruptedException e) { - throw new A2AServerException("Failed to delete task push notification config: " + e, e.getCause()); + throw new A2AClientException("Failed to delete task push notification configs: " + e, e); } } @Override - public void sendStreamingMessage(String requestId, MessageSendParams messageSendParams, Consumer eventHandler, Consumer errorHandler, Runnable failureHandler) throws A2AServerException { - SendStreamingMessageRequest.Builder sendStreamingMessageRequestBuilder = new SendStreamingMessageRequest.Builder() + public void resubscribe(TaskIdParams request, Consumer eventConsumer, + Consumer errorConsumer, ClientCallContext context) throws A2AClientException { + checkNotNullParam("request", request); + checkNotNullParam("eventConsumer", eventConsumer); + checkNotNullParam("errorConsumer", errorConsumer); + TaskResubscriptionRequest taskResubscriptionRequest = new TaskResubscriptionRequest.Builder() .jsonrpc(JSONRPCMessage.JSONRPC_VERSION) - .method(SendStreamingMessageRequest.METHOD) - .params(messageSendParams); + .method(TaskResubscriptionRequest.METHOD) + .params(request) + .build(); // id will be randomly generated - if (requestId != null) { - sendStreamingMessageRequestBuilder.id(requestId); - } + PayloadAndHeaders payloadAndHeaders = applyInterceptors(TaskResubscriptionRequest.METHOD, + taskResubscriptionRequest, agentCard, context); AtomicReference> ref = new AtomicReference<>(); - SSEEventListener sseEventListener = new SSEEventListener(eventHandler, errorHandler, failureHandler); - SendStreamingMessageRequest sendStreamingMessageRequest = sendStreamingMessageRequestBuilder.build(); + SSEEventListener sseEventListener = new SSEEventListener(eventConsumer, errorConsumer); + try { - A2AHttpClient.PostBuilder builder = createPostBuilder(sendStreamingMessageRequest); + A2AHttpClient.PostBuilder builder = createPostBuilder(payloadAndHeaders); ref.set(builder.postAsyncSSE( msg -> sseEventListener.onMessage(msg, ref.get()), throwable -> sseEventListener.onError(throwable, ref.get()), () -> { // We don't need to do anything special on completion })); - } catch (IOException e) { - throw new A2AServerException("Failed to send streaming message request: " + e, e.getCause()); + throw new A2AClientException("Failed to send task resubscription request: " + e, e); } catch (InterruptedException e) { - throw new A2AServerException("Send streaming message request timed out: " + e, e.getCause()); + throw new A2AClientException("Task resubscription request timed out: " + e, e); } } @Override - public void resubscribeToTask(String requestId, TaskIdParams taskIdParams, Consumer eventHandler, Consumer errorHandler, Runnable failureHandler) throws A2AServerException { - TaskResubscriptionRequest.Builder taskResubscriptionRequestBuilder = new TaskResubscriptionRequest.Builder() - .jsonrpc(JSONRPCMessage.JSONRPC_VERSION) - .method(TaskResubscriptionRequest.METHOD) - .params(taskIdParams); - - if (requestId != null) { - taskResubscriptionRequestBuilder.id(requestId); + public AgentCard getAgentCard(ClientCallContext context) throws A2AClientException { + A2ACardResolver resolver; + try { + if (agentCard == null) { + resolver = new A2ACardResolver(httpClient, agentUrl, null, getHttpHeaders(context)); + agentCard = resolver.getAgentCard(); + } + if (!agentCard.supportsAuthenticatedExtendedCard()) { + return agentCard; + } + resolver = new A2ACardResolver(httpClient, agentUrl, "/agent/authenticatedExtendedCard", + getHttpHeaders(context)); + agentCard = resolver.getAgentCard(); + + // TODO: Uncomment this code once support for v0.3.0 has been merged and remove the above 3 lines + /*GetAuthenticatedExtendedCardRequest getExtendedAgentCardRequest = new GetAuthenticatedExtendedCardRequest.Builder() + .jsonrpc(JSONRPCMessage.JSONRPC_VERSION) + .method(GetAuthenticatedExtendedCardRequest.METHOD) + .build(); // id will be randomly generated + + PayloadAndHeaders payloadAndHeaders = applyInterceptors(GetAuthenticatedExtendedCardRequest.METHOD, + getExtendedAgentCardRequest, agentCard, context); + + try { + String httpResponseBody = sendPostRequest(payloadAndHeaders); + GetAuthenticatedExtendedCardResponse response = unmarshalResponse(httpResponseBody, + GET_TASK_PUSH_NOTIFICATION_CONFIG_RESPONSE_REFERENCE); + return response.getResult(); + } catch (IOException | InterruptedException e) { + throw new A2AClientException("Failed to get authenticated extended agent card: " + e, e); + }*/ + return agentCard; + } catch(A2AClientError e){ + throw new A2AClientException("Failed to get agent card: " + e, e); } + } - AtomicReference> ref = new AtomicReference<>(); - SSEEventListener sseEventListener = new SSEEventListener(eventHandler, errorHandler, failureHandler); - TaskResubscriptionRequest taskResubscriptionRequest = taskResubscriptionRequestBuilder.build(); - try { - A2AHttpClient.PostBuilder builder = createPostBuilder(taskResubscriptionRequest); - ref.set(builder.postAsyncSSE( - msg -> sseEventListener.onMessage(msg, ref.get()), - throwable -> sseEventListener.onError(throwable, ref.get()), - () -> { - // We don't need to do anything special on completion - })); + @Override + public void close() { + // no-op + } - } catch (IOException e) { - throw new A2AServerException("Failed to send task resubscription request: " + e, e.getCause()); - } catch (InterruptedException e) { - throw new A2AServerException("Task resubscription request timed out: " + e, e.getCause()); + private PayloadAndHeaders applyInterceptors(String methodName, Object payload, + AgentCard agentCard, ClientCallContext clientCallContext) { + PayloadAndHeaders payloadAndHeaders = new PayloadAndHeaders(payload, getHttpHeaders(clientCallContext)); + if (interceptors != null && ! interceptors.isEmpty()) { + for (ClientCallInterceptor interceptor : interceptors) { + payloadAndHeaders = interceptor.intercept(methodName, payloadAndHeaders.getPayload(), + payloadAndHeaders.getHeaders(), agentCard, clientCallContext); + } } + return payloadAndHeaders; } - private String sendPostRequest(Object value) throws IOException, InterruptedException { - A2AHttpClient.PostBuilder builder = createPostBuilder(value); + private String sendPostRequest(PayloadAndHeaders payloadAndHeaders) throws IOException, InterruptedException { + A2AHttpClient.PostBuilder builder = createPostBuilder(payloadAndHeaders); A2AHttpResponse response = builder.post(); if (!response.success()) { throw new IOException("Request failed " + response.status()); @@ -285,21 +388,32 @@ private String sendPostRequest(Object value) throws IOException, InterruptedExce return response.body(); } - private A2AHttpClient.PostBuilder createPostBuilder(Object value) throws JsonProcessingException { - return httpClient.createPost() + private A2AHttpClient.PostBuilder createPostBuilder(PayloadAndHeaders payloadAndHeaders) throws JsonProcessingException { + A2AHttpClient.PostBuilder postBuilder = httpClient.createPost() .url(agentUrl) .addHeader("Content-Type", "application/json") - .body(Utils.OBJECT_MAPPER.writeValueAsString(value)); + .body(Utils.OBJECT_MAPPER.writeValueAsString(payloadAndHeaders.getPayload())); + + if (payloadAndHeaders.getHeaders() != null) { + for (Map.Entry entry : payloadAndHeaders.getHeaders().entrySet()) { + postBuilder.addHeader(entry.getKey(), entry.getValue()); + } + } + return postBuilder; } - private T unmarshalResponse(String response, TypeReference typeReference) - throws A2AServerException, JsonProcessingException { + private > T unmarshalResponse(String response, TypeReference typeReference) + throws A2AClientException, JsonProcessingException { T value = Utils.unmarshalFrom(response, typeReference); JSONRPCError error = value.getError(); if (error != null) { - throw new A2AServerException(error.getMessage() + (error.getData() != null ? ": " + error.getData() : ""), error); + throw new A2AClientException(error.getMessage() + (error.getData() != null ? ": " + error.getData() : ""), error); } return value; } -} + + private Map getHttpHeaders(ClientCallContext context) { + return context != null ? context.getHeaders() : null; + } +} \ No newline at end of file diff --git a/client-transport/jsonrpc/src/main/java/io/a2a/client/transport/jsonrpc/JSONRPCTransportProvider.java b/client-transport/jsonrpc/src/main/java/io/a2a/client/transport/jsonrpc/JSONRPCTransportProvider.java new file mode 100644 index 000000000..3fe865515 --- /dev/null +++ b/client-transport/jsonrpc/src/main/java/io/a2a/client/transport/jsonrpc/JSONRPCTransportProvider.java @@ -0,0 +1,24 @@ +package io.a2a.client.transport.jsonrpc; + +import java.util.List; + +import io.a2a.client.config.ClientCallInterceptor; +import io.a2a.client.config.ClientConfig; +import io.a2a.client.transport.spi.ClientTransport; +import io.a2a.client.transport.spi.ClientTransportProvider; +import io.a2a.spec.AgentCard; +import io.a2a.spec.TransportProtocol; + +public class JSONRPCTransportProvider implements ClientTransportProvider { + + @Override + public ClientTransport create(ClientConfig clientConfig, AgentCard agentCard, + String agentUrl, List interceptors) { + return new JSONRPCTransport(clientConfig.getHttpClient(), agentCard, agentUrl, interceptors); + } + + @Override + public String getTransportProtocol() { + return TransportProtocol.JSONRPC.asString(); + } +} diff --git a/client-transport/jsonrpc/src/main/java/io/a2a/client/transport/jsonrpc/sse/SSEEventListener.java b/client-transport/jsonrpc/src/main/java/io/a2a/client/transport/jsonrpc/sse/SSEEventListener.java index 0312e9873..99ca546c4 100644 --- a/client-transport/jsonrpc/src/main/java/io/a2a/client/transport/jsonrpc/sse/SSEEventListener.java +++ b/client-transport/jsonrpc/src/main/java/io/a2a/client/transport/jsonrpc/sse/SSEEventListener.java @@ -15,13 +15,12 @@ public class SSEEventListener { private static final Logger log = Logger.getLogger(SSEEventListener.class.getName()); private final Consumer eventHandler; - private final Consumer errorHandler; - private final Runnable failureHandler; + private final Consumer errorHandler; - public SSEEventListener(Consumer eventHandler, Consumer errorHandler, Runnable failureHandler) { + public SSEEventListener(Consumer eventHandler, + Consumer errorHandler) { this.eventHandler = eventHandler; this.errorHandler = errorHandler; - this.failureHandler = failureHandler; } public void onMessage(String message, Future completableFuture) { @@ -33,7 +32,9 @@ public void onMessage(String message, Future completableFuture) { } public void onError(Throwable throwable, Future future) { - failureHandler.run(); + if (errorHandler != null) { + errorHandler.accept(throwable); + } future.cancel(true); // close SSE channel } @@ -41,7 +42,9 @@ private void handleMessage(JsonNode jsonNode, Future future) { try { if (jsonNode.has("error")) { JSONRPCError error = OBJECT_MAPPER.treeToValue(jsonNode.get("error"), JSONRPCError.class); - errorHandler.accept(error); + if (errorHandler != null) { + errorHandler.accept(error); + } } else if (jsonNode.has("result")) { // result can be a Task, Message, TaskStatusUpdateEvent, or TaskArtifactUpdateEvent JsonNode result = jsonNode.path("result"); diff --git a/client-transport/jsonrpc/src/main/resources/META-INF/services/io.a2a.client.transport.spi.ClientTransportProvider b/client-transport/jsonrpc/src/main/resources/META-INF/services/io.a2a.client.transport.spi.ClientTransportProvider new file mode 100644 index 000000000..b2904cb45 --- /dev/null +++ b/client-transport/jsonrpc/src/main/resources/META-INF/services/io.a2a.client.transport.spi.ClientTransportProvider @@ -0,0 +1 @@ +io.a2a.client.transport.jsonrpc.JSONRPCTransportProvider \ No newline at end of file diff --git a/client/src/test/java/io/a2a/client/A2AClientStreamingTest.java b/client-transport/jsonrpc/src/test/java/io/a2a/client/transport/jsonrpc/JSONRPCTransportStreamingTest.java similarity index 84% rename from client/src/test/java/io/a2a/client/A2AClientStreamingTest.java rename to client-transport/jsonrpc/src/test/java/io/a2a/client/transport/jsonrpc/JSONRPCTransportStreamingTest.java index 78b0c0945..0ba3063e0 100644 --- a/client/src/test/java/io/a2a/client/A2AClientStreamingTest.java +++ b/client-transport/jsonrpc/src/test/java/io/a2a/client/transport/jsonrpc/JSONRPCTransportStreamingTest.java @@ -1,9 +1,9 @@ -package io.a2a.client; +package io.a2a.client.transport.jsonrpc; -import static io.a2a.client.JsonStreamingMessages.SEND_MESSAGE_STREAMING_TEST_REQUEST; -import static io.a2a.client.JsonStreamingMessages.SEND_MESSAGE_STREAMING_TEST_RESPONSE; -import static io.a2a.client.JsonStreamingMessages.TASK_RESUBSCRIPTION_REQUEST_TEST_RESPONSE; -import static io.a2a.client.JsonStreamingMessages.TASK_RESUBSCRIPTION_TEST_REQUEST; +import static io.a2a.client.transport.jsonrpc.JsonStreamingMessages.SEND_MESSAGE_STREAMING_TEST_REQUEST; +import static io.a2a.client.transport.jsonrpc.JsonStreamingMessages.SEND_MESSAGE_STREAMING_TEST_RESPONSE; +import static io.a2a.client.transport.jsonrpc.JsonStreamingMessages.TASK_RESUBSCRIPTION_REQUEST_TEST_RESPONSE; +import static io.a2a.client.transport.jsonrpc.JsonStreamingMessages.TASK_RESUBSCRIPTION_TEST_REQUEST; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertInstanceOf; import static org.junit.jupiter.api.Assertions.assertNotNull; @@ -18,8 +18,8 @@ import java.util.concurrent.atomic.AtomicReference; import java.util.function.Consumer; +import io.a2a.client.transport.jsonrpc.JSONRPCTransport; import io.a2a.spec.Artifact; -import io.a2a.spec.JSONRPCError; import io.a2a.spec.Message; import io.a2a.spec.MessageSendConfiguration; import io.a2a.spec.MessageSendParams; @@ -37,7 +37,7 @@ import org.mockserver.matchers.MatchType; import org.mockserver.model.JsonBody; -public class A2AClientStreamingTest { +public class JSONRPCTransportStreamingTest { private ClientAndServer server; @@ -85,7 +85,7 @@ public void testA2AClientSendStreamingMessage() throws Exception { request() .withMethod("POST") .withPath("/") - .withBody(JsonBody.json(SEND_MESSAGE_STREAMING_TEST_REQUEST, MatchType.STRICT)) + .withBody(JsonBody.json(SEND_MESSAGE_STREAMING_TEST_REQUEST, MatchType.ONLY_MATCHING_FIELDS)) ) .respond( @@ -95,7 +95,7 @@ public void testA2AClientSendStreamingMessage() throws Exception { .withBody(SEND_MESSAGE_STREAMING_TEST_RESPONSE) ); - A2AClient client = new A2AClient("http://localhost:4001"); + JSONRPCTransport client = new JSONRPCTransport("http://localhost:4001"); Message message = new Message.Builder() .role(Message.Role.USER) .parts(Collections.singletonList(new TextPart("tell me some jokes"))) @@ -117,9 +117,8 @@ public void testA2AClientSendStreamingMessage() throws Exception { receivedEvent.set(event); latch.countDown(); }; - Consumer errorHandler = error -> {}; - Runnable failureHandler = () -> {}; - client.sendStreamingMessage("request-1234", params, eventHandler, errorHandler, failureHandler); + Consumer errorHandler = error -> {}; + client.sendMessageStreaming(params, eventHandler, errorHandler, null); boolean eventReceived = latch.await(10, TimeUnit.SECONDS); assertTrue(eventReceived); @@ -132,7 +131,7 @@ public void testA2AClientResubscribeToTask() throws Exception { request() .withMethod("POST") .withPath("/") - .withBody(JsonBody.json(TASK_RESUBSCRIPTION_TEST_REQUEST, MatchType.STRICT)) + .withBody(JsonBody.json(TASK_RESUBSCRIPTION_TEST_REQUEST, MatchType.ONLY_MATCHING_FIELDS)) ) .respond( @@ -142,7 +141,7 @@ public void testA2AClientResubscribeToTask() throws Exception { .withBody(TASK_RESUBSCRIPTION_REQUEST_TEST_RESPONSE) ); - A2AClient client = new A2AClient("http://localhost:4001"); + JSONRPCTransport client = new JSONRPCTransport("http://localhost:4001"); TaskIdParams taskIdParams = new TaskIdParams("task-1234"); AtomicReference receivedEvent = new AtomicReference<>(); @@ -151,9 +150,8 @@ public void testA2AClientResubscribeToTask() throws Exception { receivedEvent.set(event); latch.countDown(); }; - Consumer errorHandler = error -> {}; - Runnable failureHandler = () -> {}; - client.resubscribeToTask("request-1234", taskIdParams, eventHandler, errorHandler, failureHandler); + Consumer errorHandler = error -> {}; + client.resubscribe(taskIdParams, eventHandler, errorHandler, null); boolean eventReceived = latch.await(10, TimeUnit.SECONDS); assertTrue(eventReceived); diff --git a/client/src/test/java/io/a2a/client/A2AClientTest.java b/client-transport/jsonrpc/src/test/java/io/a2a/client/transport/jsonrpc/JSONRPCTransportTest.java similarity index 76% rename from client/src/test/java/io/a2a/client/A2AClientTest.java rename to client-transport/jsonrpc/src/test/java/io/a2a/client/transport/jsonrpc/JSONRPCTransportTest.java index 373aa3d9f..5e2dcaa77 100644 --- a/client/src/test/java/io/a2a/client/A2AClientTest.java +++ b/client-transport/jsonrpc/src/test/java/io/a2a/client/transport/jsonrpc/JSONRPCTransportTest.java @@ -1,29 +1,28 @@ -package io.a2a.client; - -import static io.a2a.client.JsonMessages.AGENT_CARD; -import static io.a2a.client.JsonMessages.AUTHENTICATION_EXTENDED_AGENT_CARD; -import static io.a2a.client.JsonMessages.CANCEL_TASK_TEST_REQUEST; -import static io.a2a.client.JsonMessages.CANCEL_TASK_TEST_RESPONSE; -import static io.a2a.client.JsonMessages.GET_AUTHENTICATED_EXTENDED_AGENT_CARD_REQUEST; -import static io.a2a.client.JsonMessages.GET_AUTHENTICATED_EXTENDED_AGENT_CARD_RESPONSE; -import static io.a2a.client.JsonMessages.GET_TASK_PUSH_NOTIFICATION_CONFIG_TEST_REQUEST; -import static io.a2a.client.JsonMessages.GET_TASK_PUSH_NOTIFICATION_CONFIG_TEST_RESPONSE; -import static io.a2a.client.JsonMessages.GET_TASK_TEST_REQUEST; -import static io.a2a.client.JsonMessages.GET_TASK_TEST_RESPONSE; -import static io.a2a.client.JsonMessages.SEND_MESSAGE_ERROR_TEST_RESPONSE; -import static io.a2a.client.JsonMessages.SEND_MESSAGE_TEST_REQUEST; -import static io.a2a.client.JsonMessages.SEND_MESSAGE_TEST_REQUEST_WITH_MESSAGE_RESPONSE; -import static io.a2a.client.JsonMessages.SEND_MESSAGE_TEST_RESPONSE; -import static io.a2a.client.JsonMessages.SEND_MESSAGE_TEST_RESPONSE_WITH_MESSAGE_RESPONSE; -import static io.a2a.client.JsonMessages.SEND_MESSAGE_WITH_ERROR_TEST_REQUEST; -import static io.a2a.client.JsonMessages.SEND_MESSAGE_WITH_FILE_PART_TEST_REQUEST; -import static io.a2a.client.JsonMessages.SEND_MESSAGE_WITH_FILE_PART_TEST_RESPONSE; -import static io.a2a.client.JsonMessages.SEND_MESSAGE_WITH_DATA_PART_TEST_REQUEST; -import static io.a2a.client.JsonMessages.SEND_MESSAGE_WITH_DATA_PART_TEST_RESPONSE; -import static io.a2a.client.JsonMessages.SEND_MESSAGE_WITH_MIXED_PARTS_TEST_REQUEST; -import static io.a2a.client.JsonMessages.SEND_MESSAGE_WITH_MIXED_PARTS_TEST_RESPONSE; -import static io.a2a.client.JsonMessages.SET_TASK_PUSH_NOTIFICATION_CONFIG_TEST_REQUEST; -import static io.a2a.client.JsonMessages.SET_TASK_PUSH_NOTIFICATION_CONFIG_TEST_RESPONSE; +package io.a2a.client.transport.jsonrpc; + +import static io.a2a.client.transport.jsonrpc.JsonMessages.AGENT_CARD; +import static io.a2a.client.transport.jsonrpc.JsonMessages.AGENT_CARD_SUPPORTS_EXTENDED; +import static io.a2a.client.transport.jsonrpc.JsonMessages.AUTHENTICATION_EXTENDED_AGENT_CARD; +import static io.a2a.client.transport.jsonrpc.JsonMessages.CANCEL_TASK_TEST_REQUEST; +import static io.a2a.client.transport.jsonrpc.JsonMessages.CANCEL_TASK_TEST_RESPONSE; +import static io.a2a.client.transport.jsonrpc.JsonMessages.GET_TASK_PUSH_NOTIFICATION_CONFIG_TEST_REQUEST; +import static io.a2a.client.transport.jsonrpc.JsonMessages.GET_TASK_PUSH_NOTIFICATION_CONFIG_TEST_RESPONSE; +import static io.a2a.client.transport.jsonrpc.JsonMessages.GET_TASK_TEST_REQUEST; +import static io.a2a.client.transport.jsonrpc.JsonMessages.GET_TASK_TEST_RESPONSE; +import static io.a2a.client.transport.jsonrpc.JsonMessages.SEND_MESSAGE_ERROR_TEST_RESPONSE; +import static io.a2a.client.transport.jsonrpc.JsonMessages.SEND_MESSAGE_TEST_REQUEST; +import static io.a2a.client.transport.jsonrpc.JsonMessages.SEND_MESSAGE_TEST_REQUEST_WITH_MESSAGE_RESPONSE; +import static io.a2a.client.transport.jsonrpc.JsonMessages.SEND_MESSAGE_TEST_RESPONSE; +import static io.a2a.client.transport.jsonrpc.JsonMessages.SEND_MESSAGE_TEST_RESPONSE_WITH_MESSAGE_RESPONSE; +import static io.a2a.client.transport.jsonrpc.JsonMessages.SEND_MESSAGE_WITH_DATA_PART_TEST_REQUEST; +import static io.a2a.client.transport.jsonrpc.JsonMessages.SEND_MESSAGE_WITH_DATA_PART_TEST_RESPONSE; +import static io.a2a.client.transport.jsonrpc.JsonMessages.SEND_MESSAGE_WITH_ERROR_TEST_REQUEST; +import static io.a2a.client.transport.jsonrpc.JsonMessages.SEND_MESSAGE_WITH_FILE_PART_TEST_REQUEST; +import static io.a2a.client.transport.jsonrpc.JsonMessages.SEND_MESSAGE_WITH_FILE_PART_TEST_RESPONSE; +import static io.a2a.client.transport.jsonrpc.JsonMessages.SEND_MESSAGE_WITH_MIXED_PARTS_TEST_REQUEST; +import static io.a2a.client.transport.jsonrpc.JsonMessages.SEND_MESSAGE_WITH_MIXED_PARTS_TEST_RESPONSE; +import static io.a2a.client.transport.jsonrpc.JsonMessages.SET_TASK_PUSH_NOTIFICATION_CONFIG_TEST_REQUEST; +import static io.a2a.client.transport.jsonrpc.JsonMessages.SET_TASK_PUSH_NOTIFICATION_CONFIG_TEST_RESPONSE; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertInstanceOf; @@ -38,22 +37,17 @@ import java.util.List; import java.util.Map; -import io.a2a.spec.A2AServerException; +import io.a2a.spec.A2AClientException; import io.a2a.spec.AgentCard; -import io.a2a.spec.AgentCardSignature; -import io.a2a.spec.AgentInterface; import io.a2a.spec.AgentSkill; import io.a2a.spec.Artifact; -import io.a2a.spec.CancelTaskResponse; import io.a2a.spec.DataPart; +import io.a2a.spec.EventKind; import io.a2a.spec.FileContent; import io.a2a.spec.FilePart; import io.a2a.spec.FileWithBytes; import io.a2a.spec.FileWithUri; -import io.a2a.spec.GetAuthenticatedExtendedCardResponse; import io.a2a.spec.GetTaskPushNotificationConfigParams; -import io.a2a.spec.GetTaskPushNotificationConfigResponse; -import io.a2a.spec.GetTaskResponse; import io.a2a.spec.Message; import io.a2a.spec.MessageSendConfiguration; import io.a2a.spec.MessageSendParams; @@ -62,15 +56,12 @@ import io.a2a.spec.PushNotificationAuthenticationInfo; import io.a2a.spec.PushNotificationConfig; import io.a2a.spec.SecurityScheme; -import io.a2a.spec.SendMessageResponse; -import io.a2a.spec.SetTaskPushNotificationConfigResponse; import io.a2a.spec.Task; import io.a2a.spec.TaskIdParams; import io.a2a.spec.TaskPushNotificationConfig; import io.a2a.spec.TaskQueryParams; import io.a2a.spec.TaskState; import io.a2a.spec.TextPart; -import io.a2a.spec.TransportProtocol; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; @@ -79,7 +70,7 @@ import org.mockserver.matchers.MatchType; import org.mockserver.model.JsonBody; -public class A2AClientTest { +public class JSONRPCTransportTest { private ClientAndServer server; @@ -99,7 +90,7 @@ public void testA2AClientSendMessage() throws Exception { request() .withMethod("POST") .withPath("/") - .withBody(JsonBody.json(SEND_MESSAGE_TEST_REQUEST, MatchType.STRICT)) + .withBody(JsonBody.json(SEND_MESSAGE_TEST_REQUEST, MatchType.ONLY_MATCHING_FIELDS)) ) .respond( @@ -108,7 +99,7 @@ public void testA2AClientSendMessage() throws Exception { .withBody(SEND_MESSAGE_TEST_RESPONSE) ); - A2AClient client = new A2AClient("http://localhost:4001"); + JSONRPCTransport client = new JSONRPCTransport("http://localhost:4001"); Message message = new Message.Builder() .role(Message.Role.USER) .parts(Collections.singletonList(new TextPart("tell me a joke"))) @@ -124,11 +115,7 @@ public void testA2AClientSendMessage() throws Exception { .configuration(configuration) .build(); - SendMessageResponse response = client.sendMessage("request-1234", params); - - assertEquals("2.0", response.getJsonrpc()); - assertNotNull(response.getId()); - Object result = response.getResult(); + EventKind result = client.sendMessage(params, null); assertInstanceOf(Task.class, result); Task task = (Task) result; assertEquals("de38c76d-d54c-436c-8b9f-4c2703648d64", task.getId()); @@ -151,7 +138,7 @@ public void testA2AClientSendMessageWithMessageResponse() throws Exception { request() .withMethod("POST") .withPath("/") - .withBody(JsonBody.json(SEND_MESSAGE_TEST_REQUEST_WITH_MESSAGE_RESPONSE, MatchType.STRICT)) + .withBody(JsonBody.json(SEND_MESSAGE_TEST_REQUEST_WITH_MESSAGE_RESPONSE, MatchType.ONLY_MATCHING_FIELDS)) ) .respond( @@ -160,7 +147,7 @@ public void testA2AClientSendMessageWithMessageResponse() throws Exception { .withBody(SEND_MESSAGE_TEST_RESPONSE_WITH_MESSAGE_RESPONSE) ); - A2AClient client = new A2AClient("http://localhost:4001"); + JSONRPCTransport client = new JSONRPCTransport("http://localhost:4001"); Message message = new Message.Builder() .role(Message.Role.USER) .parts(Collections.singletonList(new TextPart("tell me a joke"))) @@ -176,11 +163,7 @@ public void testA2AClientSendMessageWithMessageResponse() throws Exception { .configuration(configuration) .build(); - SendMessageResponse response = client.sendMessage("request-1234-with-message-response", params); - - assertEquals("2.0", response.getJsonrpc()); - assertNotNull(response.getId()); - Object result = response.getResult(); + EventKind result = client.sendMessage(params, null); assertInstanceOf(Message.class, result); Message agentMessage = (Message) result; assertEquals(Message.Role.AGENT, agentMessage.getRole()); @@ -197,7 +180,7 @@ public void testA2AClientSendMessageWithError() throws Exception { request() .withMethod("POST") .withPath("/") - .withBody(JsonBody.json(SEND_MESSAGE_WITH_ERROR_TEST_REQUEST, MatchType.STRICT)) + .withBody(JsonBody.json(SEND_MESSAGE_WITH_ERROR_TEST_REQUEST, MatchType.ONLY_MATCHING_FIELDS)) ) .respond( @@ -206,7 +189,7 @@ public void testA2AClientSendMessageWithError() throws Exception { .withBody(SEND_MESSAGE_ERROR_TEST_RESPONSE) ); - A2AClient client = new A2AClient("http://localhost:4001"); + JSONRPCTransport client = new JSONRPCTransport("http://localhost:4001"); Message message = new Message.Builder() .role(Message.Role.USER) .parts(Collections.singletonList(new TextPart("tell me a joke"))) @@ -223,9 +206,9 @@ public void testA2AClientSendMessageWithError() throws Exception { .build(); try { - client.sendMessage("request-1234-with-error", params); + client.sendMessage(params, null); fail(); // should not reach here - } catch (A2AServerException e) { + } catch (A2AClientException e) { assertTrue(e.getMessage().contains("Invalid parameters: Hello world")); } } @@ -236,7 +219,7 @@ public void testA2AClientGetTask() throws Exception { request() .withMethod("POST") .withPath("/") - .withBody(JsonBody.json(GET_TASK_TEST_REQUEST, MatchType.STRICT)) + .withBody(JsonBody.json(GET_TASK_TEST_REQUEST, MatchType.ONLY_MATCHING_FIELDS)) ) .respond( @@ -245,15 +228,9 @@ public void testA2AClientGetTask() throws Exception { .withBody(GET_TASK_TEST_RESPONSE) ); - A2AClient client = new A2AClient("http://localhost:4001"); - GetTaskResponse response = client.getTask("request-1234", - new TaskQueryParams("de38c76d-d54c-436c-8b9f-4c2703648d64", 10)); - - assertEquals("2.0", response.getJsonrpc()); - assertEquals(1, response.getId()); - Object result = response.getResult(); - assertInstanceOf(Task.class, result); - Task task = (Task) result; + JSONRPCTransport client = new JSONRPCTransport("http://localhost:4001"); + Task task = client.getTask(new TaskQueryParams("de38c76d-d54c-436c-8b9f-4c2703648d64", + 10), null); assertEquals("de38c76d-d54c-436c-8b9f-4c2703648d64", task.getId()); assertEquals("c295ea44-7543-4f78-b524-7a38915ad6e4", task.getContextId()); assertEquals(TaskState.COMPLETED, task.getStatus().state()); @@ -295,7 +272,7 @@ public void testA2AClientCancelTask() throws Exception { request() .withMethod("POST") .withPath("/") - .withBody(JsonBody.json(CANCEL_TASK_TEST_REQUEST, MatchType.STRICT)) + .withBody(JsonBody.json(CANCEL_TASK_TEST_REQUEST, MatchType.ONLY_MATCHING_FIELDS)) ) .respond( @@ -304,15 +281,9 @@ public void testA2AClientCancelTask() throws Exception { .withBody(CANCEL_TASK_TEST_RESPONSE) ); - A2AClient client = new A2AClient("http://localhost:4001"); - CancelTaskResponse response = client.cancelTask("request-1234", - new TaskIdParams("de38c76d-d54c-436c-8b9f-4c2703648d64", new HashMap<>())); - - assertEquals("2.0", response.getJsonrpc()); - assertEquals(1, response.getId()); - Object result = response.getResult(); - assertInstanceOf(Task.class, result); - Task task = (Task) result; + JSONRPCTransport client = new JSONRPCTransport("http://localhost:4001"); + Task task = client.cancelTask(new TaskIdParams("de38c76d-d54c-436c-8b9f-4c2703648d64", + new HashMap<>()), null); assertEquals("de38c76d-d54c-436c-8b9f-4c2703648d64", task.getId()); assertEquals("c295ea44-7543-4f78-b524-7a38915ad6e4", task.getContextId()); assertEquals(TaskState.CANCELED, task.getStatus().state()); @@ -325,7 +296,7 @@ public void testA2AClientGetTaskPushNotificationConfig() throws Exception { request() .withMethod("POST") .withPath("/") - .withBody(JsonBody.json(GET_TASK_PUSH_NOTIFICATION_CONFIG_TEST_REQUEST, MatchType.STRICT)) + .withBody(JsonBody.json(GET_TASK_PUSH_NOTIFICATION_CONFIG_TEST_REQUEST, MatchType.ONLY_MATCHING_FIELDS)) ) .respond( @@ -334,13 +305,10 @@ public void testA2AClientGetTaskPushNotificationConfig() throws Exception { .withBody(GET_TASK_PUSH_NOTIFICATION_CONFIG_TEST_RESPONSE) ); - A2AClient client = new A2AClient("http://localhost:4001"); - GetTaskPushNotificationConfigResponse response = client.getTaskPushNotificationConfig("1", - new GetTaskPushNotificationConfigParams("de38c76d-d54c-436c-8b9f-4c2703648d64", null, new HashMap<>())); - assertEquals("2.0", response.getJsonrpc()); - assertEquals(1, response.getId()); - assertInstanceOf(TaskPushNotificationConfig.class, response.getResult()); - TaskPushNotificationConfig taskPushNotificationConfig = (TaskPushNotificationConfig) response.getResult(); + JSONRPCTransport client = new JSONRPCTransport("http://localhost:4001"); + TaskPushNotificationConfig taskPushNotificationConfig = client.getTaskPushNotificationConfiguration( + new GetTaskPushNotificationConfigParams("de38c76d-d54c-436c-8b9f-4c2703648d64", null, + new HashMap<>()), null); PushNotificationConfig pushNotificationConfig = taskPushNotificationConfig.pushNotificationConfig(); assertNotNull(pushNotificationConfig); assertEquals("https://example.com/callback", pushNotificationConfig.url()); @@ -355,7 +323,7 @@ public void testA2AClientSetTaskPushNotificationConfig() throws Exception { request() .withMethod("POST") .withPath("/") - .withBody(JsonBody.json(SET_TASK_PUSH_NOTIFICATION_CONFIG_TEST_REQUEST, MatchType.STRICT)) + .withBody(JsonBody.json(SET_TASK_PUSH_NOTIFICATION_CONFIG_TEST_REQUEST, MatchType.ONLY_MATCHING_FIELDS)) ) .respond( @@ -364,22 +332,19 @@ public void testA2AClientSetTaskPushNotificationConfig() throws Exception { .withBody(SET_TASK_PUSH_NOTIFICATION_CONFIG_TEST_RESPONSE) ); - A2AClient client = new A2AClient("http://localhost:4001"); - SetTaskPushNotificationConfigResponse response = client.setTaskPushNotificationConfig("1", - "de38c76d-d54c-436c-8b9f-4c2703648d64", - new PushNotificationConfig.Builder() - .url("https://example.com/callback") - .authenticationInfo(new PushNotificationAuthenticationInfo(Collections.singletonList("jwt"), null)) - .build()); - assertEquals("2.0", response.getJsonrpc()); - assertEquals(1, response.getId()); - assertInstanceOf(TaskPushNotificationConfig.class, response.getResult()); - TaskPushNotificationConfig taskPushNotificationConfig = (TaskPushNotificationConfig) response.getResult(); + JSONRPCTransport client = new JSONRPCTransport("http://localhost:4001"); + TaskPushNotificationConfig taskPushNotificationConfig = client.setTaskPushNotificationConfiguration( + new TaskPushNotificationConfig("de38c76d-d54c-436c-8b9f-4c2703648d64", + new PushNotificationConfig.Builder() + .url("https://example.com/callback") + .authenticationInfo(new PushNotificationAuthenticationInfo(Collections.singletonList("jwt"), + null)) + .build()), null); PushNotificationConfig pushNotificationConfig = taskPushNotificationConfig.pushNotificationConfig(); assertNotNull(pushNotificationConfig); assertEquals("https://example.com/callback", pushNotificationConfig.url()); PushNotificationAuthenticationInfo authenticationInfo = pushNotificationConfig.authentication(); - assertTrue(authenticationInfo.schemes().size() == 1); + assertEquals(1, authenticationInfo.schemes().size()); assertEquals("jwt", authenticationInfo.schemes().get(0)); } @@ -389,7 +354,7 @@ public void testA2AClientGetAgentCard() throws Exception { this.server.when( request() .withMethod("GET") - .withPath("/.well-known/agent-card.json") + .withPath("/.well-known/agent.json") ) .respond( response() @@ -397,8 +362,8 @@ public void testA2AClientGetAgentCard() throws Exception { .withBody(AGENT_CARD) ); - A2AClient client = new A2AClient("http://localhost:4001"); - AgentCard agentCard = client.getAgentCard(); + JSONRPCTransport client = new JSONRPCTransport("http://localhost:4001"); + AgentCard agentCard = client.getAgentCard(null); assertEquals("GeoSpatial Route Planner Agent", agentCard.name()); assertEquals("Provides advanced route planning, traffic analysis, and custom map generation services. This agent can calculate optimal routes, estimate travel times considering real-time traffic, and create personalized maps with points of interest.", agentCard.description()); assertEquals("https://georoute-agent.example.com/a2a/v1", agentCard.url()); @@ -448,38 +413,36 @@ public void testA2AClientGetAgentCard() throws Exception { assertEquals(inputModes, skills.get(1).inputModes()); outputModes = List.of("image/png", "image/jpeg", "application/json", "text/html"); assertEquals(outputModes, skills.get(1).outputModes()); - assertTrue(agentCard.supportsAuthenticatedExtendedCard()); + assertFalse(agentCard.supportsAuthenticatedExtendedCard()); assertEquals("https://georoute-agent.example.com/icon.png", agentCard.iconUrl()); - assertEquals("0.2.9", agentCard.protocolVersion()); - assertEquals("JSONRPC", agentCard.preferredTransport()); - List additionalInterfaces = agentCard.additionalInterfaces(); - assertEquals(3, additionalInterfaces.size()); - AgentInterface jsonrpc = new AgentInterface(TransportProtocol.JSONRPC.asString(), "https://georoute-agent.example.com/a2a/v1"); - AgentInterface grpc = new AgentInterface(TransportProtocol.GRPC.asString(), "https://georoute-agent.example.com/a2a/grpc"); - AgentInterface httpJson = new AgentInterface(TransportProtocol.HTTP_JSON.asString(), "https://georoute-agent.example.com/a2a/json"); - assertEquals(jsonrpc, additionalInterfaces.get(0)); - assertEquals(grpc, additionalInterfaces.get(1)); - assertEquals(httpJson, additionalInterfaces.get(2)); + assertEquals("0.2.5", agentCard.protocolVersion()); } @Test public void testA2AClientGetAuthenticatedExtendedAgentCard() throws Exception { this.server.when( request() - .withMethod("POST") - .withPath("/") - .withBody(JsonBody.json(GET_AUTHENTICATED_EXTENDED_AGENT_CARD_REQUEST, MatchType.STRICT)) - + .withMethod("GET") + .withPath("/.well-known/agent.json") ) .respond( response() .withStatusCode(200) - .withBody(GET_AUTHENTICATED_EXTENDED_AGENT_CARD_RESPONSE) + .withBody(AGENT_CARD_SUPPORTS_EXTENDED) + ); + this.server.when( + request() + .withMethod("GET") + .withPath("/agent/authenticatedExtendedCard") + ) + .respond( + response() + .withStatusCode(200) + .withBody(AUTHENTICATION_EXTENDED_AGENT_CARD) ); - A2AClient client = new A2AClient("http://localhost:4001"); - GetAuthenticatedExtendedCardResponse response = client.getAuthenticatedExtendedCard("1", null); - AgentCard agentCard = response.getResult(); + JSONRPCTransport client = new JSONRPCTransport("http://localhost:4001"); + AgentCard agentCard = client.getAgentCard(null); assertEquals("GeoSpatial Route Planner Agent Extended", agentCard.name()); assertEquals("Extended description", agentCard.description()); assertEquals("https://georoute-agent.example.com/a2a/v1", agentCard.url()); @@ -535,13 +498,7 @@ public void testA2AClientGetAuthenticatedExtendedAgentCard() throws Exception { assertEquals(List.of("extended"), skills.get(2).tags()); assertTrue(agentCard.supportsAuthenticatedExtendedCard()); assertEquals("https://georoute-agent.example.com/icon.png", agentCard.iconUrl()); - assertEquals("0.2.9", agentCard.protocolVersion()); - List signatures = agentCard.signatures(); - assertEquals(1, signatures.size()); - AgentCardSignature signature = new AgentCardSignature(null, - "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpPU0UiLCJraWQiOiJrZXktMSIsImprdSI6Imh0dHBzOi8vZXhhbXBsZS5jb20vYWdlbnQvandrcy5qc29uIn0", - "QFdkNLNszlGj3z3u0YQGt_T9LixY3qtdQpZmsTdDHDe3fXV9y9-B3m2-XgCpzuhiLt8E0tV6HXoZKHv4GtHgKQ"); - assertEquals(signature, signatures.get(0)); + assertEquals("0.2.5", agentCard.protocolVersion()); } @Test @@ -550,7 +507,7 @@ public void testA2AClientSendMessageWithFilePart() throws Exception { request() .withMethod("POST") .withPath("/") - .withBody(JsonBody.json(SEND_MESSAGE_WITH_FILE_PART_TEST_REQUEST, MatchType.STRICT)) + .withBody(JsonBody.json(SEND_MESSAGE_WITH_FILE_PART_TEST_REQUEST, MatchType.ONLY_MATCHING_FIELDS)) ) .respond( @@ -559,7 +516,7 @@ public void testA2AClientSendMessageWithFilePart() throws Exception { .withBody(SEND_MESSAGE_WITH_FILE_PART_TEST_RESPONSE) ); - A2AClient client = new A2AClient("http://localhost:4001"); + JSONRPCTransport client = new JSONRPCTransport("http://localhost:4001"); Message message = new Message.Builder() .role(Message.Role.USER) .parts(List.of( @@ -578,11 +535,7 @@ public void testA2AClientSendMessageWithFilePart() throws Exception { .configuration(configuration) .build(); - SendMessageResponse response = client.sendMessage("request-1234-with-file", params); - - assertEquals("2.0", response.getJsonrpc()); - assertNotNull(response.getId()); - Object result = response.getResult(); + EventKind result = client.sendMessage(params, null); assertInstanceOf(Task.class, result); Task task = (Task) result; assertEquals("de38c76d-d54c-436c-8b9f-4c2703648d64", task.getId()); @@ -605,7 +558,7 @@ public void testA2AClientSendMessageWithDataPart() throws Exception { request() .withMethod("POST") .withPath("/") - .withBody(JsonBody.json(SEND_MESSAGE_WITH_DATA_PART_TEST_REQUEST, MatchType.STRICT)) + .withBody(JsonBody.json(SEND_MESSAGE_WITH_DATA_PART_TEST_REQUEST, MatchType.ONLY_MATCHING_FIELDS)) ) .respond( @@ -614,14 +567,14 @@ public void testA2AClientSendMessageWithDataPart() throws Exception { .withBody(SEND_MESSAGE_WITH_DATA_PART_TEST_RESPONSE) ); - A2AClient client = new A2AClient("http://localhost:4001"); - + JSONRPCTransport client = new JSONRPCTransport("http://localhost:4001"); + Map data = new HashMap<>(); data.put("temperature", 25.5); data.put("humidity", 60.2); data.put("location", "San Francisco"); data.put("timestamp", "2024-01-15T10:30:00Z"); - + Message message = new Message.Builder() .role(Message.Role.USER) .parts(List.of( @@ -640,11 +593,7 @@ public void testA2AClientSendMessageWithDataPart() throws Exception { .configuration(configuration) .build(); - SendMessageResponse response = client.sendMessage("request-1234-with-data", params); - - assertEquals("2.0", response.getJsonrpc()); - assertNotNull(response.getId()); - Object result = response.getResult(); + EventKind result = client.sendMessage(params, null); assertInstanceOf(Task.class, result); Task task = (Task) result; assertEquals("de38c76d-d54c-436c-8b9f-4c2703648d64", task.getId()); @@ -667,7 +616,7 @@ public void testA2AClientSendMessageWithMixedParts() throws Exception { request() .withMethod("POST") .withPath("/") - .withBody(JsonBody.json(SEND_MESSAGE_WITH_MIXED_PARTS_TEST_REQUEST, MatchType.STRICT)) + .withBody(JsonBody.json(SEND_MESSAGE_WITH_MIXED_PARTS_TEST_REQUEST, MatchType.ONLY_MATCHING_FIELDS)) ) .respond( @@ -676,13 +625,13 @@ public void testA2AClientSendMessageWithMixedParts() throws Exception { .withBody(SEND_MESSAGE_WITH_MIXED_PARTS_TEST_RESPONSE) ); - A2AClient client = new A2AClient("http://localhost:4001"); - + JSONRPCTransport client = new JSONRPCTransport("http://localhost:4001"); + Map data = new HashMap<>(); data.put("chartType", "bar"); data.put("dataPoints", List.of(10, 20, 30, 40)); data.put("labels", List.of("Q1", "Q2", "Q3", "Q4")); - + Message message = new Message.Builder() .role(Message.Role.USER) .parts(List.of( @@ -702,11 +651,7 @@ public void testA2AClientSendMessageWithMixedParts() throws Exception { .configuration(configuration) .build(); - SendMessageResponse response = client.sendMessage("request-1234-with-mixed", params); - - assertEquals("2.0", response.getJsonrpc()); - assertNotNull(response.getId()); - Object result = response.getResult(); + EventKind result = client.sendMessage(params, null); assertInstanceOf(Task.class, result); Task task = (Task) result; assertEquals("de38c76d-d54c-436c-8b9f-4c2703648d64", task.getId()); diff --git a/client-transport/jsonrpc/src/test/java/io/a2a/client/transport/jsonrpc/JsonMessages.java b/client-transport/jsonrpc/src/test/java/io/a2a/client/transport/jsonrpc/JsonMessages.java new file mode 100644 index 000000000..b0a5fc111 --- /dev/null +++ b/client-transport/jsonrpc/src/test/java/io/a2a/client/transport/jsonrpc/JsonMessages.java @@ -0,0 +1,666 @@ +package io.a2a.client.transport.jsonrpc; + +/** + * Request and response messages used by the tests. These have been created following examples from + * the A2A sample messages. + */ +public class JsonMessages { + + static final String AGENT_CARD = """ + { + "name": "GeoSpatial Route Planner Agent", + "description": "Provides advanced route planning, traffic analysis, and custom map generation services. This agent can calculate optimal routes, estimate travel times considering real-time traffic, and create personalized maps with points of interest.", + "url": "https://georoute-agent.example.com/a2a/v1", + "provider": { + "organization": "Example Geo Services Inc.", + "url": "https://www.examplegeoservices.com" + }, + "iconUrl": "https://georoute-agent.example.com/icon.png", + "version": "1.2.0", + "documentationUrl": "https://docs.examplegeoservices.com/georoute-agent/api", + "capabilities": { + "streaming": true, + "pushNotifications": true, + "stateTransitionHistory": false + }, + "securitySchemes": { + "google": { + "type": "openIdConnect", + "openIdConnectUrl": "https://accounts.google.com/.well-known/openid-configuration" + } + }, + "security": [{ "google": ["openid", "profile", "email"] }], + "defaultInputModes": ["application/json", "text/plain"], + "defaultOutputModes": ["application/json", "image/png"], + "skills": [ + { + "id": "route-optimizer-traffic", + "name": "Traffic-Aware Route Optimizer", + "description": "Calculates the optimal driving route between two or more locations, taking into account real-time traffic conditions, road closures, and user preferences (e.g., avoid tolls, prefer highways).", + "tags": ["maps", "routing", "navigation", "directions", "traffic"], + "examples": [ + "Plan a route from '1600 Amphitheatre Parkway, Mountain View, CA' to 'San Francisco International Airport' avoiding tolls.", + "{\\"origin\\": {\\"lat\\": 37.422, \\"lng\\": -122.084}, \\"destination\\": {\\"lat\\": 37.7749, \\"lng\\": -122.4194}, \\"preferences\\": [\\"avoid_ferries\\"]}" + ], + "inputModes": ["application/json", "text/plain"], + "outputModes": [ + "application/json", + "application/vnd.geo+json", + "text/html" + ] + }, + { + "id": "custom-map-generator", + "name": "Personalized Map Generator", + "description": "Creates custom map images or interactive map views based on user-defined points of interest, routes, and style preferences. Can overlay data layers.", + "tags": ["maps", "customization", "visualization", "cartography"], + "examples": [ + "Generate a map of my upcoming road trip with all planned stops highlighted.", + "Show me a map visualizing all coffee shops within a 1-mile radius of my current location." + ], + "inputModes": ["application/json"], + "outputModes": [ + "image/png", + "image/jpeg", + "application/json", + "text/html" + ] + } + ], + "supportsAuthenticatedExtendedCard": false, + "protocolVersion": "0.2.5" + }"""; + + static final String AGENT_CARD_SUPPORTS_EXTENDED = """ + { + "name": "GeoSpatial Route Planner Agent", + "description": "Provides advanced route planning, traffic analysis, and custom map generation services. This agent can calculate optimal routes, estimate travel times considering real-time traffic, and create personalized maps with points of interest.", + "url": "https://georoute-agent.example.com/a2a/v1", + "provider": { + "organization": "Example Geo Services Inc.", + "url": "https://www.examplegeoservices.com" + }, + "iconUrl": "https://georoute-agent.example.com/icon.png", + "version": "1.2.0", + "documentationUrl": "https://docs.examplegeoservices.com/georoute-agent/api", + "capabilities": { + "streaming": true, + "pushNotifications": true, + "stateTransitionHistory": false + }, + "securitySchemes": { + "google": { + "type": "openIdConnect", + "openIdConnectUrl": "https://accounts.google.com/.well-known/openid-configuration" + } + }, + "security": [{ "google": ["openid", "profile", "email"] }], + "defaultInputModes": ["application/json", "text/plain"], + "defaultOutputModes": ["application/json", "image/png"], + "skills": [ + { + "id": "route-optimizer-traffic", + "name": "Traffic-Aware Route Optimizer", + "description": "Calculates the optimal driving route between two or more locations, taking into account real-time traffic conditions, road closures, and user preferences (e.g., avoid tolls, prefer highways).", + "tags": ["maps", "routing", "navigation", "directions", "traffic"], + "examples": [ + "Plan a route from '1600 Amphitheatre Parkway, Mountain View, CA' to 'San Francisco International Airport' avoiding tolls.", + "{\\"origin\\": {\\"lat\\": 37.422, \\"lng\\": -122.084}, \\"destination\\": {\\"lat\\": 37.7749, \\"lng\\": -122.4194}, \\"preferences\\": [\\"avoid_ferries\\"]}" + ], + "inputModes": ["application/json", "text/plain"], + "outputModes": [ + "application/json", + "application/vnd.geo+json", + "text/html" + ] + }, + { + "id": "custom-map-generator", + "name": "Personalized Map Generator", + "description": "Creates custom map images or interactive map views based on user-defined points of interest, routes, and style preferences. Can overlay data layers.", + "tags": ["maps", "customization", "visualization", "cartography"], + "examples": [ + "Generate a map of my upcoming road trip with all planned stops highlighted.", + "Show me a map visualizing all coffee shops within a 1-mile radius of my current location." + ], + "inputModes": ["application/json"], + "outputModes": [ + "image/png", + "image/jpeg", + "application/json", + "text/html" + ] + } + ], + "supportsAuthenticatedExtendedCard": true, + "protocolVersion": "0.2.5" + }"""; + + + static final String AUTHENTICATION_EXTENDED_AGENT_CARD = """ + { + "name": "GeoSpatial Route Planner Agent Extended", + "description": "Extended description", + "url": "https://georoute-agent.example.com/a2a/v1", + "provider": { + "organization": "Example Geo Services Inc.", + "url": "https://www.examplegeoservices.com" + }, + "iconUrl": "https://georoute-agent.example.com/icon.png", + "version": "1.2.0", + "documentationUrl": "https://docs.examplegeoservices.com/georoute-agent/api", + "capabilities": { + "streaming": true, + "pushNotifications": true, + "stateTransitionHistory": false + }, + "securitySchemes": { + "google": { + "type": "openIdConnect", + "openIdConnectUrl": "https://accounts.google.com/.well-known/openid-configuration" + } + }, + "security": [{ "google": ["openid", "profile", "email"] }], + "defaultInputModes": ["application/json", "text/plain"], + "defaultOutputModes": ["application/json", "image/png"], + "skills": [ + { + "id": "route-optimizer-traffic", + "name": "Traffic-Aware Route Optimizer", + "description": "Calculates the optimal driving route between two or more locations, taking into account real-time traffic conditions, road closures, and user preferences (e.g., avoid tolls, prefer highways).", + "tags": ["maps", "routing", "navigation", "directions", "traffic"], + "examples": [ + "Plan a route from '1600 Amphitheatre Parkway, Mountain View, CA' to 'San Francisco International Airport' avoiding tolls.", + "{\\"origin\\": {\\"lat\\": 37.422, \\"lng\\": -122.084}, \\"destination\\": {\\"lat\\": 37.7749, \\"lng\\": -122.4194}, \\"preferences\\": [\\"avoid_ferries\\"]}" + ], + "inputModes": ["application/json", "text/plain"], + "outputModes": [ + "application/json", + "application/vnd.geo+json", + "text/html" + ] + }, + { + "id": "custom-map-generator", + "name": "Personalized Map Generator", + "description": "Creates custom map images or interactive map views based on user-defined points of interest, routes, and style preferences. Can overlay data layers.", + "tags": ["maps", "customization", "visualization", "cartography"], + "examples": [ + "Generate a map of my upcoming road trip with all planned stops highlighted.", + "Show me a map visualizing all coffee shops within a 1-mile radius of my current location." + ], + "inputModes": ["application/json"], + "outputModes": [ + "image/png", + "image/jpeg", + "application/json", + "text/html" + ] + }, + { + "id": "skill-extended", + "name": "Extended Skill", + "description": "This is an extended skill.", + "tags": ["extended"] + } + ], + "supportsAuthenticatedExtendedCard": true, + "protocolVersion": "0.2.5" + }"""; + + + static final String SEND_MESSAGE_TEST_REQUEST = """ + { + "jsonrpc": "2.0", + "method": "message/send", + "params": { + "message": { + "role": "user", + "parts": [ + { + "kind": "text", + "text": "tell me a joke" + } + ], + "messageId": "message-1234", + "contextId": "context-1234", + "kind": "message" + }, + "configuration": { + "acceptedOutputModes": ["text"], + "blocking": true + }, + } + }"""; + + static final String SEND_MESSAGE_TEST_RESPONSE = """ + { + "jsonrpc": "2.0", + "result": { + "id": "de38c76d-d54c-436c-8b9f-4c2703648d64", + "contextId": "c295ea44-7543-4f78-b524-7a38915ad6e4", + "status": { + "state": "completed" + }, + "artifacts": [ + { + "artifactId": "artifact-1", + "name": "joke", + "parts": [ + { + "kind": "text", + "text": "Why did the chicken cross the road? To get to the other side!" + } + ] + } + ], + "metadata": {}, + "kind": "task" + } + }"""; + + static final String SEND_MESSAGE_TEST_REQUEST_WITH_MESSAGE_RESPONSE = """ + { + "jsonrpc": "2.0", + "method": "message/send", + "params": { + "message": { + "role": "user", + "parts": [ + { + "kind": "text", + "text": "tell me a joke" + } + ], + "messageId": "message-1234", + "contextId": "context-1234", + "kind": "message" + }, + "configuration": { + "acceptedOutputModes": ["text"], + "blocking": true + }, + } + }"""; + + + static final String SEND_MESSAGE_TEST_RESPONSE_WITH_MESSAGE_RESPONSE = """ + { + "jsonrpc": "2.0", + "id": 1, + "result": { + "role": "agent", + "parts": [ + { + "kind": "text", + "text": "Why did the chicken cross the road? To get to the other side!" + } + ], + "messageId": "msg-456", + "kind": "message" + } + }"""; + + static final String SEND_MESSAGE_WITH_ERROR_TEST_REQUEST = """ + { + "jsonrpc": "2.0", + "method": "message/send", + "params": { + "message": { + "role": "user", + "parts": [ + { + "kind": "text", + "text": "tell me a joke" + } + ], + "messageId": "message-1234", + "contextId": "context-1234", + "kind": "message" + }, + "configuration": { + "acceptedOutputModes": ["text"], + "blocking": true + }, + } + }"""; + + static final String SEND_MESSAGE_ERROR_TEST_RESPONSE = """ + { + "jsonrpc": "2.0", + "error": { + "code": -32702, + "message": "Invalid parameters", + "data": "Hello world" + } + }"""; + + static final String GET_TASK_TEST_REQUEST = """ + { + "jsonrpc": "2.0", + "method": "tasks/get", + "params": { + "id": "de38c76d-d54c-436c-8b9f-4c2703648d64", + "historyLength": 10 + } + } + """; + + static final String GET_TASK_TEST_RESPONSE = """ + { + "jsonrpc": "2.0", + "result": { + "id": "de38c76d-d54c-436c-8b9f-4c2703648d64", + "contextId": "c295ea44-7543-4f78-b524-7a38915ad6e4", + "status": { + "state": "completed" + }, + "artifacts": [ + { + "artifactId": "artifact-1", + "parts": [ + { + "kind": "text", + "text": "Why did the chicken cross the road? To get to the other side!" + } + ] + } + ], + "history": [ + { + "role": "user", + "parts": [ + { + "kind": "text", + "text": "tell me a joke" + }, + { + "kind": "file", + "file": { + "uri": "file:///path/to/file.txt", + "mimeType": "text/plain" + } + }, + { + "kind": "file", + "file": { + "bytes": "aGVsbG8=", + "name": "hello.txt" + } + } + ], + "messageId": "message-123", + "kind": "message" + } + ], + "metadata": {}, + "kind": "task" + } + } + """; + + static final String CANCEL_TASK_TEST_REQUEST = """ + { + "jsonrpc": "2.0", + "method": "tasks/cancel", + "params": { + "id": "de38c76d-d54c-436c-8b9f-4c2703648d64", + "metadata": {} + } + } + """; + + static final String CANCEL_TASK_TEST_RESPONSE = """ + { + "jsonrpc": "2.0", + "result": { + "id": "de38c76d-d54c-436c-8b9f-4c2703648d64", + "contextId": "c295ea44-7543-4f78-b524-7a38915ad6e4", + "status": { + "state": "canceled" + }, + "metadata": {}, + "kind" : "task" + } + } + """; + + static final String GET_TASK_PUSH_NOTIFICATION_CONFIG_TEST_REQUEST = """ + { + "jsonrpc": "2.0", + "method": "tasks/pushNotificationConfig/get", + "params": { + "id": "de38c76d-d54c-436c-8b9f-4c2703648d64", + "metadata": {}, + } + } + """; + + static final String GET_TASK_PUSH_NOTIFICATION_CONFIG_TEST_RESPONSE = """ + { + "jsonrpc": "2.0", + "result": { + "taskId": "de38c76d-d54c-436c-8b9f-4c2703648d64", + "pushNotificationConfig": { + "url": "https://example.com/callback", + "authentication": { + "schemes": ["jwt"] + } + } + } + } + """; + + static final String SET_TASK_PUSH_NOTIFICATION_CONFIG_TEST_REQUEST = """ + { + "jsonrpc": "2.0", + "method": "tasks/pushNotificationConfig/set", + "params": { + "taskId": "de38c76d-d54c-436c-8b9f-4c2703648d64", + "pushNotificationConfig": { + "url": "https://example.com/callback", + "authentication": { + "schemes": ["jwt"] + } + } + } + }"""; + + static final String SET_TASK_PUSH_NOTIFICATION_CONFIG_TEST_RESPONSE = """ + { + "jsonrpc": "2.0", + "result": { + "taskId": "de38c76d-d54c-436c-8b9f-4c2703648d64", + "pushNotificationConfig": { + "url": "https://example.com/callback", + "authentication": { + "schemes": ["jwt"] + } + } + } + } + """; + + static final String SEND_MESSAGE_WITH_FILE_PART_TEST_REQUEST = """ + { + "jsonrpc": "2.0", + "method": "message/send", + "params": { + "message": { + "role": "user", + "parts": [ + { + "kind": "text", + "text": "analyze this image" + }, + { + "kind": "file", + "file": { + "uri": "file:///path/to/image.jpg", + "mimeType": "image/jpeg" + } + } + ], + "messageId": "message-1234-with-file", + "contextId": "context-1234", + "kind": "message" + }, + "configuration": { + "acceptedOutputModes": ["text"], + "blocking": true + } + } + }"""; + + static final String SEND_MESSAGE_WITH_FILE_PART_TEST_RESPONSE = """ + { + "jsonrpc": "2.0", + "result": { + "id": "de38c76d-d54c-436c-8b9f-4c2703648d64", + "contextId": "c295ea44-7543-4f78-b524-7a38915ad6e4", + "status": { + "state": "completed" + }, + "artifacts": [ + { + "artifactId": "artifact-1", + "name": "image-analysis", + "parts": [ + { + "kind": "text", + "text": "This is an image of a cat sitting on a windowsill." + } + ] + } + ], + "metadata": {}, + "kind": "task" + } + }"""; + + static final String SEND_MESSAGE_WITH_DATA_PART_TEST_REQUEST = """ + { + "jsonrpc": "2.0", + "method": "message/send", + "params": { + "message": { + "role": "user", + "parts": [ + { + "kind": "text", + "text": "process this data" + }, + { + "kind": "data", + "data": { + "temperature": 25.5, + "humidity": 60.2, + "location": "San Francisco", + "timestamp": "2024-01-15T10:30:00Z" + } + } + ], + "messageId": "message-1234-with-data", + "contextId": "context-1234", + "kind": "message" + }, + "configuration": { + "acceptedOutputModes": ["text"], + "blocking": true + } + } + }"""; + + static final String SEND_MESSAGE_WITH_DATA_PART_TEST_RESPONSE = """ + { + "jsonrpc": "2.0", + "result": { + "id": "de38c76d-d54c-436c-8b9f-4c2703648d64", + "contextId": "c295ea44-7543-4f78-b524-7a38915ad6e4", + "status": { + "state": "completed" + }, + "artifacts": [ + { + "artifactId": "artifact-1", + "name": "data-analysis", + "parts": [ + { + "kind": "text", + "text": "Processed weather data: Temperature is 25.5°C, humidity is 60.2% in San Francisco." + } + ] + } + ], + "metadata": {}, + "kind": "task" + } + }"""; + + static final String SEND_MESSAGE_WITH_MIXED_PARTS_TEST_REQUEST = """ + { + "jsonrpc": "2.0", + "method": "message/send", + "params": { + "message": { + "role": "user", + "parts": [ + { + "kind": "text", + "text": "analyze this data and image" + }, + { + "kind": "file", + "file": { + "bytes": "aGVsbG8=", + "name": "chart.png", + "mimeType": "image/png" + } + }, + { + "kind": "data", + "data": { + "chartType": "bar", + "dataPoints": [10, 20, 30, 40], + "labels": ["Q1", "Q2", "Q3", "Q4"] + } + } + ], + "messageId": "message-1234-with-mixed", + "contextId": "context-1234", + "kind": "message" + }, + "configuration": { + "acceptedOutputModes": ["text"], + "blocking": true + } + } + }"""; + + static final String SEND_MESSAGE_WITH_MIXED_PARTS_TEST_RESPONSE = """ + { + "jsonrpc": "2.0", + "result": { + "id": "de38c76d-d54c-436c-8b9f-4c2703648d64", + "contextId": "c295ea44-7543-4f78-b524-7a38915ad6e4", + "status": { + "state": "completed" + }, + "artifacts": [ + { + "artifactId": "artifact-1", + "name": "mixed-analysis", + "parts": [ + { + "kind": "text", + "text": "Analyzed chart image and data: Bar chart showing quarterly data with values [10, 20, 30, 40]." + } + ] + } + ], + "metadata": {}, + "kind": "task" + } + }"""; + +} diff --git a/client/src/test/java/io/a2a/client/JsonStreamingMessages.java b/client-transport/jsonrpc/src/test/java/io/a2a/client/transport/jsonrpc/JsonStreamingMessages.java similarity index 98% rename from client/src/test/java/io/a2a/client/JsonStreamingMessages.java rename to client-transport/jsonrpc/src/test/java/io/a2a/client/transport/jsonrpc/JsonStreamingMessages.java index cf80de7b8..909955e81 100644 --- a/client/src/test/java/io/a2a/client/JsonStreamingMessages.java +++ b/client-transport/jsonrpc/src/test/java/io/a2a/client/transport/jsonrpc/JsonStreamingMessages.java @@ -1,4 +1,4 @@ -package io.a2a.client; +package io.a2a.client.transport.jsonrpc; /** * Contains JSON strings for testing SSE streaming. @@ -106,7 +106,6 @@ public class JsonStreamingMessages { public static final String SEND_MESSAGE_STREAMING_TEST_REQUEST = """ { "jsonrpc": "2.0", - "id": "request-1234", "method": "message/stream", "params": { "message": { @@ -139,7 +138,6 @@ public class JsonStreamingMessages { public static final String TASK_RESUBSCRIPTION_TEST_REQUEST = """ { "jsonrpc": "2.0", - "id": "request-1234", "method": "tasks/resubscribe", "params": { "id": "task-1234" diff --git a/client-transport/jsonrpc/src/test/java/io/a2a/client/transport/jsonrpc/sse/JsonStreamingMessages.java b/client-transport/jsonrpc/src/test/java/io/a2a/client/transport/jsonrpc/sse/JsonStreamingMessages.java deleted file mode 100644 index 4b79a57cb..000000000 --- a/client-transport/jsonrpc/src/test/java/io/a2a/client/transport/jsonrpc/sse/JsonStreamingMessages.java +++ /dev/null @@ -1,148 +0,0 @@ -package io.a2a.client.transport.jsonrpc.sse; - -/** - * Contains JSON strings for testing SSE streaming. - */ -public class JsonStreamingMessages { - - public static final String STREAMING_TASK_EVENT = """ - data: { - "jsonrpc": "2.0", - "id": "1234", - "result": { - "kind": "task", - "id": "task-123", - "contextId": "context-456", - "status": { - "state": "working" - } - } - } - """; - - - public static final String STREAMING_MESSAGE_EVENT = """ - data: { - "jsonrpc": "2.0", - "id": "1234", - "result": { - "kind": "message", - "role": "agent", - "messageId": "msg-123", - "contextId": "context-456", - "parts": [ - { - "kind": "text", - "text": "Hello, world!" - } - ] - } - }"""; - - public static final String STREAMING_STATUS_UPDATE_EVENT = """ - data: { - "jsonrpc": "2.0", - "id": "1234", - "result": { - "taskId": "1", - "contextId": "2", - "status": { - "state": "submitted" - }, - "final": false, - "kind": "status-update" - } - }"""; - - public static final String STREAMING_STATUS_UPDATE_EVENT_FINAL = """ - data: { - "jsonrpc": "2.0", - "id": "1234", - "result": { - "taskId": "1", - "contextId": "2", - "status": { - "state": "completed" - }, - "final": true, - "kind": "status-update" - } - }"""; - - public static final String STREAMING_ARTIFACT_UPDATE_EVENT = """ - data: { - "jsonrpc": "2.0", - "id": "1234", - "result": { - "kind": "artifact-update", - "taskId": "1", - "contextId": "2", - "append": false, - "lastChunk": true, - "artifact": { - "artifactId": "artifact-1", - "parts": [ - { - "kind": "text", - "text": "Why did the chicken cross the road? To get to the other side!" - } - ] - } - } - } - }"""; - - public static final String STREAMING_ERROR_EVENT = """ - data: { - "jsonrpc": "2.0", - "id": "1234", - "error": { - "code": -32602, - "message": "Invalid parameters", - "data": "Missing required field" - } - }"""; - - public static final String SEND_MESSAGE_STREAMING_TEST_REQUEST = """ - { - "jsonrpc": "2.0", - "id": "request-1234", - "method": "message/stream", - "params": { - "message": { - "role": "user", - "parts": [ - { - "kind": "text", - "text": "tell me some jokes" - } - ], - "messageId": "message-1234", - "contextId": "context-1234", - "kind": "message" - }, - "configuration": { - "acceptedOutputModes": ["text"], - "blocking": false - }, - } - }"""; - - static final String SEND_MESSAGE_STREAMING_TEST_RESPONSE = - "event: message\n" + - "data: {\"jsonrpc\":\"2.0\",\"id\":1,\"result\":{\"id\":\"2\",\"contextId\":\"context-1234\",\"status\":{\"state\":\"completed\"},\"artifacts\":[{\"artifactId\":\"artifact-1\",\"name\":\"joke\",\"parts\":[{\"kind\":\"text\",\"text\":\"Why did the chicken cross the road? To get to the other side!\"}]}],\"metadata\":{},\"kind\":\"task\"}}\n\n"; - - static final String TASK_RESUBSCRIPTION_REQUEST_TEST_RESPONSE = - "event: message\n" + - "data: {\"jsonrpc\":\"2.0\",\"id\":1,\"result\":{\"id\":\"2\",\"contextId\":\"context-1234\",\"status\":{\"state\":\"completed\"},\"artifacts\":[{\"artifactId\":\"artifact-1\",\"name\":\"joke\",\"parts\":[{\"kind\":\"text\",\"text\":\"Why did the chicken cross the road? To get to the other side!\"}]}],\"metadata\":{},\"kind\":\"task\"}}\n\n"; - - public static final String TASK_RESUBSCRIPTION_TEST_REQUEST = """ - { - "jsonrpc": "2.0", - "id": "request-1234", - "method": "tasks/resubscribe", - "params": { - "id": "task-1234" - } - }"""; -} \ No newline at end of file diff --git a/client-transport/jsonrpc/src/test/java/io/a2a/client/transport/jsonrpc/sse/SSEEventListenerTest.java b/client-transport/jsonrpc/src/test/java/io/a2a/client/transport/jsonrpc/sse/SSEEventListenerTest.java index 021b810dd..8c4c1495e 100644 --- a/client-transport/jsonrpc/src/test/java/io/a2a/client/transport/jsonrpc/sse/SSEEventListenerTest.java +++ b/client-transport/jsonrpc/src/test/java/io/a2a/client/transport/jsonrpc/sse/SSEEventListenerTest.java @@ -1,5 +1,19 @@ package io.a2a.client.transport.jsonrpc.sse; +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.assertTrue; + +import java.util.concurrent.ExecutionException; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; + +import io.a2a.client.transport.jsonrpc.JsonStreamingMessages; import io.a2a.spec.Artifact; import io.a2a.spec.JSONRPCError; import io.a2a.spec.Message; @@ -13,15 +27,6 @@ import io.a2a.spec.TextPart; import org.junit.jupiter.api.Test; -import java.util.concurrent.ExecutionException; -import java.util.concurrent.Future; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.TimeoutException; -import java.util.concurrent.atomic.AtomicBoolean; -import java.util.concurrent.atomic.AtomicReference; - -import static org.junit.jupiter.api.Assertions.*; - public class SSEEventListenerTest { @Test @@ -30,8 +35,8 @@ public void testOnEventWithTaskResult() throws Exception { AtomicReference receivedEvent = new AtomicReference<>(); SSEEventListener listener = new SSEEventListener( event -> receivedEvent.set(event), - error -> {}, - () -> {}); + error -> {} + ); // Parse the task event JSON String eventData = JsonStreamingMessages.STREAMING_TASK_EVENT.substring( @@ -55,8 +60,8 @@ public void testOnEventWithMessageResult() throws Exception { AtomicReference receivedEvent = new AtomicReference<>(); SSEEventListener listener = new SSEEventListener( event -> receivedEvent.set(event), - error -> {}, - () -> {}); + error -> {} + ); // Parse the message event JSON String eventData = JsonStreamingMessages.STREAMING_MESSAGE_EVENT.substring( @@ -83,8 +88,8 @@ public void testOnEventWithTaskStatusUpdateEventEvent() throws Exception { AtomicReference receivedEvent = new AtomicReference<>(); SSEEventListener listener = new SSEEventListener( event -> receivedEvent.set(event), - error -> {}, - () -> {}); + error -> {} + ); // Parse the message event JSON String eventData = JsonStreamingMessages.STREAMING_STATUS_UPDATE_EVENT.substring( @@ -109,8 +114,8 @@ public void testOnEventWithTaskArtifactUpdateEventEvent() throws Exception { AtomicReference receivedEvent = new AtomicReference<>(); SSEEventListener listener = new SSEEventListener( event -> receivedEvent.set(event), - error -> {}, - () -> {}); + error -> {} + ); // Parse the message event JSON String eventData = JsonStreamingMessages.STREAMING_ARTIFACT_UPDATE_EVENT.substring( @@ -138,11 +143,11 @@ public void testOnEventWithTaskArtifactUpdateEventEvent() throws Exception { @Test public void testOnEventWithError() throws Exception { // Set up event handler - AtomicReference receivedError = new AtomicReference<>(); + AtomicReference receivedError = new AtomicReference<>(); SSEEventListener listener = new SSEEventListener( event -> {}, - error -> receivedError.set(error), - () -> {}); + error -> receivedError.set(error) + ); // Parse the error event JSON String eventData = JsonStreamingMessages.STREAMING_ERROR_EVENT.substring( @@ -153,9 +158,11 @@ public void testOnEventWithError() throws Exception { // Verify the error was processed correctly assertNotNull(receivedError.get()); - assertEquals(-32602, receivedError.get().getCode()); - assertEquals("Invalid parameters", receivedError.get().getMessage()); - assertEquals("Missing required field", receivedError.get().getData()); + assertInstanceOf(JSONRPCError.class, receivedError.get()); + JSONRPCError jsonrpcError = (JSONRPCError) receivedError.get(); + assertEquals(-32602, jsonrpcError.getCode()); + assertEquals("Invalid parameters", jsonrpcError.getMessage()); + assertEquals("Missing required field", jsonrpcError.getData()); } @Test @@ -163,8 +170,8 @@ public void testOnFailure() { AtomicBoolean failureHandlerCalled = new AtomicBoolean(false); SSEEventListener listener = new SSEEventListener( event -> {}, - error -> {}, - () -> failureHandlerCalled.set(true)); + error -> failureHandlerCalled.set(true) + ); // Simulate a failure CancelCapturingFuture future = new CancelCapturingFuture(); @@ -189,8 +196,8 @@ public void testFinalTaskStatusUpdateEventCancels() { AtomicReference receivedEvent = new AtomicReference<>(); SSEEventListener listener = new SSEEventListener( event -> receivedEvent.set(event), - error -> {}, - () -> {}); + error -> {} + ); } @@ -201,8 +208,8 @@ public void testOnEventWithFinalTaskStatusUpdateEventEventCancels() throws Excep AtomicReference receivedEvent = new AtomicReference<>(); SSEEventListener listener = new SSEEventListener( event -> receivedEvent.set(event), - error -> {}, - () -> {}); + error -> {} + ); // Parse the message event JSON String eventData = JsonStreamingMessages.STREAMING_STATUS_UPDATE_EVENT_FINAL.substring( diff --git a/client-transport/spi/pom.xml b/client-transport/spi/pom.xml index 76a3002f8..7c4b112f0 100644 --- a/client-transport/spi/pom.xml +++ b/client-transport/spi/pom.xml @@ -17,12 +17,16 @@ Java SDK for the Agent2Agent Protocol (A2A) - Client Transport SPI + + io.github.a2asdk + a2a-java-sdk-client-config + ${project.version} + io.github.a2asdk a2a-java-sdk-spec ${project.version} - diff --git a/client-transport/spi/src/main/java/io/a2a/client/transport/spi/ClientTransport.java b/client-transport/spi/src/main/java/io/a2a/client/transport/spi/ClientTransport.java index 39512fced..163dd6582 100644 --- a/client-transport/spi/src/main/java/io/a2a/client/transport/spi/ClientTransport.java +++ b/client-transport/spi/src/main/java/io/a2a/client/transport/spi/ClientTransport.java @@ -1,109 +1,140 @@ package io.a2a.client.transport.spi; -import io.a2a.spec.*; - import java.util.List; import java.util.function.Consumer; +import io.a2a.client.config.ClientCallContext; +import io.a2a.spec.A2AClientException; +import io.a2a.spec.AgentCard; +import io.a2a.spec.DeleteTaskPushNotificationConfigParams; +import io.a2a.spec.EventKind; +import io.a2a.spec.GetTaskPushNotificationConfigParams; +import io.a2a.spec.ListTaskPushNotificationConfigParams; +import io.a2a.spec.MessageSendParams; +import io.a2a.spec.StreamingEventKind; +import io.a2a.spec.Task; +import io.a2a.spec.TaskIdParams; +import io.a2a.spec.TaskPushNotificationConfig; +import io.a2a.spec.TaskQueryParams; + +/** + * Interface for a client transport. + */ public interface ClientTransport { /** - * Send a message to the remote agent. + * Send a non-streaming message request to the agent. * - * @param requestId the request ID to use - * @param messageSendParams the parameters for the message to be sent - * @return the response, may contain a message or a task - * @throws A2AServerException if sending the message fails for any reason + * @param request the message send parameters + * @param context optional client call context for the request (may be {@code null}) + * @return the response, either a Task or Message + * @throws A2AClientException if sending the message fails for any reason */ - EventKind sendMessage(String requestId, MessageSendParams messageSendParams) throws A2AServerException; + EventKind sendMessage(MessageSendParams request, ClientCallContext context) + throws A2AClientException; /** - * Retrieve the generated artifacts for a task. + * Send a streaming message request to the agent and receive responses as they arrive. * - * @param requestId the request ID to use - * @param taskQueryParams the params for the task to be queried - * @return the response containing the task - * @throws A2AServerException if retrieving the task fails for any reason + * @param request the message send parameters + * @param eventConsumer consumer that will receive streaming events as they arrive + * @param errorConsumer consumer that will be called if an error occurs during streaming + * @param context optional client call context for the request (may be {@code null}) + * @throws A2AClientException if setting up the streaming connection fails */ - Task getTask(String requestId, TaskQueryParams taskQueryParams) throws A2AServerException; + void sendMessageStreaming(MessageSendParams request, Consumer eventConsumer, + Consumer errorConsumer, ClientCallContext context) throws A2AClientException; /** - * Cancel a task that was previously submitted to the A2A server. + * Retrieve the current state and history of a specific task. * - * @param requestId the request ID to use - * @param taskIdParams the params for the task to be cancelled - * @return the response indicating if the task was cancelled - * @throws A2AServerException if retrieving the task fails for any reason + * @param request the task query parameters specifying which task to retrieve + * @param context optional client call context for the request (may be {@code null}) + * @return the task + * @throws A2AClientException if retrieving the task fails for any reason */ - Task cancelTask(String requestId, TaskIdParams taskIdParams) throws A2AServerException; + Task getTask(TaskQueryParams request, ClientCallContext context) throws A2AClientException; /** - * Get the push notification configuration for a task. + * Request the agent to cancel a specific task. * - * @param requestId the request ID to use - * @param getTaskPushNotificationConfigParams the params for the task - * @return the response containing the push notification configuration - * @throws A2AServerException if getting the push notification configuration fails for any reason + * @param request the task ID parameters specifying which task to cancel + * @param context optional client call context for the request (may be {@code null}) + * @return the cancelled task + * @throws A2AClientException if cancelling the task fails for any reason */ - TaskPushNotificationConfig getTaskPushNotificationConfig(String requestId, GetTaskPushNotificationConfigParams getTaskPushNotificationConfigParams) throws A2AServerException; + Task cancelTask(TaskIdParams request, ClientCallContext context) throws A2AClientException; /** - * Set push notification configuration for a task. + * Set or update the push notification configuration for a specific task. * - * @param requestId the request ID to use - * @param taskId the task ID - * @param pushNotificationConfig the push notification configuration - * @return the response indicating whether setting the task push notification configuration succeeded - * @throws A2AServerException if setting the push notification configuration fails for any reason + * @param request the push notification configuration to set for the task + * @param context optional client call context for the request (may be {@code null}) + * @return the configured TaskPushNotificationConfig + * @throws A2AClientException if setting the task push notification configuration fails for any reason */ - TaskPushNotificationConfig setTaskPushNotificationConfig(String requestId, String taskId, - PushNotificationConfig pushNotificationConfig) throws A2AServerException; + TaskPushNotificationConfig setTaskPushNotificationConfiguration(TaskPushNotificationConfig request, + ClientCallContext context) throws A2AClientException; /** - * Retrieves the push notification configurations for a specified task. + * Retrieve the push notification configuration for a specific task. * - * @param requestId the request ID to use - * @param listTaskPushNotificationConfigParams the params for retrieving the push notification configuration - * @return the response containing the push notification configuration - * @throws A2AServerException if getting the push notification configuration fails for any reason + * @param request the parameters specifying which task's notification config to retrieve + * @param context optional client call context for the request (may be {@code null}) + * @return the task push notification config + * @throws A2AClientException if getting the task push notification config fails for any reason */ - List listTaskPushNotificationConfig(String requestId, - ListTaskPushNotificationConfigParams listTaskPushNotificationConfigParams) throws A2AServerException; + TaskPushNotificationConfig getTaskPushNotificationConfiguration( + GetTaskPushNotificationConfigParams request, + ClientCallContext context) throws A2AClientException; /** - * Delete the push notification configuration for a specified task. + * Retrieve the list of push notification configurations for a specific task. * - * @param requestId the request ID to use - * @param deleteTaskPushNotificationConfigParams the params for deleting the push notification configuration - * @throws A2AServerException if deleting the push notification configuration fails for any reason + * @param request the parameters specifying which task's notification configs to retrieve + * @param context optional client call context for the request (may be {@code null}) + * @return the list of task push notification configs + * @throws A2AClientException if getting the task push notification configs fails for any reason */ - void deleteTaskPushNotificationConfig(String requestId, - DeleteTaskPushNotificationConfigParams deleteTaskPushNotificationConfigParams) throws A2AServerException; + List listTaskPushNotificationConfigurations( + ListTaskPushNotificationConfigParams request, + ClientCallContext context) throws A2AClientException; /** - * Send a streaming message to the remote agent. + * Delete the list of push notification configurations for a specific task. * - * @param requestId the request ID to use - * @param messageSendParams the parameters for the message to be sent - * @param eventHandler a consumer that will be invoked for each event received from the remote agent - * @param errorHandler a consumer that will be invoked if the remote agent returns an error - * @param failureHandler a consumer that will be invoked if a failure occurs when processing events - * @throws A2AServerException if sending the streaming message fails for any reason + * @param request the parameters specifying which task's notification configs to delete + * @param context optional client call context for the request (may be {@code null}) + * @throws A2AClientException if deleting the task push notification configs fails for any reason */ - void sendStreamingMessage(String requestId, MessageSendParams messageSendParams, Consumer eventHandler, - Consumer errorHandler, Runnable failureHandler) throws A2AServerException; + void deleteTaskPushNotificationConfigurations( + DeleteTaskPushNotificationConfigParams request, + ClientCallContext context) throws A2AClientException; /** - * Resubscribe to an ongoing task. + * Reconnect to get task updates for an existing task. * - * @param requestId the request ID to use - * @param taskIdParams the params for the task to resubscribe to - * @param eventHandler a consumer that will be invoked for each event received from the remote agent - * @param errorHandler a consumer that will be invoked if the remote agent returns an error - * @param failureHandler a consumer that will be invoked if a failure occurs when processing events - * @throws A2AServerException if resubscribing to the task fails for any reason + * @param request the task ID parameters specifying which task to resubscribe to + * @param eventConsumer consumer that will receive streaming events as they arrive + * @param errorConsumer consumer that will be called if an error occurs during streaming + * @param context optional client call context for the request (may be {@code null}) + * @throws A2AClientException if resubscribing to the task fails for any reason + */ + void resubscribe(TaskIdParams request, Consumer eventConsumer, + Consumer errorConsumer, ClientCallContext context) throws A2AClientException; + + /** + * Retrieve the AgentCard. + * + * @param context optional client call context for the request (may be {@code null}) + * @return the AgentCard + * @throws A2AClientException if retrieving the agent card fails for any reason + */ + AgentCard getAgentCard(ClientCallContext context) throws A2AClientException; + + /** + * Close the transport and release any associated resources. */ - void resubscribeToTask(String requestId, TaskIdParams taskIdParams, Consumer eventHandler, - Consumer errorHandler, Runnable failureHandler) throws A2AServerException; + void close(); } diff --git a/client-transport/spi/src/main/java/io/a2a/client/transport/spi/ClientTransportProvider.java b/client-transport/spi/src/main/java/io/a2a/client/transport/spi/ClientTransportProvider.java new file mode 100644 index 000000000..5ebed06a9 --- /dev/null +++ b/client-transport/spi/src/main/java/io/a2a/client/transport/spi/ClientTransportProvider.java @@ -0,0 +1,32 @@ +package io.a2a.client.transport.spi; + +import java.util.List; + +import io.a2a.client.config.ClientCallInterceptor; +import io.a2a.client.config.ClientConfig; +import io.a2a.spec.AgentCard; + +/** + * Client transport provider interface. + */ +public interface ClientTransportProvider { + + /** + * Create a client transport. + * + * @param clientConfig the client config to use + * @param agentCard the agent card for the remote agent + * @param agentUrl the remote agent's URL + * @param interceptors the optional interceptors to use for a client call (may be {@code null}) + * @return the client transport + */ + ClientTransport create(ClientConfig clientConfig, AgentCard agentCard, + String agentUrl, List interceptors); + + /** + * Get the name of the client transport. + */ + String getTransportProtocol(); + +} + diff --git a/client/pom.xml b/client/pom.xml index b3a09f64f..c35fbeef6 100644 --- a/client/pom.xml +++ b/client/pom.xml @@ -17,6 +17,11 @@ Java SDK for the Agent2Agent Protocol (A2A) - Client + + ${project.groupId} + a2a-java-sdk-client-config + ${project.version} + ${project.groupId} a2a-java-sdk-client-http @@ -24,20 +29,19 @@ ${project.groupId} - a2a-java-sdk-common + a2a-java-sdk-client-transport-spi ${project.version} ${project.groupId} - a2a-java-sdk-spec + a2a-java-sdk-common ${project.version} ${project.groupId} - a2a-java-sdk-client-transport-jsonrpc + a2a-java-sdk-spec ${project.version} - org.junit.jupiter junit-jupiter-api @@ -49,6 +53,10 @@ mockserver-netty test + + io.grpc + grpc-api + \ No newline at end of file diff --git a/client/src/main/java/io/a2a/client/AbstractClient.java b/client/src/main/java/io/a2a/client/AbstractClient.java new file mode 100644 index 000000000..f6794ab38 --- /dev/null +++ b/client/src/main/java/io/a2a/client/AbstractClient.java @@ -0,0 +1,185 @@ +package io.a2a.client; + +import static io.a2a.util.Assert.checkNotNullParam; + +import java.util.List; +import java.util.Map; +import java.util.function.BiConsumer; +import java.util.function.Consumer; + +import io.a2a.client.config.ClientCallContext; +import io.a2a.spec.A2AClientException; +import io.a2a.spec.AgentCard; +import io.a2a.spec.DeleteTaskPushNotificationConfigParams; +import io.a2a.spec.GetTaskPushNotificationConfigParams; +import io.a2a.spec.ListTaskPushNotificationConfigParams; +import io.a2a.spec.Message; +import io.a2a.spec.PushNotificationConfig; +import io.a2a.spec.Task; +import io.a2a.spec.TaskIdParams; +import io.a2a.spec.TaskPushNotificationConfig; +import io.a2a.spec.TaskQueryParams; + +/** + * Abstract class representing an A2A client. Provides a standard set + * of methods for interacting with an A2A agent, regardless of the underlying + * transport protocol. It supports sending messages, managing tasks, and + * handling event streams. + */ +public abstract class AbstractClient { + + private final List> consumers; + private final Consumer streamingErrorHandler; + + public AbstractClient(List> consumers) { + this(consumers, null); + } + + public AbstractClient(List> consumers, Consumer streamingErrorHandler) { + checkNotNullParam("consumers", consumers); + this.consumers = consumers; + this.streamingErrorHandler = streamingErrorHandler; + } + + /** + * Send a message to the remote agent. This method will automatically use + * the streaming or non-streaming approach as determined by the server's + * agent card and the client configuration. The configured client consumers + * and will be used to handle messages, tasks, and update events received + * from the remote agent. The configured streaming error handler will be used + * if an error occurs during streaming. The configured client push notification + * configuration will get used for streaming. + * + * @param request the message + * @param context optional client call context for the request (may be {@code null}) + * @throws A2AClientException if sending the message fails for any reason + */ + public abstract void sendMessage(Message request, ClientCallContext context) throws A2AClientException; + + /** + * Send a message to the remote agent. This method will automatically use + * the streaming or non-streaming approach as determined by the server's + * agent card and the client configuration. The configured client consumers + * will be used to handle messages, tasks, and update events received from + * the remote agent. The configured streaming error handler will be used + * if an error occurs during streaming. + * + * @param request the message + * @param pushNotificationConfiguration the push notification configuration that should be + * used if the streaming approach is used + * @param metadata the optional metadata to include when sending the message + * @throws A2AClientException if sending the message fails for any reason + */ + public abstract void sendMessage(Message request, PushNotificationConfig pushNotificationConfiguration, + Map metadata, ClientCallContext context) throws A2AClientException; + + /** + * Retrieve the current state and history of a specific task. + * + * @param request the task query parameters specifying which task to retrieve + * @param context optional client call context for the request (may be {@code null}) + * @return the task + * @throws A2AClientException if retrieving the task fails for any reason + */ + public abstract Task getTask(TaskQueryParams request, ClientCallContext context) throws A2AClientException; + + /** + * Request the agent to cancel a specific task. + * + * @param request the task ID parameters specifying which task to cancel + * @param context optional client call context for the request (may be {@code null}) + * @return the cancelled task + * @throws A2AClientException if cancelling the task fails for any reason + */ + public abstract Task cancelTask(TaskIdParams request, ClientCallContext context) throws A2AClientException; + + /** + * Set or update the push notification configuration for a specific task. + * + * @param request the push notification configuration to set for the task + * @param context optional client call context for the request (may be {@code null}) + * @return the configured TaskPushNotificationConfig + * @throws A2AClientException if setting the task push notification configuration fails for any reason + */ + public abstract TaskPushNotificationConfig setTaskPushNotificationConfiguration( + TaskPushNotificationConfig request, + ClientCallContext context) throws A2AClientException; + + /** + * Retrieve the push notification configuration for a specific task. + * + * @param request the parameters specifying which task's notification config to retrieve + * @param context optional client call context for the request (may be {@code null}) + * @return the task push notification config + * @throws A2AClientException if getting the task push notification config fails for any reason + */ + public abstract TaskPushNotificationConfig getTaskPushNotificationConfiguration( + GetTaskPushNotificationConfigParams request, + ClientCallContext context) throws A2AClientException; + + /** + * Retrieve the list of push notification configurations for a specific task. + * + * @param request the parameters specifying which task's notification configs to retrieve + * @param context optional client call context for the request (may be {@code null}) + * @return the list of task push notification configs + * @throws A2AClientException if getting the task push notification configs fails for any reason + */ + public abstract List listTaskPushNotificationConfigurations( + ListTaskPushNotificationConfigParams request, + ClientCallContext context) throws A2AClientException; + + /** + * Delete the list of push notification configurations for a specific task. + * + * @param request the parameters specifying which task's notification configs to delete + * @param context optional client call context for the request (may be {@code null}) + * @throws A2AClientException if deleting the task push notification configs fails for any reason + */ + public abstract void deleteTaskPushNotificationConfigurations( + DeleteTaskPushNotificationConfigParams request, + ClientCallContext context) throws A2AClientException; + + /** + * Resubscribe to a task's event stream. + * This is only available if both the client and server support streaming. + * + * @param request the parameters specifying which task's notification configs to delete + * @param context optional client call context for the request (may be {@code null}) + * @throws A2AClientException if resubscribing fails for any reason + */ + public abstract void resubscribe(TaskIdParams request, ClientCallContext context) throws A2AClientException; + + /** + * Retrieve the AgentCard. + * + * @param context optional client call context for the request (may be {@code null}) + * @return the AgentCard + * @throws A2AClientException if retrieving the agent card fails for any reason + */ + public abstract AgentCard getAgentCard(ClientCallContext context) throws A2AClientException; + + /** + * Close the transport and release any associated resources. + */ + public abstract void close(); + + /** + * Process the event using all configured consumers. + */ + public void consume(ClientEvent clientEventOrMessage, AgentCard agentCard) { + for (BiConsumer consumer : consumers) { + consumer.accept(clientEventOrMessage, agentCard); + } + } + + /** + * Get the error handler that should be used during streaming. + * + * @return the streaming error handler + */ + public Consumer getStreamingErrorHandler() { + return streamingErrorHandler; + } + +} diff --git a/client/src/main/java/io/a2a/client/Client.java b/client/src/main/java/io/a2a/client/Client.java new file mode 100644 index 000000000..5dc57b56a --- /dev/null +++ b/client/src/main/java/io/a2a/client/Client.java @@ -0,0 +1,186 @@ +package io.a2a.client; + +import java.util.List; +import java.util.Map; +import java.util.function.BiConsumer; +import java.util.function.Consumer; + +import io.a2a.client.config.ClientCallContext; +import io.a2a.client.config.ClientConfig; +import io.a2a.client.transport.spi.ClientTransport; +import io.a2a.spec.A2AClientError; +import io.a2a.spec.A2AClientException; +import io.a2a.spec.A2AClientInvalidStateError; +import io.a2a.spec.AgentCard; +import io.a2a.spec.DeleteTaskPushNotificationConfigParams; +import io.a2a.spec.EventKind; +import io.a2a.spec.GetTaskPushNotificationConfigParams; +import io.a2a.spec.ListTaskPushNotificationConfigParams; +import io.a2a.spec.Message; +import io.a2a.spec.MessageSendConfiguration; +import io.a2a.spec.MessageSendParams; +import io.a2a.spec.PushNotificationConfig; +import io.a2a.spec.StreamingEventKind; +import io.a2a.spec.Task; +import io.a2a.spec.TaskArtifactUpdateEvent; +import io.a2a.spec.TaskIdParams; +import io.a2a.spec.TaskPushNotificationConfig; +import io.a2a.spec.TaskQueryParams; +import io.a2a.spec.TaskStatusUpdateEvent; + +public class Client extends AbstractClient { + + private final ClientConfig clientConfig; + private final ClientTransport clientTransport; + private AgentCard agentCard; + + public Client(AgentCard agentCard, ClientConfig clientConfig, ClientTransport clientTransport, + List> consumers, Consumer streamingErrorHandler) { + super(consumers, streamingErrorHandler); + this.agentCard = agentCard; + this.clientConfig = clientConfig; + this.clientTransport = clientTransport; + } + + + @Override + public void sendMessage(Message request, ClientCallContext context) throws A2AClientException { + MessageSendConfiguration messageSendConfiguration = new MessageSendConfiguration.Builder() + .acceptedOutputModes(clientConfig.getAcceptedOutputModes()) + .blocking(clientConfig.isPolling()) + .historyLength(clientConfig.getHistoryLength()) + .pushNotification(clientConfig.getPushNotificationConfig()) + .build(); + + MessageSendParams messageSendParams = new MessageSendParams.Builder() + .message(request) + .configuration(messageSendConfiguration) + .metadata(clientConfig.getMetadata()) + .build(); + + sendMessage(messageSendParams, context); + } + + @Override + public void sendMessage(Message request, PushNotificationConfig pushNotificationConfiguration, + Map metatadata, ClientCallContext context) throws A2AClientException { + MessageSendConfiguration messageSendConfiguration = new MessageSendConfiguration.Builder() + .acceptedOutputModes(clientConfig.getAcceptedOutputModes()) + .blocking(clientConfig.isPolling()) + .historyLength(clientConfig.getHistoryLength()) + .pushNotification(pushNotificationConfiguration) + .build(); + + MessageSendParams messageSendParams = new MessageSendParams.Builder() + .message(request) + .configuration(messageSendConfiguration) + .metadata(metatadata) + .build(); + + sendMessage(messageSendParams, context); + } + + @Override + public Task getTask(TaskQueryParams request, ClientCallContext context) throws A2AClientException { + return clientTransport.getTask(request, context); + } + + @Override + public Task cancelTask(TaskIdParams request, ClientCallContext context) throws A2AClientException { + return clientTransport.cancelTask(request, context); + } + + @Override + public TaskPushNotificationConfig setTaskPushNotificationConfiguration( + TaskPushNotificationConfig request, ClientCallContext context) throws A2AClientException { + return clientTransport.setTaskPushNotificationConfiguration(request, context); + } + + @Override + public TaskPushNotificationConfig getTaskPushNotificationConfiguration( + GetTaskPushNotificationConfigParams request, ClientCallContext context) throws A2AClientException { + return clientTransport.getTaskPushNotificationConfiguration(request, context); + } + + @Override + public List listTaskPushNotificationConfigurations( + ListTaskPushNotificationConfigParams request, ClientCallContext context) throws A2AClientException { + return clientTransport.listTaskPushNotificationConfigurations(request, context); + } + + @Override + public void deleteTaskPushNotificationConfigurations( + DeleteTaskPushNotificationConfigParams request, ClientCallContext context) throws A2AClientException { + clientTransport.deleteTaskPushNotificationConfigurations(request, context); + } + + @Override + public void resubscribe(TaskIdParams request, ClientCallContext context) throws A2AClientException { + if (! clientConfig.isStreaming() || ! agentCard.capabilities().streaming()) { + throw new A2AClientException("Client and/or server does not support resubscription"); + } + ClientTaskManager tracker = new ClientTaskManager(); + Consumer eventHandler = event -> { + try { + ClientEvent clientEvent = getClientEvent(event, tracker); + consume(clientEvent, agentCard); + } catch (A2AClientError e) { + getStreamingErrorHandler().accept(e); + } + }; + clientTransport.resubscribe(request, eventHandler, getStreamingErrorHandler(), context); + } + + @Override + public AgentCard getAgentCard(ClientCallContext context) throws A2AClientException { + agentCard = clientTransport.getAgentCard(context); + return agentCard; + } + + @Override + public void close() { + clientTransport.close(); + } + + private ClientEvent getClientEvent(StreamingEventKind event, ClientTaskManager taskManager) throws A2AClientError { + if (event instanceof Message message) { + return new MessageEvent(message); + } else if (event instanceof Task task) { + taskManager.saveTaskEvent(task); + return new TaskEvent(taskManager.getCurrentTask()); + } else if (event instanceof TaskStatusUpdateEvent updateEvent) { + taskManager.saveTaskEvent(updateEvent); + return new TaskUpdateEvent(taskManager.getCurrentTask(), updateEvent); + } else if (event instanceof TaskArtifactUpdateEvent updateEvent) { + taskManager.saveTaskEvent(updateEvent); + return new TaskUpdateEvent(taskManager.getCurrentTask(), updateEvent); + } else { + throw new A2AClientInvalidStateError("Invalid client event"); + } + } + + private void sendMessage(MessageSendParams messageSendParams, ClientCallContext context) throws A2AClientException { + if (! clientConfig.isStreaming() || ! agentCard.capabilities().streaming()) { + EventKind eventKind = clientTransport.sendMessage(messageSendParams, context); + ClientEvent clientEvent; + if (eventKind instanceof Task task) { + clientEvent = new TaskEvent(task); + } else { + // must be a message + clientEvent = new MessageEvent((Message) eventKind); + } + consume(clientEvent, agentCard); + } else { + ClientTaskManager tracker = new ClientTaskManager(); + Consumer eventHandler = event -> { + try { + ClientEvent clientEvent = getClientEvent(event, tracker); + consume(clientEvent, agentCard); + } catch (A2AClientError e) { + getStreamingErrorHandler().accept(e); + } + }; + clientTransport.sendMessageStreaming(messageSendParams, eventHandler, getStreamingErrorHandler(), context); + } + } +} diff --git a/client/src/main/java/io/a2a/client/ClientEvent.java b/client/src/main/java/io/a2a/client/ClientEvent.java new file mode 100644 index 000000000..dcaae9495 --- /dev/null +++ b/client/src/main/java/io/a2a/client/ClientEvent.java @@ -0,0 +1,4 @@ +package io.a2a.client; + +public sealed interface ClientEvent permits MessageEvent, TaskEvent, TaskUpdateEvent { +} diff --git a/client/src/main/java/io/a2a/client/ClientFactory.java b/client/src/main/java/io/a2a/client/ClientFactory.java new file mode 100644 index 000000000..6789b76ac --- /dev/null +++ b/client/src/main/java/io/a2a/client/ClientFactory.java @@ -0,0 +1,127 @@ +package io.a2a.client; + +import static io.a2a.util.Assert.checkNotNullParam; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.ServiceLoader; +import java.util.function.BiConsumer; +import java.util.function.Consumer; + +import io.a2a.client.config.ClientCallInterceptor; +import io.a2a.client.config.ClientConfig; +import io.a2a.client.transport.spi.ClientTransport; +import io.a2a.client.transport.spi.ClientTransportProvider; +import io.a2a.spec.A2AClientException; +import io.a2a.spec.AgentCard; +import io.a2a.spec.AgentInterface; +import io.a2a.spec.TransportProtocol; + +/** + * Used to generate the appropriate client for the agent. + */ +public class ClientFactory { + + private final ClientConfig clientConfig; + private final Map transportProviderRegistry = new HashMap<>(); + + public ClientFactory(ClientConfig clientConfig) { + this.clientConfig = clientConfig; + ServiceLoader loader = ServiceLoader.load(ClientTransportProvider.class); + for (ClientTransportProvider transport : loader) { + this.transportProviderRegistry.put(transport.getTransportProtocol(), transport); + } + } + + /** + * Create a new A2A client for the given agent card. + * + * @param agentCard the agent card for the remote agent + * @param consumers a list of consumers to pass responses from the remote agent to + * @param streamingErrorHandler an error handler that should be used for the streaming case if an error occurs + * @throws A2AClientException if the client cannot be created for any reason + */ + public AbstractClient create(AgentCard agentCard, List> consumers, + Consumer streamingErrorHandler) throws A2AClientException { + return create(agentCard, consumers, streamingErrorHandler, null); + } + + /** + * Create a new A2A client for the given agent card. + * + * @param agentCard the agent card for the remote agent + * @param consumers a list of consumers to pass responses from the remote agent to + * @param streamingErrorHandler an error handler that should be used for the streaming case if an error occurs + * @param interceptors the optional list of client call interceptors (may be {@code null}) + * @throws A2AClientException if the client cannot be created for any reason + */ + public AbstractClient create(AgentCard agentCard, List> consumers, + Consumer streamingErrorHandler, List interceptors) throws A2AClientException { + checkNotNullParam("agentCard", agentCard); + checkNotNullParam("consumers", consumers); + LinkedHashMap serverPreferredTransports = getServerPreferredTransports(agentCard); + List clientPreferredTransports = getClientPreferredTransports(); + ClientTransport clientTransport = getClientTransport(clientPreferredTransports, serverPreferredTransports, + agentCard, interceptors); + return new Client(agentCard, clientConfig, clientTransport, consumers, streamingErrorHandler); + } + + private static LinkedHashMap getServerPreferredTransports(AgentCard agentCard) { + LinkedHashMap serverPreferredTransports = new LinkedHashMap<>(); + serverPreferredTransports.put(agentCard.preferredTransport(), agentCard.url()); + for (AgentInterface agentInterface : agentCard.additionalInterfaces()) { + serverPreferredTransports.putIfAbsent(agentInterface.transport(), agentInterface.url()); + } + return serverPreferredTransports; + } + + private List getClientPreferredTransports() { + List preferredClientTransports = clientConfig.getSupportedTransports(); + if (preferredClientTransports == null) { + preferredClientTransports = new ArrayList<>(); + } + if (preferredClientTransports.isEmpty()) { + // default to JSONRPC if not specified + preferredClientTransports.add(TransportProtocol.JSONRPC.asString()); + } + return preferredClientTransports; + } + + private ClientTransport getClientTransport(List clientPreferredTransports, + LinkedHashMap serverPreferredTransports, + AgentCard agentCard, + List interceptors) throws A2AClientException { + String transportProtocol = null; + String transportUrl = null; + if (clientConfig.isUseClientPreference()) { + for (String clientPreferredTransport : clientPreferredTransports) { + if (serverPreferredTransports.containsKey(clientPreferredTransport)) { + transportProtocol = clientPreferredTransport; + transportUrl = serverPreferredTransports.get(transportProtocol); + break; + } + } + } else { + for (Map.Entry transport : serverPreferredTransports.entrySet()) { + if (clientPreferredTransports.contains(transport.getKey())) { + transportProtocol = transport.getKey(); + transportUrl = transport.getValue(); + break; + } + } + } + if (transportProtocol == null || transportUrl == null) { + throw new A2AClientException("No compatible transports found"); + } + if (! transportProviderRegistry.containsKey(transportProtocol)) { + throw new A2AClientException("No client available for " + transportProtocol); + } + + ClientTransportProvider clientTransportProvider = transportProviderRegistry.get(transportProtocol); + return clientTransportProvider.create(clientConfig, agentCard, transportUrl, interceptors); + } + +} diff --git a/client/src/main/java/io/a2a/client/ClientTaskManager.java b/client/src/main/java/io/a2a/client/ClientTaskManager.java new file mode 100644 index 000000000..39975c184 --- /dev/null +++ b/client/src/main/java/io/a2a/client/ClientTaskManager.java @@ -0,0 +1,141 @@ +package io.a2a.client; + +import static io.a2a.util.Utils.appendArtifactToTask; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import io.a2a.spec.A2AClientError; +import io.a2a.spec.A2AClientInvalidArgsError; +import io.a2a.spec.A2AClientInvalidStateError; +import io.a2a.spec.Message; +import io.a2a.spec.Task; +import io.a2a.spec.TaskArtifactUpdateEvent; +import io.a2a.spec.TaskState; +import io.a2a.spec.TaskStatus; +import io.a2a.spec.TaskStatusUpdateEvent; + +/** + * Helps manage a task's lifecycle during the execution of a request. + * Responsible for retrieving, saving, and updating the task based on + * events received from the agent. + */ +public class ClientTaskManager { + + private Task currentTask; + private String taskId; + private String contextId; + + public ClientTaskManager() { + this.currentTask = null; + this.taskId = null; + this.contextId = null; + } + + public Task getCurrentTask() throws A2AClientInvalidStateError { + if (currentTask == null) { + throw new A2AClientInvalidStateError("No current task"); + } + return currentTask; + } + + public Task saveTaskEvent(Task task) throws A2AClientInvalidArgsError { + if (currentTask != null) { + throw new A2AClientInvalidArgsError("Task is already set, create new manager for new tasks."); + } + saveTask(task); + return task; + } + + public Task saveTaskEvent(TaskStatusUpdateEvent taskStatusUpdateEvent) throws A2AClientError { + if (taskId == null) { + taskId = taskStatusUpdateEvent.getTaskId(); + } + if (contextId == null) { + contextId = taskStatusUpdateEvent.getContextId(); + } + Task task = currentTask; + if (task == null) { + task = new Task.Builder() + .status(new TaskStatus(TaskState.UNKNOWN)) + .id(taskId) + .contextId(contextId == null ? "" : contextId) + .build(); + } + + Task.Builder taskBuilder = new Task.Builder(task); + if (taskStatusUpdateEvent.getStatus().message() != null) { + if (task.getHistory() == null) { + taskBuilder.history(taskStatusUpdateEvent.getStatus().message()); + } else { + List history = task.getHistory(); + history.add(taskStatusUpdateEvent.getStatus().message()); + taskBuilder.history(history); + } + } + if (taskStatusUpdateEvent.getMetadata() != null) { + Map metadata = taskStatusUpdateEvent.getMetadata(); + if (metadata == null) { + metadata = new HashMap<>(); + } + metadata.putAll(taskStatusUpdateEvent.getMetadata()); + taskBuilder.metadata(metadata); + } + taskBuilder.status(taskStatusUpdateEvent.getStatus()); + currentTask = taskBuilder.build(); + return currentTask; + } + + public Task saveTaskEvent(TaskArtifactUpdateEvent taskArtifactUpdateEvent) { + if (taskId == null) { + taskId = taskArtifactUpdateEvent.getTaskId(); + } + if (contextId == null) { + contextId = taskArtifactUpdateEvent.getContextId(); + } + Task task = currentTask; + if (task == null) { + task = new Task.Builder() + .status(new TaskStatus(TaskState.UNKNOWN)) + .id(taskId) + .contextId(contextId == null ? "" : contextId) + .build(); + } + currentTask = appendArtifactToTask(task, taskArtifactUpdateEvent, taskId); + return currentTask; + } + + /** + * Update a task by adding a message to its history. If the task has a message in its current status, + * that message is moved to the history first. + * + * @param message the new message to add to the history + * @param task the task to update + * @return the updated task + */ + public Task updateWithMessage(Message message, Task task) { + Task.Builder taskBuilder = new Task.Builder(task); + List history = task.getHistory(); + if (history == null) { + history = new ArrayList<>(); + } + if (task.getStatus().message() != null) { + history.add(task.getStatus().message()); + taskBuilder.status(new TaskStatus(task.getStatus().state(), null, task.getStatus().timestamp())); + } + history.add(message); + taskBuilder.history(history); + currentTask = taskBuilder.build(); + return currentTask; + } + + private void saveTask(Task task) { + currentTask = task; + if (taskId == null) { + taskId = currentTask.getId(); + contextId = currentTask.getContextId(); + } + } +} diff --git a/client/src/main/java/io/a2a/client/MessageEvent.java b/client/src/main/java/io/a2a/client/MessageEvent.java new file mode 100644 index 000000000..b5970ab78 --- /dev/null +++ b/client/src/main/java/io/a2a/client/MessageEvent.java @@ -0,0 +1,26 @@ +package io.a2a.client; + +import io.a2a.spec.Message; + +/** + * A message event received by a client. + */ +public final class MessageEvent implements ClientEvent { + + private final Message message; + + /** + * A message event. + * + * @param message the message received + */ + public MessageEvent(Message message) { + this.message = message; + } + + public Message getMessage() { + return message; + } +} + + diff --git a/client/src/main/java/io/a2a/client/TaskEvent.java b/client/src/main/java/io/a2a/client/TaskEvent.java new file mode 100644 index 000000000..a18392841 --- /dev/null +++ b/client/src/main/java/io/a2a/client/TaskEvent.java @@ -0,0 +1,27 @@ +package io.a2a.client; + +import static io.a2a.util.Assert.checkNotNullParam; + +import io.a2a.spec.Task; + +/** + * A task event received by a client. + */ +public final class TaskEvent implements ClientEvent { + + private final Task task; + + /** + * A client task event. + * + * @param task the task received + */ + public TaskEvent(Task task) { + checkNotNullParam("task", task); + this.task = task; + } + + public Task getTask() { + return task; + } +} diff --git a/client/src/main/java/io/a2a/client/TaskUpdateEvent.java b/client/src/main/java/io/a2a/client/TaskUpdateEvent.java new file mode 100644 index 000000000..c45650822 --- /dev/null +++ b/client/src/main/java/io/a2a/client/TaskUpdateEvent.java @@ -0,0 +1,37 @@ +package io.a2a.client; + +import static io.a2a.util.Assert.checkNotNullParam; + +import io.a2a.spec.Task; +import io.a2a.spec.UpdateEvent; + +/** + * A task update event received by a client. + */ +public final class TaskUpdateEvent implements ClientEvent { + + private final Task task; + private final UpdateEvent updateEvent; + + /** + * A task update event. + * + * @param task the current task + * @param updateEvent the update event received for the current task + */ + public TaskUpdateEvent(Task task, UpdateEvent updateEvent) { + checkNotNullParam("task", task); + checkNotNullParam("updateEvent", updateEvent); + this.task = task; + this.updateEvent = updateEvent; + } + + public Task getTask() { + return task; + } + + public UpdateEvent getUpdateEvent() { + return updateEvent; + } + +} diff --git a/client/src/test/java/io/a2a/client/JsonMessages.java b/client/src/test/java/io/a2a/client/JsonMessages.java index d59ee0146..b99da3623 100644 --- a/client/src/test/java/io/a2a/client/JsonMessages.java +++ b/client/src/test/java/io/a2a/client/JsonMessages.java @@ -161,493 +161,4 @@ public class JsonMessages { }"""; - static final String SEND_MESSAGE_TEST_REQUEST = """ - { - "jsonrpc": "2.0", - "id": "request-1234", - "method": "message/send", - "params": { - "message": { - "role": "user", - "parts": [ - { - "kind": "text", - "text": "tell me a joke" - } - ], - "messageId": "message-1234", - "contextId": "context-1234", - "kind": "message" - }, - "configuration": { - "acceptedOutputModes": ["text"], - "blocking": true - }, - } - }"""; - - static final String SEND_MESSAGE_TEST_RESPONSE = """ - { - "jsonrpc": "2.0", - "id": 1, - "result": { - "id": "de38c76d-d54c-436c-8b9f-4c2703648d64", - "contextId": "c295ea44-7543-4f78-b524-7a38915ad6e4", - "status": { - "state": "completed" - }, - "artifacts": [ - { - "artifactId": "artifact-1", - "name": "joke", - "parts": [ - { - "kind": "text", - "text": "Why did the chicken cross the road? To get to the other side!" - } - ] - } - ], - "metadata": {}, - "kind": "task" - } - }"""; - - static final String SEND_MESSAGE_TEST_REQUEST_WITH_MESSAGE_RESPONSE = """ - { - "jsonrpc": "2.0", - "id": "request-1234-with-message-response", - "method": "message/send", - "params": { - "message": { - "role": "user", - "parts": [ - { - "kind": "text", - "text": "tell me a joke" - } - ], - "messageId": "message-1234", - "contextId": "context-1234", - "kind": "message" - }, - "configuration": { - "acceptedOutputModes": ["text"], - "blocking": true - }, - } - }"""; - - - static final String SEND_MESSAGE_TEST_RESPONSE_WITH_MESSAGE_RESPONSE = """ - { - "jsonrpc": "2.0", - "id": 1, - "result": { - "role": "agent", - "parts": [ - { - "kind": "text", - "text": "Why did the chicken cross the road? To get to the other side!" - } - ], - "messageId": "msg-456", - "kind": "message" - } - }"""; - - static final String SEND_MESSAGE_WITH_ERROR_TEST_REQUEST = """ - { - "jsonrpc": "2.0", - "id": "request-1234-with-error", - "method": "message/send", - "params": { - "message": { - "role": "user", - "parts": [ - { - "kind": "text", - "text": "tell me a joke" - } - ], - "messageId": "message-1234", - "contextId": "context-1234", - "kind": "message" - }, - "configuration": { - "acceptedOutputModes": ["text"], - "blocking": true - }, - } - }"""; - - static final String SEND_MESSAGE_ERROR_TEST_RESPONSE = """ - { - "jsonrpc": "2.0", - "error": { - "code": -32702, - "message": "Invalid parameters", - "data": "Hello world" - } - }"""; - - static final String GET_TASK_TEST_REQUEST = """ - { - "jsonrpc": "2.0", - "id": "request-1234", - "method": "tasks/get", - "params": { - "id": "de38c76d-d54c-436c-8b9f-4c2703648d64", - "historyLength": 10 - } - } - """; - - static final String GET_TASK_TEST_RESPONSE = """ - { - "jsonrpc": "2.0", - "id": 1, - "result": { - "id": "de38c76d-d54c-436c-8b9f-4c2703648d64", - "contextId": "c295ea44-7543-4f78-b524-7a38915ad6e4", - "status": { - "state": "completed" - }, - "artifacts": [ - { - "artifactId": "artifact-1", - "parts": [ - { - "kind": "text", - "text": "Why did the chicken cross the road? To get to the other side!" - } - ] - } - ], - "history": [ - { - "role": "user", - "parts": [ - { - "kind": "text", - "text": "tell me a joke" - }, - { - "kind": "file", - "file": { - "uri": "file:///path/to/file.txt", - "mimeType": "text/plain" - } - }, - { - "kind": "file", - "file": { - "bytes": "aGVsbG8=", - "name": "hello.txt" - } - } - ], - "messageId": "message-123", - "kind": "message" - } - ], - "metadata": {}, - "kind": "task" - } - } - """; - - static final String CANCEL_TASK_TEST_REQUEST = """ - { - "jsonrpc": "2.0", - "id": "request-1234", - "method": "tasks/cancel", - "params": { - "id": "de38c76d-d54c-436c-8b9f-4c2703648d64", - "metadata": {} - } - } - """; - - static final String CANCEL_TASK_TEST_RESPONSE = """ - { - "jsonrpc": "2.0", - "id": 1, - "result": { - "id": "de38c76d-d54c-436c-8b9f-4c2703648d64", - "contextId": "c295ea44-7543-4f78-b524-7a38915ad6e4", - "status": { - "state": "canceled" - }, - "metadata": {}, - "kind" : "task" - } - } - """; - - static final String GET_TASK_PUSH_NOTIFICATION_CONFIG_TEST_REQUEST = """ - { - "jsonrpc": "2.0", - "id": "1", - "method": "tasks/pushNotificationConfig/get", - "params": { - "id": "de38c76d-d54c-436c-8b9f-4c2703648d64", - "metadata": {}, - } - } - """; - - static final String GET_TASK_PUSH_NOTIFICATION_CONFIG_TEST_RESPONSE = """ - { - "jsonrpc": "2.0", - "id": 1, - "result": { - "taskId": "de38c76d-d54c-436c-8b9f-4c2703648d64", - "pushNotificationConfig": { - "url": "https://example.com/callback", - "authentication": { - "schemes": ["jwt"] - } - } - } - } - """; - - static final String SET_TASK_PUSH_NOTIFICATION_CONFIG_TEST_REQUEST = """ - { - "jsonrpc": "2.0", - "id": "1", - "method": "tasks/pushNotificationConfig/set", - "params": { - "taskId": "de38c76d-d54c-436c-8b9f-4c2703648d64", - "pushNotificationConfig": { - "url": "https://example.com/callback", - "authentication": { - "schemes": ["jwt"] - } - } - } - }"""; - - static final String SET_TASK_PUSH_NOTIFICATION_CONFIG_TEST_RESPONSE = """ - { - "jsonrpc": "2.0", - "id": 1, - "result": { - "taskId": "de38c76d-d54c-436c-8b9f-4c2703648d64", - "pushNotificationConfig": { - "url": "https://example.com/callback", - "authentication": { - "schemes": ["jwt"] - } - } - } - } - """; - - static final String SEND_MESSAGE_WITH_FILE_PART_TEST_REQUEST = """ - { - "jsonrpc": "2.0", - "id": "request-1234-with-file", - "method": "message/send", - "params": { - "message": { - "role": "user", - "parts": [ - { - "kind": "text", - "text": "analyze this image" - }, - { - "kind": "file", - "file": { - "uri": "file:///path/to/image.jpg", - "mimeType": "image/jpeg" - } - } - ], - "messageId": "message-1234-with-file", - "contextId": "context-1234", - "kind": "message" - }, - "configuration": { - "acceptedOutputModes": ["text"], - "blocking": true - } - } - }"""; - - static final String SEND_MESSAGE_WITH_FILE_PART_TEST_RESPONSE = """ - { - "jsonrpc": "2.0", - "id": 1, - "result": { - "id": "de38c76d-d54c-436c-8b9f-4c2703648d64", - "contextId": "c295ea44-7543-4f78-b524-7a38915ad6e4", - "status": { - "state": "completed" - }, - "artifacts": [ - { - "artifactId": "artifact-1", - "name": "image-analysis", - "parts": [ - { - "kind": "text", - "text": "This is an image of a cat sitting on a windowsill." - } - ] - } - ], - "metadata": {}, - "kind": "task" - } - }"""; - - static final String SEND_MESSAGE_WITH_DATA_PART_TEST_REQUEST = """ - { - "jsonrpc": "2.0", - "id": "request-1234-with-data", - "method": "message/send", - "params": { - "message": { - "role": "user", - "parts": [ - { - "kind": "text", - "text": "process this data" - }, - { - "kind": "data", - "data": { - "temperature": 25.5, - "humidity": 60.2, - "location": "San Francisco", - "timestamp": "2024-01-15T10:30:00Z" - } - } - ], - "messageId": "message-1234-with-data", - "contextId": "context-1234", - "kind": "message" - }, - "configuration": { - "acceptedOutputModes": ["text"], - "blocking": true - } - } - }"""; - - static final String SEND_MESSAGE_WITH_DATA_PART_TEST_RESPONSE = """ - { - "jsonrpc": "2.0", - "id": 1, - "result": { - "id": "de38c76d-d54c-436c-8b9f-4c2703648d64", - "contextId": "c295ea44-7543-4f78-b524-7a38915ad6e4", - "status": { - "state": "completed" - }, - "artifacts": [ - { - "artifactId": "artifact-1", - "name": "data-analysis", - "parts": [ - { - "kind": "text", - "text": "Processed weather data: Temperature is 25.5°C, humidity is 60.2% in San Francisco." - } - ] - } - ], - "metadata": {}, - "kind": "task" - } - }"""; - - static final String SEND_MESSAGE_WITH_MIXED_PARTS_TEST_REQUEST = """ - { - "jsonrpc": "2.0", - "id": "request-1234-with-mixed", - "method": "message/send", - "params": { - "message": { - "role": "user", - "parts": [ - { - "kind": "text", - "text": "analyze this data and image" - }, - { - "kind": "file", - "file": { - "bytes": "aGVsbG8=", - "name": "chart.png", - "mimeType": "image/png" - } - }, - { - "kind": "data", - "data": { - "chartType": "bar", - "dataPoints": [10, 20, 30, 40], - "labels": ["Q1", "Q2", "Q3", "Q4"] - } - } - ], - "messageId": "message-1234-with-mixed", - "contextId": "context-1234", - "kind": "message" - }, - "configuration": { - "acceptedOutputModes": ["text"], - "blocking": true - } - } - }"""; - - static final String SEND_MESSAGE_WITH_MIXED_PARTS_TEST_RESPONSE = """ - { - "jsonrpc": "2.0", - "id": 1, - "result": { - "id": "de38c76d-d54c-436c-8b9f-4c2703648d64", - "contextId": "c295ea44-7543-4f78-b524-7a38915ad6e4", - "status": { - "state": "completed" - }, - "artifacts": [ - { - "artifactId": "artifact-1", - "name": "mixed-analysis", - "parts": [ - { - "kind": "text", - "text": "Analyzed chart image and data: Bar chart showing quarterly data with values [10, 20, 30, 40]." - } - ] - } - ], - "metadata": {}, - "kind": "task" - } - }"""; - - static final String GET_AUTHENTICATED_EXTENDED_AGENT_CARD_REQUEST = """ - { - "jsonrpc": "2.0", - "id": "1", - "method": "agent/getAuthenticatedExtendedCard" - } - """; - - static final String GET_AUTHENTICATED_EXTENDED_AGENT_CARD_RESPONSE = """ - { - "jsonrpc": "2.0", - "id": "1", - "result": - """ + AUTHENTICATION_EXTENDED_AGENT_CARD + - """ - } - """; -} +} \ No newline at end of file diff --git a/examples/helloworld/client/pom.xml b/examples/helloworld/client/pom.xml index 3aaa5c221..25970a7d4 100644 --- a/examples/helloworld/client/pom.xml +++ b/examples/helloworld/client/pom.xml @@ -18,7 +18,7 @@ io.github.a2asdk - a2a-java-sdk-client + a2a-java-sdk-client-transport-jsonrpc diff --git a/examples/helloworld/client/src/main/java/io/a2a/examples/helloworld/HelloWorldClient.java b/examples/helloworld/client/src/main/java/io/a2a/examples/helloworld/HelloWorldClient.java index 94e595228..f810244e1 100644 --- a/examples/helloworld/client/src/main/java/io/a2a/examples/helloworld/HelloWorldClient.java +++ b/examples/helloworld/client/src/main/java/io/a2a/examples/helloworld/HelloWorldClient.java @@ -4,8 +4,8 @@ import java.util.Map; import com.fasterxml.jackson.databind.ObjectMapper; -import io.a2a.client.A2AClient; import io.a2a.A2A; +import io.a2a.client.transport.jsonrpc.JSONRPCTransport; import io.a2a.spec.AgentCard; import io.a2a.spec.EventKind; import io.a2a.spec.Message; @@ -46,15 +46,12 @@ public static void main(String[] args) { System.out.println("Public card does not indicate support for an extended card. Using public card."); } - A2AClient client = new A2AClient(finalAgentCard); + JSONRPCTransport client = new JSONRPCTransport(finalAgentCard); Message message = A2A.toUserMessage(MESSAGE_TEXT); // the message ID will be automatically generated for you MessageSendParams params = new MessageSendParams.Builder() .message(message) .build(); - SendMessageResponse response = client.sendMessage(params); - System.out.println("Message sent with ID: " + response.getId()); - - EventKind result = response.getResult(); + EventKind result = client.sendMessage(params, null); if (result instanceof Message responseMessage) { StringBuilder textBuilder = new StringBuilder(); if (responseMessage.getParts() != null) { diff --git a/examples/helloworld/pom.xml b/examples/helloworld/pom.xml index a60b123d9..47ea4ce82 100644 --- a/examples/helloworld/pom.xml +++ b/examples/helloworld/pom.xml @@ -28,7 +28,7 @@ io.github.a2asdk - a2a-java-sdk-client + a2a-java-sdk-client-transport-jsonrpc ${project.version} diff --git a/examples/helloworld/server/pom.xml b/examples/helloworld/server/pom.xml index e23537cee..4ff3752bb 100644 --- a/examples/helloworld/server/pom.xml +++ b/examples/helloworld/server/pom.xml @@ -36,7 +36,7 @@ io.github.a2asdk - a2a-java-sdk-client + a2a-java-sdk-client-transport-jsonrpc diff --git a/pom.xml b/pom.xml index 0e1e4542d..55a1d177f 100644 --- a/pom.xml +++ b/pom.xml @@ -280,6 +280,7 @@ client + client-config client-http client-transport/grpc client-transport/jsonrpc diff --git a/server-common/pom.xml b/server-common/pom.xml index 29e7f6b76..3f3ef584a 100644 --- a/server-common/pom.xml +++ b/server-common/pom.xml @@ -27,6 +27,11 @@ a2a-java-sdk-client-http ${project.version} + + ${project.groupId} + a2a-java-sdk-client-transport-jsonrpc + ${project.version} + com.fasterxml.jackson.core jackson-databind diff --git a/server-common/src/main/java/io/a2a/server/tasks/TaskManager.java b/server-common/src/main/java/io/a2a/server/tasks/TaskManager.java index cebddf85a..73d3d2ed6 100644 --- a/server-common/src/main/java/io/a2a/server/tasks/TaskManager.java +++ b/server-common/src/main/java/io/a2a/server/tasks/TaskManager.java @@ -2,6 +2,7 @@ import static io.a2a.spec.TaskState.SUBMITTED; import static io.a2a.util.Assert.checkNotNullParam; +import static io.a2a.util.Utils.appendArtifactToTask; import java.util.ArrayList; import java.util.List; @@ -82,60 +83,7 @@ Task saveTaskEvent(TaskStatusUpdateEvent event) throws A2AServerException { Task saveTaskEvent(TaskArtifactUpdateEvent event) throws A2AServerException { checkIdsAndUpdateIfNecessary(event.getTaskId(), event.getContextId()); Task task = ensureTask(event.getTaskId(), event.getContextId()); - - // Append artifacts - List artifacts = task.getArtifacts() == null ? new ArrayList<>() : new ArrayList<>(task.getArtifacts()); - - Artifact newArtifact = event.getArtifact(); - String artifactId = newArtifact.artifactId(); - boolean appendParts = event.isAppend() != null && event.isAppend(); - - Artifact existingArtifact = null; - int existingArtifactIndex = -1; - - for (int i = 0; i < artifacts.size(); i++) { - Artifact curr = artifacts.get(i); - if (curr.artifactId() != null && curr.artifactId().equals(artifactId)) { - existingArtifact = curr; - existingArtifactIndex = i; - break; - } - } - - if (!appendParts) { - // This represents the first chunk for this artifact index - if (existingArtifactIndex >= 0) { - // Replace the existing artifact entirely with the new artifact - LOGGER.debug("Replacing artifact at id {} for task {}", artifactId, taskId); - artifacts.set(existingArtifactIndex, newArtifact); - } else { - // Append the new artifact since no artifact with this id/index exists yet - LOGGER.debug("Adding artifact at id {} for task {}", artifactId, taskId); - artifacts.add(newArtifact); - } - - } else if (existingArtifact != null) { - // Append new parts to the existing artifact's parts list - // Do this to a copy - LOGGER.debug("Appending parts to artifact id {} for task {}", artifactId, taskId); - List> parts = new ArrayList<>(existingArtifact.parts()); - parts.addAll(newArtifact.parts()); - Artifact updated = new Artifact.Builder(existingArtifact) - .parts(parts) - .build(); - artifacts.set(existingArtifactIndex, updated); - } else { - // We received a chunk to append, but we don't have an existing artifact. - // We will ignore this chunk - LOGGER.warn( - "Received append=true for nonexistent artifact index for artifact {} in task {}. Ignoring chunk.", - artifactId, taskId); - } - - task = new Task.Builder(task) - .artifacts(artifacts) - .build(); - + task = appendArtifactToTask(task, event, taskId); return saveTask(task); } diff --git a/spec/src/main/java/io/a2a/spec/A2AClientException.java b/spec/src/main/java/io/a2a/spec/A2AClientException.java new file mode 100644 index 000000000..17ff073d5 --- /dev/null +++ b/spec/src/main/java/io/a2a/spec/A2AClientException.java @@ -0,0 +1,23 @@ +package io.a2a.spec; + +/** + * Exception to indicate a general failure related to an A2A client. + */ +public class A2AClientException extends A2AException { + + public A2AClientException() { + super(); + } + + public A2AClientException(final String msg) { + super(msg); + } + + public A2AClientException(final Throwable cause) { + super(cause); + } + + public A2AClientException(final String msg, final Throwable cause) { + super(msg, cause); + } +} diff --git a/spec/src/main/java/io/a2a/spec/A2AClientInvalidArgsError.java b/spec/src/main/java/io/a2a/spec/A2AClientInvalidArgsError.java new file mode 100644 index 000000000..c39c53350 --- /dev/null +++ b/spec/src/main/java/io/a2a/spec/A2AClientInvalidArgsError.java @@ -0,0 +1,15 @@ +package io.a2a.spec; + +public class A2AClientInvalidArgsError extends A2AClientError { + + public A2AClientInvalidArgsError() { + } + + public A2AClientInvalidArgsError(String message) { + super("Invalid arguments error: " + message); + } + + public A2AClientInvalidArgsError(String message, Throwable cause) { + super("Invalid arguments error: " + message, cause); + } +} diff --git a/spec/src/main/java/io/a2a/spec/A2AClientInvalidStateError.java b/spec/src/main/java/io/a2a/spec/A2AClientInvalidStateError.java new file mode 100644 index 000000000..e828fe95d --- /dev/null +++ b/spec/src/main/java/io/a2a/spec/A2AClientInvalidStateError.java @@ -0,0 +1,15 @@ +package io.a2a.spec; + +public class A2AClientInvalidStateError extends A2AClientError { + + public A2AClientInvalidStateError() { + } + + public A2AClientInvalidStateError(String message) { + super("Invalid state error: " + message); + } + + public A2AClientInvalidStateError(String message, Throwable cause) { + super("Invalid state error: " + message, cause); + } +} diff --git a/spec/src/main/java/io/a2a/spec/TaskArtifactUpdateEvent.java b/spec/src/main/java/io/a2a/spec/TaskArtifactUpdateEvent.java index 03269bf33..279916c68 100644 --- a/spec/src/main/java/io/a2a/spec/TaskArtifactUpdateEvent.java +++ b/spec/src/main/java/io/a2a/spec/TaskArtifactUpdateEvent.java @@ -14,7 +14,7 @@ */ @JsonInclude(JsonInclude.Include.NON_ABSENT) @JsonIgnoreProperties(ignoreUnknown = true) -public final class TaskArtifactUpdateEvent implements EventKind, StreamingEventKind { +public final class TaskArtifactUpdateEvent implements EventKind, StreamingEventKind, UpdateEvent { public static final String ARTIFACT_UPDATE = "artifact-update"; private final String taskId; diff --git a/spec/src/main/java/io/a2a/spec/TaskStatusUpdateEvent.java b/spec/src/main/java/io/a2a/spec/TaskStatusUpdateEvent.java index 21726d607..788655530 100644 --- a/spec/src/main/java/io/a2a/spec/TaskStatusUpdateEvent.java +++ b/spec/src/main/java/io/a2a/spec/TaskStatusUpdateEvent.java @@ -14,7 +14,7 @@ */ @JsonInclude(JsonInclude.Include.NON_ABSENT) @JsonIgnoreProperties(ignoreUnknown = true) -public final class TaskStatusUpdateEvent implements EventKind, StreamingEventKind { +public final class TaskStatusUpdateEvent implements EventKind, StreamingEventKind, UpdateEvent { public static final String STATUS_UPDATE = "status-update"; private final String taskId; diff --git a/spec/src/main/java/io/a2a/spec/TransportProtocol.java b/spec/src/main/java/io/a2a/spec/TransportProtocol.java index 289e9f5de..afd11c7a1 100644 --- a/spec/src/main/java/io/a2a/spec/TransportProtocol.java +++ b/spec/src/main/java/io/a2a/spec/TransportProtocol.java @@ -35,4 +35,4 @@ public static TransportProtocol fromString(String transport) { throw new IllegalArgumentException("Invalid transport: " + transport); } } -} \ No newline at end of file +} diff --git a/spec/src/main/java/io/a2a/spec/UpdateEvent.java b/spec/src/main/java/io/a2a/spec/UpdateEvent.java new file mode 100644 index 000000000..81060c8eb --- /dev/null +++ b/spec/src/main/java/io/a2a/spec/UpdateEvent.java @@ -0,0 +1,4 @@ +package io.a2a.spec; + +public sealed interface UpdateEvent permits TaskStatusUpdateEvent, TaskArtifactUpdateEvent { +} diff --git a/spec/src/main/java/io/a2a/util/Utils.java b/spec/src/main/java/io/a2a/util/Utils.java index aac6af61c..c9e982910 100644 --- a/spec/src/main/java/io/a2a/util/Utils.java +++ b/spec/src/main/java/io/a2a/util/Utils.java @@ -1,13 +1,23 @@ package io.a2a.util; +import java.util.ArrayList; +import java.util.List; +import java.util.logging.Logger; + import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import io.a2a.spec.Artifact; +import io.a2a.spec.Part; +import io.a2a.spec.Task; +import io.a2a.spec.TaskArtifactUpdateEvent; + public class Utils { public static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + private static final Logger log = Logger.getLogger(Utils.class.getName()); static { // needed for date/time types OBJECT_MAPPER.registerModule(new JavaTimeModule()); @@ -27,4 +37,60 @@ public static T defaultIfNull(T value, T defaultValue) { public static void rethrow(Throwable t) throws T { throw (T) t; } + + public static Task appendArtifactToTask(Task task, TaskArtifactUpdateEvent event, String taskId) { + // Append artifacts + List artifacts = task.getArtifacts() == null ? new ArrayList<>() : new ArrayList<>(task.getArtifacts()); + + Artifact newArtifact = event.getArtifact(); + String artifactId = newArtifact.artifactId(); + boolean appendParts = event.isAppend() != null && event.isAppend(); + + Artifact existingArtifact = null; + int existingArtifactIndex = -1; + + for (int i = 0; i < artifacts.size(); i++) { + Artifact curr = artifacts.get(i); + if (curr.artifactId() != null && curr.artifactId().equals(artifactId)) { + existingArtifact = curr; + existingArtifactIndex = i; + break; + } + } + + if (!appendParts) { + // This represents the first chunk for this artifact index + if (existingArtifactIndex >= 0) { + // Replace the existing artifact entirely with the new artifact + log.fine(String.format("Replacing artifact at id %s for task %s", artifactId, taskId)); + artifacts.set(existingArtifactIndex, newArtifact); + } else { + // Append the new artifact since no artifact with this id/index exists yet + log.fine(String.format("Adding artifact at id %s for task %s", artifactId, taskId)); + artifacts.add(newArtifact); + } + + } else if (existingArtifact != null) { + // Append new parts to the existing artifact's parts list + // Do this to a copy + log.fine(String.format("Appending parts to artifact id %s for task %s", artifactId, taskId)); + List> parts = new ArrayList<>(existingArtifact.parts()); + parts.addAll(newArtifact.parts()); + Artifact updated = new Artifact.Builder(existingArtifact) + .parts(parts) + .build(); + artifacts.set(existingArtifactIndex, updated); + } else { + // We received a chunk to append, but we don't have an existing artifact. + // We will ignore this chunk + log.warning( + String.format("Received append=true for nonexistent artifact index for artifact %s in task %s. Ignoring chunk.", + artifactId, taskId)); + } + + return new Task.Builder(task) + .artifacts(artifacts) + .build(); + + } } diff --git a/tests/server-common/pom.xml b/tests/server-common/pom.xml index 5b1cc3d1e..6ebf239fb 100644 --- a/tests/server-common/pom.xml +++ b/tests/server-common/pom.xml @@ -53,6 +53,12 @@ quarkus-arc test + + io.github.a2asdk + a2a-java-sdk-client-transport-jsonrpc + 0.2.6.Beta1-SNAPSHOT + test + diff --git a/tests/server-common/src/test/java/io/a2a/server/apps/common/AbstractA2AServerTest.java b/tests/server-common/src/test/java/io/a2a/server/apps/common/AbstractA2AServerTest.java index 783318c43..38693ee33 100644 --- a/tests/server-common/src/test/java/io/a2a/server/apps/common/AbstractA2AServerTest.java +++ b/tests/server-common/src/test/java/io/a2a/server/apps/common/AbstractA2AServerTest.java @@ -9,6 +9,7 @@ import static org.wildfly.common.Assert.assertNotNull; import static org.wildfly.common.Assert.assertTrue; +import java.io.EOFException; import java.io.IOException; import java.net.URI; import java.net.http.HttpClient; @@ -23,47 +24,65 @@ import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicReference; +import java.util.stream.Stream; import jakarta.ws.rs.core.MediaType; -import io.a2a.client.A2AClient; +import com.fasterxml.jackson.core.JsonProcessingException; + +import io.a2a.client.transport.spi.ClientTransport; +import io.a2a.client.transport.jsonrpc.JSONRPCTransport; +import io.a2a.spec.A2AClientException; import io.a2a.spec.A2AServerException; import io.a2a.spec.AgentCard; import io.a2a.spec.Artifact; import io.a2a.spec.AuthenticatedExtendedCardNotConfiguredError; +import io.a2a.spec.CancelTaskRequest; import io.a2a.spec.CancelTaskResponse; +import io.a2a.spec.DeleteTaskPushNotificationConfigParams; import io.a2a.spec.DeleteTaskPushNotificationConfigResponse; import io.a2a.spec.Event; import io.a2a.spec.GetAuthenticatedExtendedCardRequest; import io.a2a.spec.GetAuthenticatedExtendedCardResponse; import io.a2a.spec.GetTaskPushNotificationConfigParams; +import io.a2a.spec.GetTaskPushNotificationConfigRequest; import io.a2a.spec.GetTaskPushNotificationConfigResponse; +import io.a2a.spec.GetTaskRequest; import io.a2a.spec.GetTaskResponse; import io.a2a.spec.InvalidParamsError; import io.a2a.spec.InvalidRequestError; import io.a2a.spec.JSONParseError; import io.a2a.spec.JSONRPCError; import io.a2a.spec.JSONRPCErrorResponse; +import io.a2a.spec.ListTaskPushNotificationConfigParams; import io.a2a.spec.ListTaskPushNotificationConfigResponse; import io.a2a.spec.Message; import io.a2a.spec.MessageSendParams; import io.a2a.spec.MethodNotFoundError; import io.a2a.spec.Part; import io.a2a.spec.PushNotificationConfig; +import io.a2a.spec.SendMessageRequest; import io.a2a.spec.SendMessageResponse; +import io.a2a.spec.SendStreamingMessageRequest; +import io.a2a.spec.SendStreamingMessageResponse; +import io.a2a.spec.SetTaskPushNotificationConfigRequest; import io.a2a.spec.SetTaskPushNotificationConfigResponse; +import io.a2a.spec.StreamingJSONRPCRequest; import io.a2a.spec.Task; import io.a2a.spec.TaskArtifactUpdateEvent; import io.a2a.spec.TaskIdParams; import io.a2a.spec.TaskNotFoundError; import io.a2a.spec.TaskPushNotificationConfig; import io.a2a.spec.TaskQueryParams; +import io.a2a.spec.TaskResubscriptionRequest; import io.a2a.spec.TaskState; import io.a2a.spec.TaskStatus; import io.a2a.spec.TaskStatusUpdateEvent; import io.a2a.spec.TextPart; import io.a2a.spec.UnsupportedOperationError; import io.a2a.util.Utils; +import io.restassured.RestAssured; +import io.restassured.specification.RequestSpecification; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; @@ -74,45 +93,46 @@ * which delegates to {@link TestUtilsBean}. */ public abstract class AbstractA2AServerTest { + private static final Task MINIMAL_TASK = new Task.Builder() .id("task-123") .contextId("session-xyz") .status(new TaskStatus(TaskState.SUBMITTED)) .build(); - + private static final Task CANCEL_TASK = new Task.Builder() .id("cancel-task-123") .contextId("session-xyz") .status(new TaskStatus(TaskState.SUBMITTED)) .build(); - + private static final Task CANCEL_TASK_NOT_SUPPORTED = new Task.Builder() .id("cancel-task-not-supported-123") .contextId("session-xyz") .status(new TaskStatus(TaskState.SUBMITTED)) .build(); - + private static final Task SEND_MESSAGE_NOT_SUPPORTED = new Task.Builder() .id("task-not-supported-123") .contextId("session-xyz") .status(new TaskStatus(TaskState.SUBMITTED)) .build(); - + private static final Message MESSAGE = new Message.Builder() .messageId("111") .role(Message.Role.AGENT) .parts(new TextPart("test message")) .build(); public static final String APPLICATION_JSON = "application/json"; - + private final int serverPort; - private A2AClient client; - + private ClientTransport client; + protected AbstractA2AServerTest(int serverPort) { this.serverPort = serverPort; - this.client = new A2AClient("http://localhost:" + serverPort); + this.client = new JSONRPCTransport("http://localhost:" + serverPort); } - + @Test public void testTaskStoreMethodsSanityTest() throws Exception { Task task = new Task.Builder(MINIMAL_TASK).id("abcde").build(); @@ -121,89 +141,139 @@ public void testTaskStoreMethodsSanityTest() throws Exception { assertEquals(task.getId(), saved.getId()); assertEquals(task.getContextId(), saved.getContextId()); assertEquals(task.getStatus().state(), saved.getStatus().state()); - + deleteTaskInTaskStore(task.getId()); Task saved2 = getTaskFromTaskStore(task.getId()); assertNull(saved2); } - + @Test public void testGetTaskSuccess() throws Exception { testGetTask(); } - + private void testGetTask() throws Exception { testGetTask(null); } - + private void testGetTask(String mediaType) throws Exception { saveTaskInTaskStore(MINIMAL_TASK); try { - GetTaskResponse response = client.getTask("1", new TaskQueryParams(MINIMAL_TASK.getId())); + GetTaskRequest request = new GetTaskRequest("1", new TaskQueryParams(MINIMAL_TASK.getId())); + RequestSpecification requestSpecification = RestAssured.given() + .contentType(MediaType.APPLICATION_JSON) + .body(request); + if (mediaType != null) { + requestSpecification = requestSpecification.accept(mediaType); + } + GetTaskResponse response = requestSpecification + .when() + .post("/") + .then() + .statusCode(200) + .extract() + .as(GetTaskResponse.class); assertEquals("1", response.getId()); assertEquals("task-123", response.getResult().getId()); assertEquals("session-xyz", response.getResult().getContextId()); assertEquals(TaskState.SUBMITTED, response.getResult().getStatus().state()); assertNull(response.getError()); - } catch (A2AServerException e) { - fail("Unexpected exception during getTask: " + e.getMessage(), e); } finally { deleteTaskInTaskStore(MINIMAL_TASK.getId()); } } - + @Test public void testGetTaskNotFound() throws Exception { assertTrue(getTaskFromTaskStore("non-existent-task") == null); - try { - client.getTask("1", new TaskQueryParams("non-existent-task")); - fail("Expected A2AServerException to be thrown"); - } catch (A2AServerException e) { - assertInstanceOf(TaskNotFoundError.class, e.getCause()); - } + GetTaskRequest request = new GetTaskRequest("1", new TaskQueryParams("non-existent-task")); + GetTaskResponse response = given() + .contentType(MediaType.APPLICATION_JSON) + .body(request) + .when() + .post("/") + .then() + .statusCode(200) + .extract() + .as(GetTaskResponse.class); + assertEquals("1", response.getId()); + // this should be an instance of TaskNotFoundError, see https://github.com/a2aproject/a2a-java/issues/23 + assertInstanceOf(JSONRPCError.class, response.getError()); + assertEquals(new TaskNotFoundError().getCode(), response.getError().getCode()); + assertNull(response.getResult()); } - + @Test public void testCancelTaskSuccess() throws Exception { saveTaskInTaskStore(CANCEL_TASK); try { - CancelTaskResponse response = client.cancelTask("1", new TaskIdParams(CANCEL_TASK.getId())); - assertEquals("1", response.getId()); + CancelTaskRequest request = new CancelTaskRequest("1", new TaskIdParams(CANCEL_TASK.getId())); + CancelTaskResponse response = given() + .contentType(MediaType.APPLICATION_JSON) + .body(request) + .when() + .post("/") + .then() + .statusCode(200) + .extract() + .as(CancelTaskResponse.class); assertNull(response.getError()); + assertEquals(request.getId(), response.getId()); Task task = response.getResult(); assertEquals(CANCEL_TASK.getId(), task.getId()); assertEquals(CANCEL_TASK.getContextId(), task.getContextId()); assertEquals(TaskState.CANCELED, task.getStatus().state()); - } catch (A2AServerException e) { - fail("Unexpected exception during cancel task success test: " + e.getMessage(), e); + } catch (Exception e) { } finally { deleteTaskInTaskStore(CANCEL_TASK.getId()); } } - + @Test public void testCancelTaskNotSupported() throws Exception { saveTaskInTaskStore(CANCEL_TASK_NOT_SUPPORTED); try { - client.cancelTask("1", new TaskIdParams(CANCEL_TASK_NOT_SUPPORTED.getId())); - fail("Expected A2AServerException to be thrown"); - } catch (A2AServerException e) { - assertInstanceOf(UnsupportedOperationError.class, e.getCause()); + CancelTaskRequest request = new CancelTaskRequest("1", new TaskIdParams(CANCEL_TASK_NOT_SUPPORTED.getId())); + CancelTaskResponse response = given() + .contentType(MediaType.APPLICATION_JSON) + .body(request) + .when() + .post("/") + .then() + .statusCode(200) + .extract() + .as(CancelTaskResponse.class); + assertEquals(request.getId(), response.getId()); + assertNull(response.getResult()); + // this should be an instance of UnsupportedOperationError, see https://github.com/a2aproject/a2a-java/issues/23 + assertInstanceOf(JSONRPCError.class, response.getError()); + assertEquals(new UnsupportedOperationError().getCode(), response.getError().getCode()); + } catch (Exception e) { } finally { deleteTaskInTaskStore(CANCEL_TASK_NOT_SUPPORTED.getId()); } } - + @Test public void testCancelTaskNotFound() { - try { - client.cancelTask("1", new TaskIdParams("non-existent-task")); - fail("Expected A2AServerException to be thrown"); - } catch (A2AServerException e) { - assertInstanceOf(TaskNotFoundError.class, e.getCause()); - } + CancelTaskRequest request = new CancelTaskRequest("1", new TaskIdParams("non-existent-task")); + CancelTaskResponse response = given() + .contentType(MediaType.APPLICATION_JSON) + .body(request) + .when() + .post("/") + .then() + .statusCode(200) + .extract() + .as(CancelTaskResponse.class) + ; + assertEquals(request.getId(), response.getId()); + assertNull(response.getResult()); + // this should be an instance of UnsupportedOperationError, see https://github.com/a2aproject/a2a-java/issues/23 + assertInstanceOf(JSONRPCError.class, response.getError()); + assertEquals(new TaskNotFoundError().getCode(), response.getError().getCode()); } - + @Test public void testSendMessageNewMessageSuccess() throws Exception { assertTrue(getTaskFromTaskStore(MINIMAL_TASK.getId()) == null); @@ -211,23 +281,25 @@ public void testSendMessageNewMessageSuccess() throws Exception { .taskId(MINIMAL_TASK.getId()) .contextId(MINIMAL_TASK.getContextId()) .build(); - MessageSendParams messageSendParams = new MessageSendParams(message, null, null); - - try { - SendMessageResponse response = client.sendMessage("1", messageSendParams); - assertEquals("1", response.getId()); - assertNull(response.getError()); - Message messageResponse = (Message) response.getResult(); - assertEquals(MESSAGE.getMessageId(), messageResponse.getMessageId()); - assertEquals(MESSAGE.getRole(), messageResponse.getRole()); - Part part = messageResponse.getParts().get(0); - assertEquals(Part.Kind.TEXT, part.getKind()); - assertEquals("test message", ((TextPart) part).getText()); - } catch (A2AServerException e) { - fail("Unexpected exception during send new message test: " + e.getMessage(), e); - } + SendMessageRequest request = new SendMessageRequest("1", new MessageSendParams(message, null, null)); + SendMessageResponse response = given() + .contentType(MediaType.APPLICATION_JSON) + .body(request) + .when() + .post("/") + .then() + .statusCode(200) + .extract() + .as(SendMessageResponse.class); + assertNull(response.getError()); + Message messageResponse = (Message) response.getResult(); + assertEquals(MESSAGE.getMessageId(), messageResponse.getMessageId()); + assertEquals(MESSAGE.getRole(), messageResponse.getRole()); + Part part = messageResponse.getParts().get(0); + assertEquals(Part.Kind.TEXT, part.getKind()); + assertEquals("test message", ((TextPart) part).getText()); } - + @Test public void testSendMessageExistingTaskSuccess() throws Exception { saveTaskInTaskStore(MINIMAL_TASK); @@ -236,10 +308,16 @@ public void testSendMessageExistingTaskSuccess() throws Exception { .taskId(MINIMAL_TASK.getId()) .contextId(MINIMAL_TASK.getContextId()) .build(); - MessageSendParams messageSendParams = new MessageSendParams(message, null, null); - - SendMessageResponse response = client.sendMessage("1", messageSendParams); - assertEquals("1", response.getId()); + SendMessageRequest request = new SendMessageRequest("1", new MessageSendParams(message, null, null)); + SendMessageResponse response = given() + .contentType(MediaType.APPLICATION_JSON) + .body(request) + .when() + .post("/") + .then() + .statusCode(200) + .extract() + .as(SendMessageResponse.class); assertNull(response.getError()); Message messageResponse = (Message) response.getResult(); assertEquals(MESSAGE.getMessageId(), messageResponse.getMessageId()); @@ -247,78 +325,108 @@ public void testSendMessageExistingTaskSuccess() throws Exception { Part part = messageResponse.getParts().get(0); assertEquals(Part.Kind.TEXT, part.getKind()); assertEquals("test message", ((TextPart) part).getText()); - } catch (A2AServerException e) { - fail("Unexpected exception during send message to existing task test: " + e.getMessage(), e); + } catch (Exception e) { } finally { deleteTaskInTaskStore(MINIMAL_TASK.getId()); } } - + @Test public void testSetPushNotificationSuccess() throws Exception { saveTaskInTaskStore(MINIMAL_TASK); try { - PushNotificationConfig pushNotificationConfig = new PushNotificationConfig.Builder() - .url("http://example.com") - .build(); - SetTaskPushNotificationConfigResponse response = client.setTaskPushNotificationConfig("1", - MINIMAL_TASK.getId(), pushNotificationConfig); - assertEquals("1", response.getId()); + TaskPushNotificationConfig taskPushConfig = + new TaskPushNotificationConfig( + MINIMAL_TASK.getId(), new PushNotificationConfig.Builder().url("http://example.com").build()); + SetTaskPushNotificationConfigRequest request = new SetTaskPushNotificationConfigRequest("1", taskPushConfig); + SetTaskPushNotificationConfigResponse response = given() + .contentType(MediaType.APPLICATION_JSON) + .body(request) + .when() + .post("/") + .then() + .statusCode(200) + .extract() + .as(SetTaskPushNotificationConfigResponse.class); assertNull(response.getError()); + assertEquals(request.getId(), response.getId()); TaskPushNotificationConfig config = response.getResult(); assertEquals(MINIMAL_TASK.getId(), config.taskId()); assertEquals("http://example.com", config.pushNotificationConfig().url()); - } catch (A2AServerException e) { - fail("Unexpected exception during set push notification test: " + e.getMessage(), e); + } catch (Exception e) { } finally { deletePushNotificationConfigInStore(MINIMAL_TASK.getId(), MINIMAL_TASK.getId()); deleteTaskInTaskStore(MINIMAL_TASK.getId()); } } - + @Test public void testGetPushNotificationSuccess() throws Exception { saveTaskInTaskStore(MINIMAL_TASK); try { - PushNotificationConfig pushNotificationConfig = new PushNotificationConfig.Builder() - .url("http://example.com") - .build(); - - // First set the push notification config - SetTaskPushNotificationConfigResponse setResponse = client.setTaskPushNotificationConfig("1", - MINIMAL_TASK.getId(), pushNotificationConfig); - assertNotNull(setResponse); - - // Then get the push notification config - GetTaskPushNotificationConfigResponse response = client.getTaskPushNotificationConfig("2", new GetTaskPushNotificationConfigParams(MINIMAL_TASK.getId())); - assertEquals("2", response.getId()); + TaskPushNotificationConfig taskPushConfig = + new TaskPushNotificationConfig( + MINIMAL_TASK.getId(), new PushNotificationConfig.Builder().url("http://example.com").build()); + + SetTaskPushNotificationConfigRequest setTaskPushNotificationRequest = new SetTaskPushNotificationConfigRequest("1", taskPushConfig); + SetTaskPushNotificationConfigResponse setTaskPushNotificationResponse = given() + .contentType(MediaType.APPLICATION_JSON) + .body(setTaskPushNotificationRequest) + .when() + .post("/") + .then() + .statusCode(200) + .extract() + .as(SetTaskPushNotificationConfigResponse.class); + assertNotNull(setTaskPushNotificationResponse); + + GetTaskPushNotificationConfigRequest request = + new GetTaskPushNotificationConfigRequest("111", new GetTaskPushNotificationConfigParams(MINIMAL_TASK.getId())); + GetTaskPushNotificationConfigResponse response = given() + .contentType(MediaType.APPLICATION_JSON) + .body(request) + .when() + .post("/") + .then() + .statusCode(200) + .extract() + .as(GetTaskPushNotificationConfigResponse.class); assertNull(response.getError()); + assertEquals(request.getId(), response.getId()); TaskPushNotificationConfig config = response.getResult(); assertEquals(MINIMAL_TASK.getId(), config.taskId()); assertEquals("http://example.com", config.pushNotificationConfig().url()); - } catch (A2AServerException e) { - fail("Unexpected exception during get push notification test: " + e.getMessage(), e); + } catch (Exception e) { } finally { deletePushNotificationConfigInStore(MINIMAL_TASK.getId(), MINIMAL_TASK.getId()); + deleteTaskInTaskStore(MINIMAL_TASK.getId()); } } - + @Test public void testError() { Message message = new Message.Builder(MESSAGE) .taskId(SEND_MESSAGE_NOT_SUPPORTED.getId()) .contextId(SEND_MESSAGE_NOT_SUPPORTED.getContextId()) .build(); - MessageSendParams messageSendParams = new MessageSendParams(message, null, null); - - try { - client.sendMessage("1", messageSendParams); - fail("Expected A2AServerException to be thrown"); - } catch (A2AServerException e) { - assertInstanceOf(UnsupportedOperationError.class, e.getCause()); - } + SendMessageRequest request = new SendMessageRequest( + "1", new MessageSendParams(message, null, null)); + SendMessageResponse response = given() + .contentType(MediaType.APPLICATION_JSON) + .body(request) + .when() + .post("/") + .then() + .statusCode(200) + .extract() + .as(SendMessageResponse.class); + assertEquals(request.getId(), response.getId()); + assertNull(response.getResult()); + // this should be an instance of UnsupportedOperationError, see https://github.com/a2aproject/a2a-java/issues/23 + assertInstanceOf(JSONRPCError.class, response.getError()); + assertEquals(new UnsupportedOperationError().getCode(), response.getError().getCode()); } - + @Test public void testGetAgentCard() { AgentCard agentCard = given() @@ -340,7 +448,7 @@ public void testGetAgentCard() { assertTrue(agentCard.capabilities().stateTransitionHistory()); assertTrue(agentCard.skills().isEmpty()); } - + @Test public void testGetExtendAgentCardNotSupported() { GetAuthenticatedExtendedCardRequest request = new GetAuthenticatedExtendedCardRequest("1"); @@ -358,7 +466,7 @@ public void testGetExtendAgentCardNotSupported() { assertEquals(new AuthenticatedExtendedCardNotConfiguredError().getCode(), response.getError().getCode()); assertNull(response.getResult()); } - + @Test public void testMalformedJSONRPCRequest() { // missing closing bracket @@ -375,20 +483,20 @@ public void testMalformedJSONRPCRequest() { assertNotNull(response.getError()); assertEquals(new JSONParseError().getCode(), response.getError().getCode()); } - + @Test public void testInvalidParamsJSONRPCRequest() { String invalidParamsRequest = """ {"jsonrpc": "2.0", "method": "message/send", "params": "not_a_dict", "id": "1"} """; testInvalidParams(invalidParamsRequest); - + invalidParamsRequest = """ {"jsonrpc": "2.0", "method": "message/send", "params": {"message": {"parts": "invalid"}}, "id": "1"} """; testInvalidParams(invalidParamsRequest); } - + private void testInvalidParams(String invalidParamsRequest) { JSONRPCErrorResponse response = given() .contentType(MediaType.APPLICATION_JSON) @@ -403,7 +511,7 @@ private void testInvalidParams(String invalidParamsRequest) { assertEquals(new InvalidParamsError().getCode(), response.getError().getCode()); assertEquals("1", response.getId()); } - + @Test public void testInvalidJSONRPCRequestMissingJsonrpc() { String invalidRequest = """ @@ -424,7 +532,7 @@ public void testInvalidJSONRPCRequestMissingJsonrpc() { assertNotNull(response.getError()); assertEquals(new InvalidRequestError().getCode(), response.getError().getCode()); } - + @Test public void testInvalidJSONRPCRequestMissingMethod() { String invalidRequest = """ @@ -442,7 +550,7 @@ public void testInvalidJSONRPCRequestMissingMethod() { assertNotNull(response.getError()); assertEquals(new InvalidRequestError().getCode(), response.getError().getCode()); } - + @Test public void testInvalidJSONRPCRequestInvalidId() { String invalidRequest = """ @@ -460,7 +568,7 @@ public void testInvalidJSONRPCRequestInvalidId() { assertNotNull(response.getError()); assertEquals(new InvalidRequestError().getCode(), response.getError().getCode()); } - + @Test public void testInvalidJSONRPCRequestNonExistentMethod() { String invalidRequest = """ @@ -478,12 +586,13 @@ public void testInvalidJSONRPCRequestNonExistentMethod() { assertNotNull(response.getError()); assertEquals(new MethodNotFoundError().getCode(), response.getError().getCode()); } - + @Test public void testNonStreamingMethodWithAcceptHeader() throws Exception { testGetTask(MediaType.APPLICATION_JSON); } - + + @Test public void testSendMessageStreamExistingTaskSuccess() throws Exception { saveTaskInTaskStore(MINIMAL_TASK); @@ -492,121 +601,115 @@ public void testSendMessageStreamExistingTaskSuccess() throws Exception { .taskId(MINIMAL_TASK.getId()) .contextId(MINIMAL_TASK.getContextId()) .build(); - MessageSendParams messageSendParams = new MessageSendParams(message, null, null); - + SendStreamingMessageRequest request = new SendStreamingMessageRequest( + "1", new MessageSendParams(message, null, null)); + + CompletableFuture>> responseFuture = initialiseStreamingRequest(request, null); + CountDownLatch latch = new CountDownLatch(1); AtomicReference errorRef = new AtomicReference<>(); - AtomicReference messageResponseRef = new AtomicReference<>(); - - // Replace the native HttpClient with A2AClient's sendStreamingMessage method. - client.sendStreamingMessage( - "1", - messageSendParams, - // eventHandler - (streamingEvent) -> { - try { - if (streamingEvent instanceof Message) { - messageResponseRef.set((Message) streamingEvent); - latch.countDown(); - } - } catch (Exception e) { - errorRef.set(e); + + responseFuture.thenAccept(response -> { + if (response.statusCode() != 200) { + //errorRef.set(new IllegalStateException("Status code was " + response.statusCode())); + throw new IllegalStateException("Status code was " + response.statusCode()); + } + response.body().forEach(line -> { + try { + SendStreamingMessageResponse jsonResponse = extractJsonResponseFromSseLine(line); + if (jsonResponse != null) { + assertNull(jsonResponse.getError()); + Message messageResponse = (Message) jsonResponse.getResult(); + assertEquals(MESSAGE.getMessageId(), messageResponse.getMessageId()); + assertEquals(MESSAGE.getRole(), messageResponse.getRole()); + Part part = messageResponse.getParts().get(0); + assertEquals(Part.Kind.TEXT, part.getKind()); + assertEquals("test message", ((TextPart) part).getText()); latch.countDown(); } - }, - // errorHandler - (jsonRpcError) -> { - errorRef.set(new RuntimeException("JSON-RPC Error: " + jsonRpcError.getMessage())); - latch.countDown(); - }, - // failureHandler - () -> { - if (errorRef.get() == null) { - errorRef.set(new RuntimeException("Stream processing failed")); - } - latch.countDown(); + } catch (JsonProcessingException e) { + throw new RuntimeException(e); } - ); - + }); + }).exceptionally(t -> { + if (!isStreamClosedError(t)) { + errorRef.set(t); + } + latch.countDown(); + return null; + }); + boolean dataRead = latch.await(20, TimeUnit.SECONDS); Assertions.assertTrue(dataRead); Assertions.assertNull(errorRef.get()); - - Message messageResponse = messageResponseRef.get(); - Assertions.assertNotNull(messageResponse); - assertEquals(MESSAGE.getMessageId(), messageResponse.getMessageId()); - assertEquals(MESSAGE.getRole(), messageResponse.getRole()); - Part part = messageResponse.getParts().get(0); - assertEquals(Part.Kind.TEXT, part.getKind()); - assertEquals("test message", ((TextPart) part).getText()); } catch (Exception e) { - fail("Unexpected exception during send message stream to existing task test: " + e.getMessage(), e); } finally { deleteTaskInTaskStore(MINIMAL_TASK.getId()); } } - + @Test @Timeout(value = 3, unit = TimeUnit.MINUTES) public void testResubscribeExistingTaskSuccess() throws Exception { ExecutorService executorService = Executors.newSingleThreadExecutor(); saveTaskInTaskStore(MINIMAL_TASK); - + try { // attempting to send a streaming message instead of explicitly calling queueManager#createOrTap // does not work because after the message is sent, the queue becomes null but task resubscription // requires the queue to still be active ensureQueueForTask(MINIMAL_TASK.getId()); - + CountDownLatch taskResubscriptionRequestSent = new CountDownLatch(1); CountDownLatch taskResubscriptionResponseReceived = new CountDownLatch(2); - AtomicReference firstResponse = new AtomicReference<>(); - AtomicReference secondResponse = new AtomicReference<>(); - AtomicReference errorRef = new AtomicReference<>(); - + AtomicReference firstResponse = new AtomicReference<>(); + AtomicReference secondResponse = new AtomicReference<>(); + // resubscribe to the task, requires the task and its queue to still be active - TaskIdParams taskIdParams = new TaskIdParams(MINIMAL_TASK.getId()); - + TaskResubscriptionRequest taskResubscriptionRequest = new TaskResubscriptionRequest("1", new TaskIdParams(MINIMAL_TASK.getId())); + // Count down the latch when the MultiSseSupport on the server has started subscribing awaitStreamingSubscription() .whenComplete((unused, throwable) -> taskResubscriptionRequestSent.countDown()); - - // Use A2AClient-like resubscribeToTask Method - client.resubscribeToTask( - "1", // requestId - taskIdParams, - // eventHandler - (streamingEvent) -> { + + CompletableFuture>> responseFuture = initialiseStreamingRequest(taskResubscriptionRequest, null); + + AtomicReference errorRef = new AtomicReference<>(); + + responseFuture.thenAccept(response -> { + + if (response.statusCode() != 200) { + throw new IllegalStateException("Status code was " + response.statusCode()); + } + try { + response.body().forEach(line -> { try { - if (streamingEvent instanceof TaskArtifactUpdateEvent artifactUpdateEvent) { - firstResponse.set(artifactUpdateEvent); - taskResubscriptionResponseReceived.countDown(); - } else if (streamingEvent instanceof TaskStatusUpdateEvent statusUpdateEvent) { - secondResponse.set(statusUpdateEvent); + SendStreamingMessageResponse jsonResponse = extractJsonResponseFromSseLine(line); + if (jsonResponse != null) { + SendStreamingMessageResponse sendStreamingMessageResponse = Utils.OBJECT_MAPPER.readValue(line.substring("data: ".length()).trim(), SendStreamingMessageResponse.class); + if (taskResubscriptionResponseReceived.getCount() == 2) { + firstResponse.set(sendStreamingMessageResponse); + } else { + secondResponse.set(sendStreamingMessageResponse); + } taskResubscriptionResponseReceived.countDown(); + if (taskResubscriptionResponseReceived.getCount() == 0) { + throw new BreakException(); + } } - } catch (Exception e) { - errorRef.set(e); - taskResubscriptionResponseReceived.countDown(); - taskResubscriptionResponseReceived.countDown(); // Make sure the counter is zeroed + } catch (JsonProcessingException e) { + throw new RuntimeException(e); } - }, - // errorHandler - (jsonRpcError) -> { - errorRef.set(new RuntimeException("JSON-RPC Error: " + jsonRpcError.getMessage())); - taskResubscriptionResponseReceived.countDown(); - taskResubscriptionResponseReceived.countDown(); // Make sure the counter is zeroed - }, - // failureHandler - () -> { - if (errorRef.get() == null) { - errorRef.set(new RuntimeException("Stream processing failed")); - } - taskResubscriptionResponseReceived.countDown(); - taskResubscriptionResponseReceived.countDown(); // Make sure the counter is zeroed - } - ); - + }); + } catch (BreakException e) { + } + }).exceptionally(t -> { + if (!isStreamClosedError(t)) { + errorRef.set(t); + } + return null; + }); + try { taskResubscriptionRequestSent.await(); List events = List.of( @@ -624,34 +727,37 @@ public void testResubscribeExistingTaskSuccess() throws Exception { .status(new TaskStatus(TaskState.COMPLETED)) .isFinal(true) .build()); - + for (Event event : events) { enqueueEventOnServer(event); } } catch (InterruptedException e) { Thread.currentThread().interrupt(); } - + // wait for the client to receive the responses taskResubscriptionResponseReceived.await(); - - Assertions.assertNull(errorRef.get()); - + assertNotNull(firstResponse.get()); - TaskArtifactUpdateEvent taskArtifactUpdateEvent = firstResponse.get(); + SendStreamingMessageResponse sendStreamingMessageResponse = firstResponse.get(); + assertNull(sendStreamingMessageResponse.getError()); + TaskArtifactUpdateEvent taskArtifactUpdateEvent = (TaskArtifactUpdateEvent) sendStreamingMessageResponse.getResult(); assertEquals(MINIMAL_TASK.getId(), taskArtifactUpdateEvent.getTaskId()); assertEquals(MINIMAL_TASK.getContextId(), taskArtifactUpdateEvent.getContextId()); Part part = taskArtifactUpdateEvent.getArtifact().parts().get(0); assertEquals(Part.Kind.TEXT, part.getKind()); assertEquals("text", ((TextPart) part).getText()); - + assertNotNull(secondResponse.get()); - TaskStatusUpdateEvent taskStatusUpdateEvent = secondResponse.get(); + sendStreamingMessageResponse = secondResponse.get(); + assertNull(sendStreamingMessageResponse.getError()); + TaskStatusUpdateEvent taskStatusUpdateEvent = (TaskStatusUpdateEvent) sendStreamingMessageResponse.getResult(); assertEquals(MINIMAL_TASK.getId(), taskStatusUpdateEvent.getTaskId()); assertEquals(MINIMAL_TASK.getContextId(), taskStatusUpdateEvent.getContextId()); assertEquals(TaskState.COMPLETED, taskStatusUpdateEvent.getStatus().state()); assertNotNull(taskStatusUpdateEvent.getStatus().timestamp()); } finally { + //setStreamingSubscribedRunnable(null); deleteTaskInTaskStore(MINIMAL_TASK.getId()); executorService.shutdown(); if (!executorService.awaitTermination(10, TimeUnit.SECONDS)) { @@ -659,114 +765,109 @@ public void testResubscribeExistingTaskSuccess() throws Exception { } } } - + @Test public void testResubscribeNoExistingTaskError() throws Exception { - TaskIdParams taskIdParams = new TaskIdParams("non-existent-task"); - + TaskResubscriptionRequest request = new TaskResubscriptionRequest("1", new TaskIdParams("non-existent-task")); + + CompletableFuture>> responseFuture = initialiseStreamingRequest(request, null); + CountDownLatch latch = new CountDownLatch(1); AtomicReference errorRef = new AtomicReference<>(); - AtomicReference jsonRpcErrorRef = new AtomicReference<>(); - - // Use A2AClient-like resubscribeToTask Method - client.resubscribeToTask( - "1", // requestId - taskIdParams, - // eventHandler - (streamingEvent) -> { - // Do not expect to receive any success events, as the task does not exist - errorRef.set(new RuntimeException("Unexpected event received for non-existent task")); - latch.countDown(); - }, - // errorHandler - (jsonRpcError) -> { - jsonRpcErrorRef.set(jsonRpcError); - latch.countDown(); - }, - // failureHandler - () -> { - if (errorRef.get() == null && jsonRpcErrorRef.get() == null) { - errorRef.set(new RuntimeException("Expected error for non-existent task")); + + responseFuture.thenAccept(response -> { + if (response.statusCode() != 200) { + //errorRef.set(new IllegalStateException("Status code was " + response.statusCode())); + throw new IllegalStateException("Status code was " + response.statusCode()); + } + response.body().forEach(line -> { + try { + SendStreamingMessageResponse jsonResponse = extractJsonResponseFromSseLine(line); + if (jsonResponse != null) { + assertEquals(request.getId(), jsonResponse.getId()); + assertNull(jsonResponse.getResult()); + // this should be an instance of TaskNotFoundError, see https://github.com/a2aproject/a2a-java/issues/23 + assertInstanceOf(JSONRPCError.class, jsonResponse.getError()); + assertEquals(new TaskNotFoundError().getCode(), jsonResponse.getError().getCode()); + latch.countDown(); } - latch.countDown(); + } catch (JsonProcessingException e) { + throw new RuntimeException(e); } - ); - + }); + }).exceptionally(t -> { + if (!isStreamClosedError(t)) { + errorRef.set(t); + } + latch.countDown(); + return null; + }); + boolean dataRead = latch.await(20, TimeUnit.SECONDS); Assertions.assertTrue(dataRead); Assertions.assertNull(errorRef.get()); - - // Validation returns the expected TaskNotFoundError - JSONRPCError jsonRpcError = jsonRpcErrorRef.get(); - Assertions.assertNotNull(jsonRpcError); - assertEquals(new TaskNotFoundError().getCode(), jsonRpcError.getCode()); } - + @Test public void testStreamingMethodWithAcceptHeader() throws Exception { testSendStreamingMessage(MediaType.SERVER_SENT_EVENTS); } - + @Test public void testSendMessageStreamNewMessageSuccess() throws Exception { testSendStreamingMessage(null); } - + private void testSendStreamingMessage(String mediaType) throws Exception { Message message = new Message.Builder(MESSAGE) .taskId(MINIMAL_TASK.getId()) .contextId(MINIMAL_TASK.getContextId()) .build(); - MessageSendParams messageSendParams = new MessageSendParams(message, null, null); - + SendStreamingMessageRequest request = new SendStreamingMessageRequest( + "1", new MessageSendParams(message, null, null)); + + CompletableFuture>> responseFuture = initialiseStreamingRequest(request, mediaType); + CountDownLatch latch = new CountDownLatch(1); AtomicReference errorRef = new AtomicReference<>(); - AtomicReference messageResponseRef = new AtomicReference<>(); - - // Using A2AClient's sendStreamingMessage method - client.sendStreamingMessage( - "1", // requestId - messageSendParams, - // eventHandler - (streamingEvent) -> { - try { - if (streamingEvent instanceof Message) { - messageResponseRef.set((Message) streamingEvent); - latch.countDown(); - } - } catch (Exception e) { - errorRef.set(e); + + responseFuture.thenAccept(response -> { + if (response.statusCode() != 200) { + //errorRef.set(new IllegalStateException("Status code was " + response.statusCode())); + throw new IllegalStateException("Status code was " + response.statusCode()); + } + response.body().forEach(line -> { + try { + SendStreamingMessageResponse jsonResponse = extractJsonResponseFromSseLine(line); + if (jsonResponse != null) { + assertNull(jsonResponse.getError()); + Message messageResponse = (Message) jsonResponse.getResult(); + assertEquals(MESSAGE.getMessageId(), messageResponse.getMessageId()); + assertEquals(MESSAGE.getRole(), messageResponse.getRole()); + Part part = messageResponse.getParts().get(0); + assertEquals(Part.Kind.TEXT, part.getKind()); + assertEquals("test message", ((TextPart) part).getText()); latch.countDown(); } - }, - // errorHandler - (jsonRpcError) -> { - errorRef.set(new RuntimeException("JSON-RPC Error: " + jsonRpcError.getMessage())); - latch.countDown(); - }, - // failureHandler - () -> { - if (errorRef.get() == null) { - errorRef.set(new RuntimeException("Stream processing failed")); - } - latch.countDown(); + } catch (JsonProcessingException e) { + throw new RuntimeException(e); } - ); - + }); + }).exceptionally(t -> { + if (!isStreamClosedError(t)) { + errorRef.set(t); + } + latch.countDown(); + return null; + }); + + boolean dataRead = latch.await(20, TimeUnit.SECONDS); Assertions.assertTrue(dataRead); Assertions.assertNull(errorRef.get()); - - Message messageResponse = messageResponseRef.get(); - Assertions.assertNotNull(messageResponse); - assertEquals(MESSAGE.getMessageId(), messageResponse.getMessageId()); - assertEquals(MESSAGE.getRole(), messageResponse.getRole()); - Part part = messageResponse.getParts().get(0); - assertEquals(Part.Kind.TEXT, part.getKind()); - assertEquals("test message", ((TextPart) part).getText()); - + } - + @Test public void testListPushNotificationConfigWithConfigId() throws Exception { saveTaskInTaskStore(MINIMAL_TASK); @@ -782,13 +883,13 @@ public void testListPushNotificationConfigWithConfigId() throws Exception { .build(); savePushNotificationConfigInStore(MINIMAL_TASK.getId(), notificationConfig1); savePushNotificationConfigInStore(MINIMAL_TASK.getId(), notificationConfig2); - + try { - ListTaskPushNotificationConfigResponse listResponse = client.listTaskPushNotificationConfig("111", MINIMAL_TASK.getId()); - assertEquals("111", listResponse.getId()); - assertEquals(2, listResponse.getResult().size()); - assertEquals(new TaskPushNotificationConfig(MINIMAL_TASK.getId(), notificationConfig1), listResponse.getResult().get(0)); - assertEquals(new TaskPushNotificationConfig(MINIMAL_TASK.getId(), notificationConfig2), listResponse.getResult().get(1)); + List result = client.listTaskPushNotificationConfigurations( + new ListTaskPushNotificationConfigParams(MINIMAL_TASK.getId()), null); + assertEquals(2, result.size()); + assertEquals(new TaskPushNotificationConfig(MINIMAL_TASK.getId(), notificationConfig1), result.get(0)); + assertEquals(new TaskPushNotificationConfig(MINIMAL_TASK.getId(), notificationConfig2), result.get(1)); } catch (Exception e) { fail(); } finally { @@ -797,7 +898,7 @@ public void testListPushNotificationConfigWithConfigId() throws Exception { deleteTaskInTaskStore(MINIMAL_TASK.getId()); } } - + @Test public void testListPushNotificationConfigWithoutConfigId() throws Exception { saveTaskInTaskStore(MINIMAL_TASK); @@ -810,20 +911,20 @@ public void testListPushNotificationConfigWithoutConfigId() throws Exception { .url("http://2.example.com") .build(); savePushNotificationConfigInStore(MINIMAL_TASK.getId(), notificationConfig1); - + // will overwrite the previous one savePushNotificationConfigInStore(MINIMAL_TASK.getId(), notificationConfig2); try { - ListTaskPushNotificationConfigResponse listResponse = client.listTaskPushNotificationConfig("111", MINIMAL_TASK.getId()); - assertEquals("111", listResponse.getId()); - assertEquals(1, listResponse.getResult().size()); - + List result = client.listTaskPushNotificationConfigurations( + new ListTaskPushNotificationConfigParams(MINIMAL_TASK.getId()), null); + assertEquals(1, result.size()); + PushNotificationConfig expectedNotificationConfig = new PushNotificationConfig.Builder() .url("http://2.example.com") .id(MINIMAL_TASK.getId()) .build(); assertEquals(new TaskPushNotificationConfig(MINIMAL_TASK.getId(), expectedNotificationConfig), - listResponse.getResult().get(0)); + result.get(0)); } catch (Exception e) { fail(); } finally { @@ -831,31 +932,32 @@ public void testListPushNotificationConfigWithoutConfigId() throws Exception { deleteTaskInTaskStore(MINIMAL_TASK.getId()); } } - + @Test public void testListPushNotificationConfigTaskNotFound() { try { - client.listTaskPushNotificationConfig("111", "non-existent-task"); + List result = client.listTaskPushNotificationConfigurations( + new ListTaskPushNotificationConfigParams("non-existent-task"), null); fail(); - } catch (A2AServerException e) { + } catch (A2AClientException e) { assertInstanceOf(TaskNotFoundError.class, e.getCause()); } } - + @Test public void testListPushNotificationConfigEmptyList() throws Exception { saveTaskInTaskStore(MINIMAL_TASK); try { - ListTaskPushNotificationConfigResponse listResponse = client.listTaskPushNotificationConfig("111", MINIMAL_TASK.getId()); - assertEquals("111", listResponse.getId()); - assertEquals(0, listResponse.getResult().size()); + List result = client.listTaskPushNotificationConfigurations( + new ListTaskPushNotificationConfigParams(MINIMAL_TASK.getId()), null); + assertEquals(0, result.size()); } catch (Exception e) { fail(); } finally { deleteTaskInTaskStore(MINIMAL_TASK.getId()); } } - + @Test public void testDeletePushNotificationConfigWithValidConfigId() throws Exception { saveTaskInTaskStore(MINIMAL_TASK); @@ -864,7 +966,7 @@ public void testDeletePushNotificationConfigWithValidConfigId() throws Exception .contextId("session-xyz") .status(new TaskStatus(TaskState.SUBMITTED)) .build()); - + PushNotificationConfig notificationConfig1 = new PushNotificationConfig.Builder() .url("http://example.com") @@ -878,21 +980,22 @@ public void testDeletePushNotificationConfigWithValidConfigId() throws Exception savePushNotificationConfigInStore(MINIMAL_TASK.getId(), notificationConfig1); savePushNotificationConfigInStore(MINIMAL_TASK.getId(), notificationConfig2); savePushNotificationConfigInStore("task-456", notificationConfig1); - + try { // specify the config ID to delete - DeleteTaskPushNotificationConfigResponse deleteResponse = client.deleteTaskPushNotificationConfig(MINIMAL_TASK.getId(), - "config1"); - assertNull(deleteResponse.getError()); - assertNull(deleteResponse.getResult()); - + client.deleteTaskPushNotificationConfigurations( + new DeleteTaskPushNotificationConfigParams(MINIMAL_TASK.getId(), "config1"), + null); + // should now be 1 left - ListTaskPushNotificationConfigResponse listResponse = client.listTaskPushNotificationConfig(MINIMAL_TASK.getId()); - assertEquals(1, listResponse.getResult().size()); - + List result = client.listTaskPushNotificationConfigurations( + new ListTaskPushNotificationConfigParams(MINIMAL_TASK.getId()), null); + assertEquals(1, result.size()); + // should remain unchanged, this is a different task - listResponse = client.listTaskPushNotificationConfig("task-456"); - assertEquals(1, listResponse.getResult().size()); + result = client.listTaskPushNotificationConfigurations( + new ListTaskPushNotificationConfigParams("task-456"), null); + assertEquals(1, result.size()); } catch (Exception e) { fail(); } finally { @@ -903,7 +1006,7 @@ public void testDeletePushNotificationConfigWithValidConfigId() throws Exception deleteTaskInTaskStore("task-456"); } } - + @Test public void testDeletePushNotificationConfigWithNonExistingConfigId() throws Exception { saveTaskInTaskStore(MINIMAL_TASK); @@ -919,16 +1022,16 @@ public void testDeletePushNotificationConfigWithNonExistingConfigId() throws Exc .build(); savePushNotificationConfigInStore(MINIMAL_TASK.getId(), notificationConfig1); savePushNotificationConfigInStore(MINIMAL_TASK.getId(), notificationConfig2); - + try { - DeleteTaskPushNotificationConfigResponse deleteResponse = client.deleteTaskPushNotificationConfig(MINIMAL_TASK.getId(), - "non-existent-config-id"); - assertNull(deleteResponse.getError()); - assertNull(deleteResponse.getResult()); - + client.deleteTaskPushNotificationConfigurations( + new DeleteTaskPushNotificationConfigParams(MINIMAL_TASK.getId(), "non-existent-config-id"), + null); + // should remain unchanged - ListTaskPushNotificationConfigResponse listResponse = client.listTaskPushNotificationConfig(MINIMAL_TASK.getId()); - assertEquals(2, listResponse.getResult().size()); + List result = client.listTaskPushNotificationConfigurations( + new ListTaskPushNotificationConfigParams(MINIMAL_TASK.getId()), null); + assertEquals(2, result.size()); } catch (Exception e) { fail(); } finally { @@ -937,17 +1040,20 @@ public void testDeletePushNotificationConfigWithNonExistingConfigId() throws Exc deleteTaskInTaskStore(MINIMAL_TASK.getId()); } } - + @Test public void testDeletePushNotificationConfigTaskNotFound() { try { - client.deleteTaskPushNotificationConfig("non-existent-task", "non-existent-config-id"); + client.deleteTaskPushNotificationConfigurations( + new DeleteTaskPushNotificationConfigParams("non-existent-task", + "non-existent-config-id"), + null); fail(); - } catch (A2AServerException e) { + } catch (A2AClientException e) { assertInstanceOf(TaskNotFoundError.class, e.getCause()); } } - + @Test public void testDeletePushNotificationConfigSetWithoutConfigId() throws Exception { saveTaskInTaskStore(MINIMAL_TASK); @@ -960,19 +1066,19 @@ public void testDeletePushNotificationConfigSetWithoutConfigId() throws Exceptio .url("http://2.example.com") .build(); savePushNotificationConfigInStore(MINIMAL_TASK.getId(), notificationConfig1); - + // this one will overwrite the previous one savePushNotificationConfigInStore(MINIMAL_TASK.getId(), notificationConfig2); - + try { - DeleteTaskPushNotificationConfigResponse deleteResponse = client.deleteTaskPushNotificationConfig(MINIMAL_TASK.getId(), - MINIMAL_TASK.getId()); - assertNull(deleteResponse.getError()); - assertNull(deleteResponse.getResult()); - + client.deleteTaskPushNotificationConfigurations( + new DeleteTaskPushNotificationConfigParams(MINIMAL_TASK.getId(), MINIMAL_TASK.getId()), + null); + // should now be 0 - ListTaskPushNotificationConfigResponse listResponse = client.listTaskPushNotificationConfig(MINIMAL_TASK.getId()); - assertEquals(0, listResponse.getResult().size()); + List result = client.listTaskPushNotificationConfigurations( + new ListTaskPushNotificationConfigParams(MINIMAL_TASK.getId()), null); + assertEquals(0, result.size()); } catch (Exception e) { fail(); } finally { @@ -980,7 +1086,59 @@ public void testDeletePushNotificationConfigSetWithoutConfigId() throws Exceptio deleteTaskInTaskStore(MINIMAL_TASK.getId()); } } - + + private SendStreamingMessageResponse extractJsonResponseFromSseLine(String line) throws JsonProcessingException { + line = extractSseData(line); + if (line != null) { + return Utils.OBJECT_MAPPER.readValue(line, SendStreamingMessageResponse.class); + } + return null; + } + + private static String extractSseData(String line) { + if (line.startsWith("data:")) { + line = line.substring(5).trim(); + return line; + } + return null; + } + + private boolean isStreamClosedError(Throwable throwable) { + // Unwrap the CompletionException + Throwable cause = throwable; + + while (cause != null) { + if (cause instanceof EOFException) { + return true; + } + cause = cause.getCause(); + } + return false; + } + + private CompletableFuture>> initialiseStreamingRequest( + StreamingJSONRPCRequest request, String mediaType) throws Exception { + + // Create the client + HttpClient client = HttpClient.newBuilder() + .version(HttpClient.Version.HTTP_2) + .build(); + + // Create the request + HttpRequest.Builder builder = HttpRequest.newBuilder() + .uri(URI.create("http://localhost:" + serverPort + "/")) + .POST(HttpRequest.BodyPublishers.ofString(Utils.OBJECT_MAPPER.writeValueAsString(request))) + .header("Content-Type", APPLICATION_JSON); + if (mediaType != null) { + builder.header("Accept", mediaType); + } + HttpRequest httpRequest = builder.build(); + + + // Send request async and return the CompletableFuture + return client.sendAsync(httpRequest, HttpResponse.BodyHandlers.ofLines()); + } + protected void saveTaskInTaskStore(Task task) throws Exception { HttpClient client = HttpClient.newBuilder() .version(HttpClient.Version.HTTP_2) @@ -990,13 +1148,13 @@ protected void saveTaskInTaskStore(Task task) throws Exception { .POST(HttpRequest.BodyPublishers.ofString(Utils.OBJECT_MAPPER.writeValueAsString(task))) .header("Content-Type", APPLICATION_JSON) .build(); - + HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); if (response.statusCode() != 200) { throw new RuntimeException(String.format("Saving task failed! Status: %d, Body: %s", response.statusCode(), response.body())); } } - + protected Task getTaskFromTaskStore(String taskId) throws Exception { HttpClient client = HttpClient.newBuilder() .version(HttpClient.Version.HTTP_2) @@ -1005,7 +1163,7 @@ protected Task getTaskFromTaskStore(String taskId) throws Exception { .uri(URI.create("http://localhost:" + serverPort + "/test/task/" + taskId)) .GET() .build(); - + HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); if (response.statusCode() == 404) { return null; @@ -1015,7 +1173,7 @@ protected Task getTaskFromTaskStore(String taskId) throws Exception { } return Utils.OBJECT_MAPPER.readValue(response.body(), Task.TYPE_REFERENCE); } - + protected void deleteTaskInTaskStore(String taskId) throws Exception { HttpClient client = HttpClient.newBuilder() .version(HttpClient.Version.HTTP_2) @@ -1029,7 +1187,7 @@ protected void deleteTaskInTaskStore(String taskId) throws Exception { throw new RuntimeException(response.statusCode() + ": Deleting task failed!" + response.body()); } } - + protected void ensureQueueForTask(String taskId) throws Exception { HttpClient client = HttpClient.newBuilder() .version(HttpClient.Version.HTTP_2) @@ -1043,7 +1201,7 @@ protected void ensureQueueForTask(String taskId) throws Exception { throw new RuntimeException(String.format("Ensuring queue failed! Status: %d, Body: %s", response.statusCode(), response.body())); } } - + protected void enqueueEventOnServer(Event event) throws Exception { String path; if (event instanceof TaskArtifactUpdateEvent e) { @@ -1062,17 +1220,17 @@ protected void enqueueEventOnServer(Event event) throws Exception { .header("Content-Type", APPLICATION_JSON) .POST(HttpRequest.BodyPublishers.ofString(Utils.OBJECT_MAPPER.writeValueAsString(event))) .build(); - + HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); if (response.statusCode() != 200) { throw new RuntimeException(response.statusCode() + ": Queueing event failed!" + response.body()); } } - + private CompletableFuture awaitStreamingSubscription() { int cnt = getStreamingSubscribedCount(); AtomicInteger initialCount = new AtomicInteger(cnt); - + return CompletableFuture.runAsync(() -> { try { boolean done = false; @@ -1094,7 +1252,7 @@ private CompletableFuture awaitStreamingSubscription() { } }); } - + private int getStreamingSubscribedCount() { HttpClient client = HttpClient.newBuilder() .version(HttpClient.Version.HTTP_2) @@ -1111,7 +1269,7 @@ private int getStreamingSubscribedCount() { throw new RuntimeException(e); } } - + protected void deletePushNotificationConfigInStore(String taskId, String configId) throws Exception { HttpClient client = HttpClient.newBuilder() .version(HttpClient.Version.HTTP_2) @@ -1125,7 +1283,7 @@ protected void deletePushNotificationConfigInStore(String taskId, String configI throw new RuntimeException(response.statusCode() + ": Deleting task failed!" + response.body()); } } - + protected void savePushNotificationConfigInStore(String taskId, PushNotificationConfig notificationConfig) throws Exception { HttpClient client = HttpClient.newBuilder() .version(HttpClient.Version.HTTP_2) @@ -1135,10 +1293,14 @@ protected void savePushNotificationConfigInStore(String taskId, PushNotification .POST(HttpRequest.BodyPublishers.ofString(Utils.OBJECT_MAPPER.writeValueAsString(notificationConfig))) .header("Content-Type", APPLICATION_JSON) .build(); - + HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); if (response.statusCode() != 200) { throw new RuntimeException(response.statusCode() + ": Creating task push notification config failed! " + response.body()); } } -} + + private static class BreakException extends RuntimeException { + + } +} \ No newline at end of file From b370717e50fceba3e08c90ec6aac027769d8b121 Mon Sep 17 00:00:00 2001 From: Farah Juma Date: Mon, 18 Aug 2025 10:25:40 -0400 Subject: [PATCH 07/31] fix: Rename client-http to http-client since it's used by both client and server code --- client-config/pom.xml | 2 +- client-transport/jsonrpc/pom.xml | 2 +- client/pom.xml | 2 +- {client-http => http-client}/pom.xml | 2 +- .../src/main/java/io/a2a/client/http/A2AHttpClient.java | 0 .../src/main/java/io/a2a/client/http/A2AHttpResponse.java | 0 .../src/main/java/io/a2a/client/http/JdkA2AHttpClient.java | 0 pom.xml | 2 +- server-common/pom.xml | 2 +- 9 files changed, 6 insertions(+), 6 deletions(-) rename {client-http => http-client}/pom.xml (95%) rename {client-http => http-client}/src/main/java/io/a2a/client/http/A2AHttpClient.java (100%) rename {client-http => http-client}/src/main/java/io/a2a/client/http/A2AHttpResponse.java (100%) rename {client-http => http-client}/src/main/java/io/a2a/client/http/JdkA2AHttpClient.java (100%) diff --git a/client-config/pom.xml b/client-config/pom.xml index 3d85221fb..df0f2d650 100644 --- a/client-config/pom.xml +++ b/client-config/pom.xml @@ -19,7 +19,7 @@ ${project.groupId} - a2a-java-sdk-client-http + a2a-java-sdk-http-client ${project.version} diff --git a/client-transport/jsonrpc/pom.xml b/client-transport/jsonrpc/pom.xml index dd9a74c22..5230fed98 100644 --- a/client-transport/jsonrpc/pom.xml +++ b/client-transport/jsonrpc/pom.xml @@ -29,7 +29,7 @@ ${project.groupId} - a2a-java-sdk-client-http + a2a-java-sdk-http-client ${project.version} diff --git a/client/pom.xml b/client/pom.xml index c35fbeef6..76cbb1ef4 100644 --- a/client/pom.xml +++ b/client/pom.xml @@ -24,7 +24,7 @@ ${project.groupId} - a2a-java-sdk-client-http + a2a-java-sdk-http-client ${project.version} diff --git a/client-http/pom.xml b/http-client/pom.xml similarity index 95% rename from client-http/pom.xml rename to http-client/pom.xml index ceff12762..8ec988aa2 100644 --- a/client-http/pom.xml +++ b/http-client/pom.xml @@ -9,7 +9,7 @@ a2a-java-sdk-parent 0.2.6.Beta1-SNAPSHOT - a2a-java-sdk-client-http + a2a-java-sdk-http-client jar diff --git a/client-http/src/main/java/io/a2a/client/http/A2AHttpClient.java b/http-client/src/main/java/io/a2a/client/http/A2AHttpClient.java similarity index 100% rename from client-http/src/main/java/io/a2a/client/http/A2AHttpClient.java rename to http-client/src/main/java/io/a2a/client/http/A2AHttpClient.java diff --git a/client-http/src/main/java/io/a2a/client/http/A2AHttpResponse.java b/http-client/src/main/java/io/a2a/client/http/A2AHttpResponse.java similarity index 100% rename from client-http/src/main/java/io/a2a/client/http/A2AHttpResponse.java rename to http-client/src/main/java/io/a2a/client/http/A2AHttpResponse.java diff --git a/client-http/src/main/java/io/a2a/client/http/JdkA2AHttpClient.java b/http-client/src/main/java/io/a2a/client/http/JdkA2AHttpClient.java similarity index 100% rename from client-http/src/main/java/io/a2a/client/http/JdkA2AHttpClient.java rename to http-client/src/main/java/io/a2a/client/http/JdkA2AHttpClient.java diff --git a/pom.xml b/pom.xml index 55a1d177f..36e39cd41 100644 --- a/pom.xml +++ b/pom.xml @@ -281,7 +281,7 @@ client client-config - client-http + http-client client-transport/grpc client-transport/jsonrpc client-transport/spi diff --git a/server-common/pom.xml b/server-common/pom.xml index 3f3ef584a..1bcc2b7b9 100644 --- a/server-common/pom.xml +++ b/server-common/pom.xml @@ -24,7 +24,7 @@ ${project.groupId} - a2a-java-sdk-client-http + a2a-java-sdk-http-client ${project.version} From 49d1ad239fbbbd20859a0c7aa62b60cbb6ce5142 Mon Sep 17 00:00:00 2001 From: Farah Juma Date: Mon, 18 Aug 2025 10:30:19 -0400 Subject: [PATCH 08/31] fix: Move existing client contents to client/base --- client/{ => base}/pom.xml | 1 + client/{ => base}/src/main/java/io/a2a/A2A.java | 0 .../{ => base}/src/main/java/io/a2a/client/A2ACardResolver.java | 0 .../{ => base}/src/main/java/io/a2a/client/AbstractClient.java | 0 client/{ => base}/src/main/java/io/a2a/client/Client.java | 0 client/{ => base}/src/main/java/io/a2a/client/ClientEvent.java | 0 .../{ => base}/src/main/java/io/a2a/client/ClientFactory.java | 0 .../src/main/java/io/a2a/client/ClientTaskManager.java | 0 client/{ => base}/src/main/java/io/a2a/client/MessageEvent.java | 0 client/{ => base}/src/main/java/io/a2a/client/TaskEvent.java | 0 .../{ => base}/src/main/java/io/a2a/client/TaskUpdateEvent.java | 0 client/{ => base}/src/main/resources/META-INF/beans.xml | 0 .../src/test/java/io/a2a/client/A2ACardResolverTest.java | 0 client/{ => base}/src/test/java/io/a2a/client/JsonMessages.java | 0 pom.xml | 2 +- 15 files changed, 2 insertions(+), 1 deletion(-) rename client/{ => base}/pom.xml (97%) rename client/{ => base}/src/main/java/io/a2a/A2A.java (100%) rename client/{ => base}/src/main/java/io/a2a/client/A2ACardResolver.java (100%) rename client/{ => base}/src/main/java/io/a2a/client/AbstractClient.java (100%) rename client/{ => base}/src/main/java/io/a2a/client/Client.java (100%) rename client/{ => base}/src/main/java/io/a2a/client/ClientEvent.java (100%) rename client/{ => base}/src/main/java/io/a2a/client/ClientFactory.java (100%) rename client/{ => base}/src/main/java/io/a2a/client/ClientTaskManager.java (100%) rename client/{ => base}/src/main/java/io/a2a/client/MessageEvent.java (100%) rename client/{ => base}/src/main/java/io/a2a/client/TaskEvent.java (100%) rename client/{ => base}/src/main/java/io/a2a/client/TaskUpdateEvent.java (100%) rename client/{ => base}/src/main/resources/META-INF/beans.xml (100%) rename client/{ => base}/src/test/java/io/a2a/client/A2ACardResolverTest.java (100%) rename client/{ => base}/src/test/java/io/a2a/client/JsonMessages.java (100%) diff --git a/client/pom.xml b/client/base/pom.xml similarity index 97% rename from client/pom.xml rename to client/base/pom.xml index 76cbb1ef4..79c5966c9 100644 --- a/client/pom.xml +++ b/client/base/pom.xml @@ -8,6 +8,7 @@ io.github.a2asdk a2a-java-sdk-parent 0.3.0.Beta1-SNAPSHOT + ../../pom.xml a2a-java-sdk-client diff --git a/client/src/main/java/io/a2a/A2A.java b/client/base/src/main/java/io/a2a/A2A.java similarity index 100% rename from client/src/main/java/io/a2a/A2A.java rename to client/base/src/main/java/io/a2a/A2A.java diff --git a/client/src/main/java/io/a2a/client/A2ACardResolver.java b/client/base/src/main/java/io/a2a/client/A2ACardResolver.java similarity index 100% rename from client/src/main/java/io/a2a/client/A2ACardResolver.java rename to client/base/src/main/java/io/a2a/client/A2ACardResolver.java diff --git a/client/src/main/java/io/a2a/client/AbstractClient.java b/client/base/src/main/java/io/a2a/client/AbstractClient.java similarity index 100% rename from client/src/main/java/io/a2a/client/AbstractClient.java rename to client/base/src/main/java/io/a2a/client/AbstractClient.java diff --git a/client/src/main/java/io/a2a/client/Client.java b/client/base/src/main/java/io/a2a/client/Client.java similarity index 100% rename from client/src/main/java/io/a2a/client/Client.java rename to client/base/src/main/java/io/a2a/client/Client.java diff --git a/client/src/main/java/io/a2a/client/ClientEvent.java b/client/base/src/main/java/io/a2a/client/ClientEvent.java similarity index 100% rename from client/src/main/java/io/a2a/client/ClientEvent.java rename to client/base/src/main/java/io/a2a/client/ClientEvent.java diff --git a/client/src/main/java/io/a2a/client/ClientFactory.java b/client/base/src/main/java/io/a2a/client/ClientFactory.java similarity index 100% rename from client/src/main/java/io/a2a/client/ClientFactory.java rename to client/base/src/main/java/io/a2a/client/ClientFactory.java diff --git a/client/src/main/java/io/a2a/client/ClientTaskManager.java b/client/base/src/main/java/io/a2a/client/ClientTaskManager.java similarity index 100% rename from client/src/main/java/io/a2a/client/ClientTaskManager.java rename to client/base/src/main/java/io/a2a/client/ClientTaskManager.java diff --git a/client/src/main/java/io/a2a/client/MessageEvent.java b/client/base/src/main/java/io/a2a/client/MessageEvent.java similarity index 100% rename from client/src/main/java/io/a2a/client/MessageEvent.java rename to client/base/src/main/java/io/a2a/client/MessageEvent.java diff --git a/client/src/main/java/io/a2a/client/TaskEvent.java b/client/base/src/main/java/io/a2a/client/TaskEvent.java similarity index 100% rename from client/src/main/java/io/a2a/client/TaskEvent.java rename to client/base/src/main/java/io/a2a/client/TaskEvent.java diff --git a/client/src/main/java/io/a2a/client/TaskUpdateEvent.java b/client/base/src/main/java/io/a2a/client/TaskUpdateEvent.java similarity index 100% rename from client/src/main/java/io/a2a/client/TaskUpdateEvent.java rename to client/base/src/main/java/io/a2a/client/TaskUpdateEvent.java diff --git a/client/src/main/resources/META-INF/beans.xml b/client/base/src/main/resources/META-INF/beans.xml similarity index 100% rename from client/src/main/resources/META-INF/beans.xml rename to client/base/src/main/resources/META-INF/beans.xml diff --git a/client/src/test/java/io/a2a/client/A2ACardResolverTest.java b/client/base/src/test/java/io/a2a/client/A2ACardResolverTest.java similarity index 100% rename from client/src/test/java/io/a2a/client/A2ACardResolverTest.java rename to client/base/src/test/java/io/a2a/client/A2ACardResolverTest.java diff --git a/client/src/test/java/io/a2a/client/JsonMessages.java b/client/base/src/test/java/io/a2a/client/JsonMessages.java similarity index 100% rename from client/src/test/java/io/a2a/client/JsonMessages.java rename to client/base/src/test/java/io/a2a/client/JsonMessages.java diff --git a/pom.xml b/pom.xml index 36e39cd41..985a4492b 100644 --- a/pom.xml +++ b/pom.xml @@ -279,7 +279,7 @@ - client + client/base client-config http-client client-transport/grpc From 3f02033a9be19b1b80239e5d597c0936fe7069a2 Mon Sep 17 00:00:00 2001 From: Farah Juma Date: Mon, 18 Aug 2025 10:33:47 -0400 Subject: [PATCH 09/31] fix: Move client-config to client/config --- {client-config => client/config}/pom.xml | 1 + .../src/main/java/io/a2a/client/config/ClientCallContext.java | 0 .../main/java/io/a2a/client/config/ClientCallInterceptor.java | 0 .../src/main/java/io/a2a/client/config/ClientConfig.java | 0 .../src/main/java/io/a2a/client/config/PayloadAndHeaders.java | 0 pom.xml | 2 +- 6 files changed, 2 insertions(+), 1 deletion(-) rename {client-config => client/config}/pom.xml (96%) rename {client-config => client/config}/src/main/java/io/a2a/client/config/ClientCallContext.java (100%) rename {client-config => client/config}/src/main/java/io/a2a/client/config/ClientCallInterceptor.java (100%) rename {client-config => client/config}/src/main/java/io/a2a/client/config/ClientConfig.java (100%) rename {client-config => client/config}/src/main/java/io/a2a/client/config/PayloadAndHeaders.java (100%) diff --git a/client-config/pom.xml b/client/config/pom.xml similarity index 96% rename from client-config/pom.xml rename to client/config/pom.xml index df0f2d650..560671fac 100644 --- a/client-config/pom.xml +++ b/client/config/pom.xml @@ -8,6 +8,7 @@ io.github.a2asdk a2a-java-sdk-parent 0.2.6.Beta1-SNAPSHOT + ../../pom.xml a2a-java-sdk-client-config diff --git a/client-config/src/main/java/io/a2a/client/config/ClientCallContext.java b/client/config/src/main/java/io/a2a/client/config/ClientCallContext.java similarity index 100% rename from client-config/src/main/java/io/a2a/client/config/ClientCallContext.java rename to client/config/src/main/java/io/a2a/client/config/ClientCallContext.java diff --git a/client-config/src/main/java/io/a2a/client/config/ClientCallInterceptor.java b/client/config/src/main/java/io/a2a/client/config/ClientCallInterceptor.java similarity index 100% rename from client-config/src/main/java/io/a2a/client/config/ClientCallInterceptor.java rename to client/config/src/main/java/io/a2a/client/config/ClientCallInterceptor.java diff --git a/client-config/src/main/java/io/a2a/client/config/ClientConfig.java b/client/config/src/main/java/io/a2a/client/config/ClientConfig.java similarity index 100% rename from client-config/src/main/java/io/a2a/client/config/ClientConfig.java rename to client/config/src/main/java/io/a2a/client/config/ClientConfig.java diff --git a/client-config/src/main/java/io/a2a/client/config/PayloadAndHeaders.java b/client/config/src/main/java/io/a2a/client/config/PayloadAndHeaders.java similarity index 100% rename from client-config/src/main/java/io/a2a/client/config/PayloadAndHeaders.java rename to client/config/src/main/java/io/a2a/client/config/PayloadAndHeaders.java diff --git a/pom.xml b/pom.xml index 985a4492b..e3a1d484c 100644 --- a/pom.xml +++ b/pom.xml @@ -280,7 +280,7 @@ client/base - client-config + client/config http-client client-transport/grpc client-transport/jsonrpc From a85c6415bf18f02d0d19987189036aa598e08570 Mon Sep 17 00:00:00 2001 From: Farah Juma Date: Mon, 18 Aug 2025 10:37:35 -0400 Subject: [PATCH 10/31] fix: Move the contents of client-transport to client/transport --- {client-transport => client/transport}/grpc/pom.xml | 2 +- .../io/a2a/client/transport/grpc/EventStreamObserver.java | 0 .../java/io/a2a/client/transport/grpc/GrpcTransport.java | 0 .../a2a/client/transport/grpc/GrpcTransportProvider.java | 0 .../io.a2a.client.transport.spi.ClientTransportProvider | 0 {client-transport => client/transport}/jsonrpc/pom.xml | 2 +- .../io/a2a/client/transport/jsonrpc/JSONRPCTransport.java | 0 .../transport/jsonrpc/JSONRPCTransportProvider.java | 0 .../client/transport/jsonrpc/sse/SSEEventListener.java | 0 .../io.a2a.client.transport.spi.ClientTransportProvider | 0 .../transport/jsonrpc/JSONRPCTransportStreamingTest.java | 0 .../client/transport/jsonrpc/JSONRPCTransportTest.java | 0 .../io/a2a/client/transport/jsonrpc/JsonMessages.java | 0 .../client/transport/jsonrpc/JsonStreamingMessages.java | 0 .../transport/jsonrpc/sse/SSEEventListenerTest.java | 0 {client-transport => client/transport}/spi/pom.xml | 2 +- .../java/io/a2a/client/transport/spi/ClientTransport.java | 0 .../a2a/client/transport/spi/ClientTransportProvider.java | 0 pom.xml | 8 ++++---- transport/grpc/pom.xml | 5 ----- transport/jsonrpc/pom.xml | 5 ----- 21 files changed, 7 insertions(+), 17 deletions(-) rename {client-transport => client/transport}/grpc/pom.xml (97%) rename {client-transport => client/transport}/grpc/src/main/java/io/a2a/client/transport/grpc/EventStreamObserver.java (100%) rename {client-transport => client/transport}/grpc/src/main/java/io/a2a/client/transport/grpc/GrpcTransport.java (100%) rename {client-transport => client/transport}/grpc/src/main/java/io/a2a/client/transport/grpc/GrpcTransportProvider.java (100%) rename {client-transport => client/transport}/grpc/src/main/resources/META-INF/services/io.a2a.client.transport.spi.ClientTransportProvider (100%) rename {client-transport => client/transport}/jsonrpc/pom.xml (97%) rename {client-transport => client/transport}/jsonrpc/src/main/java/io/a2a/client/transport/jsonrpc/JSONRPCTransport.java (100%) rename {client-transport => client/transport}/jsonrpc/src/main/java/io/a2a/client/transport/jsonrpc/JSONRPCTransportProvider.java (100%) rename {client-transport => client/transport}/jsonrpc/src/main/java/io/a2a/client/transport/jsonrpc/sse/SSEEventListener.java (100%) rename {client-transport => client/transport}/jsonrpc/src/main/resources/META-INF/services/io.a2a.client.transport.spi.ClientTransportProvider (100%) rename {client-transport => client/transport}/jsonrpc/src/test/java/io/a2a/client/transport/jsonrpc/JSONRPCTransportStreamingTest.java (100%) rename {client-transport => client/transport}/jsonrpc/src/test/java/io/a2a/client/transport/jsonrpc/JSONRPCTransportTest.java (100%) rename {client-transport => client/transport}/jsonrpc/src/test/java/io/a2a/client/transport/jsonrpc/JsonMessages.java (100%) rename {client-transport => client/transport}/jsonrpc/src/test/java/io/a2a/client/transport/jsonrpc/JsonStreamingMessages.java (100%) rename {client-transport => client/transport}/jsonrpc/src/test/java/io/a2a/client/transport/jsonrpc/sse/SSEEventListenerTest.java (100%) rename {client-transport => client/transport}/spi/pom.xml (95%) rename {client-transport => client/transport}/spi/src/main/java/io/a2a/client/transport/spi/ClientTransport.java (100%) rename {client-transport => client/transport}/spi/src/main/java/io/a2a/client/transport/spi/ClientTransportProvider.java (100%) diff --git a/client-transport/grpc/pom.xml b/client/transport/grpc/pom.xml similarity index 97% rename from client-transport/grpc/pom.xml rename to client/transport/grpc/pom.xml index aaf5f734b..58cb9e9a9 100644 --- a/client-transport/grpc/pom.xml +++ b/client/transport/grpc/pom.xml @@ -8,7 +8,7 @@ io.github.a2asdk a2a-java-sdk-parent 0.2.6.Beta1-SNAPSHOT - ../../pom.xml + ../../../pom.xml a2a-java-sdk-client-transport-grpc jar diff --git a/client-transport/grpc/src/main/java/io/a2a/client/transport/grpc/EventStreamObserver.java b/client/transport/grpc/src/main/java/io/a2a/client/transport/grpc/EventStreamObserver.java similarity index 100% rename from client-transport/grpc/src/main/java/io/a2a/client/transport/grpc/EventStreamObserver.java rename to client/transport/grpc/src/main/java/io/a2a/client/transport/grpc/EventStreamObserver.java diff --git a/client-transport/grpc/src/main/java/io/a2a/client/transport/grpc/GrpcTransport.java b/client/transport/grpc/src/main/java/io/a2a/client/transport/grpc/GrpcTransport.java similarity index 100% rename from client-transport/grpc/src/main/java/io/a2a/client/transport/grpc/GrpcTransport.java rename to client/transport/grpc/src/main/java/io/a2a/client/transport/grpc/GrpcTransport.java diff --git a/client-transport/grpc/src/main/java/io/a2a/client/transport/grpc/GrpcTransportProvider.java b/client/transport/grpc/src/main/java/io/a2a/client/transport/grpc/GrpcTransportProvider.java similarity index 100% rename from client-transport/grpc/src/main/java/io/a2a/client/transport/grpc/GrpcTransportProvider.java rename to client/transport/grpc/src/main/java/io/a2a/client/transport/grpc/GrpcTransportProvider.java diff --git a/client-transport/grpc/src/main/resources/META-INF/services/io.a2a.client.transport.spi.ClientTransportProvider b/client/transport/grpc/src/main/resources/META-INF/services/io.a2a.client.transport.spi.ClientTransportProvider similarity index 100% rename from client-transport/grpc/src/main/resources/META-INF/services/io.a2a.client.transport.spi.ClientTransportProvider rename to client/transport/grpc/src/main/resources/META-INF/services/io.a2a.client.transport.spi.ClientTransportProvider diff --git a/client-transport/jsonrpc/pom.xml b/client/transport/jsonrpc/pom.xml similarity index 97% rename from client-transport/jsonrpc/pom.xml rename to client/transport/jsonrpc/pom.xml index 5230fed98..8bcac1e01 100644 --- a/client-transport/jsonrpc/pom.xml +++ b/client/transport/jsonrpc/pom.xml @@ -8,7 +8,7 @@ io.github.a2asdk a2a-java-sdk-parent 0.2.6.Beta1-SNAPSHOT - ../../pom.xml + ../../../pom.xml a2a-java-sdk-client-transport-jsonrpc jar diff --git a/client-transport/jsonrpc/src/main/java/io/a2a/client/transport/jsonrpc/JSONRPCTransport.java b/client/transport/jsonrpc/src/main/java/io/a2a/client/transport/jsonrpc/JSONRPCTransport.java similarity index 100% rename from client-transport/jsonrpc/src/main/java/io/a2a/client/transport/jsonrpc/JSONRPCTransport.java rename to client/transport/jsonrpc/src/main/java/io/a2a/client/transport/jsonrpc/JSONRPCTransport.java diff --git a/client-transport/jsonrpc/src/main/java/io/a2a/client/transport/jsonrpc/JSONRPCTransportProvider.java b/client/transport/jsonrpc/src/main/java/io/a2a/client/transport/jsonrpc/JSONRPCTransportProvider.java similarity index 100% rename from client-transport/jsonrpc/src/main/java/io/a2a/client/transport/jsonrpc/JSONRPCTransportProvider.java rename to client/transport/jsonrpc/src/main/java/io/a2a/client/transport/jsonrpc/JSONRPCTransportProvider.java diff --git a/client-transport/jsonrpc/src/main/java/io/a2a/client/transport/jsonrpc/sse/SSEEventListener.java b/client/transport/jsonrpc/src/main/java/io/a2a/client/transport/jsonrpc/sse/SSEEventListener.java similarity index 100% rename from client-transport/jsonrpc/src/main/java/io/a2a/client/transport/jsonrpc/sse/SSEEventListener.java rename to client/transport/jsonrpc/src/main/java/io/a2a/client/transport/jsonrpc/sse/SSEEventListener.java diff --git a/client-transport/jsonrpc/src/main/resources/META-INF/services/io.a2a.client.transport.spi.ClientTransportProvider b/client/transport/jsonrpc/src/main/resources/META-INF/services/io.a2a.client.transport.spi.ClientTransportProvider similarity index 100% rename from client-transport/jsonrpc/src/main/resources/META-INF/services/io.a2a.client.transport.spi.ClientTransportProvider rename to client/transport/jsonrpc/src/main/resources/META-INF/services/io.a2a.client.transport.spi.ClientTransportProvider diff --git a/client-transport/jsonrpc/src/test/java/io/a2a/client/transport/jsonrpc/JSONRPCTransportStreamingTest.java b/client/transport/jsonrpc/src/test/java/io/a2a/client/transport/jsonrpc/JSONRPCTransportStreamingTest.java similarity index 100% rename from client-transport/jsonrpc/src/test/java/io/a2a/client/transport/jsonrpc/JSONRPCTransportStreamingTest.java rename to client/transport/jsonrpc/src/test/java/io/a2a/client/transport/jsonrpc/JSONRPCTransportStreamingTest.java diff --git a/client-transport/jsonrpc/src/test/java/io/a2a/client/transport/jsonrpc/JSONRPCTransportTest.java b/client/transport/jsonrpc/src/test/java/io/a2a/client/transport/jsonrpc/JSONRPCTransportTest.java similarity index 100% rename from client-transport/jsonrpc/src/test/java/io/a2a/client/transport/jsonrpc/JSONRPCTransportTest.java rename to client/transport/jsonrpc/src/test/java/io/a2a/client/transport/jsonrpc/JSONRPCTransportTest.java diff --git a/client-transport/jsonrpc/src/test/java/io/a2a/client/transport/jsonrpc/JsonMessages.java b/client/transport/jsonrpc/src/test/java/io/a2a/client/transport/jsonrpc/JsonMessages.java similarity index 100% rename from client-transport/jsonrpc/src/test/java/io/a2a/client/transport/jsonrpc/JsonMessages.java rename to client/transport/jsonrpc/src/test/java/io/a2a/client/transport/jsonrpc/JsonMessages.java diff --git a/client-transport/jsonrpc/src/test/java/io/a2a/client/transport/jsonrpc/JsonStreamingMessages.java b/client/transport/jsonrpc/src/test/java/io/a2a/client/transport/jsonrpc/JsonStreamingMessages.java similarity index 100% rename from client-transport/jsonrpc/src/test/java/io/a2a/client/transport/jsonrpc/JsonStreamingMessages.java rename to client/transport/jsonrpc/src/test/java/io/a2a/client/transport/jsonrpc/JsonStreamingMessages.java diff --git a/client-transport/jsonrpc/src/test/java/io/a2a/client/transport/jsonrpc/sse/SSEEventListenerTest.java b/client/transport/jsonrpc/src/test/java/io/a2a/client/transport/jsonrpc/sse/SSEEventListenerTest.java similarity index 100% rename from client-transport/jsonrpc/src/test/java/io/a2a/client/transport/jsonrpc/sse/SSEEventListenerTest.java rename to client/transport/jsonrpc/src/test/java/io/a2a/client/transport/jsonrpc/sse/SSEEventListenerTest.java diff --git a/client-transport/spi/pom.xml b/client/transport/spi/pom.xml similarity index 95% rename from client-transport/spi/pom.xml rename to client/transport/spi/pom.xml index 7c4b112f0..d05c088a6 100644 --- a/client-transport/spi/pom.xml +++ b/client/transport/spi/pom.xml @@ -8,7 +8,7 @@ io.github.a2asdk a2a-java-sdk-parent 0.2.6.Beta1-SNAPSHOT - ../../pom.xml + ../../../pom.xml a2a-java-sdk-client-transport-spi jar diff --git a/client-transport/spi/src/main/java/io/a2a/client/transport/spi/ClientTransport.java b/client/transport/spi/src/main/java/io/a2a/client/transport/spi/ClientTransport.java similarity index 100% rename from client-transport/spi/src/main/java/io/a2a/client/transport/spi/ClientTransport.java rename to client/transport/spi/src/main/java/io/a2a/client/transport/spi/ClientTransport.java diff --git a/client-transport/spi/src/main/java/io/a2a/client/transport/spi/ClientTransportProvider.java b/client/transport/spi/src/main/java/io/a2a/client/transport/spi/ClientTransportProvider.java similarity index 100% rename from client-transport/spi/src/main/java/io/a2a/client/transport/spi/ClientTransportProvider.java rename to client/transport/spi/src/main/java/io/a2a/client/transport/spi/ClientTransportProvider.java diff --git a/pom.xml b/pom.xml index e3a1d484c..b5fa46a08 100644 --- a/pom.xml +++ b/pom.xml @@ -281,12 +281,12 @@ client/base client/config - http-client - client-transport/grpc - client-transport/jsonrpc - client-transport/spi + client/transport/grpc + client/transport/jsonrpc + client/transport/spi common examples/helloworld + http-client reference/common reference/grpc reference/jsonrpc diff --git a/transport/grpc/pom.xml b/transport/grpc/pom.xml index 7bcc7149d..93ec2a095 100644 --- a/transport/grpc/pom.xml +++ b/transport/grpc/pom.xml @@ -18,11 +18,6 @@ Java SDK for the Agent2Agent Protocol (A2A) - gRPC - - io.github.a2asdk - a2a-java-sdk-transport-spi - ${project.version} - io.github.a2asdk a2a-java-sdk-server-common diff --git a/transport/jsonrpc/pom.xml b/transport/jsonrpc/pom.xml index d0f3304fe..15eecd06e 100644 --- a/transport/jsonrpc/pom.xml +++ b/transport/jsonrpc/pom.xml @@ -18,11 +18,6 @@ Java SDK for the Agent2Agent Protocol (A2A) - JSONRPC - - io.github.a2asdk - a2a-java-sdk-transport-spi - ${project.version} - io.github.a2asdk a2a-java-sdk-server-common From f3bafc4d2f82b2954fa5e75b2ad7d2ac07a171b1 Mon Sep 17 00:00:00 2001 From: Farah Juma Date: Mon, 18 Aug 2025 12:28:20 -0400 Subject: [PATCH 11/31] fix: Introduce a ClientTransportConfig interface, update ClientConfig to make use of it, and remove the http-client and grpc dependencies from the client config module --- client/config/pom.xml | 9 ------ .../io/a2a/client/config/ClientConfig.java | 32 ++++++------------- .../client/config/ClientTransportConfig.java | 7 ++++ .../transport/grpc/GrpcTransportConfig.java | 17 ++++++++++ .../transport/grpc/GrpcTransportProvider.java | 17 +++++++++- .../jsonrpc/JSONRPCTransportConfig.java | 17 ++++++++++ .../jsonrpc/JSONRPCTransportProvider.java | 14 +++++++- 7 files changed, 79 insertions(+), 34 deletions(-) create mode 100644 client/config/src/main/java/io/a2a/client/config/ClientTransportConfig.java create mode 100644 client/transport/grpc/src/main/java/io/a2a/client/transport/grpc/GrpcTransportConfig.java create mode 100644 client/transport/jsonrpc/src/main/java/io/a2a/client/transport/jsonrpc/JSONRPCTransportConfig.java diff --git a/client/config/pom.xml b/client/config/pom.xml index 560671fac..b20757697 100644 --- a/client/config/pom.xml +++ b/client/config/pom.xml @@ -18,11 +18,6 @@ Java SDK for the Agent2Agent Protocol (A2A) - Client Configuration - - ${project.groupId} - a2a-java-sdk-http-client - ${project.version} - ${project.groupId} a2a-java-sdk-spec @@ -39,10 +34,6 @@ mockserver-netty test - - io.grpc - grpc-api - \ No newline at end of file diff --git a/client/config/src/main/java/io/a2a/client/config/ClientConfig.java b/client/config/src/main/java/io/a2a/client/config/ClientConfig.java index ef8cc948d..913495175 100644 --- a/client/config/src/main/java/io/a2a/client/config/ClientConfig.java +++ b/client/config/src/main/java/io/a2a/client/config/ClientConfig.java @@ -3,9 +3,7 @@ import java.util.List; import java.util.Map; -import io.a2a.client.http.A2AHttpClient; import io.a2a.spec.PushNotificationConfig; -import io.grpc.Channel; /** * Configuration for the A2A client factory. @@ -14,8 +12,7 @@ public class ClientConfig { private final Boolean streaming; private final Boolean polling; - private final A2AHttpClient httpClient; - private final Channel channel; + private final List clientTransportConfigs; private final List supportedTransports; private final Boolean useClientPreference; private final List acceptedOutputModes; @@ -23,14 +20,13 @@ public class ClientConfig { private final Integer historyLength; private final Map metadata; - public ClientConfig(Boolean streaming, Boolean polling, A2AHttpClient httpClient, Channel channel, + public ClientConfig(Boolean streaming, Boolean polling, List clientTransportConfigs, List supportedTransports, Boolean useClientPreference, List acceptedOutputModes, PushNotificationConfig pushNotificationConfig, Integer historyLength, Map metadata) { this.streaming = streaming == null ? true : streaming; this.polling = polling == null ? false : polling; - this.httpClient = httpClient; - this.channel = channel; + this.clientTransportConfigs = clientTransportConfigs; this.supportedTransports = supportedTransports; this.useClientPreference = useClientPreference == null ? false : useClientPreference; this.acceptedOutputModes = acceptedOutputModes; @@ -47,12 +43,8 @@ public boolean isPolling() { return polling; } - public A2AHttpClient getHttpClient() { - return httpClient; - } - - public Channel getChannel() { - return channel; + public List getClientTransportConfigs() { + return clientTransportConfigs; } public List getSupportedTransports() { @@ -82,8 +74,7 @@ public Map getMetadata() { public static class Builder { private Boolean streaming; private Boolean polling; - private A2AHttpClient httpClient; - private Channel channel; + private List clientTransportConfigs; private List supportedTransports; private Boolean useClientPreference; private List acceptedOutputModes; @@ -101,13 +92,8 @@ public Builder setPolling(Boolean polling) { return this; } - public Builder setHttpClient(A2AHttpClient httpClient) { - this.httpClient = httpClient; - return this; - } - - public Builder setChannel(Channel channel) { - this.channel = channel; + public Builder setClientTransportConfigs(List clientTransportConfigs) { + this.clientTransportConfigs = clientTransportConfigs; return this; } @@ -142,7 +128,7 @@ public Builder setMetadata(Map metadata) { } public ClientConfig build() { - return new ClientConfig(streaming, polling, httpClient, channel, + return new ClientConfig(streaming, polling, clientTransportConfigs, supportedTransports, useClientPreference, acceptedOutputModes, pushNotificationConfig, historyLength, metadata); } diff --git a/client/config/src/main/java/io/a2a/client/config/ClientTransportConfig.java b/client/config/src/main/java/io/a2a/client/config/ClientTransportConfig.java new file mode 100644 index 000000000..560b69afb --- /dev/null +++ b/client/config/src/main/java/io/a2a/client/config/ClientTransportConfig.java @@ -0,0 +1,7 @@ +package io.a2a.client.config; + +/** + * Configuration for an A2A client transport. + */ +public interface ClientTransportConfig { +} \ No newline at end of file diff --git a/client/transport/grpc/src/main/java/io/a2a/client/transport/grpc/GrpcTransportConfig.java b/client/transport/grpc/src/main/java/io/a2a/client/transport/grpc/GrpcTransportConfig.java new file mode 100644 index 000000000..4b0fd1930 --- /dev/null +++ b/client/transport/grpc/src/main/java/io/a2a/client/transport/grpc/GrpcTransportConfig.java @@ -0,0 +1,17 @@ +package io.a2a.client.transport.grpc; + +import io.a2a.client.config.ClientTransportConfig; +import io.grpc.ManagedChannelBuilder; + +public class GrpcTransportConfig implements ClientTransportConfig { + + private final ManagedChannelBuilder channelBuilder; + + public GrpcTransportConfig(ManagedChannelBuilder channelBuilder) { + this.channelBuilder = channelBuilder; + } + + public ManagedChannelBuilder getManagedChannelBuilder() { + return channelBuilder; + } +} \ No newline at end of file diff --git a/client/transport/grpc/src/main/java/io/a2a/client/transport/grpc/GrpcTransportProvider.java b/client/transport/grpc/src/main/java/io/a2a/client/transport/grpc/GrpcTransportProvider.java index cdaffed88..0ee3d1d1e 100644 --- a/client/transport/grpc/src/main/java/io/a2a/client/transport/grpc/GrpcTransportProvider.java +++ b/client/transport/grpc/src/main/java/io/a2a/client/transport/grpc/GrpcTransportProvider.java @@ -4,10 +4,13 @@ import io.a2a.client.config.ClientCallInterceptor; import io.a2a.client.config.ClientConfig; +import io.a2a.client.config.ClientTransportConfig; import io.a2a.client.transport.spi.ClientTransport; import io.a2a.client.transport.spi.ClientTransportProvider; import io.a2a.spec.AgentCard; import io.a2a.spec.TransportProtocol; +import io.grpc.Channel; +import io.grpc.ManagedChannelBuilder; /** * Provider for gRPC transport implementation. @@ -18,7 +21,19 @@ public class GrpcTransportProvider implements ClientTransportProvider { public ClientTransport create(ClientConfig clientConfig, AgentCard agentCard, String agentUrl, List interceptors) { // not making use of the interceptors for gRPC for now - return new GrpcTransport(clientConfig.getChannel(), agentCard); + ManagedChannelBuilder managedChannelBuilder = null; + List clientTransportConfigs = clientConfig.getClientTransportConfigs(); + if (clientTransportConfigs != null) { + for (ClientTransportConfig clientTransportConfig : clientTransportConfigs) { + if (clientTransportConfig instanceof GrpcTransportConfig grpcTransportConfig) { + managedChannelBuilder = grpcTransportConfig.getManagedChannelBuilder(); + break; + } + } + } + Channel channel = managedChannelBuilder == null ? ManagedChannelBuilder.forTarget(agentUrl).build() + : managedChannelBuilder.forTarget(agentUrl).build(); + return new GrpcTransport(channel, agentCard); } @Override diff --git a/client/transport/jsonrpc/src/main/java/io/a2a/client/transport/jsonrpc/JSONRPCTransportConfig.java b/client/transport/jsonrpc/src/main/java/io/a2a/client/transport/jsonrpc/JSONRPCTransportConfig.java new file mode 100644 index 000000000..1627b9090 --- /dev/null +++ b/client/transport/jsonrpc/src/main/java/io/a2a/client/transport/jsonrpc/JSONRPCTransportConfig.java @@ -0,0 +1,17 @@ +package io.a2a.client.transport.jsonrpc; + +import io.a2a.client.config.ClientTransportConfig; +import io.a2a.client.http.A2AHttpClient; + +public class JSONRPCTransportConfig implements ClientTransportConfig { + + private final A2AHttpClient httpClient; + + public JSONRPCTransportConfig(A2AHttpClient httpClient) { + this.httpClient = httpClient; + } + + public A2AHttpClient getHttpClient() { + return httpClient; + } +} \ No newline at end of file diff --git a/client/transport/jsonrpc/src/main/java/io/a2a/client/transport/jsonrpc/JSONRPCTransportProvider.java b/client/transport/jsonrpc/src/main/java/io/a2a/client/transport/jsonrpc/JSONRPCTransportProvider.java index 3fe865515..a72a86294 100644 --- a/client/transport/jsonrpc/src/main/java/io/a2a/client/transport/jsonrpc/JSONRPCTransportProvider.java +++ b/client/transport/jsonrpc/src/main/java/io/a2a/client/transport/jsonrpc/JSONRPCTransportProvider.java @@ -4,6 +4,8 @@ import io.a2a.client.config.ClientCallInterceptor; import io.a2a.client.config.ClientConfig; +import io.a2a.client.config.ClientTransportConfig; +import io.a2a.client.http.A2AHttpClient; import io.a2a.client.transport.spi.ClientTransport; import io.a2a.client.transport.spi.ClientTransportProvider; import io.a2a.spec.AgentCard; @@ -14,7 +16,17 @@ public class JSONRPCTransportProvider implements ClientTransportProvider { @Override public ClientTransport create(ClientConfig clientConfig, AgentCard agentCard, String agentUrl, List interceptors) { - return new JSONRPCTransport(clientConfig.getHttpClient(), agentCard, agentUrl, interceptors); + A2AHttpClient httpClient = null; + List clientTransportConfigs = clientConfig.getClientTransportConfigs(); + if (clientTransportConfigs != null) { + for (ClientTransportConfig clientTransportConfig : clientTransportConfigs) { + if (clientTransportConfig instanceof JSONRPCTransportConfig jsonrpcTransportConfig) { + httpClient = jsonrpcTransportConfig.getHttpClient(); + break; + } + } + } + return new JSONRPCTransport(httpClient, agentCard, agentUrl, interceptors); } @Override From 53e3277f5debb456bab5d2399a213b6339f66119 Mon Sep 17 00:00:00 2001 From: Farah Juma Date: Mon, 18 Aug 2025 13:53:25 -0400 Subject: [PATCH 12/31] fix: Additional updates to the client and update AbstractA2AServerTest to be able to make use of the appropriate client based on the transport and update the JSONRPC and gRPC tests to extend this --- .../java/io/a2a/client/AbstractClient.java | 44 +- .../src/main/java/io/a2a/client/Client.java | 97 +- .../java/io/a2a/client/ClientFactory.java | 10 +- client/config/pom.xml | 2 +- client/transport/grpc/pom.xml | 2 +- .../transport/grpc/EventStreamObserver.java | 7 +- .../transport/grpc/GrpcErrorMapper.java | 71 + .../client/transport/grpc/GrpcTransport.java | 23 +- .../transport/grpc/GrpcTransportConfig.java | 15 +- .../transport/grpc/GrpcTransportProvider.java | 10 +- client/transport/jsonrpc/pom.xml | 2 +- .../transport/jsonrpc/JSONRPCTransport.java | 16 +- .../jsonrpc/JSONRPCTransportTest.java | 27 +- .../transport/jsonrpc/JsonMessages.java | 363 +++-- client/transport/spi/pom.xml | 2 +- http-client/pom.xml | 2 +- reference/grpc/pom.xml | 11 + .../grpc/quarkus/QuarkusA2AGrpcTest.java | 1246 +---------------- .../src/test/resources/application.properties | 5 +- .../apps/quarkus/QuarkusA2AJSONRPCTest.java | 168 +++ .../java/io/a2a/grpc/utils/ProtoUtils.java | 25 +- tests/server-common/pom.xml | 8 +- .../apps/common/AbstractA2AServerTest.java | 898 ++++++------ 23 files changed, 1115 insertions(+), 1939 deletions(-) create mode 100644 client/transport/grpc/src/main/java/io/a2a/client/transport/grpc/GrpcErrorMapper.java diff --git a/client/base/src/main/java/io/a2a/client/AbstractClient.java b/client/base/src/main/java/io/a2a/client/AbstractClient.java index f6794ab38..ae25a0c8b 100644 --- a/client/base/src/main/java/io/a2a/client/AbstractClient.java +++ b/client/base/src/main/java/io/a2a/client/AbstractClient.java @@ -45,7 +45,7 @@ public AbstractClient(List> consumers, Consum * Send a message to the remote agent. This method will automatically use * the streaming or non-streaming approach as determined by the server's * agent card and the client configuration. The configured client consumers - * and will be used to handle messages, tasks, and update events received + * will be used to handle messages, tasks, and update events received * from the remote agent. The configured streaming error handler will be used * if an error occurs during streaming. The configured client push notification * configuration will get used for streaming. @@ -56,6 +56,26 @@ public AbstractClient(List> consumers, Consum */ public abstract void sendMessage(Message request, ClientCallContext context) throws A2AClientException; + /** + * Send a message to the remote agent. This method will automatically use + * the streaming or non-streaming approach as determined by the server's + * agent card and the client configuration. The specified client consumers + * will be used to handle messages, tasks, and update events received + * from the remote agent. The specified streaming error handler will be used + * if an error occurs during streaming. The configured client push notification + * configuration will get used for streaming. + * + * @param request the message + * @param consumers a list of consumers to pass responses from the remote agent to + * @param streamingErrorHandler an error handler that should be used for the streaming case if an error occurs + * @param context optional client call context for the request (may be {@code null}) + * @throws A2AClientException if sending the message fails for any reason + */ + public abstract void sendMessage(Message request, + List> consumers, + Consumer streamingErrorHandler, + ClientCallContext context) throws A2AClientException; + /** * Send a message to the remote agent. This method will automatically use * the streaming or non-streaming approach as determined by the server's @@ -143,6 +163,9 @@ public abstract void deleteTaskPushNotificationConfigurations( /** * Resubscribe to a task's event stream. * This is only available if both the client and server support streaming. + * The configured client consumers will be used to handle messages, tasks, + * and update events received from the remote agent. The configured streaming + * error handler will be used if an error occurs during streaming. * * @param request the parameters specifying which task's notification configs to delete * @param context optional client call context for the request (may be {@code null}) @@ -150,6 +173,23 @@ public abstract void deleteTaskPushNotificationConfigurations( */ public abstract void resubscribe(TaskIdParams request, ClientCallContext context) throws A2AClientException; + /** + * Resubscribe to a task's event stream. + * This is only available if both the client and server support streaming. + * The specified client consumers will be used to handle messages, tasks, and + * update events received from the remote agent. The specified streaming error + * handler will be used if an error occurs during streaming. + * + * @param request the parameters specifying which task's notification configs to delete + * @param consumers a list of consumers to pass responses from the remote agent to + * @param streamingErrorHandler an error handler that should be used for the streaming case if an error occurs + * @param context optional client call context for the request (may be {@code null}) + * @throws A2AClientException if resubscribing fails for any reason + */ + public abstract void resubscribe(TaskIdParams request, List> consumers, + Consumer streamingErrorHandler, ClientCallContext context) throws A2AClientException; + + /** * Retrieve the AgentCard. * @@ -167,7 +207,7 @@ public abstract void deleteTaskPushNotificationConfigurations( /** * Process the event using all configured consumers. */ - public void consume(ClientEvent clientEventOrMessage, AgentCard agentCard) { + void consume(ClientEvent clientEventOrMessage, AgentCard agentCard) { for (BiConsumer consumer : consumers) { consumer.accept(clientEventOrMessage, agentCard); } diff --git a/client/base/src/main/java/io/a2a/client/Client.java b/client/base/src/main/java/io/a2a/client/Client.java index 5dc57b56a..e3d0c556d 100644 --- a/client/base/src/main/java/io/a2a/client/Client.java +++ b/client/base/src/main/java/io/a2a/client/Client.java @@ -58,7 +58,26 @@ public void sendMessage(Message request, ClientCallContext context) throws A2ACl .metadata(clientConfig.getMetadata()) .build(); - sendMessage(messageSendParams, context); + sendMessage(messageSendParams, null, null, context); + } + + @Override + public void sendMessage(Message request, List> consumers, + Consumer streamingErrorHandler, ClientCallContext context) throws A2AClientException { + MessageSendConfiguration messageSendConfiguration = new MessageSendConfiguration.Builder() + .acceptedOutputModes(clientConfig.getAcceptedOutputModes()) + .blocking(clientConfig.isPolling()) + .historyLength(clientConfig.getHistoryLength()) + .pushNotification(clientConfig.getPushNotificationConfig()) + .build(); + + MessageSendParams messageSendParams = new MessageSendParams.Builder() + .message(request) + .configuration(messageSendConfiguration) + .metadata(clientConfig.getMetadata()) + .build(); + + sendMessage(messageSendParams, consumers, streamingErrorHandler, context); } @Override @@ -77,7 +96,7 @@ public void sendMessage(Message request, PushNotificationConfig pushNotification .metadata(metatadata) .build(); - sendMessage(messageSendParams, context); + sendMessage(messageSendParams, null, null, context); } @Override @@ -116,19 +135,13 @@ public void deleteTaskPushNotificationConfigurations( @Override public void resubscribe(TaskIdParams request, ClientCallContext context) throws A2AClientException { - if (! clientConfig.isStreaming() || ! agentCard.capabilities().streaming()) { - throw new A2AClientException("Client and/or server does not support resubscription"); - } - ClientTaskManager tracker = new ClientTaskManager(); - Consumer eventHandler = event -> { - try { - ClientEvent clientEvent = getClientEvent(event, tracker); - consume(clientEvent, agentCard); - } catch (A2AClientError e) { - getStreamingErrorHandler().accept(e); - } - }; - clientTransport.resubscribe(request, eventHandler, getStreamingErrorHandler(), context); + resubscribeToTask(request, null, null, context); + } + + @Override + public void resubscribe(TaskIdParams request, List> consumers, + Consumer streamingErrorHandler, ClientCallContext context) throws A2AClientException { + resubscribeToTask(request, consumers, streamingErrorHandler, context); } @Override @@ -159,7 +172,8 @@ private ClientEvent getClientEvent(StreamingEventKind event, ClientTaskManager t } } - private void sendMessage(MessageSendParams messageSendParams, ClientCallContext context) throws A2AClientException { + private void sendMessage(MessageSendParams messageSendParams, List> consumers, + Consumer errorHandler, ClientCallContext context) throws A2AClientException { if (! clientConfig.isStreaming() || ! agentCard.capabilities().streaming()) { EventKind eventKind = clientTransport.sendMessage(messageSendParams, context); ClientEvent clientEvent; @@ -169,18 +183,61 @@ private void sendMessage(MessageSendParams messageSendParams, ClientCallContext // must be a message clientEvent = new MessageEvent((Message) eventKind); } - consume(clientEvent, agentCard); + consume(clientEvent, agentCard, consumers); } else { ClientTaskManager tracker = new ClientTaskManager(); + Consumer overriddenErrorHandler = getOverriddenErrorHandler(errorHandler); Consumer eventHandler = event -> { try { ClientEvent clientEvent = getClientEvent(event, tracker); - consume(clientEvent, agentCard); + consume(clientEvent, agentCard, consumers); } catch (A2AClientError e) { - getStreamingErrorHandler().accept(e); + overriddenErrorHandler.accept(e); } }; - clientTransport.sendMessageStreaming(messageSendParams, eventHandler, getStreamingErrorHandler(), context); + clientTransport.sendMessageStreaming(messageSendParams, eventHandler, overriddenErrorHandler, context); + } + } + + private void resubscribeToTask(TaskIdParams request, List> consumers, + Consumer errorHandler, ClientCallContext context) throws A2AClientException { + if (! clientConfig.isStreaming() || ! agentCard.capabilities().streaming()) { + throw new A2AClientException("Client and/or server does not support resubscription"); + } + ClientTaskManager tracker = new ClientTaskManager(); + Consumer overriddenErrorHandler = getOverriddenErrorHandler(errorHandler); + Consumer eventHandler = event -> { + try { + ClientEvent clientEvent = getClientEvent(event, tracker); + consume(clientEvent, agentCard, consumers); + } catch (A2AClientError e) { + overriddenErrorHandler.accept(e); + } + }; + clientTransport.resubscribe(request, eventHandler, overriddenErrorHandler, context); + } + + private Consumer getOverriddenErrorHandler(Consumer errorHandler) { + return e -> { + if (errorHandler != null) { + errorHandler.accept(e); + } else { + if (getStreamingErrorHandler() != null) { + getStreamingErrorHandler().accept(e); + } + } + }; + } + + private void consume(ClientEvent clientEvent, AgentCard agentCard, List> consumers) { + if (consumers != null) { + // use specified consumers + for (BiConsumer consumer : consumers) { + consumer.accept(clientEvent, agentCard); + } + } else { + // use configured consumers + consume(clientEvent, agentCard); } } } diff --git a/client/base/src/main/java/io/a2a/client/ClientFactory.java b/client/base/src/main/java/io/a2a/client/ClientFactory.java index 6789b76ac..71c1a6020 100644 --- a/client/base/src/main/java/io/a2a/client/ClientFactory.java +++ b/client/base/src/main/java/io/a2a/client/ClientFactory.java @@ -42,10 +42,11 @@ public ClientFactory(ClientConfig clientConfig) { * @param agentCard the agent card for the remote agent * @param consumers a list of consumers to pass responses from the remote agent to * @param streamingErrorHandler an error handler that should be used for the streaming case if an error occurs + * @return the client to use * @throws A2AClientException if the client cannot be created for any reason */ - public AbstractClient create(AgentCard agentCard, List> consumers, - Consumer streamingErrorHandler) throws A2AClientException { + public Client create(AgentCard agentCard, List> consumers, + Consumer streamingErrorHandler) throws A2AClientException { return create(agentCard, consumers, streamingErrorHandler, null); } @@ -56,10 +57,11 @@ public AbstractClient create(AgentCard agentCard, List> consumers, - Consumer streamingErrorHandler, List interceptors) throws A2AClientException { + public Client create(AgentCard agentCard, List> consumers, + Consumer streamingErrorHandler, List interceptors) throws A2AClientException { checkNotNullParam("agentCard", agentCard); checkNotNullParam("consumers", consumers); LinkedHashMap serverPreferredTransports = getServerPreferredTransports(agentCard); diff --git a/client/config/pom.xml b/client/config/pom.xml index b20757697..a44194654 100644 --- a/client/config/pom.xml +++ b/client/config/pom.xml @@ -7,7 +7,7 @@ io.github.a2asdk a2a-java-sdk-parent - 0.2.6.Beta1-SNAPSHOT + 0.3.0.Beta1-SNAPSHOT ../../pom.xml a2a-java-sdk-client-config diff --git a/client/transport/grpc/pom.xml b/client/transport/grpc/pom.xml index 58cb9e9a9..ad4a5b12e 100644 --- a/client/transport/grpc/pom.xml +++ b/client/transport/grpc/pom.xml @@ -7,7 +7,7 @@ io.github.a2asdk a2a-java-sdk-parent - 0.2.6.Beta1-SNAPSHOT + 0.3.0.Beta1-SNAPSHOT ../../../pom.xml a2a-java-sdk-client-transport-grpc diff --git a/client/transport/grpc/src/main/java/io/a2a/client/transport/grpc/EventStreamObserver.java b/client/transport/grpc/src/main/java/io/a2a/client/transport/grpc/EventStreamObserver.java index 4edc4a3f5..627286607 100644 --- a/client/transport/grpc/src/main/java/io/a2a/client/transport/grpc/EventStreamObserver.java +++ b/client/transport/grpc/src/main/java/io/a2a/client/transport/grpc/EventStreamObserver.java @@ -48,7 +48,12 @@ public void onNext(StreamResponse response) { @Override public void onError(Throwable t) { if (errorHandler != null) { - errorHandler.accept(t); + // Map gRPC errors to proper A2A exceptions + if (t instanceof io.grpc.StatusRuntimeException) { + errorHandler.accept(GrpcErrorMapper.mapGrpcError((io.grpc.StatusRuntimeException) t)); + } else { + errorHandler.accept(t); + } } } diff --git a/client/transport/grpc/src/main/java/io/a2a/client/transport/grpc/GrpcErrorMapper.java b/client/transport/grpc/src/main/java/io/a2a/client/transport/grpc/GrpcErrorMapper.java new file mode 100644 index 000000000..7340f7cef --- /dev/null +++ b/client/transport/grpc/src/main/java/io/a2a/client/transport/grpc/GrpcErrorMapper.java @@ -0,0 +1,71 @@ +package io.a2a.client.transport.grpc; + +import io.a2a.spec.A2AClientException; +import io.a2a.spec.ContentTypeNotSupportedError; +import io.a2a.spec.InvalidAgentResponseError; +import io.a2a.spec.InvalidParamsError; +import io.a2a.spec.InvalidRequestError; +import io.a2a.spec.JSONParseError; +import io.a2a.spec.MethodNotFoundError; +import io.a2a.spec.PushNotificationNotSupportedError; +import io.a2a.spec.TaskNotCancelableError; +import io.a2a.spec.TaskNotFoundError; +import io.a2a.spec.UnsupportedOperationError; +import io.grpc.Status; +import io.grpc.StatusRuntimeException; + +/** + * Utility class to map gRPC StatusRuntimeException to appropriate A2A error types + */ +public class GrpcErrorMapper { + + public static A2AClientException mapGrpcError(StatusRuntimeException e) { + return mapGrpcError(e, "gRPC error: "); + } + + public static A2AClientException mapGrpcError(StatusRuntimeException e, String errorPrefix) { + Status.Code code = e.getStatus().getCode(); + String description = e.getStatus().getDescription(); + + // Extract the actual error type from the description if possible + // (using description because the same code can map to multiple errors - + // see GrpcHandler#handleError) + if (description != null) { + if (description.contains("TaskNotFoundError")) { + return new A2AClientException(errorPrefix + description, new TaskNotFoundError()); + } else if (description.contains("UnsupportedOperationError")) { + return new A2AClientException(errorPrefix + description, new UnsupportedOperationError()); + } else if (description.contains("InvalidParamsError")) { + return new A2AClientException(errorPrefix + description, new InvalidParamsError()); + } else if (description.contains("InvalidRequestError")) { + return new A2AClientException(errorPrefix + description, new InvalidRequestError()); + } else if (description.contains("MethodNotFoundError")) { + return new A2AClientException(errorPrefix + description, new MethodNotFoundError()); + } else if (description.contains("TaskNotCancelableError")) { + return new A2AClientException(errorPrefix + description, new TaskNotCancelableError()); + } else if (description.contains("PushNotificationNotSupportedError")) { + return new A2AClientException(errorPrefix + description, new PushNotificationNotSupportedError()); + } else if (description.contains("JSONParseError")) { + return new A2AClientException(errorPrefix + description, new JSONParseError()); + } else if (description.contains("ContentTypeNotSupportedError")) { + return new A2AClientException(errorPrefix + description, new ContentTypeNotSupportedError(null, description, null)); + } else if (description.contains("InvalidAgentResponseError")) { + return new A2AClientException(errorPrefix + description, new InvalidAgentResponseError(null, description, null)); + } + } + + // Fall back to mapping based on status code + switch (code) { + case NOT_FOUND: + return new A2AClientException(errorPrefix + (description != null ? description : e.getMessage()), new TaskNotFoundError()); + case UNIMPLEMENTED: + return new A2AClientException(errorPrefix + (description != null ? description : e.getMessage()), new UnsupportedOperationError()); + case INVALID_ARGUMENT: + return new A2AClientException(errorPrefix + (description != null ? description : e.getMessage()), new InvalidParamsError()); + case INTERNAL: + return new A2AClientException(errorPrefix + (description != null ? description : e.getMessage()), new io.a2a.spec.InternalError(null, e.getMessage(), null)); + default: + return new A2AClientException(errorPrefix + e.getMessage(), e); + } + } +} diff --git a/client/transport/grpc/src/main/java/io/a2a/client/transport/grpc/GrpcTransport.java b/client/transport/grpc/src/main/java/io/a2a/client/transport/grpc/GrpcTransport.java index 903eda965..f709ed362 100644 --- a/client/transport/grpc/src/main/java/io/a2a/client/transport/grpc/GrpcTransport.java +++ b/client/transport/grpc/src/main/java/io/a2a/client/transport/grpc/GrpcTransport.java @@ -70,7 +70,7 @@ public EventKind sendMessage(MessageSendParams request, ClientCallContext contex throw new A2AClientException("Server response did not contain a message or task"); } } catch (StatusRuntimeException e) { - throw new A2AClientException("Failed to send message: " + e.getMessage(), e); + throw GrpcErrorMapper.mapGrpcError(e, "Failed to send message: "); } } @@ -85,7 +85,7 @@ public void sendMessageStreaming(MessageSendParams request, Consumer listTaskPushNotificationConfigurations( .map(FromProto::taskPushNotificationConfig) .collect(Collectors.toList()); } catch (StatusRuntimeException e) { - throw new A2AClientException("Failed to list task push notification configs: " + e.getMessage(), e); + throw GrpcErrorMapper.mapGrpcError(e, "Failed to list task push notification config: "); } } @@ -189,7 +189,7 @@ public void deleteTaskPushNotificationConfigurations(DeleteTaskPushNotificationC try { blockingStub.deleteTaskPushNotificationConfig(grpcRequest); } catch (StatusRuntimeException e) { - throw new A2AClientException("Failed to delete task push notification configs: " + e.getMessage(), e); + throw GrpcErrorMapper.mapGrpcError(e, "Failed to delete task push notification config: "); } } @@ -208,7 +208,7 @@ public void resubscribe(TaskIdParams request, Consumer event try { asyncStub.taskSubscription(grpcRequest, streamObserver); } catch (StatusRuntimeException e) { - throw new A2AClientException("Failed to resubscribe to task: " + e.getMessage(), e); + throw GrpcErrorMapper.mapGrpcError(e, "Failed to resubscribe task push notification config: "); } } @@ -246,6 +246,9 @@ private String getTaskPushNotificationConfigName(String taskId, String pushNotif name.append("/pushNotificationConfigs/"); name.append(pushNotificationConfigId); } + //name.append("/pushNotificationConfigs/"); + // Use taskId as default config ID if none provided + //name.append(pushNotificationConfigId != null ? pushNotificationConfigId : taskId); return name.toString(); } diff --git a/client/transport/grpc/src/main/java/io/a2a/client/transport/grpc/GrpcTransportConfig.java b/client/transport/grpc/src/main/java/io/a2a/client/transport/grpc/GrpcTransportConfig.java index 4b0fd1930..5717f59b7 100644 --- a/client/transport/grpc/src/main/java/io/a2a/client/transport/grpc/GrpcTransportConfig.java +++ b/client/transport/grpc/src/main/java/io/a2a/client/transport/grpc/GrpcTransportConfig.java @@ -1,17 +1,20 @@ package io.a2a.client.transport.grpc; +import java.util.function.Function; + import io.a2a.client.config.ClientTransportConfig; -import io.grpc.ManagedChannelBuilder; +import io.grpc.Channel; public class GrpcTransportConfig implements ClientTransportConfig { - private final ManagedChannelBuilder channelBuilder; + private final Function channelFactory; - public GrpcTransportConfig(ManagedChannelBuilder channelBuilder) { - this.channelBuilder = channelBuilder; + public GrpcTransportConfig(Function channelFactory) { + this.channelFactory = channelFactory; } - public ManagedChannelBuilder getManagedChannelBuilder() { - return channelBuilder; + public Function getChannelFactory() { + return channelFactory; } + } \ No newline at end of file diff --git a/client/transport/grpc/src/main/java/io/a2a/client/transport/grpc/GrpcTransportProvider.java b/client/transport/grpc/src/main/java/io/a2a/client/transport/grpc/GrpcTransportProvider.java index 0ee3d1d1e..18e66fdb6 100644 --- a/client/transport/grpc/src/main/java/io/a2a/client/transport/grpc/GrpcTransportProvider.java +++ b/client/transport/grpc/src/main/java/io/a2a/client/transport/grpc/GrpcTransportProvider.java @@ -21,18 +21,18 @@ public class GrpcTransportProvider implements ClientTransportProvider { public ClientTransport create(ClientConfig clientConfig, AgentCard agentCard, String agentUrl, List interceptors) { // not making use of the interceptors for gRPC for now - ManagedChannelBuilder managedChannelBuilder = null; + Channel channel; List clientTransportConfigs = clientConfig.getClientTransportConfigs(); if (clientTransportConfigs != null) { for (ClientTransportConfig clientTransportConfig : clientTransportConfigs) { if (clientTransportConfig instanceof GrpcTransportConfig grpcTransportConfig) { - managedChannelBuilder = grpcTransportConfig.getManagedChannelBuilder(); - break; + channel = grpcTransportConfig.getChannelFactory().apply(agentUrl); + return new GrpcTransport(channel, agentCard); } } } - Channel channel = managedChannelBuilder == null ? ManagedChannelBuilder.forTarget(agentUrl).build() - : managedChannelBuilder.forTarget(agentUrl).build(); + // no channel factory configured + channel = ManagedChannelBuilder.forTarget(agentUrl).build(); return new GrpcTransport(channel, agentCard); } diff --git a/client/transport/jsonrpc/pom.xml b/client/transport/jsonrpc/pom.xml index 8bcac1e01..3a1d5be8b 100644 --- a/client/transport/jsonrpc/pom.xml +++ b/client/transport/jsonrpc/pom.xml @@ -7,7 +7,7 @@ io.github.a2asdk a2a-java-sdk-parent - 0.2.6.Beta1-SNAPSHOT + 0.3.0.Beta1-SNAPSHOT ../../../pom.xml a2a-java-sdk-client-transport-jsonrpc diff --git a/client/transport/jsonrpc/src/main/java/io/a2a/client/transport/jsonrpc/JSONRPCTransport.java b/client/transport/jsonrpc/src/main/java/io/a2a/client/transport/jsonrpc/JSONRPCTransport.java index 034584cec..c22690274 100644 --- a/client/transport/jsonrpc/src/main/java/io/a2a/client/transport/jsonrpc/JSONRPCTransport.java +++ b/client/transport/jsonrpc/src/main/java/io/a2a/client/transport/jsonrpc/JSONRPCTransport.java @@ -26,6 +26,8 @@ import io.a2a.spec.DeleteTaskPushNotificationConfigParams; import io.a2a.spec.EventKind; +import io.a2a.spec.GetAuthenticatedExtendedCardRequest; +import io.a2a.spec.GetAuthenticatedExtendedCardResponse; import io.a2a.spec.GetTaskPushNotificationConfigParams; import io.a2a.spec.GetTaskPushNotificationConfigRequest; import io.a2a.spec.GetTaskPushNotificationConfigResponse; @@ -67,8 +69,7 @@ public class JSONRPCTransport implements ClientTransport { private static final TypeReference SET_TASK_PUSH_NOTIFICATION_CONFIG_RESPONSE_REFERENCE = new TypeReference<>() {}; private static final TypeReference LIST_TASK_PUSH_NOTIFICATION_CONFIG_RESPONSE_REFERENCE = new TypeReference<>() {}; private static final TypeReference DELETE_TASK_PUSH_NOTIFICATION_CONFIG_RESPONSE_REFERENCE = new TypeReference<>() {}; - // TODO: Uncomment once support for v0.3.0 has been merged - //private static final TypeReference GET_AUTHENTICATED_EXTENDED_CARD_RESPONSE_REFERENCE = new TypeReference<>() {}; + private static final TypeReference GET_AUTHENTICATED_EXTENDED_CARD_RESPONSE_REFERENCE = new TypeReference<>() {}; private final A2AHttpClient httpClient; private final String agentUrl; @@ -335,12 +336,8 @@ public AgentCard getAgentCard(ClientCallContext context) throws A2AClientExcepti if (!agentCard.supportsAuthenticatedExtendedCard()) { return agentCard; } - resolver = new A2ACardResolver(httpClient, agentUrl, "/agent/authenticatedExtendedCard", - getHttpHeaders(context)); - agentCard = resolver.getAgentCard(); - // TODO: Uncomment this code once support for v0.3.0 has been merged and remove the above 3 lines - /*GetAuthenticatedExtendedCardRequest getExtendedAgentCardRequest = new GetAuthenticatedExtendedCardRequest.Builder() + GetAuthenticatedExtendedCardRequest getExtendedAgentCardRequest = new GetAuthenticatedExtendedCardRequest.Builder() .jsonrpc(JSONRPCMessage.JSONRPC_VERSION) .method(GetAuthenticatedExtendedCardRequest.METHOD) .build(); // id will be randomly generated @@ -351,12 +348,11 @@ public AgentCard getAgentCard(ClientCallContext context) throws A2AClientExcepti try { String httpResponseBody = sendPostRequest(payloadAndHeaders); GetAuthenticatedExtendedCardResponse response = unmarshalResponse(httpResponseBody, - GET_TASK_PUSH_NOTIFICATION_CONFIG_RESPONSE_REFERENCE); + GET_AUTHENTICATED_EXTENDED_CARD_RESPONSE_REFERENCE); return response.getResult(); } catch (IOException | InterruptedException e) { throw new A2AClientException("Failed to get authenticated extended agent card: " + e, e); - }*/ - return agentCard; + } } catch(A2AClientError e){ throw new A2AClientException("Failed to get agent card: " + e, e); } diff --git a/client/transport/jsonrpc/src/test/java/io/a2a/client/transport/jsonrpc/JSONRPCTransportTest.java b/client/transport/jsonrpc/src/test/java/io/a2a/client/transport/jsonrpc/JSONRPCTransportTest.java index 5e2dcaa77..99e5ef151 100644 --- a/client/transport/jsonrpc/src/test/java/io/a2a/client/transport/jsonrpc/JSONRPCTransportTest.java +++ b/client/transport/jsonrpc/src/test/java/io/a2a/client/transport/jsonrpc/JSONRPCTransportTest.java @@ -5,6 +5,8 @@ import static io.a2a.client.transport.jsonrpc.JsonMessages.AUTHENTICATION_EXTENDED_AGENT_CARD; import static io.a2a.client.transport.jsonrpc.JsonMessages.CANCEL_TASK_TEST_REQUEST; import static io.a2a.client.transport.jsonrpc.JsonMessages.CANCEL_TASK_TEST_RESPONSE; +import static io.a2a.client.transport.jsonrpc.JsonMessages.GET_AUTHENTICATED_EXTENDED_AGENT_CARD_REQUEST; +import static io.a2a.client.transport.jsonrpc.JsonMessages.GET_AUTHENTICATED_EXTENDED_AGENT_CARD_RESPONSE; import static io.a2a.client.transport.jsonrpc.JsonMessages.GET_TASK_PUSH_NOTIFICATION_CONFIG_TEST_REQUEST; import static io.a2a.client.transport.jsonrpc.JsonMessages.GET_TASK_PUSH_NOTIFICATION_CONFIG_TEST_RESPONSE; import static io.a2a.client.transport.jsonrpc.JsonMessages.GET_TASK_TEST_REQUEST; @@ -39,6 +41,7 @@ import io.a2a.spec.A2AClientException; import io.a2a.spec.AgentCard; +import io.a2a.spec.AgentInterface; import io.a2a.spec.AgentSkill; import io.a2a.spec.Artifact; import io.a2a.spec.DataPart; @@ -47,6 +50,7 @@ import io.a2a.spec.FilePart; import io.a2a.spec.FileWithBytes; import io.a2a.spec.FileWithUri; +import io.a2a.spec.GetAuthenticatedExtendedCardResponse; import io.a2a.spec.GetTaskPushNotificationConfigParams; import io.a2a.spec.Message; import io.a2a.spec.MessageSendConfiguration; @@ -62,6 +66,7 @@ import io.a2a.spec.TaskQueryParams; import io.a2a.spec.TaskState; import io.a2a.spec.TextPart; +import io.a2a.spec.TransportProtocol; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; @@ -354,7 +359,7 @@ public void testA2AClientGetAgentCard() throws Exception { this.server.when( request() .withMethod("GET") - .withPath("/.well-known/agent.json") + .withPath("/.well-known/agent-card.json") ) .respond( response() @@ -415,7 +420,16 @@ public void testA2AClientGetAgentCard() throws Exception { assertEquals(outputModes, skills.get(1).outputModes()); assertFalse(agentCard.supportsAuthenticatedExtendedCard()); assertEquals("https://georoute-agent.example.com/icon.png", agentCard.iconUrl()); - assertEquals("0.2.5", agentCard.protocolVersion()); + assertEquals("0.2.9", agentCard.protocolVersion()); + assertEquals("JSONRPC", agentCard.preferredTransport()); + List additionalInterfaces = agentCard.additionalInterfaces(); + assertEquals(3, additionalInterfaces.size()); + AgentInterface jsonrpc = new AgentInterface(TransportProtocol.JSONRPC.asString(), "https://georoute-agent.example.com/a2a/v1"); + AgentInterface grpc = new AgentInterface(TransportProtocol.GRPC.asString(), "https://georoute-agent.example.com/a2a/grpc"); + AgentInterface httpJson = new AgentInterface(TransportProtocol.HTTP_JSON.asString(), "https://georoute-agent.example.com/a2a/json"); + assertEquals(jsonrpc, additionalInterfaces.get(0)); + assertEquals(grpc, additionalInterfaces.get(1)); + assertEquals(httpJson, additionalInterfaces.get(2)); } @Test @@ -423,7 +437,7 @@ public void testA2AClientGetAuthenticatedExtendedAgentCard() throws Exception { this.server.when( request() .withMethod("GET") - .withPath("/.well-known/agent.json") + .withPath("/.well-known/agent-card.json") ) .respond( response() @@ -432,13 +446,14 @@ public void testA2AClientGetAuthenticatedExtendedAgentCard() throws Exception { ); this.server.when( request() - .withMethod("GET") - .withPath("/agent/authenticatedExtendedCard") + .withMethod("POST") + .withPath("/") + .withBody(JsonBody.json(GET_AUTHENTICATED_EXTENDED_AGENT_CARD_REQUEST, MatchType.ONLY_MATCHING_FIELDS)) ) .respond( response() .withStatusCode(200) - .withBody(AUTHENTICATION_EXTENDED_AGENT_CARD) + .withBody(GET_AUTHENTICATED_EXTENDED_AGENT_CARD_RESPONSE) ); JSONRPCTransport client = new JSONRPCTransport("http://localhost:4001"); diff --git a/client/transport/jsonrpc/src/test/java/io/a2a/client/transport/jsonrpc/JsonMessages.java b/client/transport/jsonrpc/src/test/java/io/a2a/client/transport/jsonrpc/JsonMessages.java index b0a5fc111..59838012c 100644 --- a/client/transport/jsonrpc/src/test/java/io/a2a/client/transport/jsonrpc/JsonMessages.java +++ b/client/transport/jsonrpc/src/test/java/io/a2a/client/transport/jsonrpc/JsonMessages.java @@ -8,134 +8,80 @@ public class JsonMessages { static final String AGENT_CARD = """ { - "name": "GeoSpatial Route Planner Agent", - "description": "Provides advanced route planning, traffic analysis, and custom map generation services. This agent can calculate optimal routes, estimate travel times considering real-time traffic, and create personalized maps with points of interest.", - "url": "https://georoute-agent.example.com/a2a/v1", - "provider": { - "organization": "Example Geo Services Inc.", - "url": "https://www.examplegeoservices.com" - }, - "iconUrl": "https://georoute-agent.example.com/icon.png", - "version": "1.2.0", - "documentationUrl": "https://docs.examplegeoservices.com/georoute-agent/api", - "capabilities": { - "streaming": true, - "pushNotifications": true, - "stateTransitionHistory": false - }, - "securitySchemes": { - "google": { - "type": "openIdConnect", - "openIdConnectUrl": "https://accounts.google.com/.well-known/openid-configuration" - } - }, - "security": [{ "google": ["openid", "profile", "email"] }], - "defaultInputModes": ["application/json", "text/plain"], - "defaultOutputModes": ["application/json", "image/png"], - "skills": [ - { - "id": "route-optimizer-traffic", - "name": "Traffic-Aware Route Optimizer", - "description": "Calculates the optimal driving route between two or more locations, taking into account real-time traffic conditions, road closures, and user preferences (e.g., avoid tolls, prefer highways).", - "tags": ["maps", "routing", "navigation", "directions", "traffic"], - "examples": [ - "Plan a route from '1600 Amphitheatre Parkway, Mountain View, CA' to 'San Francisco International Airport' avoiding tolls.", - "{\\"origin\\": {\\"lat\\": 37.422, \\"lng\\": -122.084}, \\"destination\\": {\\"lat\\": 37.7749, \\"lng\\": -122.4194}, \\"preferences\\": [\\"avoid_ferries\\"]}" - ], - "inputModes": ["application/json", "text/plain"], - "outputModes": [ - "application/json", - "application/vnd.geo+json", - "text/html" - ] - }, - { - "id": "custom-map-generator", - "name": "Personalized Map Generator", - "description": "Creates custom map images or interactive map views based on user-defined points of interest, routes, and style preferences. Can overlay data layers.", - "tags": ["maps", "customization", "visualization", "cartography"], - "examples": [ - "Generate a map of my upcoming road trip with all planned stops highlighted.", - "Show me a map visualizing all coffee shops within a 1-mile radius of my current location." - ], - "inputModes": ["application/json"], - "outputModes": [ - "image/png", - "image/jpeg", - "application/json", - "text/html" - ] - } - ], - "supportsAuthenticatedExtendedCard": false, - "protocolVersion": "0.2.5" - }"""; - - static final String AGENT_CARD_SUPPORTS_EXTENDED = """ - { - "name": "GeoSpatial Route Planner Agent", - "description": "Provides advanced route planning, traffic analysis, and custom map generation services. This agent can calculate optimal routes, estimate travel times considering real-time traffic, and create personalized maps with points of interest.", - "url": "https://georoute-agent.example.com/a2a/v1", - "provider": { - "organization": "Example Geo Services Inc.", - "url": "https://www.examplegeoservices.com" - }, - "iconUrl": "https://georoute-agent.example.com/icon.png", - "version": "1.2.0", - "documentationUrl": "https://docs.examplegeoservices.com/georoute-agent/api", - "capabilities": { - "streaming": true, - "pushNotifications": true, - "stateTransitionHistory": false - }, - "securitySchemes": { - "google": { - "type": "openIdConnect", - "openIdConnectUrl": "https://accounts.google.com/.well-known/openid-configuration" - } - }, - "security": [{ "google": ["openid", "profile", "email"] }], - "defaultInputModes": ["application/json", "text/plain"], - "defaultOutputModes": ["application/json", "image/png"], - "skills": [ - { - "id": "route-optimizer-traffic", - "name": "Traffic-Aware Route Optimizer", - "description": "Calculates the optimal driving route between two or more locations, taking into account real-time traffic conditions, road closures, and user preferences (e.g., avoid tolls, prefer highways).", - "tags": ["maps", "routing", "navigation", "directions", "traffic"], - "examples": [ - "Plan a route from '1600 Amphitheatre Parkway, Mountain View, CA' to 'San Francisco International Airport' avoiding tolls.", - "{\\"origin\\": {\\"lat\\": 37.422, \\"lng\\": -122.084}, \\"destination\\": {\\"lat\\": 37.7749, \\"lng\\": -122.4194}, \\"preferences\\": [\\"avoid_ferries\\"]}" - ], - "inputModes": ["application/json", "text/plain"], - "outputModes": [ - "application/json", - "application/vnd.geo+json", - "text/html" - ] - }, - { - "id": "custom-map-generator", - "name": "Personalized Map Generator", - "description": "Creates custom map images or interactive map views based on user-defined points of interest, routes, and style preferences. Can overlay data layers.", - "tags": ["maps", "customization", "visualization", "cartography"], - "examples": [ - "Generate a map of my upcoming road trip with all planned stops highlighted.", - "Show me a map visualizing all coffee shops within a 1-mile radius of my current location." - ], - "inputModes": ["application/json"], - "outputModes": [ - "image/png", - "image/jpeg", - "application/json", - "text/html" - ] - } - ], - "supportsAuthenticatedExtendedCard": true, - "protocolVersion": "0.2.5" - }"""; - + "protocolVersion": "0.2.9", + "name": "GeoSpatial Route Planner Agent", + "description": "Provides advanced route planning, traffic analysis, and custom map generation services. This agent can calculate optimal routes, estimate travel times considering real-time traffic, and create personalized maps with points of interest.", + "url": "https://georoute-agent.example.com/a2a/v1", + "preferredTransport": "JSONRPC", + "additionalInterfaces" : [ + {"url": "https://georoute-agent.example.com/a2a/v1", "transport": "JSONRPC"}, + {"url": "https://georoute-agent.example.com/a2a/grpc", "transport": "GRPC"}, + {"url": "https://georoute-agent.example.com/a2a/json", "transport": "HTTP+JSON"} + ], + "provider": { + "organization": "Example Geo Services Inc.", + "url": "https://www.examplegeoservices.com" + }, + "iconUrl": "https://georoute-agent.example.com/icon.png", + "version": "1.2.0", + "documentationUrl": "https://docs.examplegeoservices.com/georoute-agent/api", + "capabilities": { + "streaming": true, + "pushNotifications": true, + "stateTransitionHistory": false + }, + "securitySchemes": { + "google": { + "type": "openIdConnect", + "openIdConnectUrl": "https://accounts.google.com/.well-known/openid-configuration" + } + }, + "security": [{ "google": ["openid", "profile", "email"] }], + "defaultInputModes": ["application/json", "text/plain"], + "defaultOutputModes": ["application/json", "image/png"], + "skills": [ + { + "id": "route-optimizer-traffic", + "name": "Traffic-Aware Route Optimizer", + "description": "Calculates the optimal driving route between two or more locations, taking into account real-time traffic conditions, road closures, and user preferences (e.g., avoid tolls, prefer highways).", + "tags": ["maps", "routing", "navigation", "directions", "traffic"], + "examples": [ + "Plan a route from '1600 Amphitheatre Parkway, Mountain View, CA' to 'San Francisco International Airport' avoiding tolls.", + "{\\"origin\\": {\\"lat\\": 37.422, \\"lng\\": -122.084}, \\"destination\\": {\\"lat\\": 37.7749, \\"lng\\": -122.4194}, \\"preferences\\": [\\"avoid_ferries\\"]}" + ], + "inputModes": ["application/json", "text/plain"], + "outputModes": [ + "application/json", + "application/vnd.geo+json", + "text/html" + ] + }, + { + "id": "custom-map-generator", + "name": "Personalized Map Generator", + "description": "Creates custom map images or interactive map views based on user-defined points of interest, routes, and style preferences. Can overlay data layers.", + "tags": ["maps", "customization", "visualization", "cartography"], + "examples": [ + "Generate a map of my upcoming road trip with all planned stops highlighted.", + "Show me a map visualizing all coffee shops within a 1-mile radius of my current location." + ], + "inputModes": ["application/json"], + "outputModes": [ + "image/png", + "image/jpeg", + "application/json", + "text/html" + ] + } + ], + "supportsAuthenticatedExtendedCard": false, + "signatures": [ + { + "protected": "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpPU0UiLCJraWQiOiJrZXktMSIsImprdSI6Imh0dHBzOi8vZXhhbXBsZS5jb20vYWdlbnQvandrcy5qc29uIn0", + "signature": "QFdkNLNszlGj3z3u0YQGt_T9LixY3qtdQpZmsTdDHDe3fXV9y9-B3m2-XgCpzuhiLt8E0tV6HXoZKHv4GtHgKQ" + } + ] + }"""; static final String AUTHENTICATION_EXTENDED_AGENT_CARD = """ { @@ -205,10 +151,15 @@ public class JsonMessages { } ], "supportsAuthenticatedExtendedCard": true, - "protocolVersion": "0.2.5" + "protocolVersion": "0.2.9", + "signatures": [ + { + "protected": "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpPU0UiLCJraWQiOiJrZXktMSIsImprdSI6Imh0dHBzOi8vZXhhbXBsZS5jb20vYWdlbnQvandrcy5qc29uIn0", + "signature": "QFdkNLNszlGj3z3u0YQGt_T9LixY3qtdQpZmsTdDHDe3fXV9y9-B3m2-XgCpzuhiLt8E0tV6HXoZKHv4GtHgKQ" + } + ] }"""; - static final String SEND_MESSAGE_TEST_REQUEST = """ { "jsonrpc": "2.0", @@ -663,4 +614,156 @@ public class JsonMessages { } }"""; + static final String GET_AUTHENTICATED_EXTENDED_AGENT_CARD_REQUEST = """ + { + "jsonrpc": "2.0", + "method": "agent/getAuthenticatedExtendedCard" + } + """; + + static final String GET_AUTHENTICATED_EXTENDED_AGENT_CARD_RESPONSE = """ + { + "jsonrpc": "2.0", + "id": "1", + "result": { + "name": "GeoSpatial Route Planner Agent Extended", + "description": "Extended description", + "url": "https://georoute-agent.example.com/a2a/v1", + "provider": { + "organization": "Example Geo Services Inc.", + "url": "https://www.examplegeoservices.com" + }, + "iconUrl": "https://georoute-agent.example.com/icon.png", + "version": "1.2.0", + "documentationUrl": "https://docs.examplegeoservices.com/georoute-agent/api", + "capabilities": { + "streaming": true, + "pushNotifications": true, + "stateTransitionHistory": false + }, + "securitySchemes": { + "google": { + "type": "openIdConnect", + "openIdConnectUrl": "https://accounts.google.com/.well-known/openid-configuration" + } + }, + "security": [{ "google": ["openid", "profile", "email"] }], + "defaultInputModes": ["application/json", "text/plain"], + "defaultOutputModes": ["application/json", "image/png"], + "skills": [ + { + "id": "route-optimizer-traffic", + "name": "Traffic-Aware Route Optimizer", + "description": "Calculates the optimal driving route between two or more locations, taking into account real-time traffic conditions, road closures, and user preferences (e.g., avoid tolls, prefer highways).", + "tags": ["maps", "routing", "navigation", "directions", "traffic"], + "examples": [ + "Plan a route from '1600 Amphitheatre Parkway, Mountain View, CA' to 'San Francisco International Airport' avoiding tolls.", + "{\\"origin\\": {\\"lat\\": 37.422, \\"lng\\": -122.084}, \\"destination\\": {\\"lat\\": 37.7749, \\"lng\\": -122.4194}, \\"preferences\\": [\\"avoid_ferries\\"]}" + ], + "inputModes": ["application/json", "text/plain"], + "outputModes": [ + "application/json", + "application/vnd.geo+json", + "text/html" + ] + }, + { + "id": "custom-map-generator", + "name": "Personalized Map Generator", + "description": "Creates custom map images or interactive map views based on user-defined points of interest, routes, and style preferences. Can overlay data layers.", + "tags": ["maps", "customization", "visualization", "cartography"], + "examples": [ + "Generate a map of my upcoming road trip with all planned stops highlighted.", + "Show me a map visualizing all coffee shops within a 1-mile radius of my current location." + ], + "inputModes": ["application/json"], + "outputModes": [ + "image/png", + "image/jpeg", + "application/json", + "text/html" + ] + }, + { + "id": "skill-extended", + "name": "Extended Skill", + "description": "This is an extended skill.", + "tags": ["extended"] + } + ], + "supportsAuthenticatedExtendedCard": true, + "protocolVersion": "0.2.5", + "signatures": [ + { + "protected": "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpPU0UiLCJraWQiOiJrZXktMSIsImprdUI6Imh0dHBzOi8vZXhhbXBsZS5jb20vYWdlbnQvandrcy5qc29uIn0", + "signature": "QFdkNLNszlGj3z3u0YQGt_T9LixY3qtdQpZmsTdDHDe3fXV9y9-B3m2-XgCpzuhiLt8E0tV6HXoZKHv4GtHgKQ" + } + ] + } + }"""; + + static final String AGENT_CARD_SUPPORTS_EXTENDED = """ + { + "name": "GeoSpatial Route Planner Agent", + "description": "Provides advanced route planning, traffic analysis, and custom map generation services. This agent can calculate optimal routes, estimate travel times considering real-time traffic, and create personalized maps with points of interest.", + "url": "https://georoute-agent.example.com/a2a/v1", + "provider": { + "organization": "Example Geo Services Inc.", + "url": "https://www.examplegeoservices.com" + }, + "iconUrl": "https://georoute-agent.example.com/icon.png", + "version": "1.2.0", + "documentationUrl": "https://docs.examplegeoservices.com/georoute-agent/api", + "capabilities": { + "streaming": true, + "pushNotifications": true, + "stateTransitionHistory": false + }, + "securitySchemes": { + "google": { + "type": "openIdConnect", + "openIdConnectUrl": "https://accounts.google.com/.well-known/openid-configuration" + } + }, + "security": [{ "google": ["openid", "profile", "email"] }], + "defaultInputModes": ["application/json", "text/plain"], + "defaultOutputModes": ["application/json", "image/png"], + "skills": [ + { + "id": "route-optimizer-traffic", + "name": "Traffic-Aware Route Optimizer", + "description": "Calculates the optimal driving route between two or more locations, taking into account real-time traffic conditions, road closures, and user preferences (e.g., avoid tolls, prefer highways).", + "tags": ["maps", "routing", "navigation", "directions", "traffic"], + "examples": [ + "Plan a route from '1600 Amphitheatre Parkway, Mountain View, CA' to 'San Francisco International Airport' avoiding tolls.", + "{\\"origin\\": {\\"lat\\": 37.422, \\"lng\\": -122.084}, \\"destination\\": {\\"lat\\": 37.7749, \\"lng\\": -122.4194}, \\"preferences\\": [\\"avoid_ferries\\"]}" + ], + "inputModes": ["application/json", "text/plain"], + "outputModes": [ + "application/json", + "application/vnd.geo+json", + "text/html" + ] + }, + { + "id": "custom-map-generator", + "name": "Personalized Map Generator", + "description": "Creates custom map images or interactive map views based on user-defined points of interest, routes, and style preferences. Can overlay data layers.", + "tags": ["maps", "customization", "visualization", "cartography"], + "examples": [ + "Generate a map of my upcoming road trip with all planned stops highlighted.", + "Show me a map visualizing all coffee shops within a 1-mile radius of my current location." + ], + "inputModes": ["application/json"], + "outputModes": [ + "image/png", + "image/jpeg", + "application/json", + "text/html" + ] + } + ], + "supportsAuthenticatedExtendedCard": true, + "protocolVersion": "0.2.5" + }"""; } diff --git a/client/transport/spi/pom.xml b/client/transport/spi/pom.xml index d05c088a6..a809c6dea 100644 --- a/client/transport/spi/pom.xml +++ b/client/transport/spi/pom.xml @@ -7,7 +7,7 @@ io.github.a2asdk a2a-java-sdk-parent - 0.2.6.Beta1-SNAPSHOT + 0.3.0.Beta1-SNAPSHOT ../../../pom.xml a2a-java-sdk-client-transport-spi diff --git a/http-client/pom.xml b/http-client/pom.xml index 8ec988aa2..f331fba11 100644 --- a/http-client/pom.xml +++ b/http-client/pom.xml @@ -7,7 +7,7 @@ io.github.a2asdk a2a-java-sdk-parent - 0.2.6.Beta1-SNAPSHOT + 0.3.0.Beta1-SNAPSHOT a2a-java-sdk-http-client diff --git a/reference/grpc/pom.xml b/reference/grpc/pom.xml index 61d44ca8a..6a4ec4618 100644 --- a/reference/grpc/pom.xml +++ b/reference/grpc/pom.xml @@ -43,6 +43,12 @@ test ${project.version} + + ${project.groupId} + a2a-java-sdk-client-transport-grpc + ${project.version} + test + io.quarkus @@ -79,6 +85,11 @@ io.grpc grpc-stub + + io.rest-assured + rest-assured + test + \ No newline at end of file diff --git a/reference/grpc/src/test/java/io/a2a/server/grpc/quarkus/QuarkusA2AGrpcTest.java b/reference/grpc/src/test/java/io/a2a/server/grpc/quarkus/QuarkusA2AGrpcTest.java index 30fb9c71c..1a91eebde 100644 --- a/reference/grpc/src/test/java/io/a2a/server/grpc/quarkus/QuarkusA2AGrpcTest.java +++ b/reference/grpc/src/test/java/io/a2a/server/grpc/quarkus/QuarkusA2AGrpcTest.java @@ -1,1245 +1,23 @@ package io.a2a.server.grpc.quarkus; -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 java.io.IOException; -import java.net.URI; -import java.net.http.HttpClient; -import java.net.http.HttpRequest; -import java.net.http.HttpResponse; -import java.nio.charset.StandardCharsets; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.CountDownLatch; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicInteger; -import java.util.concurrent.atomic.AtomicReference; - -import io.a2a.grpc.A2AServiceGrpc; -import io.a2a.grpc.CancelTaskRequest; -import io.a2a.grpc.CreateTaskPushNotificationConfigRequest; -import io.a2a.grpc.DeleteTaskPushNotificationConfigRequest; -import io.a2a.grpc.GetTaskPushNotificationConfigRequest; -import io.a2a.grpc.GetTaskRequest; -import io.a2a.grpc.ListTaskPushNotificationConfigRequest; -import io.a2a.grpc.ListTaskPushNotificationConfigResponse; -import io.a2a.grpc.SendMessageRequest; -import io.a2a.grpc.SendMessageResponse; -import io.a2a.grpc.StreamResponse; -import io.a2a.grpc.TaskSubscriptionRequest; -import io.a2a.grpc.utils.ProtoUtils; -import io.a2a.grpc.GetAgentCardRequest; -import io.a2a.spec.AgentCard; -import io.a2a.spec.Artifact; -import io.a2a.spec.Event; -import io.a2a.spec.Message; -import io.a2a.spec.Part; -import io.a2a.spec.PushNotificationConfig; -import io.a2a.spec.Task; -import io.a2a.spec.TaskPushNotificationConfig; -import io.a2a.spec.TaskArtifactUpdateEvent; -import io.a2a.spec.TaskState; -import io.a2a.spec.TaskStatus; -import io.a2a.spec.TaskStatusUpdateEvent; -import io.a2a.spec.TextPart; -import io.a2a.util.Utils; -import io.grpc.Status; -import io.grpc.StatusRuntimeException; -import io.quarkus.grpc.GrpcClient; +import io.a2a.server.apps.common.AbstractA2AServerTest; +import io.a2a.spec.TransportProtocol; import io.quarkus.test.junit.QuarkusTest; -import org.junit.jupiter.api.Test; @QuarkusTest -public class QuarkusA2AGrpcTest { - - - private static final Task MINIMAL_TASK = new Task.Builder() - .id("task-123") - .contextId("session-xyz") - .status(new TaskStatus(TaskState.SUBMITTED)) - .build(); - - private static final Task CANCEL_TASK = new Task.Builder() - .id("cancel-task-123") - .contextId("session-xyz") - .status(new TaskStatus(TaskState.SUBMITTED)) - .build(); - - private static final Task CANCEL_TASK_NOT_SUPPORTED = new Task.Builder() - .id("cancel-task-not-supported-123") - .contextId("session-xyz") - .status(new TaskStatus(TaskState.SUBMITTED)) - .build(); - - private static final Task SEND_MESSAGE_NOT_SUPPORTED = new Task.Builder() - .id("task-not-supported-123") - .contextId("session-xyz") - .status(new TaskStatus(TaskState.SUBMITTED)) - .build(); - - private static final Message MESSAGE = new Message.Builder() - .messageId("111") - .role(Message.Role.AGENT) - .parts(new TextPart("test message")) - .build(); - public static final String APPLICATION_JSON = "application/json"; - - @GrpcClient("a2a-service") - A2AServiceGrpc.A2AServiceBlockingStub client; - - private final int serverPort = 8081; - @Test - public void testTaskStoreMethodsSanityTest() throws Exception { - Task task = new Task.Builder(MINIMAL_TASK).id("abcde").build(); - saveTaskInTaskStore(task); - Task saved = getTaskFromTaskStore(task.getId()); - assertEquals(task.getId(), saved.getId()); - assertEquals(task.getContextId(), saved.getContextId()); - assertEquals(task.getStatus().state(), saved.getStatus().state()); - - deleteTaskInTaskStore(task.getId()); - Task saved2 = getTaskFromTaskStore(task.getId()); - assertNull(saved2); - } - - @Test - public void testGetTaskSuccess() throws Exception { - saveTaskInTaskStore(MINIMAL_TASK); - try { - GetTaskRequest request = GetTaskRequest.newBuilder() - .setName("tasks/" + MINIMAL_TASK.getId()) - .build(); - io.a2a.grpc.Task response = client.getTask(request); - assertEquals("task-123", response.getId()); - assertEquals("session-xyz", response.getContextId()); - assertEquals(io.a2a.grpc.TaskState.TASK_STATE_SUBMITTED, response.getStatus().getState()); - } finally { - deleteTaskInTaskStore(MINIMAL_TASK.getId()); - } - } - - @Test - public void testGetTaskNotFound() throws Exception { - assertTrue(getTaskFromTaskStore("non-existent-task") == null); - GetTaskRequest request = GetTaskRequest.newBuilder() - .setName("tasks/non-existent-task") - .build(); - try { - client.getTask(request); - // Should not reach here - assertTrue(false, "Expected StatusRuntimeException but method returned normally"); - } catch (StatusRuntimeException e) { - assertEquals(Status.NOT_FOUND.getCode(), e.getStatus().getCode()); - String description = e.getStatus().getDescription(); - assertTrue(description != null && description.contains("TaskNotFoundError")); - } - } - - @Test - public void testCancelTaskSuccess() throws Exception { - saveTaskInTaskStore(CANCEL_TASK); - try { - CancelTaskRequest request = CancelTaskRequest.newBuilder() - .setName("tasks/" + CANCEL_TASK.getId()) - .build(); - io.a2a.grpc.Task response = client.cancelTask(request); - assertEquals(CANCEL_TASK.getId(), response.getId()); - assertEquals(CANCEL_TASK.getContextId(), response.getContextId()); - assertEquals(io.a2a.grpc.TaskState.TASK_STATE_CANCELLED, response.getStatus().getState()); - } finally { - deleteTaskInTaskStore(CANCEL_TASK.getId()); - } - } - - @Test - public void testCancelTaskNotFound() throws Exception { - CancelTaskRequest request = CancelTaskRequest.newBuilder() - .setName("tasks/non-existent-task") - .build(); - try { - client.cancelTask(request); - // Should not reach here - assertTrue(false, "Expected StatusRuntimeException but method returned normally"); - } catch (StatusRuntimeException e) { - assertEquals(Status.NOT_FOUND.getCode(), e.getStatus().getCode()); - String description = e.getStatus().getDescription(); - assertTrue(description != null && description.contains("TaskNotFoundError")); - } - } - - @Test - public void testCancelTaskNotSupported() throws Exception { - saveTaskInTaskStore(CANCEL_TASK_NOT_SUPPORTED); - try { - CancelTaskRequest request = CancelTaskRequest.newBuilder() - .setName("tasks/" + CANCEL_TASK_NOT_SUPPORTED.getId()) - .build(); - try { - client.cancelTask(request); - // Should not reach here - assertTrue(false, "Expected StatusRuntimeException but method returned normally"); - } catch (StatusRuntimeException e) { - assertEquals(Status.UNIMPLEMENTED.getCode(), e.getStatus().getCode()); - String description = e.getStatus().getDescription(); - assertTrue(description != null && description.contains("UnsupportedOperationError")); - } - } finally { - deleteTaskInTaskStore(CANCEL_TASK_NOT_SUPPORTED.getId()); - } - } - - @Test - public void testSendMessageNewMessageSuccess() throws Exception { - assertTrue(getTaskFromTaskStore(MINIMAL_TASK.getId()) == null); - Message message = new Message.Builder(MESSAGE) - .taskId(MINIMAL_TASK.getId()) - .contextId(MINIMAL_TASK.getContextId()) - .build(); - SendMessageRequest request = SendMessageRequest.newBuilder() - .setRequest(ProtoUtils.ToProto.message(message)) - .build(); - SendMessageResponse response = client.sendMessage(request); - assertTrue(response.hasMsg()); - io.a2a.grpc.Message grpcMessage = response.getMsg(); - // Convert back to spec Message for easier assertions - Message messageResponse = ProtoUtils.FromProto.message(grpcMessage); - assertEquals(MESSAGE.getMessageId(), messageResponse.getMessageId()); - assertEquals(MESSAGE.getRole(), messageResponse.getRole()); - Part part = messageResponse.getParts().get(0); - assertEquals(Part.Kind.TEXT, part.getKind()); - assertEquals("test message", ((TextPart) part).getText()); - } - - @Test - public void testSendMessageExistingTaskSuccess() throws Exception { - saveTaskInTaskStore(MINIMAL_TASK); - try { - Message message = new Message.Builder(MESSAGE) - .taskId(MINIMAL_TASK.getId()) - .contextId(MINIMAL_TASK.getContextId()) - .build(); - - SendMessageRequest request = SendMessageRequest.newBuilder() - .setRequest(ProtoUtils.ToProto.message(message)) - .build(); - SendMessageResponse response = client.sendMessage(request); - - assertTrue(response.hasMsg()); - io.a2a.grpc.Message grpcMessage = response.getMsg(); - // Convert back to spec Message for easier assertions - Message messageResponse = ProtoUtils.FromProto.message(grpcMessage); - assertEquals(MESSAGE.getMessageId(), messageResponse.getMessageId()); - assertEquals(MESSAGE.getRole(), messageResponse.getRole()); - Part part = messageResponse.getParts().get(0); - assertEquals(Part.Kind.TEXT, part.getKind()); - assertEquals("test message", ((TextPart) part).getText()); - } finally { - deleteTaskInTaskStore(MINIMAL_TASK.getId()); - } - } - - @Test - public void testError() throws Exception { - Message message = new Message.Builder(MESSAGE) - .taskId(SEND_MESSAGE_NOT_SUPPORTED.getId()) - .contextId(SEND_MESSAGE_NOT_SUPPORTED.getContextId()) - .build(); - - SendMessageRequest request = SendMessageRequest.newBuilder() - .setRequest(ProtoUtils.ToProto.message(message)) - .build(); - - try { - client.sendMessage(request); - // Should not reach here - assertTrue(false, "Expected StatusRuntimeException but method returned normally"); - } catch (StatusRuntimeException e) { - assertEquals(Status.UNIMPLEMENTED.getCode(), e.getStatus().getCode()); - String description = e.getStatus().getDescription(); - assertTrue(description != null && description.contains("UnsupportedOperationError")); - } - } - - @Test - public void testGetAgentCard() throws Exception { - // Test gRPC getAgentCard method - GetAgentCardRequest request = GetAgentCardRequest.newBuilder().build(); - - io.a2a.grpc.AgentCard grpcAgentCard = client.getAgentCard(request); - - // Verify the expected agent card fields directly on the gRPC response - assertNotNull(grpcAgentCard); - assertEquals("test-card", grpcAgentCard.getName()); - assertEquals("A test agent card", grpcAgentCard.getDescription()); - assertEquals("http://localhost:" + serverPort, grpcAgentCard.getUrl()); // Use dynamic port - assertEquals("1.0", grpcAgentCard.getVersion()); - assertEquals("http://example.com/docs", grpcAgentCard.getDocumentationUrl()); - assertTrue(grpcAgentCard.getCapabilities().getPushNotifications()); - assertTrue(grpcAgentCard.getCapabilities().getStreaming()); - // Note: stateTransitionHistory is not present in gRPC AgentCapabilities - assertTrue(grpcAgentCard.getSkillsList().isEmpty()); - } - - @Test - public void testGetExtendAgentCardNotSupported() { - // NOTE: This test is not applicable to gRPC since extended agent card retrieval - // is an HTTP/REST-specific feature that tests the /agent/authenticatedExtendedCard endpoint. - // gRPC handles agent capabilities differently through service definitions. - - // This stub is maintained to preserve method order compatibility with AbstractA2AServerTest - // for future migration when extending that base class. - } - - @Test - public void testMalformedJSONRPCRequest() { - // NOTE: This test is not applicable to gRPC since it tests JSON-RPC protocol-specific - // JSON parsing errors. gRPC uses Protocol Buffers for serialization and has its own - // parsing and validation mechanisms. - - // This stub is maintained to preserve method order compatibility with AbstractA2AServerTest - // for future migration when extending that base class. - } - - @Test - public void testInvalidParamsJSONRPCRequest() { - // NOTE: This test is not applicable to gRPC since it tests JSON-RPC protocol-specific - // parameter validation errors. gRPC uses strongly-typed Protocol Buffer messages - // which provide built-in type safety and validation. - - // This stub is maintained to preserve method order compatibility with AbstractA2AServerTest - // for future migration when extending that base class. - } - - @Test - public void testInvalidJSONRPCRequestMissingJsonrpc() { - // NOTE: This test is not applicable to gRPC since it tests JSON-RPC protocol-specific - // validation of the "jsonrpc" field. gRPC does not use JSON-RPC protocol elements. - - // This stub is maintained to preserve method order compatibility with AbstractA2AServerTest - // for future migration when extending that base class. - } - - @Test - public void testInvalidJSONRPCRequestMissingMethod() { - // NOTE: This test is not applicable to gRPC since it tests JSON-RPC protocol-specific - // validation of the "method" field. gRPC methods are defined in the service definition - // and invoked directly, not through JSON-RPC method names. - - // This stub is maintained to preserve method order compatibility with AbstractA2AServerTest - // for future migration when extending that base class. - } - - @Test - public void testInvalidJSONRPCRequestInvalidId() { - // NOTE: This test is not applicable to gRPC since it tests JSON-RPC protocol-specific - // validation of the "id" field. gRPC handles request/response correlation differently - // through its streaming mechanisms. - - // This stub is maintained to preserve method order compatibility with AbstractA2AServerTest - // for future migration when extending that base class. - } - - @Test - public void testInvalidJSONRPCRequestNonExistentMethod() { - // NOTE: This test is not applicable to gRPC since it tests JSON-RPC protocol-specific - // method not found errors. gRPC method resolution is handled at the service definition - // level and unknown methods result in different error types. - - // This stub is maintained to preserve method order compatibility with AbstractA2AServerTest - // for future migration when extending that base class. - } - - @Test - public void testNonStreamingMethodWithAcceptHeader() throws Exception { - // NOTE: This test is not applicable to gRPC since HTTP Accept headers - // are an HTTP/REST-specific concept and do not apply to gRPC protocol. - // gRPC uses Protocol Buffers for message encoding and doesn't use HTTP content negotiation. - - // This stub is maintained to preserve method order compatibility with AbstractA2AServerTest - // for future migration when extending that base class. - } - - @Test - public void testSetPushNotificationSuccess() throws Exception { - saveTaskInTaskStore(MINIMAL_TASK); - try { - // Create a PushNotificationConfig with an ID (needed for gRPC conversion) - PushNotificationConfig pushConfig = new PushNotificationConfig.Builder() - .url("http://example.com") - .id(MINIMAL_TASK.getId()) // Using task ID as config ID for simplicity - .build(); - TaskPushNotificationConfig taskPushConfig = - new TaskPushNotificationConfig(MINIMAL_TASK.getId(), pushConfig); - - CreateTaskPushNotificationConfigRequest request = CreateTaskPushNotificationConfigRequest.newBuilder() - .setParent("tasks/" + MINIMAL_TASK.getId()) - .setConfigId(MINIMAL_TASK.getId()) - .setConfig(ProtoUtils.ToProto.taskPushNotificationConfig(taskPushConfig)) - .build(); - - io.a2a.grpc.TaskPushNotificationConfig response = client.createTaskPushNotificationConfig(request); - - // Convert back to spec for easier assertions - TaskPushNotificationConfig responseConfig = ProtoUtils.FromProto.taskPushNotificationConfig(response); - assertEquals(MINIMAL_TASK.getId(), responseConfig.taskId()); - assertEquals("http://example.com", responseConfig.pushNotificationConfig().url()); - } finally { - deletePushNotificationConfigInStore(MINIMAL_TASK.getId(), MINIMAL_TASK.getId()); - deleteTaskInTaskStore(MINIMAL_TASK.getId()); - } - } - - @Test - public void testGetPushNotificationSuccess() throws Exception { - saveTaskInTaskStore(MINIMAL_TASK); - try { - // First, create a push notification config (same as previous test) - PushNotificationConfig pushConfig = new PushNotificationConfig.Builder() - .url("http://example.com") - .id(MINIMAL_TASK.getId()) - .build(); - TaskPushNotificationConfig taskPushConfig = - new TaskPushNotificationConfig(MINIMAL_TASK.getId(), pushConfig); - - CreateTaskPushNotificationConfigRequest createRequest = CreateTaskPushNotificationConfigRequest.newBuilder() - .setParent("tasks/" + MINIMAL_TASK.getId()) - .setConfigId(MINIMAL_TASK.getId()) - .setConfig(ProtoUtils.ToProto.taskPushNotificationConfig(taskPushConfig)) - .build(); - - io.a2a.grpc.TaskPushNotificationConfig createResponse = client.createTaskPushNotificationConfig(createRequest); - assertNotNull(createResponse); - - // Now, get the push notification config - GetTaskPushNotificationConfigRequest getRequest = GetTaskPushNotificationConfigRequest.newBuilder() - .setName("tasks/" + MINIMAL_TASK.getId() + "/pushNotificationConfigs/" + MINIMAL_TASK.getId()) - .build(); - - io.a2a.grpc.TaskPushNotificationConfig getResponse = client.getTaskPushNotificationConfig(getRequest); - - // Convert back to spec for easier assertions - TaskPushNotificationConfig responseConfig = ProtoUtils.FromProto.taskPushNotificationConfig(getResponse); - assertEquals(MINIMAL_TASK.getId(), responseConfig.taskId()); - assertEquals("http://example.com", responseConfig.pushNotificationConfig().url()); - } finally { - deletePushNotificationConfigInStore(MINIMAL_TASK.getId(), MINIMAL_TASK.getId()); - deleteTaskInTaskStore(MINIMAL_TASK.getId()); - } - } - - @Test - public void testListPushNotificationConfigWithConfigId() throws Exception { - saveTaskInTaskStore(MINIMAL_TASK); - try { - // Create first push notification config - PushNotificationConfig pushConfig1 = new PushNotificationConfig.Builder() - .url("http://example.com") - .id("config1") - .build(); - TaskPushNotificationConfig taskPushConfig1 = - new TaskPushNotificationConfig(MINIMAL_TASK.getId(), pushConfig1); - - CreateTaskPushNotificationConfigRequest createRequest1 = CreateTaskPushNotificationConfigRequest.newBuilder() - .setParent("tasks/" + MINIMAL_TASK.getId()) - .setConfigId("config1") - .setConfig(ProtoUtils.ToProto.taskPushNotificationConfig(taskPushConfig1)) - .build(); - client.createTaskPushNotificationConfig(createRequest1); - - // Create second push notification config - PushNotificationConfig pushConfig2 = new PushNotificationConfig.Builder() - .url("http://example.com") - .id("config2") - .build(); - TaskPushNotificationConfig taskPushConfig2 = - new TaskPushNotificationConfig(MINIMAL_TASK.getId(), pushConfig2); - - CreateTaskPushNotificationConfigRequest createRequest2 = CreateTaskPushNotificationConfigRequest.newBuilder() - .setParent("tasks/" + MINIMAL_TASK.getId()) - .setConfigId("config2") - .setConfig(ProtoUtils.ToProto.taskPushNotificationConfig(taskPushConfig2)) - .build(); - client.createTaskPushNotificationConfig(createRequest2); - - // Now, list all push notification configs for the task - ListTaskPushNotificationConfigRequest listRequest = ListTaskPushNotificationConfigRequest.newBuilder() - .setParent("tasks/" + MINIMAL_TASK.getId()) - .build(); - - ListTaskPushNotificationConfigResponse listResponse = client.listTaskPushNotificationConfig(listRequest); - - // Verify the response - assertEquals(2, listResponse.getConfigsCount()); - - // Convert back to spec for easier assertions - TaskPushNotificationConfig config1 = ProtoUtils.FromProto.taskPushNotificationConfig(listResponse.getConfigs(0)); - TaskPushNotificationConfig config2 = ProtoUtils.FromProto.taskPushNotificationConfig(listResponse.getConfigs(1)); - - assertEquals(MINIMAL_TASK.getId(), config1.taskId()); - assertEquals("http://example.com", config1.pushNotificationConfig().url()); - assertEquals("config1", config1.pushNotificationConfig().id()); - - assertEquals(MINIMAL_TASK.getId(), config2.taskId()); - assertEquals("http://example.com", config2.pushNotificationConfig().url()); - assertEquals("config2", config2.pushNotificationConfig().id()); - } finally { - deletePushNotificationConfigInStore(MINIMAL_TASK.getId(), "config1"); - deletePushNotificationConfigInStore(MINIMAL_TASK.getId(), "config2"); - deleteTaskInTaskStore(MINIMAL_TASK.getId()); - } - } - - @Test - public void testListPushNotificationConfigWithoutConfigId() throws Exception { - saveTaskInTaskStore(MINIMAL_TASK); - try { - // Create first push notification config without explicit ID (will use task ID as default) - PushNotificationConfig pushConfig1 = new PushNotificationConfig.Builder() - .url("http://1.example.com") - .id(MINIMAL_TASK.getId()) // Use task ID as config ID - .build(); - TaskPushNotificationConfig taskPushConfig1 = - new TaskPushNotificationConfig(MINIMAL_TASK.getId(), pushConfig1); - - CreateTaskPushNotificationConfigRequest createRequest1 = CreateTaskPushNotificationConfigRequest.newBuilder() - .setParent("tasks/" + MINIMAL_TASK.getId()) - .setConfigId(MINIMAL_TASK.getId()) - .setConfig(ProtoUtils.ToProto.taskPushNotificationConfig(taskPushConfig1)) - .build(); - client.createTaskPushNotificationConfig(createRequest1); - - // Create second push notification config with same ID (will overwrite the previous one) - PushNotificationConfig pushConfig2 = new PushNotificationConfig.Builder() - .url("http://2.example.com") - .id(MINIMAL_TASK.getId()) // Same ID, will overwrite - .build(); - TaskPushNotificationConfig taskPushConfig2 = - new TaskPushNotificationConfig(MINIMAL_TASK.getId(), pushConfig2); - - CreateTaskPushNotificationConfigRequest createRequest2 = CreateTaskPushNotificationConfigRequest.newBuilder() - .setParent("tasks/" + MINIMAL_TASK.getId()) - .setConfigId(MINIMAL_TASK.getId()) - .setConfig(ProtoUtils.ToProto.taskPushNotificationConfig(taskPushConfig2)) - .build(); - client.createTaskPushNotificationConfig(createRequest2); - - // Now, list all push notification configs for the task - ListTaskPushNotificationConfigRequest listRequest = ListTaskPushNotificationConfigRequest.newBuilder() - .setParent("tasks/" + MINIMAL_TASK.getId()) - .build(); - - ListTaskPushNotificationConfigResponse listResponse = client.listTaskPushNotificationConfig(listRequest); - - // Verify only 1 config exists (second one overwrote the first) - assertEquals(1, listResponse.getConfigsCount()); - - // Convert back to spec for easier assertions - TaskPushNotificationConfig config = ProtoUtils.FromProto.taskPushNotificationConfig(listResponse.getConfigs(0)); - - assertEquals(MINIMAL_TASK.getId(), config.taskId()); - assertEquals("http://2.example.com", config.pushNotificationConfig().url()); - assertEquals(MINIMAL_TASK.getId(), config.pushNotificationConfig().id()); - } finally { - deletePushNotificationConfigInStore(MINIMAL_TASK.getId(), MINIMAL_TASK.getId()); - deleteTaskInTaskStore(MINIMAL_TASK.getId()); - } - } - - @Test - public void testListPushNotificationConfigTaskNotFound() throws Exception { - ListTaskPushNotificationConfigRequest listRequest = ListTaskPushNotificationConfigRequest.newBuilder() - .setParent("tasks/non-existent-task") - .build(); - - try { - client.listTaskPushNotificationConfig(listRequest); - // Should not reach here - assertTrue(false, "Expected StatusRuntimeException but method returned normally"); - } catch (StatusRuntimeException e) { - assertEquals(Status.NOT_FOUND.getCode(), e.getStatus().getCode()); - String description = e.getStatus().getDescription(); - assertTrue(description != null && description.contains("TaskNotFoundError")); - } - } - - @Test - public void testListPushNotificationConfigEmptyList() throws Exception { - saveTaskInTaskStore(MINIMAL_TASK); - try { - // List configs for a task that has no configs - ListTaskPushNotificationConfigRequest listRequest = ListTaskPushNotificationConfigRequest.newBuilder() - .setParent("tasks/" + MINIMAL_TASK.getId()) - .build(); - - ListTaskPushNotificationConfigResponse listResponse = client.listTaskPushNotificationConfig(listRequest); - - // Verify empty list - assertEquals(0, listResponse.getConfigsCount()); - } finally { - deleteTaskInTaskStore(MINIMAL_TASK.getId()); - } - } +public class QuarkusA2AGrpcTest extends AbstractA2AServerTest { - @Test - public void testDeletePushNotificationConfigWithValidConfigId() throws Exception { - // Create a second task for testing cross-task isolation - Task secondTask = new Task.Builder() - .id("task-456") - .contextId("session-xyz") - .status(new TaskStatus(TaskState.SUBMITTED)) - .build(); - - saveTaskInTaskStore(MINIMAL_TASK); - saveTaskInTaskStore(secondTask); - try { - // Create config1 and config2 for MINIMAL_TASK - PushNotificationConfig pushConfig1 = new PushNotificationConfig.Builder() - .url("http://example.com") - .id("config1") - .build(); - TaskPushNotificationConfig taskPushConfig1 = - new TaskPushNotificationConfig(MINIMAL_TASK.getId(), pushConfig1); - - CreateTaskPushNotificationConfigRequest createRequest1 = CreateTaskPushNotificationConfigRequest.newBuilder() - .setParent("tasks/" + MINIMAL_TASK.getId()) - .setConfigId("config1") - .setConfig(ProtoUtils.ToProto.taskPushNotificationConfig(taskPushConfig1)) - .build(); - client.createTaskPushNotificationConfig(createRequest1); - - PushNotificationConfig pushConfig2 = new PushNotificationConfig.Builder() - .url("http://example.com") - .id("config2") - .build(); - TaskPushNotificationConfig taskPushConfig2 = - new TaskPushNotificationConfig(MINIMAL_TASK.getId(), pushConfig2); - - CreateTaskPushNotificationConfigRequest createRequest2 = CreateTaskPushNotificationConfigRequest.newBuilder() - .setParent("tasks/" + MINIMAL_TASK.getId()) - .setConfigId("config2") - .setConfig(ProtoUtils.ToProto.taskPushNotificationConfig(taskPushConfig2)) - .build(); - client.createTaskPushNotificationConfig(createRequest2); - - // Create config1 for secondTask - TaskPushNotificationConfig taskPushConfig3 = - new TaskPushNotificationConfig(secondTask.getId(), pushConfig1); - - CreateTaskPushNotificationConfigRequest createRequest3 = CreateTaskPushNotificationConfigRequest.newBuilder() - .setParent("tasks/" + secondTask.getId()) - .setConfigId("config1") - .setConfig(ProtoUtils.ToProto.taskPushNotificationConfig(taskPushConfig3)) - .build(); - client.createTaskPushNotificationConfig(createRequest3); - - // Delete config1 from MINIMAL_TASK - DeleteTaskPushNotificationConfigRequest deleteRequest = DeleteTaskPushNotificationConfigRequest.newBuilder() - .setName("tasks/" + MINIMAL_TASK.getId() + "/pushNotificationConfigs/config1") - .build(); - - com.google.protobuf.Empty deleteResponse = client.deleteTaskPushNotificationConfig(deleteRequest); - assertNotNull(deleteResponse); // Should return Empty, not null - - // Verify MINIMAL_TASK now has only 1 config (config2) - ListTaskPushNotificationConfigRequest listRequest1 = ListTaskPushNotificationConfigRequest.newBuilder() - .setParent("tasks/" + MINIMAL_TASK.getId()) - .build(); - ListTaskPushNotificationConfigResponse listResponse1 = client.listTaskPushNotificationConfig(listRequest1); - assertEquals(1, listResponse1.getConfigsCount()); - - TaskPushNotificationConfig remainingConfig = ProtoUtils.FromProto.taskPushNotificationConfig(listResponse1.getConfigs(0)); - assertEquals("config2", remainingConfig.pushNotificationConfig().id()); - - // Verify secondTask remains unchanged (still has config1) - ListTaskPushNotificationConfigRequest listRequest2 = ListTaskPushNotificationConfigRequest.newBuilder() - .setParent("tasks/" + secondTask.getId()) - .build(); - ListTaskPushNotificationConfigResponse listResponse2 = client.listTaskPushNotificationConfig(listRequest2); - assertEquals(1, listResponse2.getConfigsCount()); - - TaskPushNotificationConfig secondTaskConfig = ProtoUtils.FromProto.taskPushNotificationConfig(listResponse2.getConfigs(0)); - assertEquals("config1", secondTaskConfig.pushNotificationConfig().id()); - - } finally { - deletePushNotificationConfigInStore(MINIMAL_TASK.getId(), "config1"); - deletePushNotificationConfigInStore(MINIMAL_TASK.getId(), "config2"); - deletePushNotificationConfigInStore(secondTask.getId(), "config1"); - deleteTaskInTaskStore(MINIMAL_TASK.getId()); - deleteTaskInTaskStore(secondTask.getId()); - } - } - - @Test - public void testDeletePushNotificationConfigWithNonExistingConfigId() throws Exception { - saveTaskInTaskStore(MINIMAL_TASK); - try { - // Create config1 and config2 - PushNotificationConfig pushConfig1 = new PushNotificationConfig.Builder() - .url("http://example.com") - .id("config1") - .build(); - TaskPushNotificationConfig taskPushConfig1 = - new TaskPushNotificationConfig(MINIMAL_TASK.getId(), pushConfig1); - - CreateTaskPushNotificationConfigRequest createRequest1 = CreateTaskPushNotificationConfigRequest.newBuilder() - .setParent("tasks/" + MINIMAL_TASK.getId()) - .setConfigId("config1") - .setConfig(ProtoUtils.ToProto.taskPushNotificationConfig(taskPushConfig1)) - .build(); - client.createTaskPushNotificationConfig(createRequest1); - - PushNotificationConfig pushConfig2 = new PushNotificationConfig.Builder() - .url("http://example.com") - .id("config2") - .build(); - TaskPushNotificationConfig taskPushConfig2 = - new TaskPushNotificationConfig(MINIMAL_TASK.getId(), pushConfig2); - - CreateTaskPushNotificationConfigRequest createRequest2 = CreateTaskPushNotificationConfigRequest.newBuilder() - .setParent("tasks/" + MINIMAL_TASK.getId()) - .setConfigId("config2") - .setConfig(ProtoUtils.ToProto.taskPushNotificationConfig(taskPushConfig2)) - .build(); - client.createTaskPushNotificationConfig(createRequest2); - - // Try to delete non-existent config (should succeed silently) - DeleteTaskPushNotificationConfigRequest deleteRequest = DeleteTaskPushNotificationConfigRequest.newBuilder() - .setName("tasks/" + MINIMAL_TASK.getId() + "/pushNotificationConfigs/non-existent-config-id") - .build(); - - com.google.protobuf.Empty deleteResponse = client.deleteTaskPushNotificationConfig(deleteRequest); - assertNotNull(deleteResponse); // Should return Empty, not throw error - - // Verify both configs remain unchanged - ListTaskPushNotificationConfigRequest listRequest = ListTaskPushNotificationConfigRequest.newBuilder() - .setParent("tasks/" + MINIMAL_TASK.getId()) - .build(); - ListTaskPushNotificationConfigResponse listResponse = client.listTaskPushNotificationConfig(listRequest); - assertEquals(2, listResponse.getConfigsCount()); - - } finally { - deletePushNotificationConfigInStore(MINIMAL_TASK.getId(), "config1"); - deletePushNotificationConfigInStore(MINIMAL_TASK.getId(), "config2"); - deleteTaskInTaskStore(MINIMAL_TASK.getId()); - } + public QuarkusA2AGrpcTest() { + super(8081); // HTTP server port for utility endpoints } - @Test - public void testDeletePushNotificationConfigTaskNotFound() throws Exception { - DeleteTaskPushNotificationConfigRequest deleteRequest = DeleteTaskPushNotificationConfigRequest.newBuilder() - .setName("tasks/non-existent-task/pushNotificationConfigs/non-existent-config-id") - .build(); - - try { - client.deleteTaskPushNotificationConfig(deleteRequest); - // Should not reach here - assertTrue(false, "Expected StatusRuntimeException but method returned normally"); - } catch (StatusRuntimeException e) { - assertEquals(Status.NOT_FOUND.getCode(), e.getStatus().getCode()); - String description = e.getStatus().getDescription(); - assertTrue(description != null && description.contains("TaskNotFoundError")); - } - } - - @Test - public void testDeletePushNotificationConfigSetWithoutConfigId() throws Exception { - saveTaskInTaskStore(MINIMAL_TASK); - try { - // Create first config without explicit ID (will use task ID as default) - PushNotificationConfig pushConfig1 = new PushNotificationConfig.Builder() - .url("http://1.example.com") - .id(MINIMAL_TASK.getId()) - .build(); - TaskPushNotificationConfig taskPushConfig1 = - new TaskPushNotificationConfig(MINIMAL_TASK.getId(), pushConfig1); - - CreateTaskPushNotificationConfigRequest createRequest1 = CreateTaskPushNotificationConfigRequest.newBuilder() - .setParent("tasks/" + MINIMAL_TASK.getId()) - .setConfigId(MINIMAL_TASK.getId()) - .setConfig(ProtoUtils.ToProto.taskPushNotificationConfig(taskPushConfig1)) - .build(); - client.createTaskPushNotificationConfig(createRequest1); - - // Create second config with same ID (will overwrite the previous one) - PushNotificationConfig pushConfig2 = new PushNotificationConfig.Builder() - .url("http://2.example.com") - .id(MINIMAL_TASK.getId()) - .build(); - TaskPushNotificationConfig taskPushConfig2 = - new TaskPushNotificationConfig(MINIMAL_TASK.getId(), pushConfig2); - - CreateTaskPushNotificationConfigRequest createRequest2 = CreateTaskPushNotificationConfigRequest.newBuilder() - .setParent("tasks/" + MINIMAL_TASK.getId()) - .setConfigId(MINIMAL_TASK.getId()) - .setConfig(ProtoUtils.ToProto.taskPushNotificationConfig(taskPushConfig2)) - .build(); - client.createTaskPushNotificationConfig(createRequest2); - - // Delete the config using task ID - DeleteTaskPushNotificationConfigRequest deleteRequest = DeleteTaskPushNotificationConfigRequest.newBuilder() - .setName("tasks/" + MINIMAL_TASK.getId() + "/pushNotificationConfigs/" + MINIMAL_TASK.getId()) - .build(); - - com.google.protobuf.Empty deleteResponse = client.deleteTaskPushNotificationConfig(deleteRequest); - assertNotNull(deleteResponse); // Should return Empty - - // Verify no configs remain - ListTaskPushNotificationConfigRequest listRequest = ListTaskPushNotificationConfigRequest.newBuilder() - .setParent("tasks/" + MINIMAL_TASK.getId()) - .build(); - ListTaskPushNotificationConfigResponse listResponse = client.listTaskPushNotificationConfig(listRequest); - assertEquals(0, listResponse.getConfigsCount()); - - } finally { - deletePushNotificationConfigInStore(MINIMAL_TASK.getId(), MINIMAL_TASK.getId()); - deleteTaskInTaskStore(MINIMAL_TASK.getId()); - } - } - - @Test - public void testSendMessageStreamExistingTaskSuccess() throws Exception { - saveTaskInTaskStore(MINIMAL_TASK); - try { - // Build message for existing task - Message message = new Message.Builder(MESSAGE) - .taskId(MINIMAL_TASK.getId()) - .contextId(MINIMAL_TASK.getContextId()) - .build(); - - // Create gRPC streaming request - SendMessageRequest request = SendMessageRequest.newBuilder() - .setRequest(ProtoUtils.ToProto.message(message)) - .build(); - - // Use blocking iterator to consume stream responses - java.util.Iterator responseIterator = client.sendStreamingMessage(request); - - // Collect responses - expect at least one - java.util.List responses = new java.util.ArrayList<>(); - while (responseIterator.hasNext()) { - StreamResponse response = responseIterator.next(); - responses.add(response); - - // For this test, we expect to get the message back - stop after first response - if (response.hasMsg()) { - break; - } - } - - // Verify we got at least one response - assertTrue(responses.size() >= 1, "Expected at least one response from streaming call"); - - // Find the message response - StreamResponse messageResponse = null; - for (StreamResponse response : responses) { - if (response.hasMsg()) { - messageResponse = response; - break; - } - } - - assertNotNull(messageResponse, "Expected to receive a message response"); - - // Verify the message content - io.a2a.grpc.Message grpcMessage = messageResponse.getMsg(); - Message responseMessage = ProtoUtils.FromProto.message(grpcMessage); - assertEquals(MESSAGE.getMessageId(), responseMessage.getMessageId()); - assertEquals(MESSAGE.getRole(), responseMessage.getRole()); - Part part = responseMessage.getParts().get(0); - assertEquals(Part.Kind.TEXT, part.getKind()); - assertEquals("test message", ((TextPart) part).getText()); - - } finally { - deleteTaskInTaskStore(MINIMAL_TASK.getId()); - } + @Override + protected String getTransportProtocol() { + return TransportProtocol.GRPC.asString(); } - @Test - public void testStreamingMethodWithAcceptHeader() throws Exception { - // NOTE: This test is not applicable to gRPC since HTTP Accept headers - // are an HTTP/REST-specific concept and do not apply to gRPC protocol. - // gRPC uses Protocol Buffers for message encoding and doesn't use HTTP content negotiation. - - // This stub is maintained to preserve method order compatibility with AbstractA2AServerTest - // for future migration when extending that base class. + @Override + protected String getTransportUrl() { + return "localhost:9001"; // gRPC server runs on port 9001 } - - @Test - public void testSendMessageStreamNewMessageSuccess() throws Exception { - // Ensure no task exists initially (test creates new task via streaming) - assertTrue(getTaskFromTaskStore(MINIMAL_TASK.getId()) == null, "Task should not exist initially"); - - try { - // Build message for new task (no pre-existing task) - Message message = new Message.Builder(MESSAGE) - .taskId(MINIMAL_TASK.getId()) - .contextId(MINIMAL_TASK.getContextId()) - .build(); - - // Create gRPC streaming request - SendMessageRequest request = SendMessageRequest.newBuilder() - .setRequest(ProtoUtils.ToProto.message(message)) - .build(); - - // Use blocking iterator to consume stream responses - java.util.Iterator responseIterator = client.sendStreamingMessage(request); - - // Collect responses - expect at least one - java.util.List responses = new java.util.ArrayList<>(); - while (responseIterator.hasNext()) { - StreamResponse response = responseIterator.next(); - responses.add(response); - - // For this test, we expect to get the message back - stop after first response - if (response.hasMsg()) { - break; - } - } - - // Verify we got at least one response - assertTrue(responses.size() >= 1, "Expected at least one response from streaming call"); - - // Find the message response - StreamResponse messageResponse = null; - for (StreamResponse response : responses) { - if (response.hasMsg()) { - messageResponse = response; - break; - } - } - - assertNotNull(messageResponse, "Expected to receive a message response"); - - // Verify the message content - io.a2a.grpc.Message grpcMessage = messageResponse.getMsg(); - Message responseMessage = ProtoUtils.FromProto.message(grpcMessage); - assertEquals(MESSAGE.getMessageId(), responseMessage.getMessageId()); - assertEquals(MESSAGE.getRole(), responseMessage.getRole()); - Part part = responseMessage.getParts().get(0); - assertEquals(Part.Kind.TEXT, part.getKind()); - assertEquals("test message", ((TextPart) part).getText()); - - } finally { - // Clean up any task that may have been created (ignore if task doesn't exist) - try { - deleteTaskInTaskStore(MINIMAL_TASK.getId()); - } catch (RuntimeException e) { - // Ignore if task doesn't exist (404 error) - if (!e.getMessage().contains("404")) { - throw e; - } - } - } - } - - @Test - public void testResubscribeExistingTaskSuccess() throws Exception { - ExecutorService executorService = Executors.newSingleThreadExecutor(); - saveTaskInTaskStore(MINIMAL_TASK); - - try { - // Ensure queue for task exists (required for resubscription) - ensureQueueForTask(MINIMAL_TASK.getId()); - - CountDownLatch taskResubscriptionRequestSent = new CountDownLatch(1); - CountDownLatch taskResubscriptionResponseReceived = new CountDownLatch(2); - AtomicReference firstResponse = new AtomicReference<>(); - AtomicReference secondResponse = new AtomicReference<>(); - - // Create gRPC task subscription request - TaskSubscriptionRequest subscriptionRequest = TaskSubscriptionRequest.newBuilder() - .setName("tasks/" + MINIMAL_TASK.getId()) - .build(); - - // Count down the latch when the gRPC streaming subscription is established - awaitStreamingSubscription() - .whenComplete((unused, throwable) -> taskResubscriptionRequestSent.countDown()); - - AtomicReference errorRef = new AtomicReference<>(); - - // Start the subscription in a separate thread - executorService.submit(() -> { - try { - java.util.Iterator responseIterator = client.taskSubscription(subscriptionRequest); - - while (responseIterator.hasNext()) { - StreamResponse response = responseIterator.next(); - - if (taskResubscriptionResponseReceived.getCount() == 2) { - firstResponse.set(response); - } else { - secondResponse.set(response); - } - taskResubscriptionResponseReceived.countDown(); - - if (taskResubscriptionResponseReceived.getCount() == 0) { - break; - } - } - } catch (Exception e) { - errorRef.set(e); - // Count down both latches to unblock the test - taskResubscriptionRequestSent.countDown(); - while (taskResubscriptionResponseReceived.getCount() > 0) { - taskResubscriptionResponseReceived.countDown(); - } - } - }); - - // Wait for subscription to be established - assertTrue(taskResubscriptionRequestSent.await(10, TimeUnit.SECONDS), "Subscription should be established"); - - // Inject events into the server's event queue - java.util.List events = java.util.List.of( - new TaskArtifactUpdateEvent.Builder() - .taskId(MINIMAL_TASK.getId()) - .contextId(MINIMAL_TASK.getContextId()) - .artifact(new Artifact.Builder() - .artifactId("11") - .parts(new TextPart("text")) - .build()) - .build(), - new TaskStatusUpdateEvent.Builder() - .taskId(MINIMAL_TASK.getId()) - .contextId(MINIMAL_TASK.getContextId()) - .status(new TaskStatus(TaskState.COMPLETED)) - .isFinal(true) - .build()); - - for (Event event : events) { - enqueueEventOnServer(event); - } - - // Wait for the client to receive the responses - assertTrue(taskResubscriptionResponseReceived.await(20, TimeUnit.SECONDS), "Should receive both responses"); - - // Check for errors - if (errorRef.get() != null) { - throw new RuntimeException("Error in subscription thread", errorRef.get()); - } - - // Verify first response (TaskArtifactUpdateEvent) - assertNotNull(firstResponse.get(), "Should receive first response"); - StreamResponse firstStreamResponse = firstResponse.get(); - assertTrue(firstStreamResponse.hasArtifactUpdate(), "First response should be artifact update"); - - io.a2a.grpc.TaskArtifactUpdateEvent artifactUpdate = firstStreamResponse.getArtifactUpdate(); - assertEquals(MINIMAL_TASK.getId(), artifactUpdate.getTaskId()); - assertEquals(MINIMAL_TASK.getContextId(), artifactUpdate.getContextId()); - assertEquals("11", artifactUpdate.getArtifact().getArtifactId()); - assertEquals("text", artifactUpdate.getArtifact().getParts(0).getText()); - - // Verify second response (TaskStatusUpdateEvent) - assertNotNull(secondResponse.get(), "Should receive second response"); - StreamResponse secondStreamResponse = secondResponse.get(); - assertTrue(secondStreamResponse.hasStatusUpdate(), "Second response should be status update"); - - io.a2a.grpc.TaskStatusUpdateEvent statusUpdate = secondStreamResponse.getStatusUpdate(); - assertEquals(MINIMAL_TASK.getId(), statusUpdate.getTaskId()); - assertEquals(MINIMAL_TASK.getContextId(), statusUpdate.getContextId()); - assertEquals(io.a2a.grpc.TaskState.TASK_STATE_COMPLETED, statusUpdate.getStatus().getState()); - assertTrue(statusUpdate.getFinal(), "Final status update should be marked as final"); - - } finally { - deleteTaskInTaskStore(MINIMAL_TASK.getId()); - executorService.shutdown(); - if (!executorService.awaitTermination(10, TimeUnit.SECONDS)) { - executorService.shutdownNow(); - } - } - } - - @Test - public void testResubscribeNoExistingTaskError() throws Exception { - // Try to resubscribe to a non-existent task - should get TaskNotFoundError - TaskSubscriptionRequest request = TaskSubscriptionRequest.newBuilder() - .setName("tasks/non-existent-task") - .build(); - - try { - // Use blocking iterator to consume stream responses - java.util.Iterator responseIterator = client.taskSubscription(request); - - // Try to get first response - should throw StatusRuntimeException - if (responseIterator.hasNext()) { - responseIterator.next(); - } - - // Should not reach here - assertTrue(false, "Expected StatusRuntimeException but method returned normally"); - } catch (StatusRuntimeException e) { - // Verify this is a TaskNotFoundError mapped to NOT_FOUND status - assertEquals(Status.NOT_FOUND.getCode(), e.getStatus().getCode()); - String description = e.getStatus().getDescription(); - assertTrue(description != null && description.contains("TaskNotFoundError")); - } - } - - - protected void saveTaskInTaskStore(Task task) throws Exception { - HttpClient client = HttpClient.newBuilder() - .version(HttpClient.Version.HTTP_2) - .build(); - HttpRequest request = HttpRequest.newBuilder() - .uri(URI.create("http://localhost:" + serverPort + "/test/task")) - .POST(HttpRequest.BodyPublishers.ofString(Utils.OBJECT_MAPPER.writeValueAsString(task))) - .header("Content-Type", APPLICATION_JSON) - .build(); - - HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); - if (response.statusCode() != 200) { - throw new RuntimeException( - String.format("Saving task failed! Status: %d, Body: %s", response.statusCode(), response.body())); - } - } - - protected Task getTaskFromTaskStore(String taskId) throws Exception { - HttpClient client = HttpClient.newBuilder() - .version(HttpClient.Version.HTTP_2) - .build(); - HttpRequest request = HttpRequest.newBuilder() - .uri(URI.create("http://localhost:" + serverPort + "/test/task/" + taskId)) - .GET() - .build(); - - HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); - if (response.statusCode() == 404) { - return null; - } - if (response.statusCode() != 200) { - throw new RuntimeException(String.format("Getting task failed! Status: %d, Body: %s", response.statusCode(), response.body())); - } - return Utils.OBJECT_MAPPER.readValue(response.body(), Task.TYPE_REFERENCE); - } - - protected void deleteTaskInTaskStore(String taskId) throws Exception { - HttpClient client = HttpClient.newBuilder() - .version(HttpClient.Version.HTTP_2) - .build(); - HttpRequest request = HttpRequest.newBuilder() - .uri(URI.create(("http://localhost:" + serverPort + "/test/task/" + taskId))) - .DELETE() - .build(); - HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); - if (response.statusCode() != 200) { - throw new RuntimeException(response.statusCode() + ": Deleting task failed!" + response.body()); - } - } - - protected void ensureQueueForTask(String taskId) throws Exception { - HttpClient client = HttpClient.newBuilder() - .version(HttpClient.Version.HTTP_2) - .build(); - HttpRequest request = HttpRequest.newBuilder() - .uri(URI.create("http://localhost:" + serverPort + "/test/queue/ensure/" + taskId)) - .POST(HttpRequest.BodyPublishers.noBody()) - .build(); - HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); - if (response.statusCode() != 200) { - throw new RuntimeException(String.format("Ensuring queue failed! Status: %d, Body: %s", response.statusCode(), response.body())); - } - } - - protected void enqueueEventOnServer(Event event) throws Exception { - String path; - if (event instanceof TaskArtifactUpdateEvent e) { - path = "test/queue/enqueueTaskArtifactUpdateEvent/" + e.getTaskId(); - } else if (event instanceof TaskStatusUpdateEvent e) { - path = "test/queue/enqueueTaskStatusUpdateEvent/" + e.getTaskId(); - } else { - throw new RuntimeException("Unknown event type " + event.getClass() + ". If you need the ability to" + - " handle more types, please add the REST endpoints."); - } - HttpClient client = HttpClient.newBuilder() - .version(HttpClient.Version.HTTP_2) - .build(); - HttpRequest request = HttpRequest.newBuilder() - .uri(URI.create("http://localhost:" + serverPort + "/" + path)) - .header("Content-Type", APPLICATION_JSON) - .POST(HttpRequest.BodyPublishers.ofString(Utils.OBJECT_MAPPER.writeValueAsString(event))) - .build(); - - HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); - if (response.statusCode() != 200) { - throw new RuntimeException(response.statusCode() + ": Queueing event failed!" + response.body()); - } - } - - private CompletableFuture awaitStreamingSubscription() { - int cnt = getStreamingSubscribedCount(); - AtomicInteger initialCount = new AtomicInteger(cnt); - - return CompletableFuture.runAsync(() -> { - try { - boolean done = false; - long end = System.currentTimeMillis() + 15000; - while (System.currentTimeMillis() < end) { - int count = getStreamingSubscribedCount(); - if (count > initialCount.get()) { - done = true; - break; - } - Thread.sleep(500); - } - if (!done) { - throw new RuntimeException("Timed out waiting for subscription"); - } - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - throw new RuntimeException("Interrupted"); - } - }); - } - - private int getStreamingSubscribedCount() { - HttpClient client = HttpClient.newBuilder() - .version(HttpClient.Version.HTTP_2) - .build(); - HttpRequest request = HttpRequest.newBuilder() - .uri(URI.create("http://localhost:" + serverPort + "/test/streamingSubscribedCount")) - .GET() - .build(); - try { - HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); - String body = response.body().trim(); - return Integer.parseInt(body); - } catch (IOException | InterruptedException e) { - throw new RuntimeException(e); - } - } - - protected void deletePushNotificationConfigInStore(String taskId, String configId) throws Exception { - HttpClient client = HttpClient.newBuilder() - .version(HttpClient.Version.HTTP_2) - .build(); - HttpRequest request = HttpRequest.newBuilder() - .uri(URI.create(("http://localhost:" + serverPort + "/test/task/" + taskId + "/config/" + configId))) - .DELETE() - .build(); - HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); - if (response.statusCode() != 200) { - throw new RuntimeException(response.statusCode() + ": Deleting task failed!" + response.body()); - } - } - - protected void savePushNotificationConfigInStore(String taskId, PushNotificationConfig notificationConfig) throws Exception { - HttpClient client = HttpClient.newBuilder() - .version(HttpClient.Version.HTTP_2) - .build(); - HttpRequest request = HttpRequest.newBuilder() - .uri(URI.create("http://localhost:" + serverPort + "/test/task/" + taskId)) - .POST(HttpRequest.BodyPublishers.ofString(Utils.OBJECT_MAPPER.writeValueAsString(notificationConfig))) - .header("Content-Type", APPLICATION_JSON) - .build(); - - HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); - if (response.statusCode() != 200) { - throw new RuntimeException(response.statusCode() + ": Creating task push notification config failed! " + response.body()); - } - } - -} +} \ No newline at end of file diff --git a/reference/grpc/src/test/resources/application.properties b/reference/grpc/src/test/resources/application.properties index 2ddb0ae2a..9ddcb3e07 100644 --- a/reference/grpc/src/test/resources/application.properties +++ b/reference/grpc/src/test/resources/application.properties @@ -1,2 +1,3 @@ -quarkus.grpc.clients.a2a-service.host=localhost -quarkus.grpc.clients.a2a-service.port=9001 \ No newline at end of file +# Configure the gRPC server to listen on port 9001 +quarkus.grpc.server.port=9001 +quarkus.http.port=8081 \ No newline at end of file diff --git a/reference/jsonrpc/src/test/java/io/a2a/server/apps/quarkus/QuarkusA2AJSONRPCTest.java b/reference/jsonrpc/src/test/java/io/a2a/server/apps/quarkus/QuarkusA2AJSONRPCTest.java index f1442da33..ff61a433b 100644 --- a/reference/jsonrpc/src/test/java/io/a2a/server/apps/quarkus/QuarkusA2AJSONRPCTest.java +++ b/reference/jsonrpc/src/test/java/io/a2a/server/apps/quarkus/QuarkusA2AJSONRPCTest.java @@ -1,12 +1,180 @@ package io.a2a.server.apps.quarkus; +import static io.restassured.RestAssured.given; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.fail; +import static org.wildfly.common.Assert.assertNotNull; +import jakarta.ws.rs.core.MediaType; + import io.a2a.server.apps.common.AbstractA2AServerTest; +import io.a2a.spec.A2AClientException; +import io.a2a.spec.InvalidParamsError; +import io.a2a.spec.InvalidRequestError; +import io.a2a.spec.JSONParseError; +import io.a2a.spec.JSONRPCErrorResponse; +import io.a2a.spec.MethodNotFoundError; +import io.a2a.spec.Task; +import io.a2a.spec.TaskQueryParams; +import io.a2a.spec.TaskState; +import io.a2a.spec.TransportProtocol; import io.quarkus.test.junit.QuarkusTest; +import org.junit.jupiter.api.Test; + @QuarkusTest public class QuarkusA2AJSONRPCTest extends AbstractA2AServerTest { public QuarkusA2AJSONRPCTest() { super(8081); } + + @Override + protected String getTransportProtocol() { + return TransportProtocol.JSONRPC.asString(); + } + + @Override + protected String getTransportUrl() { + return "http://localhost:8081"; + } + + @Test + public void testMalformedJSONRPCRequest() { + // missing closing bracket + String malformedRequest = "{\"jsonrpc\": \"2.0\", \"method\": \"message/send\", \"params\": {\"foo\": \"bar\"}"; + JSONRPCErrorResponse response = given() + .contentType(MediaType.APPLICATION_JSON) + .body(malformedRequest) + .when() + .post("/") + .then() + .statusCode(200) + .extract() + .as(JSONRPCErrorResponse.class); + assertNotNull(response.getError()); + assertEquals(new JSONParseError().getCode(), response.getError().getCode()); + } + + @Test + public void testInvalidParamsJSONRPCRequest() { + String invalidParamsRequest = """ + {"jsonrpc": "2.0", "method": "message/send", "params": "not_a_dict", "id": "1"} + """; + testInvalidParams(invalidParamsRequest); + + invalidParamsRequest = """ + {"jsonrpc": "2.0", "method": "message/send", "params": {"message": {"parts": "invalid"}}, "id": "1"} + """; + testInvalidParams(invalidParamsRequest); + } + + private void testInvalidParams(String invalidParamsRequest) { + JSONRPCErrorResponse response = given() + .contentType(MediaType.APPLICATION_JSON) + .body(invalidParamsRequest) + .when() + .post("/") + .then() + .statusCode(200) + .extract() + .as(JSONRPCErrorResponse.class); + assertNotNull(response.getError()); + assertEquals(new InvalidParamsError().getCode(), response.getError().getCode()); + assertEquals("1", response.getId()); + } + + @Test + public void testInvalidJSONRPCRequestMissingJsonrpc() { + String invalidRequest = """ + { + "method": "message/send", + "params": {} + } + """; + JSONRPCErrorResponse response = given() + .contentType(MediaType.APPLICATION_JSON) + .body(invalidRequest) + .when() + .post("/") + .then() + .statusCode(200) + .extract() + .as(JSONRPCErrorResponse.class); + assertNotNull(response.getError()); + assertEquals(new InvalidRequestError().getCode(), response.getError().getCode()); + } + + @Test + public void testInvalidJSONRPCRequestMissingMethod() { + String invalidRequest = """ + {"jsonrpc": "2.0", "params": {}} + """; + JSONRPCErrorResponse response = given() + .contentType(MediaType.APPLICATION_JSON) + .body(invalidRequest) + .when() + .post("/") + .then() + .statusCode(200) + .extract() + .as(JSONRPCErrorResponse.class); + assertNotNull(response.getError()); + assertEquals(new InvalidRequestError().getCode(), response.getError().getCode()); + } + + @Test + public void testInvalidJSONRPCRequestInvalidId() { + String invalidRequest = """ + {"jsonrpc": "2.0", "method": "message/send", "params": {}, "id": {"bad": "type"}} + """; + JSONRPCErrorResponse response = given() + .contentType(MediaType.APPLICATION_JSON) + .body(invalidRequest) + .when() + .post("/") + .then() + .statusCode(200) + .extract() + .as(JSONRPCErrorResponse.class); + assertNotNull(response.getError()); + assertEquals(new InvalidRequestError().getCode(), response.getError().getCode()); + } + + @Test + public void testInvalidJSONRPCRequestNonExistentMethod() { + String invalidRequest = """ + {"jsonrpc": "2.0", "method" : "nonexistent/method", "params": {}} + """; + JSONRPCErrorResponse response = given() + .contentType(MediaType.APPLICATION_JSON) + .body(invalidRequest) + .when() + .post("/") + .then() + .statusCode(200) + .extract() + .as(JSONRPCErrorResponse.class); + assertNotNull(response.getError()); + assertEquals(new MethodNotFoundError().getCode(), response.getError().getCode()); + } + + @Test + public void testNonStreamingMethodWithAcceptHeader() throws Exception { + testGetTask(MediaType.APPLICATION_JSON); + } + + private void testGetTask(String mediaType) throws Exception { + saveTaskInTaskStore(MINIMAL_TASK); + try { + Task response = getClient().getTask(new TaskQueryParams(MINIMAL_TASK.getId()), null); + assertEquals("task-123", response.getId()); + assertEquals("session-xyz", response.getContextId()); + assertEquals(TaskState.SUBMITTED, response.getStatus().state()); + } catch (A2AClientException e) { + fail("Unexpected exception during getTask: " + e.getMessage(), e); + } finally { + deleteTaskInTaskStore(MINIMAL_TASK.getId()); + } + } + } diff --git a/spec-grpc/src/main/java/io/a2a/grpc/utils/ProtoUtils.java b/spec-grpc/src/main/java/io/a2a/grpc/utils/ProtoUtils.java index 52a6b6719..ddeaa11c7 100644 --- a/spec-grpc/src/main/java/io/a2a/grpc/utils/ProtoUtils.java +++ b/spec-grpc/src/main/java/io/a2a/grpc/utils/ProtoUtils.java @@ -168,7 +168,8 @@ public static io.a2a.grpc.Message message(Message message) { public static io.a2a.grpc.TaskPushNotificationConfig taskPushNotificationConfig(TaskPushNotificationConfig config) { io.a2a.grpc.TaskPushNotificationConfig.Builder builder = io.a2a.grpc.TaskPushNotificationConfig.newBuilder(); - builder.setName("tasks/" + config.taskId() + "/pushNotificationConfigs/" + config.pushNotificationConfig().id()); + String configId = config.pushNotificationConfig().id(); + builder.setName("tasks/" + config.taskId() + "/pushNotificationConfigs/" + (configId != null ? configId : config.taskId())); builder.setPushNotificationConfig(pushNotificationConfig(config.pushNotificationConfig())); return builder.build(); } @@ -703,13 +704,17 @@ public static TaskPushNotificationConfig taskPushNotificationConfig(io.a2a.grpc. } public static GetTaskPushNotificationConfigParams getTaskPushNotificationConfigParams(io.a2a.grpc.GetTaskPushNotificationConfigRequest request) { - String name = request.getName(); // "tasks/{id}/pushNotificationConfigs/{push_id}" + String name = request.getName(); // "tasks/{id}/pushNotificationConfigs/{push_id}" or /tasks/{id} String[] parts = name.split("/"); - if (parts.length < 4) { + String taskId = parts[1]; + String configId; + if (parts.length == 2) { + configId = taskId; + } else if (parts.length < 4) { throw new IllegalArgumentException("Invalid name format for GetTaskPushNotificationConfigRequest: " + name); + } else { + configId = parts[3]; } - String taskId = parts[1]; - String configId = parts[3]; return new GetTaskPushNotificationConfigParams(taskId, configId); } @@ -763,13 +768,16 @@ private static MessageSendConfiguration messageSendConfiguration(io.a2a.grpc.Sen private static PushNotificationConfig pushNotification(io.a2a.grpc.PushNotificationConfig pushNotification, String configId) { return new PushNotificationConfig( pushNotification.getUrl(), - pushNotification.getToken(), - authenticationInfo(pushNotification.getAuthentication()), + pushNotification.getToken().isEmpty() ? null : pushNotification.getToken(), + pushNotification.hasAuthentication() ? authenticationInfo(pushNotification.getAuthentication()) : null, pushNotification.getId().isEmpty() ? configId : pushNotification.getId() ); } private static PushNotificationConfig pushNotification(io.a2a.grpc.PushNotificationConfig pushNotification) { + /*if (pushNotification == null) { + return null; + }*/ return pushNotification(pushNotification, pushNotification.getId()); } @@ -795,6 +803,7 @@ public static Message message(io.a2a.grpc.Message message) { if (message.getMessageId().isEmpty()) { throw new InvalidParamsError(); } + return new Message( role(message.getRole()), message.getContentList().stream().map(item -> part(item)).collect(Collectors.toList()), @@ -868,7 +877,7 @@ private static DataPart dataPart(io.a2a.grpc.DataPart dataPart) { private static TaskStatus taskStatus(io.a2a.grpc.TaskStatus taskStatus) { return new TaskStatus( taskState(taskStatus.getState()), - message(taskStatus.getUpdate()), + taskStatus.hasUpdate() ? message(taskStatus.getUpdate()) : null, LocalDateTime.ofInstant(Instant.ofEpochSecond(taskStatus.getTimestamp().getSeconds(), taskStatus.getTimestamp().getNanos()), ZoneOffset.UTC) ); } diff --git a/tests/server-common/pom.xml b/tests/server-common/pom.xml index 6ebf239fb..fb6cbc5db 100644 --- a/tests/server-common/pom.xml +++ b/tests/server-common/pom.xml @@ -56,7 +56,13 @@ io.github.a2asdk a2a-java-sdk-client-transport-jsonrpc - 0.2.6.Beta1-SNAPSHOT + ${project.version} + test + + + io.github.a2asdk + a2a-java-sdk-client-transport-grpc + ${project.version} test diff --git a/tests/server-common/src/test/java/io/a2a/server/apps/common/AbstractA2AServerTest.java b/tests/server-common/src/test/java/io/a2a/server/apps/common/AbstractA2AServerTest.java index 38693ee33..d9464033c 100644 --- a/tests/server-common/src/test/java/io/a2a/server/apps/common/AbstractA2AServerTest.java +++ b/tests/server-common/src/test/java/io/a2a/server/apps/common/AbstractA2AServerTest.java @@ -3,6 +3,7 @@ import static io.restassured.RestAssured.given; import static org.hamcrest.Matchers.equalTo; 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.assertNull; import static org.junit.jupiter.api.Assertions.fail; @@ -16,57 +17,55 @@ import java.net.http.HttpRequest; import java.net.http.HttpResponse; import java.nio.charset.StandardCharsets; +import java.util.ArrayList; import java.util.List; import java.util.concurrent.CompletableFuture; import java.util.concurrent.CountDownLatch; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicReference; import java.util.stream.Stream; +import java.util.function.BiConsumer; +import java.util.function.Consumer; import jakarta.ws.rs.core.MediaType; import com.fasterxml.jackson.core.JsonProcessingException; -import io.a2a.client.transport.spi.ClientTransport; -import io.a2a.client.transport.jsonrpc.JSONRPCTransport; +import io.a2a.client.Client; +import io.a2a.client.ClientEvent; +import io.a2a.client.ClientFactory; +import io.a2a.client.MessageEvent; +import io.a2a.client.TaskUpdateEvent; +import io.a2a.client.config.ClientConfig; +import io.a2a.client.config.ClientTransportConfig; +import io.a2a.client.transport.jsonrpc.JSONRPCTransportConfig; +import io.a2a.client.transport.grpc.GrpcTransportConfig; +import io.a2a.client.http.JdkA2AHttpClient; +import io.a2a.spec.AuthenticatedExtendedCardNotConfiguredError; +import io.a2a.spec.JSONRPCError; +import io.grpc.ManagedChannelBuilder; import io.a2a.spec.A2AClientException; -import io.a2a.spec.A2AServerException; import io.a2a.spec.AgentCard; +import io.a2a.spec.AgentCapabilities; +import io.a2a.spec.AgentInterface; +import io.a2a.spec.TransportProtocol; import io.a2a.spec.Artifact; -import io.a2a.spec.AuthenticatedExtendedCardNotConfiguredError; -import io.a2a.spec.CancelTaskRequest; -import io.a2a.spec.CancelTaskResponse; import io.a2a.spec.DeleteTaskPushNotificationConfigParams; -import io.a2a.spec.DeleteTaskPushNotificationConfigResponse; import io.a2a.spec.Event; import io.a2a.spec.GetAuthenticatedExtendedCardRequest; import io.a2a.spec.GetAuthenticatedExtendedCardResponse; import io.a2a.spec.GetTaskPushNotificationConfigParams; -import io.a2a.spec.GetTaskPushNotificationConfigRequest; -import io.a2a.spec.GetTaskPushNotificationConfigResponse; -import io.a2a.spec.GetTaskRequest; -import io.a2a.spec.GetTaskResponse; -import io.a2a.spec.InvalidParamsError; -import io.a2a.spec.InvalidRequestError; -import io.a2a.spec.JSONParseError; -import io.a2a.spec.JSONRPCError; -import io.a2a.spec.JSONRPCErrorResponse; import io.a2a.spec.ListTaskPushNotificationConfigParams; -import io.a2a.spec.ListTaskPushNotificationConfigResponse; import io.a2a.spec.Message; import io.a2a.spec.MessageSendParams; -import io.a2a.spec.MethodNotFoundError; import io.a2a.spec.Part; import io.a2a.spec.PushNotificationConfig; -import io.a2a.spec.SendMessageRequest; -import io.a2a.spec.SendMessageResponse; import io.a2a.spec.SendStreamingMessageRequest; import io.a2a.spec.SendStreamingMessageResponse; -import io.a2a.spec.SetTaskPushNotificationConfigRequest; -import io.a2a.spec.SetTaskPushNotificationConfigResponse; import io.a2a.spec.StreamingJSONRPCRequest; import io.a2a.spec.Task; import io.a2a.spec.TaskArtifactUpdateEvent; @@ -74,17 +73,15 @@ import io.a2a.spec.TaskNotFoundError; import io.a2a.spec.TaskPushNotificationConfig; import io.a2a.spec.TaskQueryParams; -import io.a2a.spec.TaskResubscriptionRequest; import io.a2a.spec.TaskState; import io.a2a.spec.TaskStatus; import io.a2a.spec.TaskStatusUpdateEvent; import io.a2a.spec.TextPart; import io.a2a.spec.UnsupportedOperationError; import io.a2a.util.Utils; -import io.restassured.RestAssured; -import io.restassured.specification.RequestSpecification; import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Assumptions; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Timeout; @@ -94,7 +91,7 @@ */ public abstract class AbstractA2AServerTest { - private static final Task MINIMAL_TASK = new Task.Builder() + protected static final Task MINIMAL_TASK = new Task.Builder() .id("task-123") .contextId("session-xyz") .status(new TaskStatus(TaskState.SUBMITTED)) @@ -126,13 +123,23 @@ public abstract class AbstractA2AServerTest { public static final String APPLICATION_JSON = "application/json"; private final int serverPort; - private ClientTransport client; + private Client client; + private Client nonStreamingClient; protected AbstractA2AServerTest(int serverPort) { this.serverPort = serverPort; - this.client = new JSONRPCTransport("http://localhost:" + serverPort); } + /** + * Get the transport protocol to use for this test (e.g., "JSONRPC", "GRPC"). + */ + protected abstract String getTransportProtocol(); + + /** + * Get the transport URL for this test. + */ + protected abstract String getTransportUrl(); + @Test public void testTaskStoreMethodsSanityTest() throws Exception { Task task = new Task.Builder(MINIMAL_TASK).id("abcde").build(); @@ -159,25 +166,12 @@ private void testGetTask() throws Exception { private void testGetTask(String mediaType) throws Exception { saveTaskInTaskStore(MINIMAL_TASK); try { - GetTaskRequest request = new GetTaskRequest("1", new TaskQueryParams(MINIMAL_TASK.getId())); - RequestSpecification requestSpecification = RestAssured.given() - .contentType(MediaType.APPLICATION_JSON) - .body(request); - if (mediaType != null) { - requestSpecification = requestSpecification.accept(mediaType); - } - GetTaskResponse response = requestSpecification - .when() - .post("/") - .then() - .statusCode(200) - .extract() - .as(GetTaskResponse.class); - assertEquals("1", response.getId()); - assertEquals("task-123", response.getResult().getId()); - assertEquals("session-xyz", response.getResult().getContextId()); - assertEquals(TaskState.SUBMITTED, response.getResult().getStatus().state()); - assertNull(response.getError()); + Task response = getClient().getTask(new TaskQueryParams(MINIMAL_TASK.getId()), null); + assertEquals("task-123", response.getId()); + assertEquals("session-xyz", response.getContextId()); + assertEquals(TaskState.SUBMITTED, response.getStatus().state()); + } catch (A2AClientException e) { + fail("Unexpected exception during getTask: " + e.getMessage(), e); } finally { deleteTaskInTaskStore(MINIMAL_TASK.getId()); } @@ -186,44 +180,25 @@ private void testGetTask(String mediaType) throws Exception { @Test public void testGetTaskNotFound() throws Exception { assertTrue(getTaskFromTaskStore("non-existent-task") == null); - GetTaskRequest request = new GetTaskRequest("1", new TaskQueryParams("non-existent-task")); - GetTaskResponse response = given() - .contentType(MediaType.APPLICATION_JSON) - .body(request) - .when() - .post("/") - .then() - .statusCode(200) - .extract() - .as(GetTaskResponse.class); - assertEquals("1", response.getId()); - // this should be an instance of TaskNotFoundError, see https://github.com/a2aproject/a2a-java/issues/23 - assertInstanceOf(JSONRPCError.class, response.getError()); - assertEquals(new TaskNotFoundError().getCode(), response.getError().getCode()); - assertNull(response.getResult()); + try { + getClient().getTask(new TaskQueryParams("non-existent-task"), null); + fail("Expected A2AClientException for non-existent task"); + } catch (A2AClientException e) { + // Expected - the client should throw an exception for non-existent tasks + assertInstanceOf(TaskNotFoundError.class, e.getCause()); + } } @Test public void testCancelTaskSuccess() throws Exception { saveTaskInTaskStore(CANCEL_TASK); try { - CancelTaskRequest request = new CancelTaskRequest("1", new TaskIdParams(CANCEL_TASK.getId())); - CancelTaskResponse response = given() - .contentType(MediaType.APPLICATION_JSON) - .body(request) - .when() - .post("/") - .then() - .statusCode(200) - .extract() - .as(CancelTaskResponse.class); - assertNull(response.getError()); - assertEquals(request.getId(), response.getId()); - Task task = response.getResult(); + Task task = getClient().cancelTask(new TaskIdParams(CANCEL_TASK.getId()), null); assertEquals(CANCEL_TASK.getId(), task.getId()); assertEquals(CANCEL_TASK.getContextId(), task.getContextId()); assertEquals(TaskState.CANCELED, task.getStatus().state()); - } catch (Exception e) { + } catch (A2AClientException e) { + fail("Unexpected exception during cancel task: " + e.getMessage(), e); } finally { deleteTaskInTaskStore(CANCEL_TASK.getId()); } @@ -233,22 +208,11 @@ public void testCancelTaskSuccess() throws Exception { public void testCancelTaskNotSupported() throws Exception { saveTaskInTaskStore(CANCEL_TASK_NOT_SUPPORTED); try { - CancelTaskRequest request = new CancelTaskRequest("1", new TaskIdParams(CANCEL_TASK_NOT_SUPPORTED.getId())); - CancelTaskResponse response = given() - .contentType(MediaType.APPLICATION_JSON) - .body(request) - .when() - .post("/") - .then() - .statusCode(200) - .extract() - .as(CancelTaskResponse.class); - assertEquals(request.getId(), response.getId()); - assertNull(response.getResult()); - // this should be an instance of UnsupportedOperationError, see https://github.com/a2aproject/a2a-java/issues/23 - assertInstanceOf(JSONRPCError.class, response.getError()); - assertEquals(new UnsupportedOperationError().getCode(), response.getError().getCode()); - } catch (Exception e) { + getClient().cancelTask(new TaskIdParams(CANCEL_TASK_NOT_SUPPORTED.getId()), null); + fail("Expected A2AClientException for unsupported cancel operation"); + } catch (A2AClientException e) { + // Expected - the client should throw an exception for unsupported operations + assertInstanceOf(UnsupportedOperationError.class, e.getCause()); } finally { deleteTaskInTaskStore(CANCEL_TASK_NOT_SUPPORTED.getId()); } @@ -256,22 +220,13 @@ public void testCancelTaskNotSupported() throws Exception { @Test public void testCancelTaskNotFound() { - CancelTaskRequest request = new CancelTaskRequest("1", new TaskIdParams("non-existent-task")); - CancelTaskResponse response = given() - .contentType(MediaType.APPLICATION_JSON) - .body(request) - .when() - .post("/") - .then() - .statusCode(200) - .extract() - .as(CancelTaskResponse.class) - ; - assertEquals(request.getId(), response.getId()); - assertNull(response.getResult()); - // this should be an instance of UnsupportedOperationError, see https://github.com/a2aproject/a2a-java/issues/23 - assertInstanceOf(JSONRPCError.class, response.getError()); - assertEquals(new TaskNotFoundError().getCode(), response.getError().getCode()); + try { + getClient().cancelTask(new TaskIdParams("non-existent-task"), null); + fail("Expected A2AClientException for non-existent task"); + } catch (A2AClientException e) { + // Expected - the client should throw an exception for non-existent tasks + assertInstanceOf(TaskNotFoundError.class, e.getCause()); + } } @Test @@ -281,18 +236,31 @@ public void testSendMessageNewMessageSuccess() throws Exception { .taskId(MINIMAL_TASK.getId()) .contextId(MINIMAL_TASK.getContextId()) .build(); - SendMessageRequest request = new SendMessageRequest("1", new MessageSendParams(message, null, null)); - SendMessageResponse response = given() - .contentType(MediaType.APPLICATION_JSON) - .body(request) - .when() - .post("/") - .then() - .statusCode(200) - .extract() - .as(SendMessageResponse.class); - assertNull(response.getError()); - Message messageResponse = (Message) response.getResult(); + + + CountDownLatch latch = new CountDownLatch(1); + AtomicReference receivedMessage = new AtomicReference<>(); + AtomicBoolean wasUnexpectedEvent = new AtomicBoolean(false); + BiConsumer consumer = (event, agentCard) -> { + if (event instanceof MessageEvent messageEvent) { + if (latch.getCount() > 0) { + receivedMessage.set(messageEvent.getMessage()); + latch.countDown(); + } else { + wasUnexpectedEvent.set(true); + } + } else { + wasUnexpectedEvent.set(true); + } + }; + + // testing the non-streaming send message + getNonStreamingClient().sendMessage(message, List.of(consumer), null, null); + + assertTrue(latch.await(10, TimeUnit.SECONDS)); + assertFalse(wasUnexpectedEvent.get()); + Message messageResponse = receivedMessage.get(); + assertNotNull(messageResponse); assertEquals(MESSAGE.getMessageId(), messageResponse.getMessageId()); assertEquals(MESSAGE.getRole(), messageResponse.getRole()); Part part = messageResponse.getParts().get(0); @@ -308,24 +276,36 @@ public void testSendMessageExistingTaskSuccess() throws Exception { .taskId(MINIMAL_TASK.getId()) .contextId(MINIMAL_TASK.getContextId()) .build(); - SendMessageRequest request = new SendMessageRequest("1", new MessageSendParams(message, null, null)); - SendMessageResponse response = given() - .contentType(MediaType.APPLICATION_JSON) - .body(request) - .when() - .post("/") - .then() - .statusCode(200) - .extract() - .as(SendMessageResponse.class); - assertNull(response.getError()); - Message messageResponse = (Message) response.getResult(); + + CountDownLatch latch = new CountDownLatch(1); + AtomicReference receivedMessage = new AtomicReference<>(); + AtomicBoolean wasUnexpectedEvent = new AtomicBoolean(false); + BiConsumer consumer = (event, agentCard) -> { + if (event instanceof MessageEvent messageEvent) { + if (latch.getCount() > 0) { + receivedMessage.set(messageEvent.getMessage()); + latch.countDown(); + } else { + wasUnexpectedEvent.set(true); + } + } else { + wasUnexpectedEvent.set(true); + } + }; + + // testing the non-streaming send message + getNonStreamingClient().sendMessage(message, List.of(consumer), null, null); + assertFalse(wasUnexpectedEvent.get()); + assertTrue(latch.await(10, TimeUnit.SECONDS)); + Message messageResponse = receivedMessage.get(); + assertNotNull(messageResponse); assertEquals(MESSAGE.getMessageId(), messageResponse.getMessageId()); assertEquals(MESSAGE.getRole(), messageResponse.getRole()); Part part = messageResponse.getParts().get(0); assertEquals(Part.Kind.TEXT, part.getKind()); assertEquals("test message", ((TextPart) part).getText()); - } catch (Exception e) { + } catch (A2AClientException e) { + fail("Unexpected exception during sendMessage: " + e.getMessage(), e); } finally { deleteTaskInTaskStore(MINIMAL_TASK.getId()); } @@ -338,22 +318,11 @@ public void testSetPushNotificationSuccess() throws Exception { TaskPushNotificationConfig taskPushConfig = new TaskPushNotificationConfig( MINIMAL_TASK.getId(), new PushNotificationConfig.Builder().url("http://example.com").build()); - SetTaskPushNotificationConfigRequest request = new SetTaskPushNotificationConfigRequest("1", taskPushConfig); - SetTaskPushNotificationConfigResponse response = given() - .contentType(MediaType.APPLICATION_JSON) - .body(request) - .when() - .post("/") - .then() - .statusCode(200) - .extract() - .as(SetTaskPushNotificationConfigResponse.class); - assertNull(response.getError()); - assertEquals(request.getId(), response.getId()); - TaskPushNotificationConfig config = response.getResult(); + TaskPushNotificationConfig config = getClient().setTaskPushNotificationConfiguration(taskPushConfig, null); assertEquals(MINIMAL_TASK.getId(), config.taskId()); assertEquals("http://example.com", config.pushNotificationConfig().url()); - } catch (Exception e) { + } catch (A2AClientException e) { + fail("Unexpected exception during set push notification test: " + e.getMessage(), e); } finally { deletePushNotificationConfigInStore(MINIMAL_TASK.getId(), MINIMAL_TASK.getId()); deleteTaskInTaskStore(MINIMAL_TASK.getId()); @@ -368,35 +337,15 @@ public void testGetPushNotificationSuccess() throws Exception { new TaskPushNotificationConfig( MINIMAL_TASK.getId(), new PushNotificationConfig.Builder().url("http://example.com").build()); - SetTaskPushNotificationConfigRequest setTaskPushNotificationRequest = new SetTaskPushNotificationConfigRequest("1", taskPushConfig); - SetTaskPushNotificationConfigResponse setTaskPushNotificationResponse = given() - .contentType(MediaType.APPLICATION_JSON) - .body(setTaskPushNotificationRequest) - .when() - .post("/") - .then() - .statusCode(200) - .extract() - .as(SetTaskPushNotificationConfigResponse.class); - assertNotNull(setTaskPushNotificationResponse); - - GetTaskPushNotificationConfigRequest request = - new GetTaskPushNotificationConfigRequest("111", new GetTaskPushNotificationConfigParams(MINIMAL_TASK.getId())); - GetTaskPushNotificationConfigResponse response = given() - .contentType(MediaType.APPLICATION_JSON) - .body(request) - .when() - .post("/") - .then() - .statusCode(200) - .extract() - .as(GetTaskPushNotificationConfigResponse.class); - assertNull(response.getError()); - assertEquals(request.getId(), response.getId()); - TaskPushNotificationConfig config = response.getResult(); + TaskPushNotificationConfig setResult = getClient().setTaskPushNotificationConfiguration(taskPushConfig, null); + assertNotNull(setResult); + + TaskPushNotificationConfig config = getClient().getTaskPushNotificationConfiguration( + new GetTaskPushNotificationConfigParams(MINIMAL_TASK.getId()), null); assertEquals(MINIMAL_TASK.getId(), config.taskId()); assertEquals("http://example.com", config.pushNotificationConfig().url()); - } catch (Exception e) { + } catch (A2AClientException e) { + fail("Unexpected exception during get push notification test: " + e.getMessage(), e); } finally { deletePushNotificationConfigInStore(MINIMAL_TASK.getId(), MINIMAL_TASK.getId()); deleteTaskInTaskStore(MINIMAL_TASK.getId()); @@ -404,43 +353,30 @@ public void testGetPushNotificationSuccess() throws Exception { } @Test - public void testError() { + public void testError() throws A2AClientException { Message message = new Message.Builder(MESSAGE) .taskId(SEND_MESSAGE_NOT_SUPPORTED.getId()) .contextId(SEND_MESSAGE_NOT_SUPPORTED.getContextId()) .build(); - SendMessageRequest request = new SendMessageRequest( - "1", new MessageSendParams(message, null, null)); - SendMessageResponse response = given() - .contentType(MediaType.APPLICATION_JSON) - .body(request) - .when() - .post("/") - .then() - .statusCode(200) - .extract() - .as(SendMessageResponse.class); - assertEquals(request.getId(), response.getId()); - assertNull(response.getResult()); - // this should be an instance of UnsupportedOperationError, see https://github.com/a2aproject/a2a-java/issues/23 - assertInstanceOf(JSONRPCError.class, response.getError()); - assertEquals(new UnsupportedOperationError().getCode(), response.getError().getCode()); + + try { + getNonStreamingClient().sendMessage(message, null); + + // For non-streaming clients, the error should still be thrown as an exception + fail("Expected A2AClientException for unsupported send message operation"); + } catch (A2AClientException e) { + // Expected - the client should throw an exception for unsupported operations + assertInstanceOf(UnsupportedOperationError.class, e.getCause()); + } } @Test - public void testGetAgentCard() { - AgentCard agentCard = given() - .contentType(MediaType.APPLICATION_JSON) - .when() - .get("/.well-known/agent-card.json") - .then() - .statusCode(200) - .extract() - .as(AgentCard.class); + public void testGetAgentCard() throws A2AClientException { + AgentCard agentCard = getClient().getAgentCard(null); assertNotNull(agentCard); assertEquals("test-card", agentCard.name()); assertEquals("A test agent card", agentCard.description()); - assertEquals("http://localhost:8081", agentCard.url()); + assertEquals(getTransportUrl(), agentCard.url()); assertEquals("1.0", agentCard.version()); assertEquals("http://example.com/docs", agentCard.documentationUrl()); assertTrue(agentCard.capabilities().pushNotifications()); @@ -467,132 +403,6 @@ public void testGetExtendAgentCardNotSupported() { assertNull(response.getResult()); } - @Test - public void testMalformedJSONRPCRequest() { - // missing closing bracket - String malformedRequest = "{\"jsonrpc\": \"2.0\", \"method\": \"message/send\", \"params\": {\"foo\": \"bar\"}"; - JSONRPCErrorResponse response = given() - .contentType(MediaType.APPLICATION_JSON) - .body(malformedRequest) - .when() - .post("/") - .then() - .statusCode(200) - .extract() - .as(JSONRPCErrorResponse.class); - assertNotNull(response.getError()); - assertEquals(new JSONParseError().getCode(), response.getError().getCode()); - } - - @Test - public void testInvalidParamsJSONRPCRequest() { - String invalidParamsRequest = """ - {"jsonrpc": "2.0", "method": "message/send", "params": "not_a_dict", "id": "1"} - """; - testInvalidParams(invalidParamsRequest); - - invalidParamsRequest = """ - {"jsonrpc": "2.0", "method": "message/send", "params": {"message": {"parts": "invalid"}}, "id": "1"} - """; - testInvalidParams(invalidParamsRequest); - } - - private void testInvalidParams(String invalidParamsRequest) { - JSONRPCErrorResponse response = given() - .contentType(MediaType.APPLICATION_JSON) - .body(invalidParamsRequest) - .when() - .post("/") - .then() - .statusCode(200) - .extract() - .as(JSONRPCErrorResponse.class); - assertNotNull(response.getError()); - assertEquals(new InvalidParamsError().getCode(), response.getError().getCode()); - assertEquals("1", response.getId()); - } - - @Test - public void testInvalidJSONRPCRequestMissingJsonrpc() { - String invalidRequest = """ - { - "method": "message/send", - "params": {} - } - """; - JSONRPCErrorResponse response = given() - .contentType(MediaType.APPLICATION_JSON) - .body(invalidRequest) - .when() - .post("/") - .then() - .statusCode(200) - .extract() - .as(JSONRPCErrorResponse.class); - assertNotNull(response.getError()); - assertEquals(new InvalidRequestError().getCode(), response.getError().getCode()); - } - - @Test - public void testInvalidJSONRPCRequestMissingMethod() { - String invalidRequest = """ - {"jsonrpc": "2.0", "params": {}} - """; - JSONRPCErrorResponse response = given() - .contentType(MediaType.APPLICATION_JSON) - .body(invalidRequest) - .when() - .post("/") - .then() - .statusCode(200) - .extract() - .as(JSONRPCErrorResponse.class); - assertNotNull(response.getError()); - assertEquals(new InvalidRequestError().getCode(), response.getError().getCode()); - } - - @Test - public void testInvalidJSONRPCRequestInvalidId() { - String invalidRequest = """ - {"jsonrpc": "2.0", "method": "message/send", "params": {}, "id": {"bad": "type"}} - """; - JSONRPCErrorResponse response = given() - .contentType(MediaType.APPLICATION_JSON) - .body(invalidRequest) - .when() - .post("/") - .then() - .statusCode(200) - .extract() - .as(JSONRPCErrorResponse.class); - assertNotNull(response.getError()); - assertEquals(new InvalidRequestError().getCode(), response.getError().getCode()); - } - - @Test - public void testInvalidJSONRPCRequestNonExistentMethod() { - String invalidRequest = """ - {"jsonrpc": "2.0", "method" : "nonexistent/method", "params": {}} - """; - JSONRPCErrorResponse response = given() - .contentType(MediaType.APPLICATION_JSON) - .body(invalidRequest) - .when() - .post("/") - .then() - .statusCode(200) - .extract() - .as(JSONRPCErrorResponse.class); - assertNotNull(response.getError()); - assertEquals(new MethodNotFoundError().getCode(), response.getError().getCode()); - } - - @Test - public void testNonStreamingMethodWithAcceptHeader() throws Exception { - testGetTask(MediaType.APPLICATION_JSON); - } - - @Test public void testSendMessageStreamExistingTaskSuccess() throws Exception { saveTaskInTaskStore(MINIMAL_TASK); @@ -601,48 +411,46 @@ public void testSendMessageStreamExistingTaskSuccess() throws Exception { .taskId(MINIMAL_TASK.getId()) .contextId(MINIMAL_TASK.getContextId()) .build(); - SendStreamingMessageRequest request = new SendStreamingMessageRequest( - "1", new MessageSendParams(message, null, null)); - - CompletableFuture>> responseFuture = initialiseStreamingRequest(request, null); CountDownLatch latch = new CountDownLatch(1); + AtomicReference receivedMessage = new AtomicReference<>(); + AtomicBoolean wasUnexpectedEvent = new AtomicBoolean(false); AtomicReference errorRef = new AtomicReference<>(); - responseFuture.thenAccept(response -> { - if (response.statusCode() != 200) { - //errorRef.set(new IllegalStateException("Status code was " + response.statusCode())); - throw new IllegalStateException("Status code was " + response.statusCode()); - } - response.body().forEach(line -> { - try { - SendStreamingMessageResponse jsonResponse = extractJsonResponseFromSseLine(line); - if (jsonResponse != null) { - assertNull(jsonResponse.getError()); - Message messageResponse = (Message) jsonResponse.getResult(); - assertEquals(MESSAGE.getMessageId(), messageResponse.getMessageId()); - assertEquals(MESSAGE.getRole(), messageResponse.getRole()); - Part part = messageResponse.getParts().get(0); - assertEquals(Part.Kind.TEXT, part.getKind()); - assertEquals("test message", ((TextPart) part).getText()); - latch.countDown(); - } - } catch (JsonProcessingException e) { - throw new RuntimeException(e); + BiConsumer consumer = (event, agentCard) -> { + if (event instanceof MessageEvent messageEvent) { + if (latch.getCount() > 0) { + receivedMessage.set(messageEvent.getMessage()); + latch.countDown(); + } else { + wasUnexpectedEvent.set(true); } - }); - }).exceptionally(t -> { - if (!isStreamClosedError(t)) { - errorRef.set(t); + } else { + wasUnexpectedEvent.set(true); } + }; + + Consumer errorHandler = error -> { + errorRef.set(error); latch.countDown(); - return null; - }); + }; - boolean dataRead = latch.await(20, TimeUnit.SECONDS); - Assertions.assertTrue(dataRead); - Assertions.assertNull(errorRef.get()); - } catch (Exception e) { + // testing the streaming send message + getClient().sendMessage(message, List.of(consumer), errorHandler, null); + + assertTrue(latch.await(10, TimeUnit.SECONDS)); + assertFalse(wasUnexpectedEvent.get()); + assertNull(errorRef.get()); + + Message messageResponse = receivedMessage.get(); + assertNotNull(messageResponse); + assertEquals(MESSAGE.getMessageId(), messageResponse.getMessageId()); + assertEquals(MESSAGE.getRole(), messageResponse.getRole()); + Part part = messageResponse.getParts().get(0); + assertEquals(Part.Kind.TEXT, part.getKind()); + assertEquals("test message", ((TextPart) part).getText()); + } catch (A2AClientException e) { + fail("Unexpected exception during sendMessage: " + e.getMessage(), e); } finally { deleteTaskInTaskStore(MINIMAL_TASK.getId()); } @@ -653,6 +461,7 @@ public void testSendMessageStreamExistingTaskSuccess() throws Exception { public void testResubscribeExistingTaskSuccess() throws Exception { ExecutorService executorService = Executors.newSingleThreadExecutor(); saveTaskInTaskStore(MINIMAL_TASK); + Client client = null; try { // attempting to send a streaming message instead of explicitly calling queueManager#createOrTap @@ -660,104 +469,100 @@ public void testResubscribeExistingTaskSuccess() throws Exception { // requires the queue to still be active ensureQueueForTask(MINIMAL_TASK.getId()); - CountDownLatch taskResubscriptionRequestSent = new CountDownLatch(1); - CountDownLatch taskResubscriptionResponseReceived = new CountDownLatch(2); - AtomicReference firstResponse = new AtomicReference<>(); - AtomicReference secondResponse = new AtomicReference<>(); - - // resubscribe to the task, requires the task and its queue to still be active - TaskResubscriptionRequest taskResubscriptionRequest = new TaskResubscriptionRequest("1", new TaskIdParams(MINIMAL_TASK.getId())); - - // Count down the latch when the MultiSseSupport on the server has started subscribing - awaitStreamingSubscription() - .whenComplete((unused, throwable) -> taskResubscriptionRequestSent.countDown()); - - CompletableFuture>> responseFuture = initialiseStreamingRequest(taskResubscriptionRequest, null); - + CountDownLatch eventLatch = new CountDownLatch(2); + AtomicReference artifactUpdateEvent = new AtomicReference<>(); + AtomicReference statusUpdateEvent = new AtomicReference<>(); + AtomicBoolean wasUnexpectedEvent = new AtomicBoolean(false); AtomicReference errorRef = new AtomicReference<>(); - responseFuture.thenAccept(response -> { - - if (response.statusCode() != 200) { - throw new IllegalStateException("Status code was " + response.statusCode()); - } - try { - response.body().forEach(line -> { - try { - SendStreamingMessageResponse jsonResponse = extractJsonResponseFromSseLine(line); - if (jsonResponse != null) { - SendStreamingMessageResponse sendStreamingMessageResponse = Utils.OBJECT_MAPPER.readValue(line.substring("data: ".length()).trim(), SendStreamingMessageResponse.class); - if (taskResubscriptionResponseReceived.getCount() == 2) { - firstResponse.set(sendStreamingMessageResponse); - } else { - secondResponse.set(sendStreamingMessageResponse); - } - taskResubscriptionResponseReceived.countDown(); - if (taskResubscriptionResponseReceived.getCount() == 0) { - throw new BreakException(); - } - } - } catch (JsonProcessingException e) { - throw new RuntimeException(e); - } - }); - } catch (BreakException e) { - } - }).exceptionally(t -> { - if (!isStreamClosedError(t)) { - errorRef.set(t); + // Create consumer to handle resubscribed events + BiConsumer consumer = (event, agentCard) -> { + if (event instanceof TaskUpdateEvent taskUpdateEvent) { + if (taskUpdateEvent.getUpdateEvent() instanceof TaskArtifactUpdateEvent artifactEvent) { + artifactUpdateEvent.set(artifactEvent); + eventLatch.countDown(); + } else if (taskUpdateEvent.getUpdateEvent() instanceof TaskStatusUpdateEvent statusEvent) { + statusUpdateEvent.set(statusEvent); + eventLatch.countDown(); + } else { + wasUnexpectedEvent.set(true); + } + } else { + wasUnexpectedEvent.set(true); } - return null; - }); + }; - try { - taskResubscriptionRequestSent.await(); - List events = List.of( - new TaskArtifactUpdateEvent.Builder() - .taskId(MINIMAL_TASK.getId()) - .contextId(MINIMAL_TASK.getContextId()) - .artifact(new Artifact.Builder() - .artifactId("11") - .parts(new TextPart("text")) - .build()) - .build(), - new TaskStatusUpdateEvent.Builder() - .taskId(MINIMAL_TASK.getId()) - .contextId(MINIMAL_TASK.getContextId()) - .status(new TaskStatus(TaskState.COMPLETED)) - .isFinal(true) - .build()); - - for (Event event : events) { - enqueueEventOnServer(event); - } - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - } + // Create error handler + Consumer errorHandler = error -> { + errorRef.set(error); + eventLatch.countDown(); + }; - // wait for the client to receive the responses - taskResubscriptionResponseReceived.await(); + // Get client for resubscription + client = getClient(); + + // Count down when the streaming subscription is established + CountDownLatch subscriptionLatch = new CountDownLatch(1); + awaitStreamingSubscription() + .whenComplete((unused, throwable) -> subscriptionLatch.countDown()); + + // Resubscribe to the task with specific consumer and error handler + List> consumers = consumer != null ? List.of(consumer) : List.of(); + client.resubscribe(new TaskIdParams(MINIMAL_TASK.getId()), consumers, errorHandler, null); + + // Wait for subscription to be established + assertTrue(subscriptionLatch.await(15, TimeUnit.SECONDS)); + + // Enqueue events on the server + List events = List.of( + new TaskArtifactUpdateEvent.Builder() + .taskId(MINIMAL_TASK.getId()) + .contextId(MINIMAL_TASK.getContextId()) + .artifact(new Artifact.Builder() + .artifactId("11") + .parts(new TextPart("text")) + .build()) + .build(), + new TaskStatusUpdateEvent.Builder() + .taskId(MINIMAL_TASK.getId()) + .contextId(MINIMAL_TASK.getContextId()) + .status(new TaskStatus(TaskState.COMPLETED)) + .isFinal(true) + .build()); + + for (Event event : events) { + enqueueEventOnServer(event); + } - assertNotNull(firstResponse.get()); - SendStreamingMessageResponse sendStreamingMessageResponse = firstResponse.get(); - assertNull(sendStreamingMessageResponse.getError()); - TaskArtifactUpdateEvent taskArtifactUpdateEvent = (TaskArtifactUpdateEvent) sendStreamingMessageResponse.getResult(); - assertEquals(MINIMAL_TASK.getId(), taskArtifactUpdateEvent.getTaskId()); - assertEquals(MINIMAL_TASK.getContextId(), taskArtifactUpdateEvent.getContextId()); - Part part = taskArtifactUpdateEvent.getArtifact().parts().get(0); + // Wait for events to be received + assertTrue(eventLatch.await(30, TimeUnit.SECONDS)); + assertFalse(wasUnexpectedEvent.get()); + assertNull(errorRef.get()); + + // Verify artifact update event + TaskArtifactUpdateEvent receivedArtifactEvent = artifactUpdateEvent.get(); + assertNotNull(receivedArtifactEvent); + assertEquals(MINIMAL_TASK.getId(), receivedArtifactEvent.getTaskId()); + assertEquals(MINIMAL_TASK.getContextId(), receivedArtifactEvent.getContextId()); + Part part = receivedArtifactEvent.getArtifact().parts().get(0); assertEquals(Part.Kind.TEXT, part.getKind()); assertEquals("text", ((TextPart) part).getText()); - assertNotNull(secondResponse.get()); - sendStreamingMessageResponse = secondResponse.get(); - assertNull(sendStreamingMessageResponse.getError()); - TaskStatusUpdateEvent taskStatusUpdateEvent = (TaskStatusUpdateEvent) sendStreamingMessageResponse.getResult(); - assertEquals(MINIMAL_TASK.getId(), taskStatusUpdateEvent.getTaskId()); - assertEquals(MINIMAL_TASK.getContextId(), taskStatusUpdateEvent.getContextId()); - assertEquals(TaskState.COMPLETED, taskStatusUpdateEvent.getStatus().state()); - assertNotNull(taskStatusUpdateEvent.getStatus().timestamp()); + // Verify status update event + TaskStatusUpdateEvent receivedStatusEvent = statusUpdateEvent.get(); + assertNotNull(receivedStatusEvent); + assertEquals(MINIMAL_TASK.getId(), receivedStatusEvent.getTaskId()); + assertEquals(MINIMAL_TASK.getContextId(), receivedStatusEvent.getContextId()); + assertEquals(TaskState.COMPLETED, receivedStatusEvent.getStatus().state()); + assertNotNull(receivedStatusEvent.getStatus().timestamp()); } finally { - //setStreamingSubscribedRunnable(null); + try { + if (client != null) { + client.close(); + } + } catch (Exception e) { + // Ignore cleanup errors + } deleteTaskInTaskStore(MINIMAL_TASK.getId()); executorService.shutdown(); if (!executorService.awaitTermination(10, TimeUnit.SECONDS)) { @@ -768,53 +573,74 @@ public void testResubscribeExistingTaskSuccess() throws Exception { @Test public void testResubscribeNoExistingTaskError() throws Exception { - TaskResubscriptionRequest request = new TaskResubscriptionRequest("1", new TaskIdParams("non-existent-task")); + CountDownLatch errorLatch = new CountDownLatch(1); + AtomicReference errorRef = new AtomicReference<>(); - CompletableFuture>> responseFuture = initialiseStreamingRequest(request, null); + // Create error handler to capture the TaskNotFoundError + Consumer errorHandler = error -> { + errorRef.set(error); + errorLatch.countDown(); + }; - CountDownLatch latch = new CountDownLatch(1); - AtomicReference errorRef = new AtomicReference<>(); + // Get client for resubscription + Client client = getClient(); - responseFuture.thenAccept(response -> { - if (response.statusCode() != 200) { - //errorRef.set(new IllegalStateException("Status code was " + response.statusCode())); - throw new IllegalStateException("Status code was " + response.statusCode()); - } - response.body().forEach(line -> { - try { - SendStreamingMessageResponse jsonResponse = extractJsonResponseFromSseLine(line); - if (jsonResponse != null) { - assertEquals(request.getId(), jsonResponse.getId()); - assertNull(jsonResponse.getResult()); - // this should be an instance of TaskNotFoundError, see https://github.com/a2aproject/a2a-java/issues/23 - assertInstanceOf(JSONRPCError.class, jsonResponse.getError()); - assertEquals(new TaskNotFoundError().getCode(), jsonResponse.getError().getCode()); - latch.countDown(); + try { + client.resubscribe(new TaskIdParams("non-existent-task"), List.of(), errorHandler, null); + + // Wait for error to be captured (may come via error handler for streaming) + boolean errorReceived = errorLatch.await(10, TimeUnit.SECONDS); + + if (errorReceived) { + // Error came via error handler + Throwable error = errorRef.get(); + assertNotNull(error); + if (error instanceof A2AClientException) { + assertInstanceOf(TaskNotFoundError.class, ((A2AClientException) error).getCause()); + } else { + // Check if it's directly a TaskNotFoundError or walk the cause chain + Throwable cause = error; + boolean foundTaskNotFound = false; + while (cause != null && !foundTaskNotFound) { + if (cause instanceof TaskNotFoundError) { + foundTaskNotFound = true; + } + cause = cause.getCause(); + } + if (!foundTaskNotFound) { + fail("Expected TaskNotFoundError in error chain"); } - } catch (JsonProcessingException e) { - throw new RuntimeException(e); } - }); - }).exceptionally(t -> { - if (!isStreamClosedError(t)) { - errorRef.set(t); + } else { + fail("Expected error for non-existent task resubscription"); } - latch.countDown(); - return null; - }); - - boolean dataRead = latch.await(20, TimeUnit.SECONDS); - Assertions.assertTrue(dataRead); - Assertions.assertNull(errorRef.get()); + } catch (A2AClientException e) { + // Also acceptable - the client might throw an exception immediately + assertInstanceOf(TaskNotFoundError.class, e.getCause()); + } finally { + try { + if (client != null) { + client.close(); + } + } catch (Exception e) { + // Ignore cleanup errors + } + } } @Test public void testStreamingMethodWithAcceptHeader() throws Exception { + // Skip this test for gRPC transport as it uses HTTP SSE which is JSONRPC-specific + Assumptions.assumeTrue(TransportProtocol.JSONRPC.asString().equals(getTransportProtocol()), + "HTTP streaming tests are only supported for JSONRPC transport"); testSendStreamingMessage(MediaType.SERVER_SENT_EVENTS); } @Test public void testSendMessageStreamNewMessageSuccess() throws Exception { + // Skip this test for gRPC transport as it uses HTTP SSE which is JSONRPC-specific + Assumptions.assumeTrue(TransportProtocol.JSONRPC.asString().equals(getTransportProtocol()), + "HTTP streaming tests are only supported for JSONRPC transport"); testSendStreamingMessage(null); } @@ -885,7 +711,7 @@ public void testListPushNotificationConfigWithConfigId() throws Exception { savePushNotificationConfigInStore(MINIMAL_TASK.getId(), notificationConfig2); try { - List result = client.listTaskPushNotificationConfigurations( + List result = getClient().listTaskPushNotificationConfigurations( new ListTaskPushNotificationConfigParams(MINIMAL_TASK.getId()), null); assertEquals(2, result.size()); assertEquals(new TaskPushNotificationConfig(MINIMAL_TASK.getId(), notificationConfig1), result.get(0)); @@ -915,7 +741,7 @@ public void testListPushNotificationConfigWithoutConfigId() throws Exception { // will overwrite the previous one savePushNotificationConfigInStore(MINIMAL_TASK.getId(), notificationConfig2); try { - List result = client.listTaskPushNotificationConfigurations( + List result = getClient().listTaskPushNotificationConfigurations( new ListTaskPushNotificationConfigParams(MINIMAL_TASK.getId()), null); assertEquals(1, result.size()); @@ -936,7 +762,7 @@ public void testListPushNotificationConfigWithoutConfigId() throws Exception { @Test public void testListPushNotificationConfigTaskNotFound() { try { - List result = client.listTaskPushNotificationConfigurations( + List result = getClient().listTaskPushNotificationConfigurations( new ListTaskPushNotificationConfigParams("non-existent-task"), null); fail(); } catch (A2AClientException e) { @@ -948,11 +774,11 @@ public void testListPushNotificationConfigTaskNotFound() { public void testListPushNotificationConfigEmptyList() throws Exception { saveTaskInTaskStore(MINIMAL_TASK); try { - List result = client.listTaskPushNotificationConfigurations( + List result = getClient().listTaskPushNotificationConfigurations( new ListTaskPushNotificationConfigParams(MINIMAL_TASK.getId()), null); assertEquals(0, result.size()); } catch (Exception e) { - fail(); + fail(e.getMessage()); } finally { deleteTaskInTaskStore(MINIMAL_TASK.getId()); } @@ -983,21 +809,21 @@ public void testDeletePushNotificationConfigWithValidConfigId() throws Exception try { // specify the config ID to delete - client.deleteTaskPushNotificationConfigurations( + getClient().deleteTaskPushNotificationConfigurations( new DeleteTaskPushNotificationConfigParams(MINIMAL_TASK.getId(), "config1"), null); // should now be 1 left - List result = client.listTaskPushNotificationConfigurations( + List result = getClient().listTaskPushNotificationConfigurations( new ListTaskPushNotificationConfigParams(MINIMAL_TASK.getId()), null); assertEquals(1, result.size()); // should remain unchanged, this is a different task - result = client.listTaskPushNotificationConfigurations( + result = getClient().listTaskPushNotificationConfigurations( new ListTaskPushNotificationConfigParams("task-456"), null); assertEquals(1, result.size()); } catch (Exception e) { - fail(); + fail(e.getMessage()); } finally { deletePushNotificationConfigInStore(MINIMAL_TASK.getId(), "config1"); deletePushNotificationConfigInStore(MINIMAL_TASK.getId(), "config2"); @@ -1024,12 +850,12 @@ public void testDeletePushNotificationConfigWithNonExistingConfigId() throws Exc savePushNotificationConfigInStore(MINIMAL_TASK.getId(), notificationConfig2); try { - client.deleteTaskPushNotificationConfigurations( + getClient().deleteTaskPushNotificationConfigurations( new DeleteTaskPushNotificationConfigParams(MINIMAL_TASK.getId(), "non-existent-config-id"), null); // should remain unchanged - List result = client.listTaskPushNotificationConfigurations( + List result = getClient().listTaskPushNotificationConfigurations( new ListTaskPushNotificationConfigParams(MINIMAL_TASK.getId()), null); assertEquals(2, result.size()); } catch (Exception e) { @@ -1044,7 +870,7 @@ public void testDeletePushNotificationConfigWithNonExistingConfigId() throws Exc @Test public void testDeletePushNotificationConfigTaskNotFound() { try { - client.deleteTaskPushNotificationConfigurations( + getClient().deleteTaskPushNotificationConfigurations( new DeleteTaskPushNotificationConfigParams("non-existent-task", "non-existent-config-id"), null); @@ -1071,12 +897,12 @@ public void testDeletePushNotificationConfigSetWithoutConfigId() throws Exceptio savePushNotificationConfigInStore(MINIMAL_TASK.getId(), notificationConfig2); try { - client.deleteTaskPushNotificationConfigurations( + getClient().deleteTaskPushNotificationConfigurations( new DeleteTaskPushNotificationConfigParams(MINIMAL_TASK.getId(), MINIMAL_TASK.getId()), null); // should now be 0 - List result = client.listTaskPushNotificationConfigurations( + List result = getClient().listTaskPushNotificationConfigurations( new ListTaskPushNotificationConfigParams(MINIMAL_TASK.getId()), null); assertEquals(0, result.size()); } catch (Exception e) { @@ -1300,6 +1126,88 @@ protected void savePushNotificationConfigInStore(String taskId, PushNotification } } + /** + * Get a client instance. + */ + protected Client getClient() throws A2AClientException { + if (client == null) { + client = createClient(true); + } + return client; + } + + /** + * Get a client configured for non-streaming operations. + */ + protected Client getNonStreamingClient() throws A2AClientException { + if (nonStreamingClient == null) { + nonStreamingClient = createClient(false); + } + return nonStreamingClient; + } + + /** + * Create a client with the specified streaming configuration. + */ + private Client createClient(boolean streaming) throws A2AClientException { + AgentCard agentCard = createTestAgentCard(); + ClientConfig clientConfig = createClientConfig(streaming); + ClientFactory clientFactory = new ClientFactory(clientConfig); + return clientFactory.create(agentCard, List.of(), null, null); + } + + + + /** + * Create a test agent card with the appropriate transport configuration. + */ + private AgentCard createTestAgentCard() { + return new AgentCard.Builder() + .name("test-card") + .description("A test agent card") + .url(getTransportUrl()) + .version("1.0") + .documentationUrl("http://example.com/docs") + .preferredTransport(getTransportProtocol()) + .capabilities(new AgentCapabilities.Builder() + .streaming(true) + .pushNotifications(true) + .stateTransitionHistory(true) + .build()) + .defaultInputModes(List.of("text")) + .defaultOutputModes(List.of("text")) + .skills(List.of()) + .additionalInterfaces(List.of(new AgentInterface(getTransportProtocol(), getTransportUrl()))) + .protocolVersion("0.2.5") + .build(); + } + + /** + * Create client configuration with transport-specific settings. + */ + private ClientConfig createClientConfig(boolean streaming) { + ClientConfig.Builder builder = new ClientConfig.Builder() + .setStreaming(streaming) + .setSupportedTransports(List.of(getTransportProtocol())) + .setAcceptedOutputModes(List.of("text")); + + // Set transport-specific configuration + List transportConfigs = new ArrayList<>(); + if (TransportProtocol.JSONRPC.asString().equals(getTransportProtocol())) { + transportConfigs.add(new JSONRPCTransportConfig(new JdkA2AHttpClient())); + } else if (TransportProtocol.GRPC.asString().equals(getTransportProtocol())) { + // For gRPC, use a function that creates a channel with plaintext communication + transportConfigs.add(new GrpcTransportConfig( + target -> ManagedChannelBuilder.forTarget(target).usePlaintext().build())); + } + + if (!transportConfigs.isEmpty()) { + builder.setClientTransportConfigs(transportConfigs); + } + + return builder.build(); + } + private static class BreakException extends RuntimeException { } From a5a7c44fce28d640699a952b0491422a4c85f1f1 Mon Sep 17 00:00:00 2001 From: Farah Juma Date: Wed, 20 Aug 2025 09:23:24 -0400 Subject: [PATCH 13/31] fix: Ensure onSetTaskPushNotificationConfig returns the updated pushNotificationConfig --- .../DefaultRequestHandler.java | 5 ++- .../InMemoryPushNotificationConfigStore.java | 3 +- .../tasks/PushNotificationConfigStore.java | 3 +- .../java/io/a2a/grpc/utils/ProtoUtils.java | 31 +++++++++++++------ .../jsonrpc/handler/JSONRPCHandlerTest.java | 5 ++- 5 files changed, 31 insertions(+), 16 deletions(-) diff --git a/server-common/src/main/java/io/a2a/server/requesthandlers/DefaultRequestHandler.java b/server-common/src/main/java/io/a2a/server/requesthandlers/DefaultRequestHandler.java index e79a97201..7cb223c98 100644 --- a/server-common/src/main/java/io/a2a/server/requesthandlers/DefaultRequestHandler.java +++ b/server-common/src/main/java/io/a2a/server/requesthandlers/DefaultRequestHandler.java @@ -273,9 +273,8 @@ public TaskPushNotificationConfig onSetTaskPushNotificationConfig( throw new TaskNotFoundError(); } - pushConfigStore.setInfo(params.taskId(), params.pushNotificationConfig()); - - return params; + PushNotificationConfig pushNotificationConfig = pushConfigStore.setInfo(params.taskId(), params.pushNotificationConfig()); + return new TaskPushNotificationConfig(params.taskId(), pushNotificationConfig); } @Override diff --git a/server-common/src/main/java/io/a2a/server/tasks/InMemoryPushNotificationConfigStore.java b/server-common/src/main/java/io/a2a/server/tasks/InMemoryPushNotificationConfigStore.java index e66fc1669..eac60fbc6 100644 --- a/server-common/src/main/java/io/a2a/server/tasks/InMemoryPushNotificationConfigStore.java +++ b/server-common/src/main/java/io/a2a/server/tasks/InMemoryPushNotificationConfigStore.java @@ -27,7 +27,7 @@ public InMemoryPushNotificationConfigStore() { } @Override - public void setInfo(String taskId, PushNotificationConfig notificationConfig) { + public PushNotificationConfig setInfo(String taskId, PushNotificationConfig notificationConfig) { List notificationConfigList = pushNotificationInfos.getOrDefault(taskId, new ArrayList<>()); PushNotificationConfig.Builder builder = new PushNotificationConfig.Builder(notificationConfig); if (notificationConfig.id() == null) { @@ -45,6 +45,7 @@ public void setInfo(String taskId, PushNotificationConfig notificationConfig) { } notificationConfigList.add(notificationConfig); pushNotificationInfos.put(taskId, notificationConfigList); + return notificationConfig; } @Override diff --git a/server-common/src/main/java/io/a2a/server/tasks/PushNotificationConfigStore.java b/server-common/src/main/java/io/a2a/server/tasks/PushNotificationConfigStore.java index 68f132620..de7a27deb 100644 --- a/server-common/src/main/java/io/a2a/server/tasks/PushNotificationConfigStore.java +++ b/server-common/src/main/java/io/a2a/server/tasks/PushNotificationConfigStore.java @@ -13,8 +13,9 @@ public interface PushNotificationConfigStore { * Sets or updates the push notification configuration for a task. * @param taskId the task ID * @param notificationConfig the push notification configuration + * @return the potentially updated push notification configuration */ - void setInfo(String taskId, PushNotificationConfig notificationConfig); + PushNotificationConfig setInfo(String taskId, PushNotificationConfig notificationConfig); /** * Retrieves the push notification configuration for a task. diff --git a/spec-grpc/src/main/java/io/a2a/grpc/utils/ProtoUtils.java b/spec-grpc/src/main/java/io/a2a/grpc/utils/ProtoUtils.java index ddeaa11c7..8aaf9224a 100644 --- a/spec-grpc/src/main/java/io/a2a/grpc/utils/ProtoUtils.java +++ b/spec-grpc/src/main/java/io/a2a/grpc/utils/ProtoUtils.java @@ -687,24 +687,38 @@ public static MessageSendParams messageSendParams(io.a2a.grpc.SendMessageRequest return builder.build(); } - public static TaskPushNotificationConfig taskPushNotificationConfig(io.a2a.grpc.CreateTaskPushNotificationConfigRequest request) { - return taskPushNotificationConfig(request.getConfig()); + public static TaskPushNotificationConfig taskPushNotificationConfig(io.a2a.grpc.CreateTaskPushNotificationConfigRequestOrBuilder request) { + return taskPushNotificationConfig(request.getConfig(), true); } - public static TaskPushNotificationConfig taskPushNotificationConfig(io.a2a.grpc.TaskPushNotificationConfig config) { + public static TaskPushNotificationConfig taskPushNotificationConfig(io.a2a.grpc.TaskPushNotificationConfigOrBuilder config) { + return taskPushNotificationConfig(config, false); + } + + private static TaskPushNotificationConfig taskPushNotificationConfig(io.a2a.grpc.TaskPushNotificationConfigOrBuilder config, boolean create) { String name = config.getName(); // "tasks/{id}/pushNotificationConfigs/{push_id}" String[] parts = name.split("/"); - if (parts.length < 4) { - throw new IllegalArgumentException("Invalid name format for TaskPushNotificationConfig: " + name); + String configId = ""; + if (create) { + if (parts.length < 3) { + throw new IllegalArgumentException("Invalid name format for TaskPushNotificationConfig: " + name); + } + if (parts.length == 4) { + configId = parts[3]; + } + } else { + if (parts.length < 4) { + throw new IllegalArgumentException("Invalid name format for TaskPushNotificationConfig: " + name); + } + configId = parts[3]; } String taskId = parts[1]; - String configId = parts[3]; PushNotificationConfig pnc = pushNotification(config.getPushNotificationConfig(), configId); return new TaskPushNotificationConfig(taskId, pnc); } public static GetTaskPushNotificationConfigParams getTaskPushNotificationConfigParams(io.a2a.grpc.GetTaskPushNotificationConfigRequest request) { - String name = request.getName(); // "tasks/{id}/pushNotificationConfigs/{push_id}" or /tasks/{id} + String name = request.getName(); // "tasks/{id}/pushNotificationConfigs/{push_id}" String[] parts = name.split("/"); String taskId = parts[1]; String configId; @@ -775,9 +789,6 @@ private static PushNotificationConfig pushNotification(io.a2a.grpc.PushNotificat } private static PushNotificationConfig pushNotification(io.a2a.grpc.PushNotificationConfig pushNotification) { - /*if (pushNotification == null) { - return null; - }*/ return pushNotification(pushNotification, pushNotification.getId()); } diff --git a/transport/jsonrpc/src/test/java/io/a2a/transport/jsonrpc/handler/JSONRPCHandlerTest.java b/transport/jsonrpc/src/test/java/io/a2a/transport/jsonrpc/handler/JSONRPCHandlerTest.java index 728a26d9d..0754cd055 100644 --- a/transport/jsonrpc/src/test/java/io/a2a/transport/jsonrpc/handler/JSONRPCHandlerTest.java +++ b/transport/jsonrpc/src/test/java/io/a2a/transport/jsonrpc/handler/JSONRPCHandlerTest.java @@ -557,7 +557,10 @@ public void testSetPushNotificationConfigSuccess() { MINIMAL_TASK.getId(), new PushNotificationConfig.Builder().url("http://example.com").build()); SetTaskPushNotificationConfigRequest request = new SetTaskPushNotificationConfigRequest("1", taskPushConfig); SetTaskPushNotificationConfigResponse response = handler.setPushNotificationConfig(request, callContext); - Assertions.assertSame(taskPushConfig, response.getResult()); + TaskPushNotificationConfig taskPushConfigResult = + new TaskPushNotificationConfig( + MINIMAL_TASK.getId(), new PushNotificationConfig.Builder().url("http://example.com").id(MINIMAL_TASK.getId()).build()); + Assertions.assertEquals(taskPushConfigResult, response.getResult()); } @Test From 2a31b13214ce9c871a98c9108edbe7edff33350b Mon Sep 17 00:00:00 2001 From: Farah Juma Date: Wed, 20 Aug 2025 10:31:24 -0400 Subject: [PATCH 14/31] fix: Test cleanup --- .../apps/common/AbstractA2AServerTest.java | 51 ++----------------- .../grpc/handler/GrpcHandlerTest.java | 6 +++ 2 files changed, 9 insertions(+), 48 deletions(-) diff --git a/tests/server-common/src/test/java/io/a2a/server/apps/common/AbstractA2AServerTest.java b/tests/server-common/src/test/java/io/a2a/server/apps/common/AbstractA2AServerTest.java index d9464033c..26b572b9c 100644 --- a/tests/server-common/src/test/java/io/a2a/server/apps/common/AbstractA2AServerTest.java +++ b/tests/server-common/src/test/java/io/a2a/server/apps/common/AbstractA2AServerTest.java @@ -383,24 +383,7 @@ public void testGetAgentCard() throws A2AClientException { assertTrue(agentCard.capabilities().streaming()); assertTrue(agentCard.capabilities().stateTransitionHistory()); assertTrue(agentCard.skills().isEmpty()); - } - - @Test - public void testGetExtendAgentCardNotSupported() { - GetAuthenticatedExtendedCardRequest request = new GetAuthenticatedExtendedCardRequest("1"); - GetAuthenticatedExtendedCardResponse response = given() - .contentType(MediaType.APPLICATION_JSON) - .body(request) - .when() - .post("/") - .then() - .statusCode(200) - .extract() - .as(GetAuthenticatedExtendedCardResponse.class); - assertEquals("1", response.getId()); - assertInstanceOf(JSONRPCError.class, response.getError()); - assertEquals(new AuthenticatedExtendedCardNotConfiguredError().getCode(), response.getError().getCode()); - assertNull(response.getResult()); + assertFalse(agentCard.supportsAuthenticatedExtendedCard()); } @Test @@ -459,10 +442,7 @@ public void testSendMessageStreamExistingTaskSuccess() throws Exception { @Test @Timeout(value = 3, unit = TimeUnit.MINUTES) public void testResubscribeExistingTaskSuccess() throws Exception { - ExecutorService executorService = Executors.newSingleThreadExecutor(); saveTaskInTaskStore(MINIMAL_TASK); - Client client = null; - try { // attempting to send a streaming message instead of explicitly calling queueManager#createOrTap // does not work because after the message is sent, the queue becomes null but task resubscription @@ -498,9 +478,6 @@ public void testResubscribeExistingTaskSuccess() throws Exception { eventLatch.countDown(); }; - // Get client for resubscription - client = getClient(); - // Count down when the streaming subscription is established CountDownLatch subscriptionLatch = new CountDownLatch(1); awaitStreamingSubscription() @@ -508,7 +485,7 @@ public void testResubscribeExistingTaskSuccess() throws Exception { // Resubscribe to the task with specific consumer and error handler List> consumers = consumer != null ? List.of(consumer) : List.of(); - client.resubscribe(new TaskIdParams(MINIMAL_TASK.getId()), consumers, errorHandler, null); + getClient().resubscribe(new TaskIdParams(MINIMAL_TASK.getId()), consumers, errorHandler, null); // Wait for subscription to be established assertTrue(subscriptionLatch.await(15, TimeUnit.SECONDS)); @@ -556,18 +533,7 @@ public void testResubscribeExistingTaskSuccess() throws Exception { assertEquals(TaskState.COMPLETED, receivedStatusEvent.getStatus().state()); assertNotNull(receivedStatusEvent.getStatus().timestamp()); } finally { - try { - if (client != null) { - client.close(); - } - } catch (Exception e) { - // Ignore cleanup errors - } deleteTaskInTaskStore(MINIMAL_TASK.getId()); - executorService.shutdown(); - if (!executorService.awaitTermination(10, TimeUnit.SECONDS)) { - executorService.shutdownNow(); - } } } @@ -582,11 +548,8 @@ public void testResubscribeNoExistingTaskError() throws Exception { errorLatch.countDown(); }; - // Get client for resubscription - Client client = getClient(); - try { - client.resubscribe(new TaskIdParams("non-existent-task"), List.of(), errorHandler, null); + getClient().resubscribe(new TaskIdParams("non-existent-task"), List.of(), errorHandler, null); // Wait for error to be captured (may come via error handler for streaming) boolean errorReceived = errorLatch.await(10, TimeUnit.SECONDS); @@ -617,14 +580,6 @@ public void testResubscribeNoExistingTaskError() throws Exception { } catch (A2AClientException e) { // Also acceptable - the client might throw an exception immediately assertInstanceOf(TaskNotFoundError.class, e.getCause()); - } finally { - try { - if (client != null) { - client.close(); - } - } catch (Exception e) { - // Ignore cleanup errors - } } } diff --git a/transport/grpc/src/test/java/io/a2a/transport/grpc/handler/GrpcHandlerTest.java b/transport/grpc/src/test/java/io/a2a/transport/grpc/handler/GrpcHandlerTest.java index c49f91d84..e5aba9097 100644 --- a/transport/grpc/src/test/java/io/a2a/transport/grpc/handler/GrpcHandlerTest.java +++ b/transport/grpc/src/test/java/io/a2a/transport/grpc/handler/GrpcHandlerTest.java @@ -50,6 +50,7 @@ import io.grpc.stub.StreamObserver; import mutiny.zero.ZeroPublisher; import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; import org.mockito.MockedConstruction; import org.mockito.Mockito; @@ -738,6 +739,11 @@ public void testDeletePushNotificationConfigNoPushConfigStore() { assertGrpcError(streamRecorder, Status.Code.UNIMPLEMENTED); } + @Disabled + public void testOnGetAuthenticatedExtendedAgentCard() throws Exception { + // TODO - getting the authenticated extended agent card isn't support for gRPC right now + } + private StreamRecorder sendMessageRequest(GrpcHandler handler) throws Exception { SendMessageRequest request = SendMessageRequest.newBuilder() .setRequest(GRPC_MESSAGE) From 0740c0f4f7c7364f6a97cce7bf7d666c9fe04255 Mon Sep 17 00:00:00 2001 From: Farah Juma Date: Wed, 20 Aug 2025 12:38:20 -0400 Subject: [PATCH 15/31] fix: Check for empty notification ID in InMemoryPushNotificationConfigStore#setInfo --- .../a2a/server/tasks/InMemoryPushNotificationConfigStore.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server-common/src/main/java/io/a2a/server/tasks/InMemoryPushNotificationConfigStore.java b/server-common/src/main/java/io/a2a/server/tasks/InMemoryPushNotificationConfigStore.java index eac60fbc6..451b60451 100644 --- a/server-common/src/main/java/io/a2a/server/tasks/InMemoryPushNotificationConfigStore.java +++ b/server-common/src/main/java/io/a2a/server/tasks/InMemoryPushNotificationConfigStore.java @@ -30,7 +30,7 @@ public InMemoryPushNotificationConfigStore() { public PushNotificationConfig setInfo(String taskId, PushNotificationConfig notificationConfig) { List notificationConfigList = pushNotificationInfos.getOrDefault(taskId, new ArrayList<>()); PushNotificationConfig.Builder builder = new PushNotificationConfig.Builder(notificationConfig); - if (notificationConfig.id() == null) { + if (notificationConfig.id() == null || notificationConfig.id().isEmpty()) { builder.id(taskId); } notificationConfig = builder.build(); From 4d16d15b7f53d948b424e4ec0c0ead1bc03f7b83 Mon Sep 17 00:00:00 2001 From: Farah Juma Date: Wed, 20 Aug 2025 12:58:13 -0400 Subject: [PATCH 16/31] fix: Check if pushNotification is the default value for gRPC in FromProto#pushNotificationConfig --- spec-grpc/src/main/java/io/a2a/grpc/utils/ProtoUtils.java | 3 +++ 1 file changed, 3 insertions(+) diff --git a/spec-grpc/src/main/java/io/a2a/grpc/utils/ProtoUtils.java b/spec-grpc/src/main/java/io/a2a/grpc/utils/ProtoUtils.java index 8aaf9224a..e53b0dc9b 100644 --- a/spec-grpc/src/main/java/io/a2a/grpc/utils/ProtoUtils.java +++ b/spec-grpc/src/main/java/io/a2a/grpc/utils/ProtoUtils.java @@ -780,6 +780,9 @@ private static MessageSendConfiguration messageSendConfiguration(io.a2a.grpc.Sen } private static PushNotificationConfig pushNotification(io.a2a.grpc.PushNotificationConfig pushNotification, String configId) { + if (pushNotification == null || pushNotification.getDefaultInstanceForType().equals(pushNotification)) { + return null; + } return new PushNotificationConfig( pushNotification.getUrl(), pushNotification.getToken().isEmpty() ? null : pushNotification.getToken(), From df0ba782b3a94d7347ac3b8f1ba5b79bd1be9fa9 Mon Sep 17 00:00:00 2001 From: Farah Juma Date: Wed, 20 Aug 2025 14:11:36 -0400 Subject: [PATCH 17/31] fix: Update JSONRPCTransport#getAgentCard so the extended agent card gets cached once retrieved --- .../a2a/client/transport/jsonrpc/JSONRPCTransport.java | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/client/transport/jsonrpc/src/main/java/io/a2a/client/transport/jsonrpc/JSONRPCTransport.java b/client/transport/jsonrpc/src/main/java/io/a2a/client/transport/jsonrpc/JSONRPCTransport.java index c22690274..d3d93e463 100644 --- a/client/transport/jsonrpc/src/main/java/io/a2a/client/transport/jsonrpc/JSONRPCTransport.java +++ b/client/transport/jsonrpc/src/main/java/io/a2a/client/transport/jsonrpc/JSONRPCTransport.java @@ -75,6 +75,7 @@ public class JSONRPCTransport implements ClientTransport { private final String agentUrl; private final List interceptors; private AgentCard agentCard; + private boolean needsExtendedCard = false; public JSONRPCTransport(String agentUrl) { this(null, null, agentUrl, null); @@ -90,6 +91,7 @@ public JSONRPCTransport(A2AHttpClient httpClient, AgentCard agentCard, this.agentCard = agentCard; this.agentUrl = agentUrl; this.interceptors = interceptors; + this.needsExtendedCard = agentCard == null || agentCard.supportsAuthenticatedExtendedCard(); } @Override @@ -332,8 +334,9 @@ public AgentCard getAgentCard(ClientCallContext context) throws A2AClientExcepti if (agentCard == null) { resolver = new A2ACardResolver(httpClient, agentUrl, null, getHttpHeaders(context)); agentCard = resolver.getAgentCard(); + needsExtendedCard = agentCard.supportsAuthenticatedExtendedCard(); } - if (!agentCard.supportsAuthenticatedExtendedCard()) { + if (!needsExtendedCard) { return agentCard; } @@ -349,7 +352,9 @@ public AgentCard getAgentCard(ClientCallContext context) throws A2AClientExcepti String httpResponseBody = sendPostRequest(payloadAndHeaders); GetAuthenticatedExtendedCardResponse response = unmarshalResponse(httpResponseBody, GET_AUTHENTICATED_EXTENDED_CARD_RESPONSE_REFERENCE); - return response.getResult(); + agentCard = response.getResult(); + needsExtendedCard = false; + return agentCard; } catch (IOException | InterruptedException e) { throw new A2AClientException("Failed to get authenticated extended agent card: " + e, e); } From aa033df31457a3ff8926af72f3944907f25e2bbc Mon Sep 17 00:00:00 2001 From: Farah Juma Date: Wed, 20 Aug 2025 14:32:08 -0400 Subject: [PATCH 18/31] fix: Test cleanup --- .../apps/common/AbstractA2AServerTest.java | 20 ++++++++----------- 1 file changed, 8 insertions(+), 12 deletions(-) diff --git a/tests/server-common/src/test/java/io/a2a/server/apps/common/AbstractA2AServerTest.java b/tests/server-common/src/test/java/io/a2a/server/apps/common/AbstractA2AServerTest.java index 26b572b9c..4758aab02 100644 --- a/tests/server-common/src/test/java/io/a2a/server/apps/common/AbstractA2AServerTest.java +++ b/tests/server-common/src/test/java/io/a2a/server/apps/common/AbstractA2AServerTest.java @@ -1,7 +1,5 @@ package io.a2a.server.apps.common; -import static io.restassured.RestAssured.given; -import static org.hamcrest.Matchers.equalTo; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertInstanceOf; @@ -21,8 +19,6 @@ import java.util.List; import java.util.concurrent.CompletableFuture; import java.util.concurrent.CountDownLatch; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicInteger; @@ -45,8 +41,6 @@ import io.a2a.client.transport.jsonrpc.JSONRPCTransportConfig; import io.a2a.client.transport.grpc.GrpcTransportConfig; import io.a2a.client.http.JdkA2AHttpClient; -import io.a2a.spec.AuthenticatedExtendedCardNotConfiguredError; -import io.a2a.spec.JSONRPCError; import io.grpc.ManagedChannelBuilder; import io.a2a.spec.A2AClientException; import io.a2a.spec.AgentCard; @@ -474,7 +468,9 @@ public void testResubscribeExistingTaskSuccess() throws Exception { // Create error handler Consumer errorHandler = error -> { - errorRef.set(error); + if (!isStreamClosedError(error)) { + errorRef.set(error); + } eventLatch.countDown(); }; @@ -484,8 +480,7 @@ public void testResubscribeExistingTaskSuccess() throws Exception { .whenComplete((unused, throwable) -> subscriptionLatch.countDown()); // Resubscribe to the task with specific consumer and error handler - List> consumers = consumer != null ? List.of(consumer) : List.of(); - getClient().resubscribe(new TaskIdParams(MINIMAL_TASK.getId()), consumers, errorHandler, null); + getClient().resubscribe(new TaskIdParams(MINIMAL_TASK.getId()), List.of(consumer), errorHandler, null); // Wait for subscription to be established assertTrue(subscriptionLatch.await(15, TimeUnit.SECONDS)); @@ -544,7 +539,9 @@ public void testResubscribeNoExistingTaskError() throws Exception { // Create error handler to capture the TaskNotFoundError Consumer errorHandler = error -> { - errorRef.set(error); + if (!isStreamClosedError(error)) { + errorRef.set(error); + } errorLatch.countDown(); }; @@ -578,8 +575,7 @@ public void testResubscribeNoExistingTaskError() throws Exception { fail("Expected error for non-existent task resubscription"); } } catch (A2AClientException e) { - // Also acceptable - the client might throw an exception immediately - assertInstanceOf(TaskNotFoundError.class, e.getCause()); + fail("Expected error for non-existent task resubscription"); } } From e241126cf84704ea34e560ab39e276b44322f7bf Mon Sep 17 00:00:00 2001 From: Farah Juma Date: Wed, 20 Aug 2025 15:25:41 -0400 Subject: [PATCH 19/31] fix: Require channel configuration for gRPC --- .../transport/grpc/GrpcTransportProvider.java | 11 +++---- .../jsonrpc/JSONRPCTransportProvider.java | 3 +- .../spi/ClientTransportProvider.java | 4 ++- .../grpc/quarkus/QuarkusA2AGrpcTest.java | 33 +++++++++++++++++++ .../apps/common/AbstractA2AServerTest.java | 16 ++++----- 5 files changed, 50 insertions(+), 17 deletions(-) diff --git a/client/transport/grpc/src/main/java/io/a2a/client/transport/grpc/GrpcTransportProvider.java b/client/transport/grpc/src/main/java/io/a2a/client/transport/grpc/GrpcTransportProvider.java index 18e66fdb6..bb28ef9c9 100644 --- a/client/transport/grpc/src/main/java/io/a2a/client/transport/grpc/GrpcTransportProvider.java +++ b/client/transport/grpc/src/main/java/io/a2a/client/transport/grpc/GrpcTransportProvider.java @@ -7,10 +7,10 @@ import io.a2a.client.config.ClientTransportConfig; import io.a2a.client.transport.spi.ClientTransport; import io.a2a.client.transport.spi.ClientTransportProvider; +import io.a2a.spec.A2AClientException; import io.a2a.spec.AgentCard; import io.a2a.spec.TransportProtocol; import io.grpc.Channel; -import io.grpc.ManagedChannelBuilder; /** * Provider for gRPC transport implementation. @@ -19,21 +19,18 @@ public class GrpcTransportProvider implements ClientTransportProvider { @Override public ClientTransport create(ClientConfig clientConfig, AgentCard agentCard, - String agentUrl, List interceptors) { + String agentUrl, List interceptors) throws A2AClientException { // not making use of the interceptors for gRPC for now - Channel channel; List clientTransportConfigs = clientConfig.getClientTransportConfigs(); if (clientTransportConfigs != null) { for (ClientTransportConfig clientTransportConfig : clientTransportConfigs) { if (clientTransportConfig instanceof GrpcTransportConfig grpcTransportConfig) { - channel = grpcTransportConfig.getChannelFactory().apply(agentUrl); + Channel channel = grpcTransportConfig.getChannelFactory().apply(agentUrl); return new GrpcTransport(channel, agentCard); } } } - // no channel factory configured - channel = ManagedChannelBuilder.forTarget(agentUrl).build(); - return new GrpcTransport(channel, agentCard); + throw new A2AClientException("Missing required GrpcTransportConfig"); } @Override diff --git a/client/transport/jsonrpc/src/main/java/io/a2a/client/transport/jsonrpc/JSONRPCTransportProvider.java b/client/transport/jsonrpc/src/main/java/io/a2a/client/transport/jsonrpc/JSONRPCTransportProvider.java index a72a86294..b9f8ce42b 100644 --- a/client/transport/jsonrpc/src/main/java/io/a2a/client/transport/jsonrpc/JSONRPCTransportProvider.java +++ b/client/transport/jsonrpc/src/main/java/io/a2a/client/transport/jsonrpc/JSONRPCTransportProvider.java @@ -8,6 +8,7 @@ import io.a2a.client.http.A2AHttpClient; import io.a2a.client.transport.spi.ClientTransport; import io.a2a.client.transport.spi.ClientTransportProvider; +import io.a2a.spec.A2AClientException; import io.a2a.spec.AgentCard; import io.a2a.spec.TransportProtocol; @@ -15,7 +16,7 @@ public class JSONRPCTransportProvider implements ClientTransportProvider { @Override public ClientTransport create(ClientConfig clientConfig, AgentCard agentCard, - String agentUrl, List interceptors) { + String agentUrl, List interceptors) throws A2AClientException { A2AHttpClient httpClient = null; List clientTransportConfigs = clientConfig.getClientTransportConfigs(); if (clientTransportConfigs != null) { diff --git a/client/transport/spi/src/main/java/io/a2a/client/transport/spi/ClientTransportProvider.java b/client/transport/spi/src/main/java/io/a2a/client/transport/spi/ClientTransportProvider.java index 5ebed06a9..e4127ff82 100644 --- a/client/transport/spi/src/main/java/io/a2a/client/transport/spi/ClientTransportProvider.java +++ b/client/transport/spi/src/main/java/io/a2a/client/transport/spi/ClientTransportProvider.java @@ -4,6 +4,7 @@ import io.a2a.client.config.ClientCallInterceptor; import io.a2a.client.config.ClientConfig; +import io.a2a.spec.A2AClientException; import io.a2a.spec.AgentCard; /** @@ -19,9 +20,10 @@ public interface ClientTransportProvider { * @param agentUrl the remote agent's URL * @param interceptors the optional interceptors to use for a client call (may be {@code null}) * @return the client transport + * @throws io.a2a.spec.A2AClientException if an error occurs trying to create the client */ ClientTransport create(ClientConfig clientConfig, AgentCard agentCard, - String agentUrl, List interceptors); + String agentUrl, List interceptors) throws A2AClientException; /** * Get the name of the client transport. diff --git a/reference/grpc/src/test/java/io/a2a/server/grpc/quarkus/QuarkusA2AGrpcTest.java b/reference/grpc/src/test/java/io/a2a/server/grpc/quarkus/QuarkusA2AGrpcTest.java index 1a91eebde..959188408 100644 --- a/reference/grpc/src/test/java/io/a2a/server/grpc/quarkus/QuarkusA2AGrpcTest.java +++ b/reference/grpc/src/test/java/io/a2a/server/grpc/quarkus/QuarkusA2AGrpcTest.java @@ -1,12 +1,24 @@ package io.a2a.server.grpc.quarkus; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.TimeUnit; + +import io.a2a.client.config.ClientTransportConfig; +import io.a2a.client.transport.grpc.GrpcTransportConfig; import io.a2a.server.apps.common.AbstractA2AServerTest; import io.a2a.spec.TransportProtocol; +import io.grpc.ManagedChannel; +import io.grpc.ManagedChannelBuilder; import io.quarkus.test.junit.QuarkusTest; +import org.junit.jupiter.api.AfterAll; + @QuarkusTest public class QuarkusA2AGrpcTest extends AbstractA2AServerTest { + private static ManagedChannel channel; + public QuarkusA2AGrpcTest() { super(8081); // HTTP server port for utility endpoints } @@ -20,4 +32,25 @@ protected String getTransportProtocol() { protected String getTransportUrl() { return "localhost:9001"; // gRPC server runs on port 9001 } + + @Override + protected List getClientTransportConfigs() { + List transportConfigs = new ArrayList<>(); + transportConfigs.add(new GrpcTransportConfig( + target -> { + channel = ManagedChannelBuilder.forTarget(target).usePlaintext().build(); + return channel; + })); + return transportConfigs; + } + + @AfterAll + public static void closeChannel() { + channel.shutdownNow(); + try { + channel.awaitTermination(10, TimeUnit.SECONDS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } } \ No newline at end of file diff --git a/tests/server-common/src/test/java/io/a2a/server/apps/common/AbstractA2AServerTest.java b/tests/server-common/src/test/java/io/a2a/server/apps/common/AbstractA2AServerTest.java index 4758aab02..bec07deff 100644 --- a/tests/server-common/src/test/java/io/a2a/server/apps/common/AbstractA2AServerTest.java +++ b/tests/server-common/src/test/java/io/a2a/server/apps/common/AbstractA2AServerTest.java @@ -134,6 +134,13 @@ protected AbstractA2AServerTest(int serverPort) { */ protected abstract String getTransportUrl(); + /** + * Get the transport configs to use for this test. + */ + protected List getClientTransportConfigs() { + return new ArrayList<>(); + } + @Test public void testTaskStoreMethodsSanityTest() throws Exception { Task task = new Task.Builder(MINIMAL_TASK).id("abcde").build(); @@ -1143,14 +1150,7 @@ private ClientConfig createClientConfig(boolean streaming) { .setAcceptedOutputModes(List.of("text")); // Set transport-specific configuration - List transportConfigs = new ArrayList<>(); - if (TransportProtocol.JSONRPC.asString().equals(getTransportProtocol())) { - transportConfigs.add(new JSONRPCTransportConfig(new JdkA2AHttpClient())); - } else if (TransportProtocol.GRPC.asString().equals(getTransportProtocol())) { - // For gRPC, use a function that creates a channel with plaintext communication - transportConfigs.add(new GrpcTransportConfig( - target -> ManagedChannelBuilder.forTarget(target).usePlaintext().build())); - } + List transportConfigs = getClientTransportConfigs(); if (!transportConfigs.isEmpty()) { builder.setClientTransportConfigs(transportConfigs); From 9acb35385ddf0694e6f2e389e72e47d5ec290b34 Mon Sep 17 00:00:00 2001 From: Farah Juma Date: Wed, 20 Aug 2025 15:32:21 -0400 Subject: [PATCH 20/31] fix: Move JSONRPC specific test cases to QuarkusA2AJSONRPCTest --- .../apps/quarkus/QuarkusA2AJSONRPCTest.java | 120 ++++++++++++++++ .../apps/common/AbstractA2AServerTest.java | 129 +----------------- 2 files changed, 123 insertions(+), 126 deletions(-) diff --git a/reference/jsonrpc/src/test/java/io/a2a/server/apps/quarkus/QuarkusA2AJSONRPCTest.java b/reference/jsonrpc/src/test/java/io/a2a/server/apps/quarkus/QuarkusA2AJSONRPCTest.java index ff61a433b..7b6257d58 100644 --- a/reference/jsonrpc/src/test/java/io/a2a/server/apps/quarkus/QuarkusA2AJSONRPCTest.java +++ b/reference/jsonrpc/src/test/java/io/a2a/server/apps/quarkus/QuarkusA2AJSONRPCTest.java @@ -2,23 +2,45 @@ import static io.restassured.RestAssured.given; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.fail; import static org.wildfly.common.Assert.assertNotNull; import jakarta.ws.rs.core.MediaType; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; +import java.util.stream.Stream; + +import com.fasterxml.jackson.core.JsonProcessingException; + import io.a2a.server.apps.common.AbstractA2AServerTest; import io.a2a.spec.A2AClientException; import io.a2a.spec.InvalidParamsError; import io.a2a.spec.InvalidRequestError; import io.a2a.spec.JSONParseError; import io.a2a.spec.JSONRPCErrorResponse; +import io.a2a.spec.Message; +import io.a2a.spec.MessageSendParams; import io.a2a.spec.MethodNotFoundError; +import io.a2a.spec.Part; +import io.a2a.spec.SendStreamingMessageRequest; +import io.a2a.spec.SendStreamingMessageResponse; +import io.a2a.spec.StreamingJSONRPCRequest; import io.a2a.spec.Task; import io.a2a.spec.TaskQueryParams; import io.a2a.spec.TaskState; +import io.a2a.spec.TextPart; import io.a2a.spec.TransportProtocol; +import io.a2a.util.Utils; import io.quarkus.test.junit.QuarkusTest; +import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; @QuarkusTest @@ -177,4 +199,102 @@ private void testGetTask(String mediaType) throws Exception { } } + @Test + public void testStreamingMethodWithAcceptHeader() throws Exception { + testSendStreamingMessage(MediaType.SERVER_SENT_EVENTS); + } + + @Test + public void testSendMessageStreamNewMessageSuccess() throws Exception { + testSendStreamingMessage(null); + } + + private void testSendStreamingMessage(String mediaType) throws Exception { + Message message = new Message.Builder(MESSAGE) + .taskId(MINIMAL_TASK.getId()) + .contextId(MINIMAL_TASK.getContextId()) + .build(); + SendStreamingMessageRequest request = new SendStreamingMessageRequest( + "1", new MessageSendParams(message, null, null)); + + CompletableFuture>> responseFuture = initialiseStreamingRequest(request, mediaType); + + CountDownLatch latch = new CountDownLatch(1); + AtomicReference errorRef = new AtomicReference<>(); + + responseFuture.thenAccept(response -> { + if (response.statusCode() != 200) { + //errorRef.set(new IllegalStateException("Status code was " + response.statusCode())); + throw new IllegalStateException("Status code was " + response.statusCode()); + } + response.body().forEach(line -> { + try { + SendStreamingMessageResponse jsonResponse = extractJsonResponseFromSseLine(line); + if (jsonResponse != null) { + assertNull(jsonResponse.getError()); + Message messageResponse = (Message) jsonResponse.getResult(); + assertEquals(MESSAGE.getMessageId(), messageResponse.getMessageId()); + assertEquals(MESSAGE.getRole(), messageResponse.getRole()); + Part part = messageResponse.getParts().get(0); + assertEquals(Part.Kind.TEXT, part.getKind()); + assertEquals("test message", ((TextPart) part).getText()); + latch.countDown(); + } + } catch (JsonProcessingException e) { + throw new RuntimeException(e); + } + }); + }).exceptionally(t -> { + if (!isStreamClosedError(t)) { + errorRef.set(t); + } + latch.countDown(); + return null; + }); + + + boolean dataRead = latch.await(20, TimeUnit.SECONDS); + Assertions.assertTrue(dataRead); + Assertions.assertNull(errorRef.get()); + + } + + private CompletableFuture>> initialiseStreamingRequest( + StreamingJSONRPCRequest request, String mediaType) throws Exception { + + // Create the client + HttpClient client = HttpClient.newBuilder() + .version(HttpClient.Version.HTTP_2) + .build(); + + // Create the request + HttpRequest.Builder builder = HttpRequest.newBuilder() + .uri(URI.create("http://localhost:" + serverPort + "/")) + .POST(HttpRequest.BodyPublishers.ofString(Utils.OBJECT_MAPPER.writeValueAsString(request))) + .header("Content-Type", APPLICATION_JSON); + if (mediaType != null) { + builder.header("Accept", mediaType); + } + HttpRequest httpRequest = builder.build(); + + + // Send request async and return the CompletableFuture + return client.sendAsync(httpRequest, HttpResponse.BodyHandlers.ofLines()); + } + + private SendStreamingMessageResponse extractJsonResponseFromSseLine(String line) throws JsonProcessingException { + line = extractSseData(line); + if (line != null) { + return Utils.OBJECT_MAPPER.readValue(line, SendStreamingMessageResponse.class); + } + return null; + } + + private static String extractSseData(String line) { + if (line.startsWith("data:")) { + line = line.substring(5).trim(); + return line; + } + return null; + } } diff --git a/tests/server-common/src/test/java/io/a2a/server/apps/common/AbstractA2AServerTest.java b/tests/server-common/src/test/java/io/a2a/server/apps/common/AbstractA2AServerTest.java index bec07deff..63a07e395 100644 --- a/tests/server-common/src/test/java/io/a2a/server/apps/common/AbstractA2AServerTest.java +++ b/tests/server-common/src/test/java/io/a2a/server/apps/common/AbstractA2AServerTest.java @@ -23,14 +23,9 @@ import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicReference; -import java.util.stream.Stream; import java.util.function.BiConsumer; import java.util.function.Consumer; -import jakarta.ws.rs.core.MediaType; - -import com.fasterxml.jackson.core.JsonProcessingException; - import io.a2a.client.Client; import io.a2a.client.ClientEvent; import io.a2a.client.ClientFactory; @@ -38,29 +33,18 @@ import io.a2a.client.TaskUpdateEvent; import io.a2a.client.config.ClientConfig; import io.a2a.client.config.ClientTransportConfig; -import io.a2a.client.transport.jsonrpc.JSONRPCTransportConfig; -import io.a2a.client.transport.grpc.GrpcTransportConfig; -import io.a2a.client.http.JdkA2AHttpClient; -import io.grpc.ManagedChannelBuilder; import io.a2a.spec.A2AClientException; import io.a2a.spec.AgentCard; import io.a2a.spec.AgentCapabilities; import io.a2a.spec.AgentInterface; -import io.a2a.spec.TransportProtocol; import io.a2a.spec.Artifact; import io.a2a.spec.DeleteTaskPushNotificationConfigParams; import io.a2a.spec.Event; -import io.a2a.spec.GetAuthenticatedExtendedCardRequest; -import io.a2a.spec.GetAuthenticatedExtendedCardResponse; import io.a2a.spec.GetTaskPushNotificationConfigParams; import io.a2a.spec.ListTaskPushNotificationConfigParams; import io.a2a.spec.Message; -import io.a2a.spec.MessageSendParams; import io.a2a.spec.Part; import io.a2a.spec.PushNotificationConfig; -import io.a2a.spec.SendStreamingMessageRequest; -import io.a2a.spec.SendStreamingMessageResponse; -import io.a2a.spec.StreamingJSONRPCRequest; import io.a2a.spec.Task; import io.a2a.spec.TaskArtifactUpdateEvent; import io.a2a.spec.TaskIdParams; @@ -74,8 +58,6 @@ import io.a2a.spec.UnsupportedOperationError; import io.a2a.util.Utils; -import org.junit.jupiter.api.Assertions; -import org.junit.jupiter.api.Assumptions; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Timeout; @@ -109,14 +91,14 @@ public abstract class AbstractA2AServerTest { .status(new TaskStatus(TaskState.SUBMITTED)) .build(); - private static final Message MESSAGE = new Message.Builder() + protected static final Message MESSAGE = new Message.Builder() .messageId("111") .role(Message.Role.AGENT) .parts(new TextPart("test message")) .build(); public static final String APPLICATION_JSON = "application/json"; - private final int serverPort; + protected final int serverPort; private Client client; private Client nonStreamingClient; @@ -586,72 +568,6 @@ public void testResubscribeNoExistingTaskError() throws Exception { } } - @Test - public void testStreamingMethodWithAcceptHeader() throws Exception { - // Skip this test for gRPC transport as it uses HTTP SSE which is JSONRPC-specific - Assumptions.assumeTrue(TransportProtocol.JSONRPC.asString().equals(getTransportProtocol()), - "HTTP streaming tests are only supported for JSONRPC transport"); - testSendStreamingMessage(MediaType.SERVER_SENT_EVENTS); - } - - @Test - public void testSendMessageStreamNewMessageSuccess() throws Exception { - // Skip this test for gRPC transport as it uses HTTP SSE which is JSONRPC-specific - Assumptions.assumeTrue(TransportProtocol.JSONRPC.asString().equals(getTransportProtocol()), - "HTTP streaming tests are only supported for JSONRPC transport"); - testSendStreamingMessage(null); - } - - private void testSendStreamingMessage(String mediaType) throws Exception { - Message message = new Message.Builder(MESSAGE) - .taskId(MINIMAL_TASK.getId()) - .contextId(MINIMAL_TASK.getContextId()) - .build(); - SendStreamingMessageRequest request = new SendStreamingMessageRequest( - "1", new MessageSendParams(message, null, null)); - - CompletableFuture>> responseFuture = initialiseStreamingRequest(request, mediaType); - - CountDownLatch latch = new CountDownLatch(1); - AtomicReference errorRef = new AtomicReference<>(); - - responseFuture.thenAccept(response -> { - if (response.statusCode() != 200) { - //errorRef.set(new IllegalStateException("Status code was " + response.statusCode())); - throw new IllegalStateException("Status code was " + response.statusCode()); - } - response.body().forEach(line -> { - try { - SendStreamingMessageResponse jsonResponse = extractJsonResponseFromSseLine(line); - if (jsonResponse != null) { - assertNull(jsonResponse.getError()); - Message messageResponse = (Message) jsonResponse.getResult(); - assertEquals(MESSAGE.getMessageId(), messageResponse.getMessageId()); - assertEquals(MESSAGE.getRole(), messageResponse.getRole()); - Part part = messageResponse.getParts().get(0); - assertEquals(Part.Kind.TEXT, part.getKind()); - assertEquals("test message", ((TextPart) part).getText()); - latch.countDown(); - } - } catch (JsonProcessingException e) { - throw new RuntimeException(e); - } - }); - }).exceptionally(t -> { - if (!isStreamClosedError(t)) { - errorRef.set(t); - } - latch.countDown(); - return null; - }); - - - boolean dataRead = latch.await(20, TimeUnit.SECONDS); - Assertions.assertTrue(dataRead); - Assertions.assertNull(errorRef.get()); - - } - @Test public void testListPushNotificationConfigWithConfigId() throws Exception { saveTaskInTaskStore(MINIMAL_TASK); @@ -871,23 +787,7 @@ public void testDeletePushNotificationConfigSetWithoutConfigId() throws Exceptio } } - private SendStreamingMessageResponse extractJsonResponseFromSseLine(String line) throws JsonProcessingException { - line = extractSseData(line); - if (line != null) { - return Utils.OBJECT_MAPPER.readValue(line, SendStreamingMessageResponse.class); - } - return null; - } - - private static String extractSseData(String line) { - if (line.startsWith("data:")) { - line = line.substring(5).trim(); - return line; - } - return null; - } - - private boolean isStreamClosedError(Throwable throwable) { + protected boolean isStreamClosedError(Throwable throwable) { // Unwrap the CompletionException Throwable cause = throwable; @@ -900,29 +800,6 @@ private boolean isStreamClosedError(Throwable throwable) { return false; } - private CompletableFuture>> initialiseStreamingRequest( - StreamingJSONRPCRequest request, String mediaType) throws Exception { - - // Create the client - HttpClient client = HttpClient.newBuilder() - .version(HttpClient.Version.HTTP_2) - .build(); - - // Create the request - HttpRequest.Builder builder = HttpRequest.newBuilder() - .uri(URI.create("http://localhost:" + serverPort + "/")) - .POST(HttpRequest.BodyPublishers.ofString(Utils.OBJECT_MAPPER.writeValueAsString(request))) - .header("Content-Type", APPLICATION_JSON); - if (mediaType != null) { - builder.header("Accept", mediaType); - } - HttpRequest httpRequest = builder.build(); - - - // Send request async and return the CompletableFuture - return client.sendAsync(httpRequest, HttpResponse.BodyHandlers.ofLines()); - } - protected void saveTaskInTaskStore(Task task) throws Exception { HttpClient client = HttpClient.newBuilder() .version(HttpClient.Version.HTTP_2) From 36982e5a2767871e1bd9390a90a39dce4a9052ab Mon Sep 17 00:00:00 2001 From: Farah Juma Date: Wed, 20 Aug 2025 15:42:44 -0400 Subject: [PATCH 21/31] fix: Apply Gemini suggestions --- .../src/main/java/io/a2a/client/Client.java | 43 ++++++++----------- .../java/io/a2a/client/ClientTaskManager.java | 11 ++--- 2 files changed, 21 insertions(+), 33 deletions(-) diff --git a/client/base/src/main/java/io/a2a/client/Client.java b/client/base/src/main/java/io/a2a/client/Client.java index e3d0c556d..103b78eef 100644 --- a/client/base/src/main/java/io/a2a/client/Client.java +++ b/client/base/src/main/java/io/a2a/client/Client.java @@ -45,38 +45,14 @@ public Client(AgentCard agentCard, ClientConfig clientConfig, ClientTransport cl @Override public void sendMessage(Message request, ClientCallContext context) throws A2AClientException { - MessageSendConfiguration messageSendConfiguration = new MessageSendConfiguration.Builder() - .acceptedOutputModes(clientConfig.getAcceptedOutputModes()) - .blocking(clientConfig.isPolling()) - .historyLength(clientConfig.getHistoryLength()) - .pushNotification(clientConfig.getPushNotificationConfig()) - .build(); - - MessageSendParams messageSendParams = new MessageSendParams.Builder() - .message(request) - .configuration(messageSendConfiguration) - .metadata(clientConfig.getMetadata()) - .build(); - + MessageSendParams messageSendParams = getMessageSendParams(request, clientConfig); sendMessage(messageSendParams, null, null, context); } @Override public void sendMessage(Message request, List> consumers, Consumer streamingErrorHandler, ClientCallContext context) throws A2AClientException { - MessageSendConfiguration messageSendConfiguration = new MessageSendConfiguration.Builder() - .acceptedOutputModes(clientConfig.getAcceptedOutputModes()) - .blocking(clientConfig.isPolling()) - .historyLength(clientConfig.getHistoryLength()) - .pushNotification(clientConfig.getPushNotificationConfig()) - .build(); - - MessageSendParams messageSendParams = new MessageSendParams.Builder() - .message(request) - .configuration(messageSendConfiguration) - .metadata(clientConfig.getMetadata()) - .build(); - + MessageSendParams messageSendParams = getMessageSendParams(request, clientConfig); sendMessage(messageSendParams, consumers, streamingErrorHandler, context); } @@ -240,4 +216,19 @@ private void consume(ClientEvent clientEvent, AgentCard agentCard, List history = task.getHistory(); + List history = new ArrayList<>(task.getHistory()); history.add(taskStatusUpdateEvent.getStatus().message()); taskBuilder.history(history); } } if (taskStatusUpdateEvent.getMetadata() != null) { - Map metadata = taskStatusUpdateEvent.getMetadata(); - if (metadata == null) { - metadata = new HashMap<>(); - } - metadata.putAll(taskStatusUpdateEvent.getMetadata()); - taskBuilder.metadata(metadata); + Map newMetadata = task.getMetadata() != null ? new HashMap<>(task.getMetadata()) : new HashMap<>(); + newMetadata.putAll(taskStatusUpdateEvent.getMetadata()); + taskBuilder.metadata(newMetadata); } taskBuilder.status(taskStatusUpdateEvent.getStatus()); currentTask = taskBuilder.build(); From dec99486c7b7948ff8073438474fa8ed3d3fbd1e Mon Sep 17 00:00:00 2001 From: Farah Juma Date: Wed, 20 Aug 2025 16:08:01 -0400 Subject: [PATCH 22/31] fix: Remove unneeded dependency from client/base/pom.xml --- client/base/pom.xml | 4 ---- 1 file changed, 4 deletions(-) diff --git a/client/base/pom.xml b/client/base/pom.xml index 79c5966c9..d39f6b91f 100644 --- a/client/base/pom.xml +++ b/client/base/pom.xml @@ -54,10 +54,6 @@ mockserver-netty test - - io.grpc - grpc-api - \ No newline at end of file From 79cbb48ae0a26efb6d44eec85c3f4ca472d68a36 Mon Sep 17 00:00:00 2001 From: Farah Juma Date: Wed, 20 Aug 2025 16:42:43 -0400 Subject: [PATCH 23/31] fix: Test clean up --- .../server/apps/common/AbstractA2AServerTest.java | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/tests/server-common/src/test/java/io/a2a/server/apps/common/AbstractA2AServerTest.java b/tests/server-common/src/test/java/io/a2a/server/apps/common/AbstractA2AServerTest.java index 63a07e395..dd306b0d9 100644 --- a/tests/server-common/src/test/java/io/a2a/server/apps/common/AbstractA2AServerTest.java +++ b/tests/server-common/src/test/java/io/a2a/server/apps/common/AbstractA2AServerTest.java @@ -239,7 +239,7 @@ public void testSendMessageNewMessageSuccess() throws Exception { // testing the non-streaming send message getNonStreamingClient().sendMessage(message, List.of(consumer), null, null); - + assertTrue(latch.await(10, TimeUnit.SECONDS)); assertFalse(wasUnexpectedEvent.get()); Message messageResponse = receivedMessage.get(); @@ -344,7 +344,7 @@ public void testError() throws A2AClientException { try { getNonStreamingClient().sendMessage(message, null); - + // For non-streaming clients, the error should still be thrown as an exception fail("Expected A2AClientException for unsupported send message operation"); } catch (A2AClientException e) { @@ -407,7 +407,7 @@ public void testSendMessageStreamExistingTaskSuccess() throws Exception { assertTrue(latch.await(10, TimeUnit.SECONDS)); assertFalse(wasUnexpectedEvent.get()); assertNull(errorRef.get()); - + Message messageResponse = receivedMessage.get(); assertNotNull(messageResponse); assertEquals(MESSAGE.getMessageId(), messageResponse.getMessageId()); @@ -536,10 +536,10 @@ public void testResubscribeNoExistingTaskError() throws Exception { try { getClient().resubscribe(new TaskIdParams("non-existent-task"), List.of(), errorHandler, null); - + // Wait for error to be captured (may come via error handler for streaming) boolean errorReceived = errorLatch.await(10, TimeUnit.SECONDS); - + if (errorReceived) { // Error came via error handler Throwable error = errorRef.get(); @@ -1028,7 +1028,7 @@ private ClientConfig createClientConfig(boolean streaming) { // Set transport-specific configuration List transportConfigs = getClientTransportConfigs(); - + if (!transportConfigs.isEmpty()) { builder.setClientTransportConfigs(transportConfigs); } @@ -1036,7 +1036,4 @@ private ClientConfig createClientConfig(boolean streaming) { return builder.build(); } - private static class BreakException extends RuntimeException { - - } } \ No newline at end of file From 114a3a7e3491832491518c9089d486880c875b14 Mon Sep 17 00:00:00 2001 From: Farah Juma Date: Wed, 20 Aug 2025 16:59:23 -0400 Subject: [PATCH 24/31] fix: Add details about how to use the new Client to the README and add some convenience methods to AbstractClient --- README.md | 289 ++++++++++++++---- .../java/io/a2a/client/A2ACardResolver.java | 14 + .../java/io/a2a/client/AbstractClient.java | 165 ++++++++++ .../java/io/a2a/client/ClientFactory.java | 6 + examples/helloworld/client/README.md | 2 +- .../examples/helloworld/HelloWorldClient.java | 2 + .../apps/common/AbstractA2AServerTest.java | 56 ++-- 7 files changed, 437 insertions(+), 97 deletions(-) diff --git a/README.md b/README.md index 0b528adf0..aa41fd0ae 100644 --- a/README.md +++ b/README.md @@ -211,8 +211,14 @@ public class WeatherAgentExecutorProducer { ## A2A Client -The A2A Java SDK provides a Java client implementation of the [Agent2Agent (A2A) Protocol](https://google-a2a.github.io/A2A), allowing communication with A2A servers. -To make use of the Java `A2AClient`, simply add the following dependency: +The A2A Java SDK provides a Java client implementation of the [Agent2Agent (A2A) Protocol](https://google-a2a.github.io/A2A), allowing communication with A2A servers. The Java client implementation currently supports two transport protocols: JSON-RPC 2.0 and gRPC. + +To make use of the Java `Client`: + +### 1. Add the A2A Java SDK Client dependency to your project + +Adding a dependency on `a2a-java-sdk-client` will provide access to a `ClientFactory` +that you can use to create your A2A `Client`. ---- > *⚠️ The `io.github.a2asdk` `groupId` below is temporary and will likely change for future releases.* @@ -227,67 +233,222 @@ To make use of the Java `A2AClient`, simply add the following dependency: ``` +### 2. Add dependencies on the A2A Java SDK Client Transport(s) you'd like to use + +You need to add a dependency on at least one of the following client transport modules: + +---- +> *⚠️ The `io.github.a2asdk` `groupId` below is temporary and will likely change for future releases.* +---- + +```xml + + io.github.a2asdk + a2a-java-sdk-client-transport-jsonrpc + + ${io.a2a.sdk.version} + +``` + +```xml + + io.github.a2asdk + a2a-java-sdk-client-transport-grpc + + ${io.a2a.sdk.version} + +``` + +Support for the HTTP+JSON/REST transport will be coming soon. + ### Sample Usage -#### Create an A2A client +#### Create a Client using the ClientFactory + +```java +// First, get the agent card for the A2A server agent you want to connect to +AgentCard agentCard = new A2ACardResolver("http://localhost:1234").getAgentCard(); + +// Specify configuration for the ClientFactory +ClientConfig clientConfig = new ClientConfig.Builder() + .setAcceptedOutputModes(List.of("text")) + .build(); + +// Create event consumers to handle responses that will be received from the A2A server +// (these consumers will be used for both streaming and non-streaming responses) +List> consumers = List.of( + (event, card) -> { + if (event instanceof MessageEvent messageEvent) { + // handle the messageEvent.getMessage() + ... + } else if (event instanceof TaskEvent taskEvent) { + // handle the taskEvent.getTask() + ... + } else if (event instanceof TaskUpdateEvent updateEvent) { + // handle the updateEvent.getTask() + ... + } + } +); + +// Create a handler that will be used for any errors that occur during streaming +Consumer errorHandler = error -> { + // handle the error.getMessage() + ... +}; + +// Create the client using ClientFactory +ClientFactory clientFactory = new ClientFactory(clientConfig); +Client client = clientFactory.create(agentCard, consumers, errorHandler); +``` + +#### Configuring Transport-Specific Settings + +Different transport protocols can be configured with specific settings using `ClientTransportConfig` implementations. The A2A Java SDK provides `JSONRPCTransportConfig` for the JSON-RPC transport and `GrpcTransportConfig` for the gRPC transport. + +##### JSON-RPC Transport Configuration + +For the JSON-RPC transport, if you'd like to use the default `JdkA2AHttpClient`, no additional +configuration is needed. To use a custom HTTP client instead, simply create a `JSONRPCTransportConfig` +as follows: + +```java +// Create a custom HTTP client +A2AHttpClient customHttpClient = ... + +// Create JSON-RPC transport configuration +JSONRPCTransportConfig jsonrpcConfig = new JSONRPCTransportConfig(customHttpClient); + +// Configure the client with transport-specific settings +ClientConfig clientConfig = new ClientConfig.Builder() + .setAcceptedOutputModes(List.of("text")) + .setClientTransportConfigs(List.of(jsonrpcConfig)) + .build(); +``` + +##### gRPC Transport Configuration + +For the gRPC transport, you must configure a channel factory: ```java -// Create an A2AClient (the URL specified is the server agent's URL, be sure to replace it with the actual URL of the A2A server you want to connect to) -A2AClient client = new A2AClient("http://localhost:1234"); +// Create a channel factory function that takes the agent URL and returns a Channel +Function channelFactory = agentUrl -> { + return ManagedChannelBuilder.forTarget(agentUrl) + ... + .build(); +}; + +// Create gRPC transport configuration +GrpcTransportConfig grpcConfig = new GrpcTransportConfig(channelFactory); + +// Configure the client with transport-specific settings +ClientConfig clientConfig = new ClientConfig.Builder() + .setAcceptedOutputModes(List.of("text")) + .setClientTransportConfigs(List.of(grpcConfig)) + .build(); +``` + +##### Multiple Transport Configurations + +You can specify configuration for multiple transports, the appropriate configuration +will be used based on the selected transport: + +```java +// Configure both JSON-RPC and gRPC transports +List transportConfigs = List.of( + new JSONRPCTransportConfig(...), + new GrpcTransportConfig(...) +); + +ClientConfig clientConfig = new ClientConfig.Builder() + .setAcceptedOutputModes(List.of("text")) + .setClientTransportConfigs(transportConfigs) + .build(); ``` #### Send a message to the A2A server agent ```java // Send a text message to the A2A server agent -Message message = A2A.toUserMessage("tell me a joke"); // the message ID will be automatically generated for you -MessageSendParams params = new MessageSendParams.Builder() - .message(message) - .build(); -SendMessageResponse response = client.sendMessage(params); +Message message = A2A.toUserMessage("tell me a joke"); + +// Send the message (uses configured consumers to handle responses) +// Streaming will automatically be used if supported by both client and server, +// otherwise the non-streaming send message method will be used automatically +client.sendMessage(message); + +// You can also optionally specify a ClientCallContext with call-specific config to use +client.sendMessage(message, clientCallContext); ``` -Note that `A2A#toUserMessage` will automatically generate a message ID for you when creating the `Message` -if you don't specify it. You can also explicitly specify a message ID like this: +#### Send a message with custom event handling ```java -Message message = A2A.toUserMessage("tell me a joke", "message-1234"); // messageId is message-1234 +// Create custom consumers for this specific message +List> customConsumers = List.of( + (event, card) -> { + // handle this specific message's responses + ... + } +); + +// Create custom error handler +Consumer customErrorHandler = error -> { + // handle the error + ... +}; + +Message message = A2A.toUserMessage("tell me a joke"); +client.sendMessage(message, customConsumers, customErrorHandler); ``` #### Get the current state of a task ```java // Retrieve the task with id "task-1234" -GetTaskResponse response = client.getTask("task-1234"); +Task task = client.getTask(new TaskQueryParams("task-1234")); // You can also specify the maximum number of items of history for the task -// to include in the response -GetTaskResponse response = client.getTask(new TaskQueryParams("task-1234", 10)); +// to include in the response and +Task task = client.getTask(new TaskQueryParams("task-1234", 10)); + +// You can also optionally specify a ClientCallContext with call-specific config to use +Task task = client.getTask(new TaskQueryParams("task-1234"), clientCallContext); ``` #### Cancel an ongoing task ```java // Cancel the task we previously submitted with id "task-1234" -CancelTaskResponse response = client.cancelTask("task-1234"); +Task cancelledTask = client.cancelTask(new TaskIdParams("task-1234")); // You can also specify additional properties using a map -Map metadata = ... -CancelTaskResponse response = client.cancelTask(new TaskIdParams("task-1234", metadata)); +Map metadata = Map.of("reason", "user_requested"); +Task cancelledTask = client.cancelTask(new TaskIdParams("task-1234", metadata)); + +// You can also optionally specify a ClientCallContext with call-specific config to use +Task cancelledTask = client.cancelTask(new TaskIdParams("task-1234"), clientCallContext); ``` #### Get a push notification configuration for a task ```java // Get task push notification configuration -GetTaskPushNotificationConfigResponse response = client.getTaskPushNotificationConfig("task-1234"); +TaskPushNotificationConfig config = client.getTaskPushNotificationConfiguration( + new GetTaskPushNotificationConfigParams("task-1234")); // The push notification configuration ID can also be optionally specified -GetTaskPushNotificationConfigResponse response = client.getTaskPushNotificationConfig("task-1234", "config-4567"); +TaskPushNotificationConfig config = client.getTaskPushNotificationConfiguration( + new GetTaskPushNotificationConfigParams("task-1234", "config-4567")); // Additional properties can be specified using a map -Map metadata = ... -GetTaskPushNotificationConfigResponse response = client.getTaskPushNotificationConfig(new GetTaskPushNotificationConfigParams("task-1234", "config-1234", metadata)); +Map metadata = Map.of("source", "client"); +TaskPushNotificationConfig config = client.getTaskPushNotificationConfiguration( + new GetTaskPushNotificationConfigParams("task-1234", "config-1234", metadata)); + +// You can also optionally specify a ClientCallContext with call-specific config to use +TaskPushNotificationConfig config = client.getTaskPushNotificationConfiguration( + new GetTaskPushNotificationConfigParams("task-1234"), clientCallContext); ``` #### Set a push notification configuration for a task @@ -298,66 +459,68 @@ PushNotificationConfig pushNotificationConfig = new PushNotificationConfig.Build .url("https://example.com/callback") .authenticationInfo(new AuthenticationInfo(Collections.singletonList("jwt"), null)) .build(); -SetTaskPushNotificationResponse response = client.setTaskPushNotificationConfig("task-1234", pushNotificationConfig); -``` -#### List the push notification configurations for a task +TaskPushNotificationConfig taskConfig = new TaskPushNotificationConfig.Builder() + .taskId("task-1234") + .pushNotificationConfig(pushNotificationConfig) + .build(); -```java -ListTaskPushNotificationConfigResponse response = client.listTaskPushNotificationConfig("task-1234"); +TaskPushNotificationConfig result = client.setTaskPushNotificationConfiguration(taskConfig); -// Additional properties can be specified using a map -Map metadata = ... -ListTaskPushNotificationConfigResponse response = client.listTaskPushNotificationConfig(new ListTaskPushNotificationConfigParams("task-123", metadata)); +// You can also optionally specify a ClientCallContext with call-specific config to use +TaskPushNotificationConfig result = client.setTaskPushNotificationConfiguration(taskConfig, clientCallContext); ``` -#### Delete a push notification configuration for a task +#### List the push notification configurations for a task ```java -DeleteTaskPushNotificationConfigResponse response = client.deleteTaskPushNotificationConfig("task-1234", "config-4567"); +List configs = client.listTaskPushNotificationConfigurations( + new ListTaskPushNotificationConfigParams("task-1234")); // Additional properties can be specified using a map -Map metadata = ... -DeleteTaskPushNotificationConfigResponse response = client.deleteTaskPushNotificationConfig(new DeleteTaskPushNotificationConfigParams("task-1234", "config-4567", metadata)); +Map metadata = Map.of("filter", "active"); +List configs = client.listTaskPushNotificationConfigurations( + new ListTaskPushNotificationConfigParams("task-1234", metadata)); + +// You can also optionally specify a ClientCallContext with call-specific config to use +List configs = client.listTaskPushNotificationConfigurations( + new ListTaskPushNotificationConfigParams("task-1234"), clientCallContext); ``` -#### Send a streaming message +#### Delete a push notification configuration for a task ```java -// Send a text message to the remote agent -Message message = A2A.toUserMessage("tell me some jokes"); // the message ID will be automatically generated for you -MessageSendParams params = new MessageSendParams.Builder() - .message(message) - .build(); - -// Create a handler that will be invoked for Task, Message, TaskStatusUpdateEvent, and TaskArtifactUpdateEvent -Consumer eventHandler = event -> {...}; +client.deleteTaskPushNotificationConfigurations( + new DeleteTaskPushNotificationConfigParams("task-1234", "config-4567")); -// Create a handler that will be invoked if an error is received -Consumer errorHandler = error -> {...}; - -// Create a handler that will be invoked in the event of a failure -Runnable failureHandler = () -> {...}; +// Additional properties can be specified using a map +Map metadata = Map.of("reason", "cleanup"); +client.deleteTaskPushNotificationConfigurations( + new DeleteTaskPushNotificationConfigParams("task-1234", "config-4567", metadata)); -// Send the streaming message to the remote agent -client.sendStreamingMessage(params, eventHandler, errorHandler, failureHandler); +// You can also optionally specify a ClientCallContext with call-specific config to use +client.deleteTaskPushNotificationConfigurations( + new DeleteTaskPushNotificationConfigParams("task-1234", "config-4567", clientCallContext); ``` #### Resubscribe to a task ```java -// Create a handler that will be invoked for Task, Message, TaskStatusUpdateEvent, and TaskArtifactUpdateEvent -Consumer eventHandler = event -> {...}; +// Resubscribe to an ongoing task with id "task-1234" using configured consumers +TaskIdParams taskIdParams = new TaskIdParams("task-1234"); +client.resubscribe(taskIdParams); -// Create a handler that will be invoked if an error is received -Consumer errorHandler = error -> {...}; +// Or resubscribe with custom consumers and error handler +List> customConsumers = List.of( + (event, card) -> System.out.println("Resubscribe event: " + event) +); +Consumer customErrorHandler = error -> + System.err.println("Resubscribe error: " + error.getMessage()); -// Create a handler that will be invoked in the event of a failure -Runnable failureHandler = () -> {...}; +client.resubscribe(taskIdParams, customConsumers, customErrorHandler); -// Resubscribe to an ongoing task with id "task-1234" -TaskIdParams taskIdParams = new TaskIdParams("task-1234"); -client.resubscribeToTask("request-1234", taskIdParams, eventHandler, errorHandler, failureHandler); +// You can also optionally specify a ClientCallContext with call-specific config to use +client.resubscribe(taskIdParams, clientCallContext); ``` #### Retrieve details about the server agent that this client agent is communicating with @@ -365,12 +528,6 @@ client.resubscribeToTask("request-1234", taskIdParams, eventHandler, errorHandle AgentCard serverAgentCard = client.getAgentCard(); ``` -An agent card can also be retrieved using the `A2A#getAgentCard` method: -```java -// http://localhost:1234 is the base URL for the agent whose card we want to retrieve -AgentCard agentCard = A2A.getAgentCard("http://localhost:1234"); -``` - ## Additional Examples ### Hello World Client Example diff --git a/client/base/src/main/java/io/a2a/client/A2ACardResolver.java b/client/base/src/main/java/io/a2a/client/A2ACardResolver.java index bbbc6caf0..f5de4746e 100644 --- a/client/base/src/main/java/io/a2a/client/A2ACardResolver.java +++ b/client/base/src/main/java/io/a2a/client/A2ACardResolver.java @@ -11,6 +11,7 @@ import com.fasterxml.jackson.core.type.TypeReference; import io.a2a.client.http.A2AHttpClient; import io.a2a.client.http.A2AHttpResponse; +import io.a2a.client.http.JdkA2AHttpClient; import io.a2a.spec.A2AClientError; import io.a2a.spec.A2AClientJSONError; import io.a2a.spec.AgentCard; @@ -25,6 +26,19 @@ public class A2ACardResolver { private static final TypeReference AGENT_CARD_TYPE_REFERENCE = new TypeReference<>() {}; /** + * Get the agent card for an A2A agent. + * The {@code JdkA2AHttpClient} will be used to fetch the agent card. + * + * @param baseUrl the base URL for the agent whose agent card we want to retrieve + * @throws A2AClientError if the URL for the agent is invalid + */ + public A2ACardResolver(String baseUrl) throws A2AClientError { + this(new JdkA2AHttpClient(), baseUrl, null, null); + } + + /** + /**Get the agent card for an A2A agent. + * * @param httpClient the http client to use * @param baseUrl the base URL for the agent whose agent card we want to retrieve * @throws A2AClientError if the URL for the agent is invalid diff --git a/client/base/src/main/java/io/a2a/client/AbstractClient.java b/client/base/src/main/java/io/a2a/client/AbstractClient.java index ae25a0c8b..1667ed56f 100644 --- a/client/base/src/main/java/io/a2a/client/AbstractClient.java +++ b/client/base/src/main/java/io/a2a/client/AbstractClient.java @@ -41,6 +41,22 @@ public AbstractClient(List> consumers, Consum this.streamingErrorHandler = streamingErrorHandler; } + /** + * Send a message to the remote agent. This method will automatically use + * the streaming or non-streaming approach as determined by the server's + * agent card and the client configuration. The configured client consumers + * will be used to handle messages, tasks, and update events received + * from the remote agent. The configured streaming error handler will be used + * if an error occurs during streaming. The configured client push notification + * configuration will get used for streaming. + * + * @param request the message + * @throws A2AClientException if sending the message fails for any reason + */ + public void sendMessage(Message request) throws A2AClientException { + sendMessage(request, null); + } + /** * Send a message to the remote agent. This method will automatically use * the streaming or non-streaming approach as determined by the server's @@ -56,6 +72,26 @@ public AbstractClient(List> consumers, Consum */ public abstract void sendMessage(Message request, ClientCallContext context) throws A2AClientException; + /** + * Send a message to the remote agent. This method will automatically use + * the streaming or non-streaming approach as determined by the server's + * agent card and the client configuration. The specified client consumers + * will be used to handle messages, tasks, and update events received + * from the remote agent. The specified streaming error handler will be used + * if an error occurs during streaming. The configured client push notification + * configuration will get used for streaming. + * + * @param request the message + * @param consumers a list of consumers to pass responses from the remote agent to + * @param streamingErrorHandler an error handler that should be used for the streaming case if an error occurs + * @throws A2AClientException if sending the message fails for any reason + */ + public void sendMessage(Message request, + List> consumers, + Consumer streamingErrorHandler) throws A2AClientException { + sendMessage(request, consumers, streamingErrorHandler, null); + } + /** * Send a message to the remote agent. This method will automatically use * the streaming or non-streaming approach as determined by the server's @@ -90,9 +126,40 @@ public abstract void sendMessage(Message request, * @param metadata the optional metadata to include when sending the message * @throws A2AClientException if sending the message fails for any reason */ + public void sendMessage(Message request, PushNotificationConfig pushNotificationConfiguration, + Map metadata) throws A2AClientException { + sendMessage(request, pushNotificationConfiguration, metadata, null); + } + + /** + * Send a message to the remote agent. This method will automatically use + * the streaming or non-streaming approach as determined by the server's + * agent card and the client configuration. The configured client consumers + * will be used to handle messages, tasks, and update events received from + * the remote agent. The configured streaming error handler will be used + * if an error occurs during streaming. + * + * @param request the message + * @param pushNotificationConfiguration the push notification configuration that should be + * used if the streaming approach is used + * @param metadata the optional metadata to include when sending the message + * @param context optional client call context for the request (may be {@code null}) + * @throws A2AClientException if sending the message fails for any reason + */ public abstract void sendMessage(Message request, PushNotificationConfig pushNotificationConfiguration, Map metadata, ClientCallContext context) throws A2AClientException; + /** + * Retrieve the current state and history of a specific task. + * + * @param request the task query parameters specifying which task to retrieve + * @return the task + * @throws A2AClientException if retrieving the task fails for any reason + */ + public Task getTask(TaskQueryParams request) throws A2AClientException { + return getTask(request, null); + } + /** * Retrieve the current state and history of a specific task. * @@ -103,6 +170,17 @@ public abstract void sendMessage(Message request, PushNotificationConfig pushNot */ public abstract Task getTask(TaskQueryParams request, ClientCallContext context) throws A2AClientException; + /** + * Request the agent to cancel a specific task. + * + * @param request the task ID parameters specifying which task to cancel + * @return the cancelled task + * @throws A2AClientException if cancelling the task fails for any reason + */ + public Task cancelTask(TaskIdParams request) throws A2AClientException { + return cancelTask(request, null); + } + /** * Request the agent to cancel a specific task. * @@ -113,6 +191,18 @@ public abstract void sendMessage(Message request, PushNotificationConfig pushNot */ public abstract Task cancelTask(TaskIdParams request, ClientCallContext context) throws A2AClientException; + /** + * Set or update the push notification configuration for a specific task. + * + * @param request the push notification configuration to set for the task + * @return the configured TaskPushNotificationConfig + * @throws A2AClientException if setting the task push notification configuration fails for any reason + */ + public TaskPushNotificationConfig setTaskPushNotificationConfiguration( + TaskPushNotificationConfig request) throws A2AClientException { + return setTaskPushNotificationConfiguration(request, null); + } + /** * Set or update the push notification configuration for a specific task. * @@ -125,6 +215,18 @@ public abstract TaskPushNotificationConfig setTaskPushNotificationConfiguration( TaskPushNotificationConfig request, ClientCallContext context) throws A2AClientException; + /** + * Retrieve the push notification configuration for a specific task. + * + * @param request the parameters specifying which task's notification config to retrieve + * @return the task push notification config + * @throws A2AClientException if getting the task push notification config fails for any reason + */ + public TaskPushNotificationConfig getTaskPushNotificationConfiguration( + GetTaskPushNotificationConfigParams request) throws A2AClientException { + return getTaskPushNotificationConfiguration(request, null); + } + /** * Retrieve the push notification configuration for a specific task. * @@ -137,6 +239,18 @@ public abstract TaskPushNotificationConfig getTaskPushNotificationConfiguration( GetTaskPushNotificationConfigParams request, ClientCallContext context) throws A2AClientException; + /** + * Retrieve the list of push notification configurations for a specific task. + * + * @param request the parameters specifying which task's notification configs to retrieve + * @return the list of task push notification configs + * @throws A2AClientException if getting the task push notification configs fails for any reason + */ + public List listTaskPushNotificationConfigurations( + ListTaskPushNotificationConfigParams request) throws A2AClientException { + return listTaskPushNotificationConfigurations(request, null); + } + /** * Retrieve the list of push notification configurations for a specific task. * @@ -149,6 +263,17 @@ public abstract List listTaskPushNotificationConfigu ListTaskPushNotificationConfigParams request, ClientCallContext context) throws A2AClientException; + /** + * Delete the list of push notification configurations for a specific task. + * + * @param request the parameters specifying which task's notification configs to delete + * @throws A2AClientException if deleting the task push notification configs fails for any reason + */ + public void deleteTaskPushNotificationConfigurations( + DeleteTaskPushNotificationConfigParams request) throws A2AClientException { + deleteTaskPushNotificationConfigurations(request, null); + } + /** * Delete the list of push notification configurations for a specific task. * @@ -160,6 +285,20 @@ public abstract void deleteTaskPushNotificationConfigurations( DeleteTaskPushNotificationConfigParams request, ClientCallContext context) throws A2AClientException; + /** + * Resubscribe to a task's event stream. + * This is only available if both the client and server support streaming. + * The configured client consumers will be used to handle messages, tasks, + * and update events received from the remote agent. The configured streaming + * error handler will be used if an error occurs during streaming. + * + * @param request the parameters specifying which task's notification configs to delete + * @throws A2AClientException if resubscribing fails for any reason + */ + public void resubscribe(TaskIdParams request) throws A2AClientException { + resubscribe(request, null); + } + /** * Resubscribe to a task's event stream. * This is only available if both the client and server support streaming. @@ -173,6 +312,23 @@ public abstract void deleteTaskPushNotificationConfigurations( */ public abstract void resubscribe(TaskIdParams request, ClientCallContext context) throws A2AClientException; + /** + * Resubscribe to a task's event stream. + * This is only available if both the client and server support streaming. + * The specified client consumers will be used to handle messages, tasks, and + * update events received from the remote agent. The specified streaming error + * handler will be used if an error occurs during streaming. + * + * @param request the parameters specifying which task's notification configs to delete + * @param consumers a list of consumers to pass responses from the remote agent to + * @param streamingErrorHandler an error handler that should be used for the streaming case if an error occurs + * @throws A2AClientException if resubscribing fails for any reason + */ + public void resubscribe(TaskIdParams request, List> consumers, + Consumer streamingErrorHandler) throws A2AClientException { + resubscribe(request, consumers, streamingErrorHandler, null); + } + /** * Resubscribe to a task's event stream. * This is only available if both the client and server support streaming. @@ -189,6 +345,15 @@ public abstract void deleteTaskPushNotificationConfigurations( public abstract void resubscribe(TaskIdParams request, List> consumers, Consumer streamingErrorHandler, ClientCallContext context) throws A2AClientException; + /** + * Retrieve the AgentCard. + * + * @return the AgentCard + * @throws A2AClientException if retrieving the agent card fails for any reason + */ + public AgentCard getAgentCard() throws A2AClientException { + return getAgentCard(null); + } /** * Retrieve the AgentCard. diff --git a/client/base/src/main/java/io/a2a/client/ClientFactory.java b/client/base/src/main/java/io/a2a/client/ClientFactory.java index 71c1a6020..ff38201f3 100644 --- a/client/base/src/main/java/io/a2a/client/ClientFactory.java +++ b/client/base/src/main/java/io/a2a/client/ClientFactory.java @@ -28,7 +28,13 @@ public class ClientFactory { private final ClientConfig clientConfig; private final Map transportProviderRegistry = new HashMap<>(); + /** + * Create a client factory used to generate the appropriate client for the agent. + * + * @param clientConfig the client config to use + */ public ClientFactory(ClientConfig clientConfig) { + checkNotNullParam("clientConfig", clientConfig); this.clientConfig = clientConfig; ServiceLoader loader = ServiceLoader.load(ClientTransportProvider.class); for (ClientTransportProvider transport : loader) { diff --git a/examples/helloworld/client/README.md b/examples/helloworld/client/README.md index 9bea69eb5..ac01c890f 100644 --- a/examples/helloworld/client/README.md +++ b/examples/helloworld/client/README.md @@ -78,7 +78,7 @@ The Java client (`HelloWorldClient.java`) performs the following actions: 1. Fetches the server's public agent card 2. Fetches the server's extended agent card -3. Creates an A2A client using the extended agent card that connects to the Python server at `http://localhost:9999`. +3. Creates a client using the extended agent card that connects to the Python server at `http://localhost:9999`. 4. Sends a regular message asking "how much is 10 USD in INR?". 5. Prints the server's response. 6. Sends the same message as a streaming request. diff --git a/examples/helloworld/client/src/main/java/io/a2a/examples/helloworld/HelloWorldClient.java b/examples/helloworld/client/src/main/java/io/a2a/examples/helloworld/HelloWorldClient.java index f810244e1..b5917dc33 100644 --- a/examples/helloworld/client/src/main/java/io/a2a/examples/helloworld/HelloWorldClient.java +++ b/examples/helloworld/client/src/main/java/io/a2a/examples/helloworld/HelloWorldClient.java @@ -5,6 +5,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import io.a2a.A2A; +import io.a2a.client.ClientFactory; import io.a2a.client.transport.jsonrpc.JSONRPCTransport; import io.a2a.spec.AgentCard; import io.a2a.spec.EventKind; @@ -46,6 +47,7 @@ public static void main(String[] args) { System.out.println("Public card does not indicate support for an extended card. Using public card."); } + ClientFactory clientFactory = new ClientFactory(); JSONRPCTransport client = new JSONRPCTransport(finalAgentCard); Message message = A2A.toUserMessage(MESSAGE_TEXT); // the message ID will be automatically generated for you MessageSendParams params = new MessageSendParams.Builder() diff --git a/tests/server-common/src/test/java/io/a2a/server/apps/common/AbstractA2AServerTest.java b/tests/server-common/src/test/java/io/a2a/server/apps/common/AbstractA2AServerTest.java index dd306b0d9..e9967c7db 100644 --- a/tests/server-common/src/test/java/io/a2a/server/apps/common/AbstractA2AServerTest.java +++ b/tests/server-common/src/test/java/io/a2a/server/apps/common/AbstractA2AServerTest.java @@ -149,7 +149,7 @@ private void testGetTask() throws Exception { private void testGetTask(String mediaType) throws Exception { saveTaskInTaskStore(MINIMAL_TASK); try { - Task response = getClient().getTask(new TaskQueryParams(MINIMAL_TASK.getId()), null); + Task response = getClient().getTask(new TaskQueryParams(MINIMAL_TASK.getId())); assertEquals("task-123", response.getId()); assertEquals("session-xyz", response.getContextId()); assertEquals(TaskState.SUBMITTED, response.getStatus().state()); @@ -164,7 +164,7 @@ private void testGetTask(String mediaType) throws Exception { public void testGetTaskNotFound() throws Exception { assertTrue(getTaskFromTaskStore("non-existent-task") == null); try { - getClient().getTask(new TaskQueryParams("non-existent-task"), null); + getClient().getTask(new TaskQueryParams("non-existent-task")); fail("Expected A2AClientException for non-existent task"); } catch (A2AClientException e) { // Expected - the client should throw an exception for non-existent tasks @@ -176,7 +176,7 @@ public void testGetTaskNotFound() throws Exception { public void testCancelTaskSuccess() throws Exception { saveTaskInTaskStore(CANCEL_TASK); try { - Task task = getClient().cancelTask(new TaskIdParams(CANCEL_TASK.getId()), null); + Task task = getClient().cancelTask(new TaskIdParams(CANCEL_TASK.getId())); assertEquals(CANCEL_TASK.getId(), task.getId()); assertEquals(CANCEL_TASK.getContextId(), task.getContextId()); assertEquals(TaskState.CANCELED, task.getStatus().state()); @@ -191,7 +191,7 @@ public void testCancelTaskSuccess() throws Exception { public void testCancelTaskNotSupported() throws Exception { saveTaskInTaskStore(CANCEL_TASK_NOT_SUPPORTED); try { - getClient().cancelTask(new TaskIdParams(CANCEL_TASK_NOT_SUPPORTED.getId()), null); + getClient().cancelTask(new TaskIdParams(CANCEL_TASK_NOT_SUPPORTED.getId())); fail("Expected A2AClientException for unsupported cancel operation"); } catch (A2AClientException e) { // Expected - the client should throw an exception for unsupported operations @@ -204,7 +204,7 @@ public void testCancelTaskNotSupported() throws Exception { @Test public void testCancelTaskNotFound() { try { - getClient().cancelTask(new TaskIdParams("non-existent-task"), null); + getClient().cancelTask(new TaskIdParams("non-existent-task")); fail("Expected A2AClientException for non-existent task"); } catch (A2AClientException e) { // Expected - the client should throw an exception for non-existent tasks @@ -238,7 +238,7 @@ public void testSendMessageNewMessageSuccess() throws Exception { }; // testing the non-streaming send message - getNonStreamingClient().sendMessage(message, List.of(consumer), null, null); + getNonStreamingClient().sendMessage(message, List.of(consumer), null); assertTrue(latch.await(10, TimeUnit.SECONDS)); assertFalse(wasUnexpectedEvent.get()); @@ -277,7 +277,7 @@ public void testSendMessageExistingTaskSuccess() throws Exception { }; // testing the non-streaming send message - getNonStreamingClient().sendMessage(message, List.of(consumer), null, null); + getNonStreamingClient().sendMessage(message, List.of(consumer), null); assertFalse(wasUnexpectedEvent.get()); assertTrue(latch.await(10, TimeUnit.SECONDS)); Message messageResponse = receivedMessage.get(); @@ -301,7 +301,7 @@ public void testSetPushNotificationSuccess() throws Exception { TaskPushNotificationConfig taskPushConfig = new TaskPushNotificationConfig( MINIMAL_TASK.getId(), new PushNotificationConfig.Builder().url("http://example.com").build()); - TaskPushNotificationConfig config = getClient().setTaskPushNotificationConfiguration(taskPushConfig, null); + TaskPushNotificationConfig config = getClient().setTaskPushNotificationConfiguration(taskPushConfig); assertEquals(MINIMAL_TASK.getId(), config.taskId()); assertEquals("http://example.com", config.pushNotificationConfig().url()); } catch (A2AClientException e) { @@ -320,11 +320,11 @@ public void testGetPushNotificationSuccess() throws Exception { new TaskPushNotificationConfig( MINIMAL_TASK.getId(), new PushNotificationConfig.Builder().url("http://example.com").build()); - TaskPushNotificationConfig setResult = getClient().setTaskPushNotificationConfiguration(taskPushConfig, null); + TaskPushNotificationConfig setResult = getClient().setTaskPushNotificationConfiguration(taskPushConfig); assertNotNull(setResult); TaskPushNotificationConfig config = getClient().getTaskPushNotificationConfiguration( - new GetTaskPushNotificationConfigParams(MINIMAL_TASK.getId()), null); + new GetTaskPushNotificationConfigParams(MINIMAL_TASK.getId())); assertEquals(MINIMAL_TASK.getId(), config.taskId()); assertEquals("http://example.com", config.pushNotificationConfig().url()); } catch (A2AClientException e) { @@ -343,7 +343,7 @@ public void testError() throws A2AClientException { .build(); try { - getNonStreamingClient().sendMessage(message, null); + getNonStreamingClient().sendMessage(message); // For non-streaming clients, the error should still be thrown as an exception fail("Expected A2AClientException for unsupported send message operation"); @@ -355,7 +355,7 @@ public void testError() throws A2AClientException { @Test public void testGetAgentCard() throws A2AClientException { - AgentCard agentCard = getClient().getAgentCard(null); + AgentCard agentCard = getClient().getAgentCard(); assertNotNull(agentCard); assertEquals("test-card", agentCard.name()); assertEquals("A test agent card", agentCard.description()); @@ -402,7 +402,7 @@ public void testSendMessageStreamExistingTaskSuccess() throws Exception { }; // testing the streaming send message - getClient().sendMessage(message, List.of(consumer), errorHandler, null); + getClient().sendMessage(message, List.of(consumer), errorHandler); assertTrue(latch.await(10, TimeUnit.SECONDS)); assertFalse(wasUnexpectedEvent.get()); @@ -469,7 +469,7 @@ public void testResubscribeExistingTaskSuccess() throws Exception { .whenComplete((unused, throwable) -> subscriptionLatch.countDown()); // Resubscribe to the task with specific consumer and error handler - getClient().resubscribe(new TaskIdParams(MINIMAL_TASK.getId()), List.of(consumer), errorHandler, null); + getClient().resubscribe(new TaskIdParams(MINIMAL_TASK.getId()), List.of(consumer), errorHandler); // Wait for subscription to be established assertTrue(subscriptionLatch.await(15, TimeUnit.SECONDS)); @@ -535,7 +535,7 @@ public void testResubscribeNoExistingTaskError() throws Exception { }; try { - getClient().resubscribe(new TaskIdParams("non-existent-task"), List.of(), errorHandler, null); + getClient().resubscribe(new TaskIdParams("non-existent-task"), List.of(), errorHandler); // Wait for error to be captured (may come via error handler for streaming) boolean errorReceived = errorLatch.await(10, TimeUnit.SECONDS); @@ -586,7 +586,7 @@ public void testListPushNotificationConfigWithConfigId() throws Exception { try { List result = getClient().listTaskPushNotificationConfigurations( - new ListTaskPushNotificationConfigParams(MINIMAL_TASK.getId()), null); + new ListTaskPushNotificationConfigParams(MINIMAL_TASK.getId())); assertEquals(2, result.size()); assertEquals(new TaskPushNotificationConfig(MINIMAL_TASK.getId(), notificationConfig1), result.get(0)); assertEquals(new TaskPushNotificationConfig(MINIMAL_TASK.getId(), notificationConfig2), result.get(1)); @@ -616,7 +616,7 @@ public void testListPushNotificationConfigWithoutConfigId() throws Exception { savePushNotificationConfigInStore(MINIMAL_TASK.getId(), notificationConfig2); try { List result = getClient().listTaskPushNotificationConfigurations( - new ListTaskPushNotificationConfigParams(MINIMAL_TASK.getId()), null); + new ListTaskPushNotificationConfigParams(MINIMAL_TASK.getId())); assertEquals(1, result.size()); PushNotificationConfig expectedNotificationConfig = new PushNotificationConfig.Builder() @@ -637,7 +637,7 @@ public void testListPushNotificationConfigWithoutConfigId() throws Exception { public void testListPushNotificationConfigTaskNotFound() { try { List result = getClient().listTaskPushNotificationConfigurations( - new ListTaskPushNotificationConfigParams("non-existent-task"), null); + new ListTaskPushNotificationConfigParams("non-existent-task")); fail(); } catch (A2AClientException e) { assertInstanceOf(TaskNotFoundError.class, e.getCause()); @@ -649,7 +649,7 @@ public void testListPushNotificationConfigEmptyList() throws Exception { saveTaskInTaskStore(MINIMAL_TASK); try { List result = getClient().listTaskPushNotificationConfigurations( - new ListTaskPushNotificationConfigParams(MINIMAL_TASK.getId()), null); + new ListTaskPushNotificationConfigParams(MINIMAL_TASK.getId())); assertEquals(0, result.size()); } catch (Exception e) { fail(e.getMessage()); @@ -684,17 +684,16 @@ public void testDeletePushNotificationConfigWithValidConfigId() throws Exception try { // specify the config ID to delete getClient().deleteTaskPushNotificationConfigurations( - new DeleteTaskPushNotificationConfigParams(MINIMAL_TASK.getId(), "config1"), - null); + new DeleteTaskPushNotificationConfigParams(MINIMAL_TASK.getId(), "config1")); // should now be 1 left List result = getClient().listTaskPushNotificationConfigurations( - new ListTaskPushNotificationConfigParams(MINIMAL_TASK.getId()), null); + new ListTaskPushNotificationConfigParams(MINIMAL_TASK.getId())); assertEquals(1, result.size()); // should remain unchanged, this is a different task result = getClient().listTaskPushNotificationConfigurations( - new ListTaskPushNotificationConfigParams("task-456"), null); + new ListTaskPushNotificationConfigParams("task-456")); assertEquals(1, result.size()); } catch (Exception e) { fail(e.getMessage()); @@ -725,12 +724,11 @@ public void testDeletePushNotificationConfigWithNonExistingConfigId() throws Exc try { getClient().deleteTaskPushNotificationConfigurations( - new DeleteTaskPushNotificationConfigParams(MINIMAL_TASK.getId(), "non-existent-config-id"), - null); + new DeleteTaskPushNotificationConfigParams(MINIMAL_TASK.getId(), "non-existent-config-id")); // should remain unchanged List result = getClient().listTaskPushNotificationConfigurations( - new ListTaskPushNotificationConfigParams(MINIMAL_TASK.getId()), null); + new ListTaskPushNotificationConfigParams(MINIMAL_TASK.getId())); assertEquals(2, result.size()); } catch (Exception e) { fail(); @@ -746,8 +744,7 @@ public void testDeletePushNotificationConfigTaskNotFound() { try { getClient().deleteTaskPushNotificationConfigurations( new DeleteTaskPushNotificationConfigParams("non-existent-task", - "non-existent-config-id"), - null); + "non-existent-config-id")); fail(); } catch (A2AClientException e) { assertInstanceOf(TaskNotFoundError.class, e.getCause()); @@ -772,8 +769,7 @@ public void testDeletePushNotificationConfigSetWithoutConfigId() throws Exceptio try { getClient().deleteTaskPushNotificationConfigurations( - new DeleteTaskPushNotificationConfigParams(MINIMAL_TASK.getId(), MINIMAL_TASK.getId()), - null); + new DeleteTaskPushNotificationConfigParams(MINIMAL_TASK.getId(), MINIMAL_TASK.getId())); // should now be 0 List result = getClient().listTaskPushNotificationConfigurations( From 793c36551fac93e2ea569003f99690b8cea974d4 Mon Sep 17 00:00:00 2001 From: Farah Juma Date: Thu, 21 Aug 2025 11:00:04 -0400 Subject: [PATCH 25/31] fix: Update the helloworld client example to use the ClientFactory --- .../java/io/a2a/client/ClientFactory.java | 6 +- examples/helloworld/client/pom.xml | 4 ++ .../examples/helloworld/HelloWorldClient.java | 68 +++++++++++++------ .../examples/helloworld/HelloWorldRunner.java | 1 + examples/helloworld/pom.xml | 5 ++ 5 files changed, 63 insertions(+), 21 deletions(-) diff --git a/client/base/src/main/java/io/a2a/client/ClientFactory.java b/client/base/src/main/java/io/a2a/client/ClientFactory.java index ff38201f3..988df4ea4 100644 --- a/client/base/src/main/java/io/a2a/client/ClientFactory.java +++ b/client/base/src/main/java/io/a2a/client/ClientFactory.java @@ -80,8 +80,10 @@ public Client create(AgentCard agentCard, List getServerPreferredTransports(AgentCard agentCard) { LinkedHashMap serverPreferredTransports = new LinkedHashMap<>(); serverPreferredTransports.put(agentCard.preferredTransport(), agentCard.url()); - for (AgentInterface agentInterface : agentCard.additionalInterfaces()) { - serverPreferredTransports.putIfAbsent(agentInterface.transport(), agentInterface.url()); + if (agentCard.additionalInterfaces() != null) { + for (AgentInterface agentInterface : agentCard.additionalInterfaces()) { + serverPreferredTransports.putIfAbsent(agentInterface.transport(), agentInterface.url()); + } } return serverPreferredTransports; } diff --git a/examples/helloworld/client/pom.xml b/examples/helloworld/client/pom.xml index 25970a7d4..cdfeef28d 100644 --- a/examples/helloworld/client/pom.xml +++ b/examples/helloworld/client/pom.xml @@ -16,6 +16,10 @@ Examples for the Java SDK for the Agent2Agent Protocol (A2A) + + io.github.a2asdk + a2a-java-sdk-client + io.github.a2asdk a2a-java-sdk-client-transport-jsonrpc diff --git a/examples/helloworld/client/src/main/java/io/a2a/examples/helloworld/HelloWorldClient.java b/examples/helloworld/client/src/main/java/io/a2a/examples/helloworld/HelloWorldClient.java index b5917dc33..e2303ac30 100644 --- a/examples/helloworld/client/src/main/java/io/a2a/examples/helloworld/HelloWorldClient.java +++ b/examples/helloworld/client/src/main/java/io/a2a/examples/helloworld/HelloWorldClient.java @@ -1,17 +1,23 @@ package io.a2a.examples.helloworld; +import java.util.ArrayList; import java.util.HashMap; +import java.util.List; import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.function.BiConsumer; +import java.util.function.Consumer; import com.fasterxml.jackson.databind.ObjectMapper; import io.a2a.A2A; +import io.a2a.client.A2ACardResolver; +import io.a2a.client.Client; +import io.a2a.client.ClientEvent; import io.a2a.client.ClientFactory; -import io.a2a.client.transport.jsonrpc.JSONRPCTransport; +import io.a2a.client.MessageEvent; +import io.a2a.client.config.ClientConfig; import io.a2a.spec.AgentCard; -import io.a2a.spec.EventKind; import io.a2a.spec.Message; -import io.a2a.spec.MessageSendParams; -import io.a2a.spec.SendMessageResponse; import io.a2a.spec.Part; import io.a2a.spec.TextPart; @@ -28,7 +34,7 @@ public class HelloWorldClient { public static void main(String[] args) { try { AgentCard finalAgentCard = null; - AgentCard publicAgentCard = A2A.getAgentCard("http://localhost:9999"); + AgentCard publicAgentCard = new A2ACardResolver("http://localhost:9999").getAgentCard(); System.out.println("Successfully fetched public agent card:"); System.out.println(OBJECT_MAPPER.writeValueAsString(publicAgentCard)); System.out.println("Using public agent card for client initialization (default)."); @@ -47,23 +53,47 @@ public static void main(String[] args) { System.out.println("Public card does not indicate support for an extended card. Using public card."); } - ClientFactory clientFactory = new ClientFactory(); - JSONRPCTransport client = new JSONRPCTransport(finalAgentCard); - Message message = A2A.toUserMessage(MESSAGE_TEXT); // the message ID will be automatically generated for you - MessageSendParams params = new MessageSendParams.Builder() - .message(message) - .build(); - EventKind result = client.sendMessage(params, null); - if (result instanceof Message responseMessage) { - StringBuilder textBuilder = new StringBuilder(); - if (responseMessage.getParts() != null) { - for (Part part : responseMessage.getParts()) { - if (part instanceof TextPart textPart) { - textBuilder.append(textPart.getText()); + final CompletableFuture messageResponse = new CompletableFuture<>(); + + // Create consumers list for handling client events + List> consumers = new ArrayList<>(); + consumers.add((event, agentCard) -> { + if (event instanceof MessageEvent messageEvent) { + Message responseMessage = messageEvent.getMessage(); + StringBuilder textBuilder = new StringBuilder(); + if (responseMessage.getParts() != null) { + for (Part part : responseMessage.getParts()) { + if (part instanceof TextPart textPart) { + textBuilder.append(textPart.getText()); + } } } + messageResponse.complete(textBuilder.toString()); + } else { + System.out.println("Received client event: " + event.getClass().getSimpleName()); } - System.out.println("Response: " + textBuilder.toString()); + }); + + // Create error handler for streaming errors + Consumer streamingErrorHandler = (error) -> { + System.err.println("Streaming error occurred: " + error.getMessage()); + error.printStackTrace(); + messageResponse.completeExceptionally(error); + }; + + ClientFactory clientFactory = new ClientFactory(new ClientConfig.Builder().build()); + Client client = clientFactory.create(finalAgentCard, consumers, streamingErrorHandler); + Message message = A2A.toUserMessage(MESSAGE_TEXT); // the message ID will be automatically generated for you + + System.out.println("Sending message: " + MESSAGE_TEXT); + client.sendMessage(message); + System.out.println("Message sent successfully. Responses will be handled by the configured consumers."); + + try { + String responseText = messageResponse.get(); + System.out.println("Response: " + responseText); + } catch (Exception e) { + System.err.println("Failed to get response: " + e.getMessage()); } } catch (Exception e) { System.err.println("An error occurred: " + e.getMessage()); diff --git a/examples/helloworld/client/src/main/java/io/a2a/examples/helloworld/HelloWorldRunner.java b/examples/helloworld/client/src/main/java/io/a2a/examples/helloworld/HelloWorldRunner.java index 84a489367..f7831133f 100644 --- a/examples/helloworld/client/src/main/java/io/a2a/examples/helloworld/HelloWorldRunner.java +++ b/examples/helloworld/client/src/main/java/io/a2a/examples/helloworld/HelloWorldRunner.java @@ -1,5 +1,6 @@ ///usr/bin/env jbang "$0" "$@" ; exit $? //DEPS io.github.a2asdk:a2a-java-sdk-client:0.3.0.Beta1-SNAPSHOT +//DEPS io.github.a2asdk:a2a-java-sdk-client-transport-jsonrpc:0.3.0.Beta1-SNAPSHOT //SOURCES HelloWorldClient.java /** diff --git a/examples/helloworld/pom.xml b/examples/helloworld/pom.xml index 47ea4ce82..a9579b75c 100644 --- a/examples/helloworld/pom.xml +++ b/examples/helloworld/pom.xml @@ -26,6 +26,11 @@ pom import + + io.github.a2asdk + a2a-java-sdk-client + ${project.version} + io.github.a2asdk a2a-java-sdk-client-transport-jsonrpc From 349f612c5658906dc4beda76212536632f7af1b5 Mon Sep 17 00:00:00 2001 From: Farah Juma Date: Thu, 21 Aug 2025 11:55:04 -0400 Subject: [PATCH 26/31] fix: Client pom cleanup --- client/base/pom.xml | 5 ----- client/config/pom.xml | 1 - client/transport/grpc/pom.xml | 5 ----- client/transport/jsonrpc/pom.xml | 6 ------ client/transport/spi/pom.xml | 2 -- pom.xml | 35 ++++++++++++++++++++++++++++++++ 6 files changed, 35 insertions(+), 19 deletions(-) diff --git a/client/base/pom.xml b/client/base/pom.xml index d39f6b91f..30750e13d 100644 --- a/client/base/pom.xml +++ b/client/base/pom.xml @@ -21,27 +21,22 @@ ${project.groupId} a2a-java-sdk-client-config - ${project.version} ${project.groupId} a2a-java-sdk-http-client - ${project.version} ${project.groupId} a2a-java-sdk-client-transport-spi - ${project.version} ${project.groupId} a2a-java-sdk-common - ${project.version} ${project.groupId} a2a-java-sdk-spec - ${project.version} org.junit.jupiter diff --git a/client/config/pom.xml b/client/config/pom.xml index a44194654..db7afdc30 100644 --- a/client/config/pom.xml +++ b/client/config/pom.xml @@ -21,7 +21,6 @@ ${project.groupId} a2a-java-sdk-spec - ${project.version} org.junit.jupiter diff --git a/client/transport/grpc/pom.xml b/client/transport/grpc/pom.xml index ad4a5b12e..758130291 100644 --- a/client/transport/grpc/pom.xml +++ b/client/transport/grpc/pom.xml @@ -20,27 +20,22 @@ ${project.groupId} a2a-java-sdk-client-config - ${project.version} ${project.groupId} a2a-java-sdk-common - ${project.version} ${project.groupId} a2a-java-sdk-spec - ${project.version} ${project.groupId} a2a-java-sdk-spec-grpc - ${project.version} ${project.groupId} a2a-java-sdk-client-transport-spi - ${project.version} org.junit.jupiter diff --git a/client/transport/jsonrpc/pom.xml b/client/transport/jsonrpc/pom.xml index 3a1d5be8b..66010fec3 100644 --- a/client/transport/jsonrpc/pom.xml +++ b/client/transport/jsonrpc/pom.xml @@ -20,32 +20,26 @@ ${project.groupId} a2a-java-sdk-client - ${project.version} ${project.groupId} a2a-java-sdk-client-config - ${project.version} ${project.groupId} a2a-java-sdk-http-client - ${project.version} ${project.groupId} a2a-java-sdk-client-transport-spi - ${project.version} ${project.groupId} a2a-java-sdk-common - ${project.version} ${project.groupId} a2a-java-sdk-spec - ${project.version} org.junit.jupiter diff --git a/client/transport/spi/pom.xml b/client/transport/spi/pom.xml index a809c6dea..b83d5adaf 100644 --- a/client/transport/spi/pom.xml +++ b/client/transport/spi/pom.xml @@ -20,12 +20,10 @@ io.github.a2asdk a2a-java-sdk-client-config - ${project.version} io.github.a2asdk a2a-java-sdk-spec - ${project.version} diff --git a/pom.xml b/pom.xml index b5fa46a08..a242b91c2 100644 --- a/pom.xml +++ b/pom.xml @@ -74,6 +74,41 @@ + + ${project.groupId} + a2a-java-sdk-client + ${project.version} + + + ${project.groupId} + a2a-java-sdk-client-config + ${project.version} + + + ${project.groupId} + a2a-java-sdk-client-transport-spi + ${project.version} + + + ${project.groupId} + a2a-java-sdk-common + ${project.version} + + + ${project.groupId} + a2a-java-sdk-http-client + ${project.version} + + + ${project.groupId} + a2a-java-sdk-spec + ${project.version} + + + ${project.groupId} + a2a-java-sdk-spec-grpc + ${project.version} + io.grpc grpc-bom From 42968a0deaeb90fad9714439b671432451c35de8 Mon Sep 17 00:00:00 2001 From: Farah Juma Date: Thu, 21 Aug 2025 13:02:27 -0400 Subject: [PATCH 27/31] fix: Avoid intermittent failures in resubscribe test case --- .../io/a2a/server/apps/common/AbstractA2AServerTest.java | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/server-common/src/test/java/io/a2a/server/apps/common/AbstractA2AServerTest.java b/tests/server-common/src/test/java/io/a2a/server/apps/common/AbstractA2AServerTest.java index e9967c7db..03cd53cfb 100644 --- a/tests/server-common/src/test/java/io/a2a/server/apps/common/AbstractA2AServerTest.java +++ b/tests/server-common/src/test/java/io/a2a/server/apps/common/AbstractA2AServerTest.java @@ -791,6 +791,11 @@ protected boolean isStreamClosedError(Throwable throwable) { if (cause instanceof EOFException) { return true; } + if (cause instanceof IOException && cause.getMessage() != null + && cause.getMessage().contains("cancelled")) { + // stream is closed upon cancellation + return true; + } cause = cause.getCause(); } return false; From e3d3b174f5baf5459a95679ceca383f6d8660910 Mon Sep 17 00:00:00 2001 From: Farah Juma Date: Thu, 21 Aug 2025 14:52:34 -0400 Subject: [PATCH 28/31] fix: Move JSONRPC-specific tests back to AbstractA2AServerTest so that other server implementations that also support JSONRPC can make use of these tests. These tests will be skipped when testing other transports --- .../apps/quarkus/QuarkusA2AJSONRPCTest.java | 277 ------------------ .../apps/common/AbstractA2AServerTest.java | 277 ++++++++++++++++++ 2 files changed, 277 insertions(+), 277 deletions(-) diff --git a/reference/jsonrpc/src/test/java/io/a2a/server/apps/quarkus/QuarkusA2AJSONRPCTest.java b/reference/jsonrpc/src/test/java/io/a2a/server/apps/quarkus/QuarkusA2AJSONRPCTest.java index 7b6257d58..acebe4cb2 100644 --- a/reference/jsonrpc/src/test/java/io/a2a/server/apps/quarkus/QuarkusA2AJSONRPCTest.java +++ b/reference/jsonrpc/src/test/java/io/a2a/server/apps/quarkus/QuarkusA2AJSONRPCTest.java @@ -1,48 +1,9 @@ package io.a2a.server.apps.quarkus; -import static io.restassured.RestAssured.given; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNull; -import static org.junit.jupiter.api.Assertions.fail; -import static org.wildfly.common.Assert.assertNotNull; -import jakarta.ws.rs.core.MediaType; - -import java.net.URI; -import java.net.http.HttpClient; -import java.net.http.HttpRequest; -import java.net.http.HttpResponse; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.CountDownLatch; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicReference; -import java.util.stream.Stream; - -import com.fasterxml.jackson.core.JsonProcessingException; - import io.a2a.server.apps.common.AbstractA2AServerTest; -import io.a2a.spec.A2AClientException; -import io.a2a.spec.InvalidParamsError; -import io.a2a.spec.InvalidRequestError; -import io.a2a.spec.JSONParseError; -import io.a2a.spec.JSONRPCErrorResponse; -import io.a2a.spec.Message; -import io.a2a.spec.MessageSendParams; -import io.a2a.spec.MethodNotFoundError; -import io.a2a.spec.Part; -import io.a2a.spec.SendStreamingMessageRequest; -import io.a2a.spec.SendStreamingMessageResponse; -import io.a2a.spec.StreamingJSONRPCRequest; -import io.a2a.spec.Task; -import io.a2a.spec.TaskQueryParams; -import io.a2a.spec.TaskState; -import io.a2a.spec.TextPart; import io.a2a.spec.TransportProtocol; -import io.a2a.util.Utils; import io.quarkus.test.junit.QuarkusTest; -import org.junit.jupiter.api.Assertions; -import org.junit.jupiter.api.Test; - @QuarkusTest public class QuarkusA2AJSONRPCTest extends AbstractA2AServerTest { @@ -59,242 +20,4 @@ protected String getTransportProtocol() { protected String getTransportUrl() { return "http://localhost:8081"; } - - @Test - public void testMalformedJSONRPCRequest() { - // missing closing bracket - String malformedRequest = "{\"jsonrpc\": \"2.0\", \"method\": \"message/send\", \"params\": {\"foo\": \"bar\"}"; - JSONRPCErrorResponse response = given() - .contentType(MediaType.APPLICATION_JSON) - .body(malformedRequest) - .when() - .post("/") - .then() - .statusCode(200) - .extract() - .as(JSONRPCErrorResponse.class); - assertNotNull(response.getError()); - assertEquals(new JSONParseError().getCode(), response.getError().getCode()); - } - - @Test - public void testInvalidParamsJSONRPCRequest() { - String invalidParamsRequest = """ - {"jsonrpc": "2.0", "method": "message/send", "params": "not_a_dict", "id": "1"} - """; - testInvalidParams(invalidParamsRequest); - - invalidParamsRequest = """ - {"jsonrpc": "2.0", "method": "message/send", "params": {"message": {"parts": "invalid"}}, "id": "1"} - """; - testInvalidParams(invalidParamsRequest); - } - - private void testInvalidParams(String invalidParamsRequest) { - JSONRPCErrorResponse response = given() - .contentType(MediaType.APPLICATION_JSON) - .body(invalidParamsRequest) - .when() - .post("/") - .then() - .statusCode(200) - .extract() - .as(JSONRPCErrorResponse.class); - assertNotNull(response.getError()); - assertEquals(new InvalidParamsError().getCode(), response.getError().getCode()); - assertEquals("1", response.getId()); - } - - @Test - public void testInvalidJSONRPCRequestMissingJsonrpc() { - String invalidRequest = """ - { - "method": "message/send", - "params": {} - } - """; - JSONRPCErrorResponse response = given() - .contentType(MediaType.APPLICATION_JSON) - .body(invalidRequest) - .when() - .post("/") - .then() - .statusCode(200) - .extract() - .as(JSONRPCErrorResponse.class); - assertNotNull(response.getError()); - assertEquals(new InvalidRequestError().getCode(), response.getError().getCode()); - } - - @Test - public void testInvalidJSONRPCRequestMissingMethod() { - String invalidRequest = """ - {"jsonrpc": "2.0", "params": {}} - """; - JSONRPCErrorResponse response = given() - .contentType(MediaType.APPLICATION_JSON) - .body(invalidRequest) - .when() - .post("/") - .then() - .statusCode(200) - .extract() - .as(JSONRPCErrorResponse.class); - assertNotNull(response.getError()); - assertEquals(new InvalidRequestError().getCode(), response.getError().getCode()); - } - - @Test - public void testInvalidJSONRPCRequestInvalidId() { - String invalidRequest = """ - {"jsonrpc": "2.0", "method": "message/send", "params": {}, "id": {"bad": "type"}} - """; - JSONRPCErrorResponse response = given() - .contentType(MediaType.APPLICATION_JSON) - .body(invalidRequest) - .when() - .post("/") - .then() - .statusCode(200) - .extract() - .as(JSONRPCErrorResponse.class); - assertNotNull(response.getError()); - assertEquals(new InvalidRequestError().getCode(), response.getError().getCode()); - } - - @Test - public void testInvalidJSONRPCRequestNonExistentMethod() { - String invalidRequest = """ - {"jsonrpc": "2.0", "method" : "nonexistent/method", "params": {}} - """; - JSONRPCErrorResponse response = given() - .contentType(MediaType.APPLICATION_JSON) - .body(invalidRequest) - .when() - .post("/") - .then() - .statusCode(200) - .extract() - .as(JSONRPCErrorResponse.class); - assertNotNull(response.getError()); - assertEquals(new MethodNotFoundError().getCode(), response.getError().getCode()); - } - - @Test - public void testNonStreamingMethodWithAcceptHeader() throws Exception { - testGetTask(MediaType.APPLICATION_JSON); - } - - private void testGetTask(String mediaType) throws Exception { - saveTaskInTaskStore(MINIMAL_TASK); - try { - Task response = getClient().getTask(new TaskQueryParams(MINIMAL_TASK.getId()), null); - assertEquals("task-123", response.getId()); - assertEquals("session-xyz", response.getContextId()); - assertEquals(TaskState.SUBMITTED, response.getStatus().state()); - } catch (A2AClientException e) { - fail("Unexpected exception during getTask: " + e.getMessage(), e); - } finally { - deleteTaskInTaskStore(MINIMAL_TASK.getId()); - } - } - - @Test - public void testStreamingMethodWithAcceptHeader() throws Exception { - testSendStreamingMessage(MediaType.SERVER_SENT_EVENTS); - } - - @Test - public void testSendMessageStreamNewMessageSuccess() throws Exception { - testSendStreamingMessage(null); - } - - private void testSendStreamingMessage(String mediaType) throws Exception { - Message message = new Message.Builder(MESSAGE) - .taskId(MINIMAL_TASK.getId()) - .contextId(MINIMAL_TASK.getContextId()) - .build(); - SendStreamingMessageRequest request = new SendStreamingMessageRequest( - "1", new MessageSendParams(message, null, null)); - - CompletableFuture>> responseFuture = initialiseStreamingRequest(request, mediaType); - - CountDownLatch latch = new CountDownLatch(1); - AtomicReference errorRef = new AtomicReference<>(); - - responseFuture.thenAccept(response -> { - if (response.statusCode() != 200) { - //errorRef.set(new IllegalStateException("Status code was " + response.statusCode())); - throw new IllegalStateException("Status code was " + response.statusCode()); - } - response.body().forEach(line -> { - try { - SendStreamingMessageResponse jsonResponse = extractJsonResponseFromSseLine(line); - if (jsonResponse != null) { - assertNull(jsonResponse.getError()); - Message messageResponse = (Message) jsonResponse.getResult(); - assertEquals(MESSAGE.getMessageId(), messageResponse.getMessageId()); - assertEquals(MESSAGE.getRole(), messageResponse.getRole()); - Part part = messageResponse.getParts().get(0); - assertEquals(Part.Kind.TEXT, part.getKind()); - assertEquals("test message", ((TextPart) part).getText()); - latch.countDown(); - } - } catch (JsonProcessingException e) { - throw new RuntimeException(e); - } - }); - }).exceptionally(t -> { - if (!isStreamClosedError(t)) { - errorRef.set(t); - } - latch.countDown(); - return null; - }); - - - boolean dataRead = latch.await(20, TimeUnit.SECONDS); - Assertions.assertTrue(dataRead); - Assertions.assertNull(errorRef.get()); - - } - - private CompletableFuture>> initialiseStreamingRequest( - StreamingJSONRPCRequest request, String mediaType) throws Exception { - - // Create the client - HttpClient client = HttpClient.newBuilder() - .version(HttpClient.Version.HTTP_2) - .build(); - - // Create the request - HttpRequest.Builder builder = HttpRequest.newBuilder() - .uri(URI.create("http://localhost:" + serverPort + "/")) - .POST(HttpRequest.BodyPublishers.ofString(Utils.OBJECT_MAPPER.writeValueAsString(request))) - .header("Content-Type", APPLICATION_JSON); - if (mediaType != null) { - builder.header("Accept", mediaType); - } - HttpRequest httpRequest = builder.build(); - - - // Send request async and return the CompletableFuture - return client.sendAsync(httpRequest, HttpResponse.BodyHandlers.ofLines()); - } - - private SendStreamingMessageResponse extractJsonResponseFromSseLine(String line) throws JsonProcessingException { - line = extractSseData(line); - if (line != null) { - return Utils.OBJECT_MAPPER.readValue(line, SendStreamingMessageResponse.class); - } - return null; - } - - private static String extractSseData(String line) { - if (line.startsWith("data:")) { - line = line.substring(5).trim(); - return line; - } - return null; - } } diff --git a/tests/server-common/src/test/java/io/a2a/server/apps/common/AbstractA2AServerTest.java b/tests/server-common/src/test/java/io/a2a/server/apps/common/AbstractA2AServerTest.java index 03cd53cfb..0cbee983f 100644 --- a/tests/server-common/src/test/java/io/a2a/server/apps/common/AbstractA2AServerTest.java +++ b/tests/server-common/src/test/java/io/a2a/server/apps/common/AbstractA2AServerTest.java @@ -1,13 +1,17 @@ package io.a2a.server.apps.common; +import static io.restassured.RestAssured.given; 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.assertNull; import static org.junit.jupiter.api.Assertions.fail; +import static org.junit.jupiter.api.Assumptions.assumeTrue; import static org.wildfly.common.Assert.assertNotNull; import static org.wildfly.common.Assert.assertTrue; +import jakarta.ws.rs.core.MediaType; + import java.io.EOFException; import java.io.IOException; import java.net.URI; @@ -25,6 +29,9 @@ import java.util.concurrent.atomic.AtomicReference; import java.util.function.BiConsumer; import java.util.function.Consumer; +import java.util.stream.Stream; + +import com.fasterxml.jackson.core.JsonProcessingException; import io.a2a.client.Client; import io.a2a.client.ClientEvent; @@ -41,10 +48,19 @@ import io.a2a.spec.DeleteTaskPushNotificationConfigParams; import io.a2a.spec.Event; import io.a2a.spec.GetTaskPushNotificationConfigParams; +import io.a2a.spec.InvalidParamsError; +import io.a2a.spec.InvalidRequestError; +import io.a2a.spec.JSONParseError; +import io.a2a.spec.JSONRPCErrorResponse; import io.a2a.spec.ListTaskPushNotificationConfigParams; import io.a2a.spec.Message; +import io.a2a.spec.MessageSendParams; +import io.a2a.spec.MethodNotFoundError; import io.a2a.spec.Part; import io.a2a.spec.PushNotificationConfig; +import io.a2a.spec.SendStreamingMessageRequest; +import io.a2a.spec.SendStreamingMessageResponse; +import io.a2a.spec.StreamingJSONRPCRequest; import io.a2a.spec.Task; import io.a2a.spec.TaskArtifactUpdateEvent; import io.a2a.spec.TaskIdParams; @@ -55,9 +71,11 @@ import io.a2a.spec.TaskStatus; import io.a2a.spec.TaskStatusUpdateEvent; import io.a2a.spec.TextPart; +import io.a2a.spec.TransportProtocol; import io.a2a.spec.UnsupportedOperationError; import io.a2a.util.Utils; +import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Timeout; @@ -783,6 +801,265 @@ public void testDeletePushNotificationConfigSetWithoutConfigId() throws Exceptio } } + @Test + public void testMalformedJSONRPCRequest() { + // skip this test for non-JSONRPC transports + assumeTrue(TransportProtocol.JSONRPC.asString().equals(getTransportProtocol()), + "JSONRPC-specific test"); + + // missing closing bracket + String malformedRequest = "{\"jsonrpc\": \"2.0\", \"method\": \"message/send\", \"params\": {\"foo\": \"bar\"}"; + JSONRPCErrorResponse response = given() + .contentType(MediaType.APPLICATION_JSON) + .body(malformedRequest) + .when() + .post("/") + .then() + .statusCode(200) + .extract() + .as(JSONRPCErrorResponse.class); + assertNotNull(response.getError()); + assertEquals(new JSONParseError().getCode(), response.getError().getCode()); + } + + @Test + public void testInvalidParamsJSONRPCRequest() { + // skip this test for non-JSONRPC transports + assumeTrue(TransportProtocol.JSONRPC.asString().equals(getTransportProtocol()), + "JSONRPC-specific test"); + + String invalidParamsRequest = """ + {"jsonrpc": "2.0", "method": "message/send", "params": "not_a_dict", "id": "1"} + """; + testInvalidParams(invalidParamsRequest); + + invalidParamsRequest = """ + {"jsonrpc": "2.0", "method": "message/send", "params": {"message": {"parts": "invalid"}}, "id": "1"} + """; + testInvalidParams(invalidParamsRequest); + } + + private void testInvalidParams(String invalidParamsRequest) { + JSONRPCErrorResponse response = given() + .contentType(MediaType.APPLICATION_JSON) + .body(invalidParamsRequest) + .when() + .post("/") + .then() + .statusCode(200) + .extract() + .as(JSONRPCErrorResponse.class); + assertNotNull(response.getError()); + assertEquals(new InvalidParamsError().getCode(), response.getError().getCode()); + assertEquals("1", response.getId()); + } + + @Test + public void testInvalidJSONRPCRequestMissingJsonrpc() { + // skip this test for non-JSONRPC transports + assumeTrue(TransportProtocol.JSONRPC.asString().equals(getTransportProtocol()), + "JSONRPC-specific test"); + + String invalidRequest = """ + { + "method": "message/send", + "params": {} + } + """; + JSONRPCErrorResponse response = given() + .contentType(MediaType.APPLICATION_JSON) + .body(invalidRequest) + .when() + .post("/") + .then() + .statusCode(200) + .extract() + .as(JSONRPCErrorResponse.class); + assertNotNull(response.getError()); + assertEquals(new InvalidRequestError().getCode(), response.getError().getCode()); + } + + @Test + public void testInvalidJSONRPCRequestMissingMethod() { + // skip this test for non-JSONRPC transports + assumeTrue(TransportProtocol.JSONRPC.asString().equals(getTransportProtocol()), + "JSONRPC-specific test"); + + String invalidRequest = """ + {"jsonrpc": "2.0", "params": {}} + """; + JSONRPCErrorResponse response = given() + .contentType(MediaType.APPLICATION_JSON) + .body(invalidRequest) + .when() + .post("/") + .then() + .statusCode(200) + .extract() + .as(JSONRPCErrorResponse.class); + assertNotNull(response.getError()); + assertEquals(new InvalidRequestError().getCode(), response.getError().getCode()); + } + + @Test + public void testInvalidJSONRPCRequestInvalidId() { + // skip this test for non-JSONRPC transports + assumeTrue(TransportProtocol.JSONRPC.asString().equals(getTransportProtocol()), + "JSONRPC-specific test"); + + String invalidRequest = """ + {"jsonrpc": "2.0", "method": "message/send", "params": {}, "id": {"bad": "type"}} + """; + JSONRPCErrorResponse response = given() + .contentType(MediaType.APPLICATION_JSON) + .body(invalidRequest) + .when() + .post("/") + .then() + .statusCode(200) + .extract() + .as(JSONRPCErrorResponse.class); + assertNotNull(response.getError()); + assertEquals(new InvalidRequestError().getCode(), response.getError().getCode()); + } + + @Test + public void testInvalidJSONRPCRequestNonExistentMethod() { + // skip this test for non-JSONRPC transports + assumeTrue(TransportProtocol.JSONRPC.asString().equals(getTransportProtocol()), + "JSONRPC-specific test"); + + String invalidRequest = """ + {"jsonrpc": "2.0", "method" : "nonexistent/method", "params": {}} + """; + JSONRPCErrorResponse response = given() + .contentType(MediaType.APPLICATION_JSON) + .body(invalidRequest) + .when() + .post("/") + .then() + .statusCode(200) + .extract() + .as(JSONRPCErrorResponse.class); + assertNotNull(response.getError()); + assertEquals(new MethodNotFoundError().getCode(), response.getError().getCode()); + } + + @Test + public void testNonStreamingMethodWithAcceptHeader() throws Exception { + // skip this test for non-JSONRPC transports + assumeTrue(TransportProtocol.JSONRPC.asString().equals(getTransportProtocol()), + "JSONRPC-specific test"); + testGetTask(MediaType.APPLICATION_JSON); + } + + @Test + public void testStreamingMethodWithAcceptHeader() throws Exception { + // skip this test for non-JSONRPC transports + assumeTrue(TransportProtocol.JSONRPC.asString().equals(getTransportProtocol()), + "JSONRPC-specific test"); + + testSendStreamingMessage(MediaType.SERVER_SENT_EVENTS); + } + + @Test + public void testSendMessageStreamNewMessageSuccess() throws Exception { + // skip this test for non-JSONRPC transports + assumeTrue(TransportProtocol.JSONRPC.asString().equals(getTransportProtocol()), + "JSONRPC-specific test"); + + testSendStreamingMessage(null); + } + + private void testSendStreamingMessage(String mediaType) throws Exception { + Message message = new Message.Builder(MESSAGE) + .taskId(MINIMAL_TASK.getId()) + .contextId(MINIMAL_TASK.getContextId()) + .build(); + SendStreamingMessageRequest request = new SendStreamingMessageRequest( + "1", new MessageSendParams(message, null, null)); + + CompletableFuture>> responseFuture = initialiseStreamingRequest(request, mediaType); + + CountDownLatch latch = new CountDownLatch(1); + AtomicReference errorRef = new AtomicReference<>(); + + responseFuture.thenAccept(response -> { + if (response.statusCode() != 200) { + //errorRef.set(new IllegalStateException("Status code was " + response.statusCode())); + throw new IllegalStateException("Status code was " + response.statusCode()); + } + response.body().forEach(line -> { + try { + SendStreamingMessageResponse jsonResponse = extractJsonResponseFromSseLine(line); + if (jsonResponse != null) { + assertNull(jsonResponse.getError()); + Message messageResponse = (Message) jsonResponse.getResult(); + assertEquals(MESSAGE.getMessageId(), messageResponse.getMessageId()); + assertEquals(MESSAGE.getRole(), messageResponse.getRole()); + Part part = messageResponse.getParts().get(0); + assertEquals(Part.Kind.TEXT, part.getKind()); + assertEquals("test message", ((TextPart) part).getText()); + latch.countDown(); + } + } catch (JsonProcessingException e) { + throw new RuntimeException(e); + } + }); + }).exceptionally(t -> { + if (!isStreamClosedError(t)) { + errorRef.set(t); + } + latch.countDown(); + return null; + }); + + + boolean dataRead = latch.await(20, TimeUnit.SECONDS); + Assertions.assertTrue(dataRead); + Assertions.assertNull(errorRef.get()); + + } + + private CompletableFuture>> initialiseStreamingRequest( + StreamingJSONRPCRequest request, String mediaType) throws Exception { + + // Create the client + HttpClient client = HttpClient.newBuilder() + .version(HttpClient.Version.HTTP_2) + .build(); + + // Create the request + HttpRequest.Builder builder = HttpRequest.newBuilder() + .uri(URI.create("http://localhost:" + serverPort + "/")) + .POST(HttpRequest.BodyPublishers.ofString(Utils.OBJECT_MAPPER.writeValueAsString(request))) + .header("Content-Type", APPLICATION_JSON); + if (mediaType != null) { + builder.header("Accept", mediaType); + } + HttpRequest httpRequest = builder.build(); + + + // Send request async and return the CompletableFuture + return client.sendAsync(httpRequest, HttpResponse.BodyHandlers.ofLines()); + } + + private SendStreamingMessageResponse extractJsonResponseFromSseLine(String line) throws JsonProcessingException { + line = extractSseData(line); + if (line != null) { + return Utils.OBJECT_MAPPER.readValue(line, SendStreamingMessageResponse.class); + } + return null; + } + + private static String extractSseData(String line) { + if (line.startsWith("data:")) { + line = line.substring(5).trim(); + return line; + } + return null; + } + protected boolean isStreamClosedError(Throwable throwable) { // Unwrap the CompletionException Throwable cause = throwable; From c826f3aa0fbeedf046f5a1314d963ae778d15d89 Mon Sep 17 00:00:00 2001 From: Farah Juma Date: Thu, 21 Aug 2025 15:30:34 -0400 Subject: [PATCH 29/31] fix: Update server and client transport descriptions in the README --- README.md | 25 +++++++++++++++++-------- 1 file changed, 17 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index aa41fd0ae..54b66a8f0 100644 --- a/README.md +++ b/README.md @@ -40,16 +40,19 @@ The A2A Java SDK provides a Java server implementation of the [Agent2Agent (A2A) - [Add a class that creates an A2A Agent Card](#2-add-a-class-that-creates-an-a2a-agent-card) - [Add a class that creates an A2A Agent Executor](#3-add-a-class-that-creates-an-a2a-agent-executor) -### 1. Add the A2A Java SDK Server Maven dependency to your project +### 1. Add an A2A Java SDK Reference Server dependency to your project -Adding a dependency on an A2A Java SDK Server will provide access to the core classes that make up the A2A specification -and allow you to run your agentic Java application as an A2A server agent. +Adding a dependency on an A2A Java SDK Reference Server will provide access to the core classes +that make up the A2A specification and allow you to run your agentic Java application as an A2A server agent. -The A2A Java SDK provides a [reference A2A server implementation](reference-impl/README.md) based on [Quarkus](https://quarkus.io) for use with our tests and examples. However, the project is designed in such a way that it is trivial to integrate with various Java runtimes. +The A2A Java SDK provides [reference A2A server implementations](reference) based on [Quarkus](https://quarkus.io) for use with our tests and examples. However, the project is designed in such a way that it is trivial to integrate with various Java runtimes. [Server Integrations](#server-integrations) contains a list of community contributed integrations of the server with various runtimes. You might be able to use one of these for your target runtime, or you can use them as inspiration to create your own. -To use the reference implementation with the JSONRPC protocol add the following dependency to your project: +#### Server Transports +The A2A Java SDK Reference Server implementations support a couple transports: JSON-RPC 2.0 and gRPC. + +To use the reference implementation with the JSON-RPC protocol, add the following dependency to your project: > *⚠️ The `io.github.a2asdk` `groupId` below is temporary and will likely change for future releases.* @@ -62,7 +65,7 @@ To use the reference implementation with the JSONRPC protocol add the following ``` -To use the reference implementation with the gRPC protocol add the following dependency to your project: +To use the reference implementation with the gRPC protocol, add the following dependency to your project: > *⚠️ The `io.github.a2asdk` `groupId` below is temporary and will likely change for future releases.* @@ -75,6 +78,11 @@ To use the reference implementation with the gRPC protocol add the following dep ``` +Note that you can add more than one of the above dependencies to your project depending on the transports +you'd like to support. + +Support for the HTTP+JSON/REST transport will be coming soon. + ### 2. Add a class that creates an A2A Agent Card ```java @@ -233,7 +241,7 @@ that you can use to create your A2A `Client`. ``` -### 2. Add dependencies on the A2A Java SDK Client Transport(s) you'd like to use +### 2. Add one or more dependencies on the A2A Java SDK Client Transport(s) you'd like to use You need to add a dependency on at least one of the following client transport modules: @@ -569,7 +577,8 @@ The following list contains community contributed integrations with various Java To contribute an integration, please see [CONTRIBUTING_INTEGRATIONS.md](CONTRIBUTING_INTEGRATIONS.md). -* [reference-impl/README.md](reference-impl/README.md) - Reference implementation, based on Quarkus. +* [reference/jsonrpc/README.md](reference/jsonrpc/README.md) - JSON-RPC 2.0 Reference implementation, based on Quarkus. +* [reference/grpc/README.md](reference/grpc/README.md) - gRPC Reference implementation, based on Quarkus. * https://github.com/wildfly-extras/a2a-java-sdk-server-jakarta - This integration is based on Jakarta EE, and should work in all runtimes supporting the [Jakarta EE Web Profile](https://jakarta.ee/specifications/webprofile/). From a9e0d3a6f00d50545efc5b0871fba855d21fd9ef Mon Sep 17 00:00:00 2001 From: Farah Juma Date: Fri, 8 Aug 2025 09:51:51 -0400 Subject: [PATCH 30/31] feat: Update the ClientTransport interface, introducing ClientCallContext, ClientConfig, and ClientCallInterceptor similar to the Python SDK. Introduce a ClientTransportProvider and update the JSONRPC and gRPC transport implementations. Introduce a new Client and ClientFactory implementations. --- client-config/pom.xml | 47 ++++++ .../a2a/client/config/ClientCallContext.java | 27 ++++ .../client/config/ClientCallInterceptor.java | 26 +++ .../io/a2a/client/config/ClientConfig.java | 150 ++++++++++++++++++ .../a2a/client/config/PayloadAndHeaders.java | 22 +++ ...ient.transport.spi.ClientTransportProvider | 1 + ...ient.transport.spi.ClientTransportProvider | 1 + .../spi/ClientTransportProvider.java | 32 ++++ client/base/pom.xml | 4 + .../java/io/a2a/client/AbstractClient.java | 17 ++ .../src/main/java/io/a2a/client/Client.java | 58 +++++++ .../java/io/a2a/client/ClientFactory.java | 14 ++ .../java/io/a2a/client/ClientTaskManager.java | 13 ++ .../transport/grpc/GrpcTransportProvider.java | 13 ++ .../jsonrpc/JSONRPCTransportProvider.java | 10 ++ .../jsonrpc/JSONRPCTransportTest.java | 27 ++-- .../transport/jsonrpc/JsonMessages.java | 147 +++++++++++++++++ server-common/pom.xml | 5 + 18 files changed, 600 insertions(+), 14 deletions(-) create mode 100644 client-config/pom.xml create mode 100644 client-config/src/main/java/io/a2a/client/config/ClientCallContext.java create mode 100644 client-config/src/main/java/io/a2a/client/config/ClientCallInterceptor.java create mode 100644 client-config/src/main/java/io/a2a/client/config/ClientConfig.java create mode 100644 client-config/src/main/java/io/a2a/client/config/PayloadAndHeaders.java create mode 100644 client-transport/grpc/src/main/resources/META-INF/services/io.a2a.client.transport.spi.ClientTransportProvider create mode 100644 client-transport/jsonrpc/src/main/resources/META-INF/services/io.a2a.client.transport.spi.ClientTransportProvider create mode 100644 client-transport/spi/src/main/java/io/a2a/client/transport/spi/ClientTransportProvider.java diff --git a/client-config/pom.xml b/client-config/pom.xml new file mode 100644 index 000000000..3d85221fb --- /dev/null +++ b/client-config/pom.xml @@ -0,0 +1,47 @@ + + + 4.0.0 + + + io.github.a2asdk + a2a-java-sdk-parent + 0.2.6.Beta1-SNAPSHOT + + a2a-java-sdk-client-config + + jar + + Java SDK A2A Client Configuration + Java SDK for the Agent2Agent Protocol (A2A) - Client Configuration + + + + ${project.groupId} + a2a-java-sdk-client-http + ${project.version} + + + ${project.groupId} + a2a-java-sdk-spec + ${project.version} + + + org.junit.jupiter + junit-jupiter-api + test + + + + org.mock-server + mockserver-netty + test + + + io.grpc + grpc-api + + + + \ No newline at end of file diff --git a/client-config/src/main/java/io/a2a/client/config/ClientCallContext.java b/client-config/src/main/java/io/a2a/client/config/ClientCallContext.java new file mode 100644 index 000000000..0cfff4d65 --- /dev/null +++ b/client-config/src/main/java/io/a2a/client/config/ClientCallContext.java @@ -0,0 +1,27 @@ +package io.a2a.client.config; + +import java.util.Map; + +/** + * A context passed with each client call, allowing for call-specific. + * configuration and data passing. Such as authentication details or + * request deadlines. + */ +public class ClientCallContext { + + private final Map state; + private final Map headers; + + public ClientCallContext(Map state, Map headers) { + this.state = state; + this.headers = headers; + } + + public Map getState() { + return state; + } + + public Map getHeaders() { + return headers; + } +} diff --git a/client-config/src/main/java/io/a2a/client/config/ClientCallInterceptor.java b/client-config/src/main/java/io/a2a/client/config/ClientCallInterceptor.java new file mode 100644 index 000000000..631cd8353 --- /dev/null +++ b/client-config/src/main/java/io/a2a/client/config/ClientCallInterceptor.java @@ -0,0 +1,26 @@ +package io.a2a.client.config; + +import java.util.Map; + +import io.a2a.spec.AgentCard; + +/** + * An abstract base class for client-side call interceptors. + * Interceptors can inspect and modify requests before they are sent, + * which is ideal for concerns like authentication, logging, or tracing. + */ +public abstract class ClientCallInterceptor { + + /** + * Intercept a client call before the request is sent. + * + * @param methodName the name of the protocol method (e.g., 'message/send') + * @param payload the request payload + * @param headers the headers to use + * @param agentCard the agent card (may be {@code null}) + * @param clientCallContext the {@code ClientCallContext} for this call (may be {@code null}) + * @return the potentially modified payload and headers + */ + public abstract PayloadAndHeaders intercept(String methodName, Object payload, Map headers, + AgentCard agentCard, ClientCallContext clientCallContext); +} diff --git a/client-config/src/main/java/io/a2a/client/config/ClientConfig.java b/client-config/src/main/java/io/a2a/client/config/ClientConfig.java new file mode 100644 index 000000000..ef8cc948d --- /dev/null +++ b/client-config/src/main/java/io/a2a/client/config/ClientConfig.java @@ -0,0 +1,150 @@ +package io.a2a.client.config; + +import java.util.List; +import java.util.Map; + +import io.a2a.client.http.A2AHttpClient; +import io.a2a.spec.PushNotificationConfig; +import io.grpc.Channel; + +/** + * Configuration for the A2A client factory. + */ +public class ClientConfig { + + private final Boolean streaming; + private final Boolean polling; + private final A2AHttpClient httpClient; + private final Channel channel; + private final List supportedTransports; + private final Boolean useClientPreference; + private final List acceptedOutputModes; + private final PushNotificationConfig pushNotificationConfig; + private final Integer historyLength; + private final Map metadata; + + public ClientConfig(Boolean streaming, Boolean polling, A2AHttpClient httpClient, Channel channel, + List supportedTransports, Boolean useClientPreference, + List acceptedOutputModes, PushNotificationConfig pushNotificationConfig, + Integer historyLength, Map metadata) { + this.streaming = streaming == null ? true : streaming; + this.polling = polling == null ? false : polling; + this.httpClient = httpClient; + this.channel = channel; + this.supportedTransports = supportedTransports; + this.useClientPreference = useClientPreference == null ? false : useClientPreference; + this.acceptedOutputModes = acceptedOutputModes; + this.pushNotificationConfig = pushNotificationConfig; + this.historyLength = historyLength; + this.metadata = metadata; + } + + public boolean isStreaming() { + return streaming; + } + + public boolean isPolling() { + return polling; + } + + public A2AHttpClient getHttpClient() { + return httpClient; + } + + public Channel getChannel() { + return channel; + } + + public List getSupportedTransports() { + return supportedTransports; + } + + public boolean isUseClientPreference() { + return useClientPreference; + } + + public List getAcceptedOutputModes() { + return acceptedOutputModes; + } + + public PushNotificationConfig getPushNotificationConfig() { + return pushNotificationConfig; + } + + public Integer getHistoryLength() { + return historyLength; + } + + public Map getMetadata() { + return metadata; + } + + public static class Builder { + private Boolean streaming; + private Boolean polling; + private A2AHttpClient httpClient; + private Channel channel; + private List supportedTransports; + private Boolean useClientPreference; + private List acceptedOutputModes; + private PushNotificationConfig pushNotificationConfig; + private Integer historyLength; + private Map metadata; + + public Builder setStreaming(Boolean streaming) { + this.streaming = streaming; + return this; + } + + public Builder setPolling(Boolean polling) { + this.polling = polling; + return this; + } + + public Builder setHttpClient(A2AHttpClient httpClient) { + this.httpClient = httpClient; + return this; + } + + public Builder setChannel(Channel channel) { + this.channel = channel; + return this; + } + + public Builder setSupportedTransports(List supportedTransports) { + this.supportedTransports = supportedTransports; + return this; + } + + public Builder setUseClientPreference(Boolean useClientPreference) { + this.useClientPreference = useClientPreference; + return this; + } + + public Builder setAcceptedOutputModes(List acceptedOutputModes) { + this.acceptedOutputModes = acceptedOutputModes; + return this; + } + + public Builder setPushNotificationConfig(PushNotificationConfig pushNotificationConfig) { + this.pushNotificationConfig = pushNotificationConfig; + return this; + } + + public Builder setHistoryLength(Integer historyLength) { + this.historyLength = historyLength; + return this; + } + + public Builder setMetadata(Map metadata) { + this.metadata = metadata; + return this; + } + + public ClientConfig build() { + return new ClientConfig(streaming, polling, httpClient, channel, + supportedTransports, useClientPreference, acceptedOutputModes, + pushNotificationConfig, historyLength, metadata); + } + } +} \ No newline at end of file diff --git a/client-config/src/main/java/io/a2a/client/config/PayloadAndHeaders.java b/client-config/src/main/java/io/a2a/client/config/PayloadAndHeaders.java new file mode 100644 index 000000000..2146a5547 --- /dev/null +++ b/client-config/src/main/java/io/a2a/client/config/PayloadAndHeaders.java @@ -0,0 +1,22 @@ +package io.a2a.client.config; + +import java.util.Map; + +public class PayloadAndHeaders { + + private final Object payload; + private final Map headers; + + public PayloadAndHeaders(Object payload, Map headers) { + this.payload = payload; + this.headers = headers; + } + + public Object getPayload() { + return payload; + } + + public Map getHeaders() { + return headers; + } +} diff --git a/client-transport/grpc/src/main/resources/META-INF/services/io.a2a.client.transport.spi.ClientTransportProvider b/client-transport/grpc/src/main/resources/META-INF/services/io.a2a.client.transport.spi.ClientTransportProvider new file mode 100644 index 000000000..86d4fa7e5 --- /dev/null +++ b/client-transport/grpc/src/main/resources/META-INF/services/io.a2a.client.transport.spi.ClientTransportProvider @@ -0,0 +1 @@ +io.a2a.client.transport.grpc.GrpcTransportProvider \ No newline at end of file diff --git a/client-transport/jsonrpc/src/main/resources/META-INF/services/io.a2a.client.transport.spi.ClientTransportProvider b/client-transport/jsonrpc/src/main/resources/META-INF/services/io.a2a.client.transport.spi.ClientTransportProvider new file mode 100644 index 000000000..b2904cb45 --- /dev/null +++ b/client-transport/jsonrpc/src/main/resources/META-INF/services/io.a2a.client.transport.spi.ClientTransportProvider @@ -0,0 +1 @@ +io.a2a.client.transport.jsonrpc.JSONRPCTransportProvider \ No newline at end of file diff --git a/client-transport/spi/src/main/java/io/a2a/client/transport/spi/ClientTransportProvider.java b/client-transport/spi/src/main/java/io/a2a/client/transport/spi/ClientTransportProvider.java new file mode 100644 index 000000000..5ebed06a9 --- /dev/null +++ b/client-transport/spi/src/main/java/io/a2a/client/transport/spi/ClientTransportProvider.java @@ -0,0 +1,32 @@ +package io.a2a.client.transport.spi; + +import java.util.List; + +import io.a2a.client.config.ClientCallInterceptor; +import io.a2a.client.config.ClientConfig; +import io.a2a.spec.AgentCard; + +/** + * Client transport provider interface. + */ +public interface ClientTransportProvider { + + /** + * Create a client transport. + * + * @param clientConfig the client config to use + * @param agentCard the agent card for the remote agent + * @param agentUrl the remote agent's URL + * @param interceptors the optional interceptors to use for a client call (may be {@code null}) + * @return the client transport + */ + ClientTransport create(ClientConfig clientConfig, AgentCard agentCard, + String agentUrl, List interceptors); + + /** + * Get the name of the client transport. + */ + String getTransportProtocol(); + +} + diff --git a/client/base/pom.xml b/client/base/pom.xml index 30750e13d..5b74deb8a 100644 --- a/client/base/pom.xml +++ b/client/base/pom.xml @@ -49,6 +49,10 @@ mockserver-netty test + + io.grpc + grpc-api + \ No newline at end of file diff --git a/client/base/src/main/java/io/a2a/client/AbstractClient.java b/client/base/src/main/java/io/a2a/client/AbstractClient.java index 1667ed56f..93ed8dca9 100644 --- a/client/base/src/main/java/io/a2a/client/AbstractClient.java +++ b/client/base/src/main/java/io/a2a/client/AbstractClient.java @@ -45,7 +45,11 @@ public AbstractClient(List> consumers, Consum * Send a message to the remote agent. This method will automatically use * the streaming or non-streaming approach as determined by the server's * agent card and the client configuration. The configured client consumers +<<<<<<< HEAD:client/base/src/main/java/io/a2a/client/AbstractClient.java * will be used to handle messages, tasks, and update events received +======= + * and will be used to handle messages, tasks, and update events received +>>>>>>> 5955029 (feat: Update the ClientTransport interface, introducing ClientCallContext, ClientConfig, and ClientCallInterceptor similar to the Python SDK. Introduce a ClientTransportProvider and update the JSONRPC and gRPC transport implementations. Introduce a new Client and ClientFactory implementations.):client/src/main/java/io/a2a/client/AbstractClient.java * from the remote agent. The configured streaming error handler will be used * if an error occurs during streaming. The configured client push notification * configuration will get used for streaming. @@ -75,6 +79,7 @@ public void sendMessage(Message request) throws A2AClientException { /** * Send a message to the remote agent. This method will automatically use * the streaming or non-streaming approach as determined by the server's +<<<<<<< HEAD:client/base/src/main/java/io/a2a/client/AbstractClient.java * agent card and the client configuration. The specified client consumers * will be used to handle messages, tasks, and update events received * from the remote agent. The specified streaming error handler will be used @@ -115,6 +120,8 @@ public abstract void sendMessage(Message request, /** * Send a message to the remote agent. This method will automatically use * the streaming or non-streaming approach as determined by the server's +======= +>>>>>>> 5955029 (feat: Update the ClientTransport interface, introducing ClientCallContext, ClientConfig, and ClientCallInterceptor similar to the Python SDK. Introduce a ClientTransportProvider and update the JSONRPC and gRPC transport implementations. Introduce a new Client and ClientFactory implementations.):client/src/main/java/io/a2a/client/AbstractClient.java * agent card and the client configuration. The configured client consumers * will be used to handle messages, tasks, and update events received from * the remote agent. The configured streaming error handler will be used @@ -288,9 +295,12 @@ public abstract void deleteTaskPushNotificationConfigurations( /** * Resubscribe to a task's event stream. * This is only available if both the client and server support streaming. +<<<<<<< HEAD:client/base/src/main/java/io/a2a/client/AbstractClient.java * The configured client consumers will be used to handle messages, tasks, * and update events received from the remote agent. The configured streaming * error handler will be used if an error occurs during streaming. +======= +>>>>>>> 5955029 (feat: Update the ClientTransport interface, introducing ClientCallContext, ClientConfig, and ClientCallInterceptor similar to the Python SDK. Introduce a ClientTransportProvider and update the JSONRPC and gRPC transport implementations. Introduce a new Client and ClientFactory implementations.):client/src/main/java/io/a2a/client/AbstractClient.java * * @param request the parameters specifying which task's notification configs to delete * @throws A2AClientException if resubscribing fails for any reason @@ -313,6 +323,7 @@ public void resubscribe(TaskIdParams request) throws A2AClientException { public abstract void resubscribe(TaskIdParams request, ClientCallContext context) throws A2AClientException; /** +<<<<<<< HEAD:client/base/src/main/java/io/a2a/client/AbstractClient.java * Resubscribe to a task's event stream. * This is only available if both the client and server support streaming. * The specified client consumers will be used to handle messages, tasks, and @@ -356,6 +367,8 @@ public AgentCard getAgentCard() throws A2AClientException { } /** +======= +>>>>>>> 5955029 (feat: Update the ClientTransport interface, introducing ClientCallContext, ClientConfig, and ClientCallInterceptor similar to the Python SDK. Introduce a ClientTransportProvider and update the JSONRPC and gRPC transport implementations. Introduce a new Client and ClientFactory implementations.):client/src/main/java/io/a2a/client/AbstractClient.java * Retrieve the AgentCard. * * @param context optional client call context for the request (may be {@code null}) @@ -372,7 +385,11 @@ public AgentCard getAgentCard() throws A2AClientException { /** * Process the event using all configured consumers. */ +<<<<<<< HEAD:client/base/src/main/java/io/a2a/client/AbstractClient.java void consume(ClientEvent clientEventOrMessage, AgentCard agentCard) { +======= + public void consume(ClientEvent clientEventOrMessage, AgentCard agentCard) { +>>>>>>> 5955029 (feat: Update the ClientTransport interface, introducing ClientCallContext, ClientConfig, and ClientCallInterceptor similar to the Python SDK. Introduce a ClientTransportProvider and update the JSONRPC and gRPC transport implementations. Introduce a new Client and ClientFactory implementations.):client/src/main/java/io/a2a/client/AbstractClient.java for (BiConsumer consumer : consumers) { consumer.accept(clientEventOrMessage, agentCard); } diff --git a/client/base/src/main/java/io/a2a/client/Client.java b/client/base/src/main/java/io/a2a/client/Client.java index 103b78eef..2d941faad 100644 --- a/client/base/src/main/java/io/a2a/client/Client.java +++ b/client/base/src/main/java/io/a2a/client/Client.java @@ -45,6 +45,7 @@ public Client(AgentCard agentCard, ClientConfig clientConfig, ClientTransport cl @Override public void sendMessage(Message request, ClientCallContext context) throws A2AClientException { +<<<<<<< HEAD:client/base/src/main/java/io/a2a/client/Client.java MessageSendParams messageSendParams = getMessageSendParams(request, clientConfig); sendMessage(messageSendParams, null, null, context); } @@ -54,6 +55,22 @@ public void sendMessage(Message request, List Consumer streamingErrorHandler, ClientCallContext context) throws A2AClientException { MessageSendParams messageSendParams = getMessageSendParams(request, clientConfig); sendMessage(messageSendParams, consumers, streamingErrorHandler, context); +======= + MessageSendConfiguration messageSendConfiguration = new MessageSendConfiguration.Builder() + .acceptedOutputModes(clientConfig.getAcceptedOutputModes()) + .blocking(clientConfig.isPolling()) + .historyLength(clientConfig.getHistoryLength()) + .pushNotification(clientConfig.getPushNotificationConfig()) + .build(); + + MessageSendParams messageSendParams = new MessageSendParams.Builder() + .message(request) + .configuration(messageSendConfiguration) + .metadata(clientConfig.getMetadata()) + .build(); + + sendMessage(messageSendParams, context); +>>>>>>> 5955029 (feat: Update the ClientTransport interface, introducing ClientCallContext, ClientConfig, and ClientCallInterceptor similar to the Python SDK. Introduce a ClientTransportProvider and update the JSONRPC and gRPC transport implementations. Introduce a new Client and ClientFactory implementations.):client/src/main/java/io/a2a/client/Client.java } @Override @@ -72,7 +89,11 @@ public void sendMessage(Message request, PushNotificationConfig pushNotification .metadata(metatadata) .build(); +<<<<<<< HEAD:client/base/src/main/java/io/a2a/client/Client.java sendMessage(messageSendParams, null, null, context); +======= + sendMessage(messageSendParams, context); +>>>>>>> 5955029 (feat: Update the ClientTransport interface, introducing ClientCallContext, ClientConfig, and ClientCallInterceptor similar to the Python SDK. Introduce a ClientTransportProvider and update the JSONRPC and gRPC transport implementations. Introduce a new Client and ClientFactory implementations.):client/src/main/java/io/a2a/client/Client.java } @Override @@ -111,6 +132,7 @@ public void deleteTaskPushNotificationConfigurations( @Override public void resubscribe(TaskIdParams request, ClientCallContext context) throws A2AClientException { +<<<<<<< HEAD:client/base/src/main/java/io/a2a/client/Client.java resubscribeToTask(request, null, null, context); } @@ -118,6 +140,21 @@ public void resubscribe(TaskIdParams request, ClientCallContext context) throws public void resubscribe(TaskIdParams request, List> consumers, Consumer streamingErrorHandler, ClientCallContext context) throws A2AClientException { resubscribeToTask(request, consumers, streamingErrorHandler, context); +======= + if (! clientConfig.isStreaming() || ! agentCard.capabilities().streaming()) { + throw new A2AClientException("Client and/or server does not support resubscription"); + } + ClientTaskManager tracker = new ClientTaskManager(); + Consumer eventHandler = event -> { + try { + ClientEvent clientEvent = getClientEvent(event, tracker); + consume(clientEvent, agentCard); + } catch (A2AClientError e) { + getStreamingErrorHandler().accept(e); + } + }; + clientTransport.resubscribe(request, eventHandler, getStreamingErrorHandler(), context); +>>>>>>> 5955029 (feat: Update the ClientTransport interface, introducing ClientCallContext, ClientConfig, and ClientCallInterceptor similar to the Python SDK. Introduce a ClientTransportProvider and update the JSONRPC and gRPC transport implementations. Introduce a new Client and ClientFactory implementations.):client/src/main/java/io/a2a/client/Client.java } @Override @@ -148,8 +185,12 @@ private ClientEvent getClientEvent(StreamingEventKind event, ClientTaskManager t } } +<<<<<<< HEAD:client/base/src/main/java/io/a2a/client/Client.java private void sendMessage(MessageSendParams messageSendParams, List> consumers, Consumer errorHandler, ClientCallContext context) throws A2AClientException { +======= + private void sendMessage(MessageSendParams messageSendParams, ClientCallContext context) throws A2AClientException { +>>>>>>> 5955029 (feat: Update the ClientTransport interface, introducing ClientCallContext, ClientConfig, and ClientCallInterceptor similar to the Python SDK. Introduce a ClientTransportProvider and update the JSONRPC and gRPC transport implementations. Introduce a new Client and ClientFactory implementations.):client/src/main/java/io/a2a/client/Client.java if (! clientConfig.isStreaming() || ! agentCard.capabilities().streaming()) { EventKind eventKind = clientTransport.sendMessage(messageSendParams, context); ClientEvent clientEvent; @@ -159,6 +200,7 @@ private void sendMessage(MessageSendParams messageSendParams, List eventHandler = event -> { + try { + ClientEvent clientEvent = getClientEvent(event, tracker); + consume(clientEvent, agentCard); + } catch (A2AClientError e) { + getStreamingErrorHandler().accept(e); + } + }; + clientTransport.sendMessageStreaming(messageSendParams, eventHandler, getStreamingErrorHandler(), context); + } + } +>>>>>>> 5955029 (feat: Update the ClientTransport interface, introducing ClientCallContext, ClientConfig, and ClientCallInterceptor similar to the Python SDK. Introduce a ClientTransportProvider and update the JSONRPC and gRPC transport implementations. Introduce a new Client and ClientFactory implementations.):client/src/main/java/io/a2a/client/Client.java } diff --git a/client/base/src/main/java/io/a2a/client/ClientFactory.java b/client/base/src/main/java/io/a2a/client/ClientFactory.java index 988df4ea4..8035761ab 100644 --- a/client/base/src/main/java/io/a2a/client/ClientFactory.java +++ b/client/base/src/main/java/io/a2a/client/ClientFactory.java @@ -48,11 +48,18 @@ public ClientFactory(ClientConfig clientConfig) { * @param agentCard the agent card for the remote agent * @param consumers a list of consumers to pass responses from the remote agent to * @param streamingErrorHandler an error handler that should be used for the streaming case if an error occurs +<<<<<<< HEAD:client/base/src/main/java/io/a2a/client/ClientFactory.java * @return the client to use * @throws A2AClientException if the client cannot be created for any reason */ public Client create(AgentCard agentCard, List> consumers, Consumer streamingErrorHandler) throws A2AClientException { +======= + * @throws A2AClientException if the client cannot be created for any reason + */ + public AbstractClient create(AgentCard agentCard, List> consumers, + Consumer streamingErrorHandler) throws A2AClientException { +>>>>>>> 5955029 (feat: Update the ClientTransport interface, introducing ClientCallContext, ClientConfig, and ClientCallInterceptor similar to the Python SDK. Introduce a ClientTransportProvider and update the JSONRPC and gRPC transport implementations. Introduce a new Client and ClientFactory implementations.):client/src/main/java/io/a2a/client/ClientFactory.java return create(agentCard, consumers, streamingErrorHandler, null); } @@ -63,11 +70,18 @@ public Client create(AgentCard agentCard, List> consumers, Consumer streamingErrorHandler, List interceptors) throws A2AClientException { +======= + * @throws A2AClientException if the client cannot be created for any reason + */ + public AbstractClient create(AgentCard agentCard, List> consumers, + Consumer streamingErrorHandler, List interceptors) throws A2AClientException { +>>>>>>> 5955029 (feat: Update the ClientTransport interface, introducing ClientCallContext, ClientConfig, and ClientCallInterceptor similar to the Python SDK. Introduce a ClientTransportProvider and update the JSONRPC and gRPC transport implementations. Introduce a new Client and ClientFactory implementations.):client/src/main/java/io/a2a/client/ClientFactory.java checkNotNullParam("agentCard", agentCard); checkNotNullParam("consumers", consumers); LinkedHashMap serverPreferredTransports = getServerPreferredTransports(agentCard); diff --git a/client/base/src/main/java/io/a2a/client/ClientTaskManager.java b/client/base/src/main/java/io/a2a/client/ClientTaskManager.java index 602102f5f..78dc235c2 100644 --- a/client/base/src/main/java/io/a2a/client/ClientTaskManager.java +++ b/client/base/src/main/java/io/a2a/client/ClientTaskManager.java @@ -70,15 +70,28 @@ public Task saveTaskEvent(TaskStatusUpdateEvent taskStatusUpdateEvent) throws A2 if (task.getHistory() == null) { taskBuilder.history(taskStatusUpdateEvent.getStatus().message()); } else { +<<<<<<< HEAD:client/base/src/main/java/io/a2a/client/ClientTaskManager.java List history = new ArrayList<>(task.getHistory()); +======= + List history = task.getHistory(); +>>>>>>> 5955029 (feat: Update the ClientTransport interface, introducing ClientCallContext, ClientConfig, and ClientCallInterceptor similar to the Python SDK. Introduce a ClientTransportProvider and update the JSONRPC and gRPC transport implementations. Introduce a new Client and ClientFactory implementations.):client/src/main/java/io/a2a/client/ClientTaskManager.java history.add(taskStatusUpdateEvent.getStatus().message()); taskBuilder.history(history); } } if (taskStatusUpdateEvent.getMetadata() != null) { +<<<<<<< HEAD:client/base/src/main/java/io/a2a/client/ClientTaskManager.java Map newMetadata = task.getMetadata() != null ? new HashMap<>(task.getMetadata()) : new HashMap<>(); newMetadata.putAll(taskStatusUpdateEvent.getMetadata()); taskBuilder.metadata(newMetadata); +======= + Map metadata = taskStatusUpdateEvent.getMetadata(); + if (metadata == null) { + metadata = new HashMap<>(); + } + metadata.putAll(taskStatusUpdateEvent.getMetadata()); + taskBuilder.metadata(metadata); +>>>>>>> 5955029 (feat: Update the ClientTransport interface, introducing ClientCallContext, ClientConfig, and ClientCallInterceptor similar to the Python SDK. Introduce a ClientTransportProvider and update the JSONRPC and gRPC transport implementations. Introduce a new Client and ClientFactory implementations.):client/src/main/java/io/a2a/client/ClientTaskManager.java } taskBuilder.status(taskStatusUpdateEvent.getStatus()); currentTask = taskBuilder.build(); diff --git a/client/transport/grpc/src/main/java/io/a2a/client/transport/grpc/GrpcTransportProvider.java b/client/transport/grpc/src/main/java/io/a2a/client/transport/grpc/GrpcTransportProvider.java index bb28ef9c9..7d941f5b1 100644 --- a/client/transport/grpc/src/main/java/io/a2a/client/transport/grpc/GrpcTransportProvider.java +++ b/client/transport/grpc/src/main/java/io/a2a/client/transport/grpc/GrpcTransportProvider.java @@ -4,6 +4,7 @@ import io.a2a.client.config.ClientCallInterceptor; import io.a2a.client.config.ClientConfig; +<<<<<<< HEAD:client/transport/grpc/src/main/java/io/a2a/client/transport/grpc/GrpcTransportProvider.java import io.a2a.client.config.ClientTransportConfig; import io.a2a.client.transport.spi.ClientTransport; import io.a2a.client.transport.spi.ClientTransportProvider; @@ -11,6 +12,12 @@ import io.a2a.spec.AgentCard; import io.a2a.spec.TransportProtocol; import io.grpc.Channel; +======= +import io.a2a.client.transport.spi.ClientTransport; +import io.a2a.client.transport.spi.ClientTransportProvider; +import io.a2a.spec.AgentCard; +import io.a2a.spec.TransportProtocol; +>>>>>>> 5955029 (feat: Update the ClientTransport interface, introducing ClientCallContext, ClientConfig, and ClientCallInterceptor similar to the Python SDK. Introduce a ClientTransportProvider and update the JSONRPC and gRPC transport implementations. Introduce a new Client and ClientFactory implementations.):client-transport/grpc/src/main/java/io/a2a/client/transport/grpc/GrpcTransportProvider.java /** * Provider for gRPC transport implementation. @@ -19,6 +26,7 @@ public class GrpcTransportProvider implements ClientTransportProvider { @Override public ClientTransport create(ClientConfig clientConfig, AgentCard agentCard, +<<<<<<< HEAD:client/transport/grpc/src/main/java/io/a2a/client/transport/grpc/GrpcTransportProvider.java String agentUrl, List interceptors) throws A2AClientException { // not making use of the interceptors for gRPC for now List clientTransportConfigs = clientConfig.getClientTransportConfigs(); @@ -31,6 +39,11 @@ public ClientTransport create(ClientConfig clientConfig, AgentCard agentCard, } } throw new A2AClientException("Missing required GrpcTransportConfig"); +======= + String agentUrl, List interceptors) { + // not making use of the interceptors for gRPC for now + return new GrpcTransport(clientConfig.getChannel(), agentCard); +>>>>>>> 5955029 (feat: Update the ClientTransport interface, introducing ClientCallContext, ClientConfig, and ClientCallInterceptor similar to the Python SDK. Introduce a ClientTransportProvider and update the JSONRPC and gRPC transport implementations. Introduce a new Client and ClientFactory implementations.):client-transport/grpc/src/main/java/io/a2a/client/transport/grpc/GrpcTransportProvider.java } @Override diff --git a/client/transport/jsonrpc/src/main/java/io/a2a/client/transport/jsonrpc/JSONRPCTransportProvider.java b/client/transport/jsonrpc/src/main/java/io/a2a/client/transport/jsonrpc/JSONRPCTransportProvider.java index b9f8ce42b..c6e7824af 100644 --- a/client/transport/jsonrpc/src/main/java/io/a2a/client/transport/jsonrpc/JSONRPCTransportProvider.java +++ b/client/transport/jsonrpc/src/main/java/io/a2a/client/transport/jsonrpc/JSONRPCTransportProvider.java @@ -4,11 +4,16 @@ import io.a2a.client.config.ClientCallInterceptor; import io.a2a.client.config.ClientConfig; +<<<<<<< HEAD:client/transport/jsonrpc/src/main/java/io/a2a/client/transport/jsonrpc/JSONRPCTransportProvider.java import io.a2a.client.config.ClientTransportConfig; import io.a2a.client.http.A2AHttpClient; import io.a2a.client.transport.spi.ClientTransport; import io.a2a.client.transport.spi.ClientTransportProvider; import io.a2a.spec.A2AClientException; +======= +import io.a2a.client.transport.spi.ClientTransport; +import io.a2a.client.transport.spi.ClientTransportProvider; +>>>>>>> 5955029 (feat: Update the ClientTransport interface, introducing ClientCallContext, ClientConfig, and ClientCallInterceptor similar to the Python SDK. Introduce a ClientTransportProvider and update the JSONRPC and gRPC transport implementations. Introduce a new Client and ClientFactory implementations.):client-transport/jsonrpc/src/main/java/io/a2a/client/transport/jsonrpc/JSONRPCTransportProvider.java import io.a2a.spec.AgentCard; import io.a2a.spec.TransportProtocol; @@ -16,6 +21,7 @@ public class JSONRPCTransportProvider implements ClientTransportProvider { @Override public ClientTransport create(ClientConfig clientConfig, AgentCard agentCard, +<<<<<<< HEAD:client/transport/jsonrpc/src/main/java/io/a2a/client/transport/jsonrpc/JSONRPCTransportProvider.java String agentUrl, List interceptors) throws A2AClientException { A2AHttpClient httpClient = null; List clientTransportConfigs = clientConfig.getClientTransportConfigs(); @@ -28,6 +34,10 @@ public ClientTransport create(ClientConfig clientConfig, AgentCard agentCard, } } return new JSONRPCTransport(httpClient, agentCard, agentUrl, interceptors); +======= + String agentUrl, List interceptors) { + return new JSONRPCTransport(clientConfig.getHttpClient(), agentCard, agentUrl, interceptors); +>>>>>>> 5955029 (feat: Update the ClientTransport interface, introducing ClientCallContext, ClientConfig, and ClientCallInterceptor similar to the Python SDK. Introduce a ClientTransportProvider and update the JSONRPC and gRPC transport implementations. Introduce a new Client and ClientFactory implementations.):client-transport/jsonrpc/src/main/java/io/a2a/client/transport/jsonrpc/JSONRPCTransportProvider.java } @Override diff --git a/client/transport/jsonrpc/src/test/java/io/a2a/client/transport/jsonrpc/JSONRPCTransportTest.java b/client/transport/jsonrpc/src/test/java/io/a2a/client/transport/jsonrpc/JSONRPCTransportTest.java index 99e5ef151..1e871441c 100644 --- a/client/transport/jsonrpc/src/test/java/io/a2a/client/transport/jsonrpc/JSONRPCTransportTest.java +++ b/client/transport/jsonrpc/src/test/java/io/a2a/client/transport/jsonrpc/JSONRPCTransportTest.java @@ -50,7 +50,6 @@ import io.a2a.spec.FilePart; import io.a2a.spec.FileWithBytes; import io.a2a.spec.FileWithUri; -import io.a2a.spec.GetAuthenticatedExtendedCardResponse; import io.a2a.spec.GetTaskPushNotificationConfigParams; import io.a2a.spec.Message; import io.a2a.spec.MessageSendConfiguration; @@ -66,7 +65,6 @@ import io.a2a.spec.TaskQueryParams; import io.a2a.spec.TaskState; import io.a2a.spec.TextPart; -import io.a2a.spec.TransportProtocol; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; @@ -359,7 +357,7 @@ public void testA2AClientGetAgentCard() throws Exception { this.server.when( request() .withMethod("GET") - .withPath("/.well-known/agent-card.json") + .withPath("/.well-known/agent.json") ) .respond( response() @@ -420,16 +418,7 @@ public void testA2AClientGetAgentCard() throws Exception { assertEquals(outputModes, skills.get(1).outputModes()); assertFalse(agentCard.supportsAuthenticatedExtendedCard()); assertEquals("https://georoute-agent.example.com/icon.png", agentCard.iconUrl()); - assertEquals("0.2.9", agentCard.protocolVersion()); - assertEquals("JSONRPC", agentCard.preferredTransport()); - List additionalInterfaces = agentCard.additionalInterfaces(); - assertEquals(3, additionalInterfaces.size()); - AgentInterface jsonrpc = new AgentInterface(TransportProtocol.JSONRPC.asString(), "https://georoute-agent.example.com/a2a/v1"); - AgentInterface grpc = new AgentInterface(TransportProtocol.GRPC.asString(), "https://georoute-agent.example.com/a2a/grpc"); - AgentInterface httpJson = new AgentInterface(TransportProtocol.HTTP_JSON.asString(), "https://georoute-agent.example.com/a2a/json"); - assertEquals(jsonrpc, additionalInterfaces.get(0)); - assertEquals(grpc, additionalInterfaces.get(1)); - assertEquals(httpJson, additionalInterfaces.get(2)); + assertEquals("0.2.5", agentCard.protocolVersion()); } @Test @@ -453,7 +442,17 @@ public void testA2AClientGetAuthenticatedExtendedAgentCard() throws Exception { .respond( response() .withStatusCode(200) - .withBody(GET_AUTHENTICATED_EXTENDED_AGENT_CARD_RESPONSE) + .withBody(AGENT_CARD_SUPPORTS_EXTENDED) + ); + this.server.when( + request() + .withMethod("GET") + .withPath("/agent/authenticatedExtendedCard") + ) + .respond( + response() + .withStatusCode(200) + .withBody(AUTHENTICATION_EXTENDED_AGENT_CARD) ); JSONRPCTransport client = new JSONRPCTransport("http://localhost:4001"); diff --git a/client/transport/jsonrpc/src/test/java/io/a2a/client/transport/jsonrpc/JsonMessages.java b/client/transport/jsonrpc/src/test/java/io/a2a/client/transport/jsonrpc/JsonMessages.java index 59838012c..84c28628d 100644 --- a/client/transport/jsonrpc/src/test/java/io/a2a/client/transport/jsonrpc/JsonMessages.java +++ b/client/transport/jsonrpc/src/test/java/io/a2a/client/transport/jsonrpc/JsonMessages.java @@ -8,6 +8,7 @@ public class JsonMessages { static final String AGENT_CARD = """ { +<<<<<<< HEAD:client/transport/jsonrpc/src/test/java/io/a2a/client/transport/jsonrpc/JsonMessages.java "protocolVersion": "0.2.9", "name": "GeoSpatial Route Planner Agent", "description": "Provides advanced route planning, traffic analysis, and custom map generation services. This agent can calculate optimal routes, estimate travel times considering real-time traffic, and create personalized maps with points of interest.", @@ -82,6 +83,136 @@ public class JsonMessages { } ] }"""; +======= + "name": "GeoSpatial Route Planner Agent", + "description": "Provides advanced route planning, traffic analysis, and custom map generation services. This agent can calculate optimal routes, estimate travel times considering real-time traffic, and create personalized maps with points of interest.", + "url": "https://georoute-agent.example.com/a2a/v1", + "provider": { + "organization": "Example Geo Services Inc.", + "url": "https://www.examplegeoservices.com" + }, + "iconUrl": "https://georoute-agent.example.com/icon.png", + "version": "1.2.0", + "documentationUrl": "https://docs.examplegeoservices.com/georoute-agent/api", + "capabilities": { + "streaming": true, + "pushNotifications": true, + "stateTransitionHistory": false + }, + "securitySchemes": { + "google": { + "type": "openIdConnect", + "openIdConnectUrl": "https://accounts.google.com/.well-known/openid-configuration" + } + }, + "security": [{ "google": ["openid", "profile", "email"] }], + "defaultInputModes": ["application/json", "text/plain"], + "defaultOutputModes": ["application/json", "image/png"], + "skills": [ + { + "id": "route-optimizer-traffic", + "name": "Traffic-Aware Route Optimizer", + "description": "Calculates the optimal driving route between two or more locations, taking into account real-time traffic conditions, road closures, and user preferences (e.g., avoid tolls, prefer highways).", + "tags": ["maps", "routing", "navigation", "directions", "traffic"], + "examples": [ + "Plan a route from '1600 Amphitheatre Parkway, Mountain View, CA' to 'San Francisco International Airport' avoiding tolls.", + "{\\"origin\\": {\\"lat\\": 37.422, \\"lng\\": -122.084}, \\"destination\\": {\\"lat\\": 37.7749, \\"lng\\": -122.4194}, \\"preferences\\": [\\"avoid_ferries\\"]}" + ], + "inputModes": ["application/json", "text/plain"], + "outputModes": [ + "application/json", + "application/vnd.geo+json", + "text/html" + ] + }, + { + "id": "custom-map-generator", + "name": "Personalized Map Generator", + "description": "Creates custom map images or interactive map views based on user-defined points of interest, routes, and style preferences. Can overlay data layers.", + "tags": ["maps", "customization", "visualization", "cartography"], + "examples": [ + "Generate a map of my upcoming road trip with all planned stops highlighted.", + "Show me a map visualizing all coffee shops within a 1-mile radius of my current location." + ], + "inputModes": ["application/json"], + "outputModes": [ + "image/png", + "image/jpeg", + "application/json", + "text/html" + ] + } + ], + "supportsAuthenticatedExtendedCard": false, + "protocolVersion": "0.2.5" + }"""; + + static final String AGENT_CARD_SUPPORTS_EXTENDED = """ + { + "name": "GeoSpatial Route Planner Agent", + "description": "Provides advanced route planning, traffic analysis, and custom map generation services. This agent can calculate optimal routes, estimate travel times considering real-time traffic, and create personalized maps with points of interest.", + "url": "https://georoute-agent.example.com/a2a/v1", + "provider": { + "organization": "Example Geo Services Inc.", + "url": "https://www.examplegeoservices.com" + }, + "iconUrl": "https://georoute-agent.example.com/icon.png", + "version": "1.2.0", + "documentationUrl": "https://docs.examplegeoservices.com/georoute-agent/api", + "capabilities": { + "streaming": true, + "pushNotifications": true, + "stateTransitionHistory": false + }, + "securitySchemes": { + "google": { + "type": "openIdConnect", + "openIdConnectUrl": "https://accounts.google.com/.well-known/openid-configuration" + } + }, + "security": [{ "google": ["openid", "profile", "email"] }], + "defaultInputModes": ["application/json", "text/plain"], + "defaultOutputModes": ["application/json", "image/png"], + "skills": [ + { + "id": "route-optimizer-traffic", + "name": "Traffic-Aware Route Optimizer", + "description": "Calculates the optimal driving route between two or more locations, taking into account real-time traffic conditions, road closures, and user preferences (e.g., avoid tolls, prefer highways).", + "tags": ["maps", "routing", "navigation", "directions", "traffic"], + "examples": [ + "Plan a route from '1600 Amphitheatre Parkway, Mountain View, CA' to 'San Francisco International Airport' avoiding tolls.", + "{\\"origin\\": {\\"lat\\": 37.422, \\"lng\\": -122.084}, \\"destination\\": {\\"lat\\": 37.7749, \\"lng\\": -122.4194}, \\"preferences\\": [\\"avoid_ferries\\"]}" + ], + "inputModes": ["application/json", "text/plain"], + "outputModes": [ + "application/json", + "application/vnd.geo+json", + "text/html" + ] + }, + { + "id": "custom-map-generator", + "name": "Personalized Map Generator", + "description": "Creates custom map images or interactive map views based on user-defined points of interest, routes, and style preferences. Can overlay data layers.", + "tags": ["maps", "customization", "visualization", "cartography"], + "examples": [ + "Generate a map of my upcoming road trip with all planned stops highlighted.", + "Show me a map visualizing all coffee shops within a 1-mile radius of my current location." + ], + "inputModes": ["application/json"], + "outputModes": [ + "image/png", + "image/jpeg", + "application/json", + "text/html" + ] + } + ], + "supportsAuthenticatedExtendedCard": true, + "protocolVersion": "0.2.5" + }"""; + +>>>>>>> 5955029 (feat: Update the ClientTransport interface, introducing ClientCallContext, ClientConfig, and ClientCallInterceptor similar to the Python SDK. Introduce a ClientTransportProvider and update the JSONRPC and gRPC transport implementations. Introduce a new Client and ClientFactory implementations.):client-transport/jsonrpc/src/test/java/io/a2a/client/transport/jsonrpc/JsonMessages.java static final String AUTHENTICATION_EXTENDED_AGENT_CARD = """ { @@ -151,6 +282,7 @@ public class JsonMessages { } ], "supportsAuthenticatedExtendedCard": true, +<<<<<<< HEAD:client/transport/jsonrpc/src/test/java/io/a2a/client/transport/jsonrpc/JsonMessages.java "protocolVersion": "0.2.9", "signatures": [ { @@ -160,6 +292,13 @@ public class JsonMessages { ] }"""; +<<<<<<<< HEAD:client/transport/jsonrpc/src/test/java/io/a2a/client/transport/jsonrpc/JsonMessages.java +======= + "protocolVersion": "0.2.5" + }"""; + + +>>>>>>> 5955029 (feat: Update the ClientTransport interface, introducing ClientCallContext, ClientConfig, and ClientCallInterceptor similar to the Python SDK. Introduce a ClientTransportProvider and update the JSONRPC and gRPC transport implementations. Introduce a new Client and ClientFactory implementations.):client-transport/jsonrpc/src/test/java/io/a2a/client/transport/jsonrpc/JsonMessages.java static final String SEND_MESSAGE_TEST_REQUEST = """ { "jsonrpc": "2.0", @@ -614,6 +753,7 @@ public class JsonMessages { } }"""; +<<<<<<< HEAD:client/transport/jsonrpc/src/test/java/io/a2a/client/transport/jsonrpc/JsonMessages.java static final String GET_AUTHENTICATED_EXTENDED_AGENT_CARD_REQUEST = """ { "jsonrpc": "2.0", @@ -767,3 +907,10 @@ public class JsonMessages { "protocolVersion": "0.2.5" }"""; } +======== + +} +>>>>>>>> 5955029 (feat: Update the ClientTransport interface, introducing ClientCallContext, ClientConfig, and ClientCallInterceptor similar to the Python SDK. Introduce a ClientTransportProvider and update the JSONRPC and gRPC transport implementations. Introduce a new Client and ClientFactory implementations.):client/src/test/java/io/a2a/client/JsonMessages.java +======= +} +>>>>>>> 5955029 (feat: Update the ClientTransport interface, introducing ClientCallContext, ClientConfig, and ClientCallInterceptor similar to the Python SDK. Introduce a ClientTransportProvider and update the JSONRPC and gRPC transport implementations. Introduce a new Client and ClientFactory implementations.):client-transport/jsonrpc/src/test/java/io/a2a/client/transport/jsonrpc/JsonMessages.java diff --git a/server-common/pom.xml b/server-common/pom.xml index 1bcc2b7b9..9364fa595 100644 --- a/server-common/pom.xml +++ b/server-common/pom.xml @@ -32,6 +32,11 @@ a2a-java-sdk-client-transport-jsonrpc ${project.version} + + ${project.groupId} + a2a-java-sdk-client-transport-jsonrpc + ${project.version} + com.fasterxml.jackson.core jackson-databind From cde0b3ed3a75979d71f8f37eae1353abbd292e68 Mon Sep 17 00:00:00 2001 From: David Brassely Date: Thu, 21 Aug 2025 13:37:05 +0100 Subject: [PATCH 31/31] --wip-- [skip ci] --- README.md | 62 +++--- client-config/pom.xml | 47 ----- .../client/config/ClientCallInterceptor.java | 26 --- .../io/a2a/client/config/ClientConfig.java | 150 --------------- ...ient.transport.spi.ClientTransportProvider | 1 - ...ient.transport.spi.ClientTransportProvider | 1 - .../spi/ClientTransportProvider.java | 32 ---- client/base/pom.xml | 19 +- client/base/src/main/java/io/a2a/A2A.java | 2 +- .../java/io/a2a/client/AbstractClient.java | 21 +- .../src/main/java/io/a2a/client/Client.java | 74 ++------ .../java/io/a2a/client/ClientBuilder.java | 179 ++++++++++++++++++ .../java/io/a2a/client}/ClientConfig.java | 18 +- .../java/io/a2a/client/ClientFactory.java | 151 --------------- .../java/io/a2a/client/ClientTaskManager.java | 15 +- .../java/io/a2a/client/ClientBuilderTest.java | 88 +++++++++ client/config/pom.xml | 38 ---- .../a2a/client/config/ClientCallContext.java | 27 --- .../client/config/ClientTransportConfig.java | 7 - .../a2a/client/config/PayloadAndHeaders.java | 22 --- client/transport/grpc/pom.xml | 4 - .../client/transport/grpc/GrpcTransport.java | 2 +- .../transport/grpc/GrpcTransportConfig.java | 17 +- .../grpc/GrpcTransportConfigBuilder.java | 20 ++ .../transport/grpc/GrpcTransportProvider.java | 47 ++--- client/transport/jsonrpc/pom.xml | 8 - .../transport/jsonrpc/JSONRPCTransport.java | 8 +- .../jsonrpc/JSONRPCTransportConfig.java | 8 +- .../JSONRPCTransportConfigBuilder.java | 28 +++ .../jsonrpc/JSONRPCTransportProvider.java | 43 ++--- .../JSONRPCTransportStreamingTest.java | 7 +- .../jsonrpc/JSONRPCTransportTest.java | 26 +-- .../transport/jsonrpc/JsonMessages.java | 149 +-------------- client/transport/spi/pom.xml | 4 - .../client/transport/spi/ClientTransport.java | 2 +- .../transport/spi/ClientTransportConfig.java | 21 ++ .../spi/ClientTransportConfigBuilder.java | 24 +++ .../spi/ClientTransportProvider.java | 15 +- .../spi/interceptors}/ClientCallContext.java | 2 +- .../interceptors}/ClientCallInterceptor.java | 2 +- .../spi/interceptors}/PayloadAndHeaders.java | 2 +- examples/helloworld/client/pom.xml | 4 - .../examples/helloworld/HelloWorldClient.java | 12 +- examples/helloworld/pom.xml | 10 - examples/helloworld/server/pom.xml | 11 +- http-client/pom.xml | 6 + .../io/a2a/client/http}/A2ACardResolver.java | 5 +- .../a2a/client/http}/A2ACardResolverTest.java | 4 +- .../io/a2a/client/http}/JsonMessages.java | 2 +- pom.xml | 1 - .../grpc/quarkus/QuarkusA2AGrpcTest.java | 18 +- .../apps/quarkus/QuarkusA2AJSONRPCTest.java | 1 + .../apps/common/AbstractA2AServerTest.java | 42 ++-- 53 files changed, 540 insertions(+), 995 deletions(-) delete mode 100644 client-config/pom.xml delete mode 100644 client-config/src/main/java/io/a2a/client/config/ClientCallInterceptor.java delete mode 100644 client-config/src/main/java/io/a2a/client/config/ClientConfig.java delete mode 100644 client-transport/grpc/src/main/resources/META-INF/services/io.a2a.client.transport.spi.ClientTransportProvider delete mode 100644 client-transport/jsonrpc/src/main/resources/META-INF/services/io.a2a.client.transport.spi.ClientTransportProvider delete mode 100644 client-transport/spi/src/main/java/io/a2a/client/transport/spi/ClientTransportProvider.java create mode 100644 client/base/src/main/java/io/a2a/client/ClientBuilder.java rename client/{config/src/main/java/io/a2a/client/config => base/src/main/java/io/a2a/client}/ClientConfig.java (83%) delete mode 100644 client/base/src/main/java/io/a2a/client/ClientFactory.java create mode 100644 client/base/src/test/java/io/a2a/client/ClientBuilderTest.java delete mode 100644 client/config/pom.xml delete mode 100644 client/config/src/main/java/io/a2a/client/config/ClientCallContext.java delete mode 100644 client/config/src/main/java/io/a2a/client/config/ClientTransportConfig.java delete mode 100644 client/config/src/main/java/io/a2a/client/config/PayloadAndHeaders.java create mode 100644 client/transport/grpc/src/main/java/io/a2a/client/transport/grpc/GrpcTransportConfigBuilder.java create mode 100644 client/transport/jsonrpc/src/main/java/io/a2a/client/transport/jsonrpc/JSONRPCTransportConfigBuilder.java create mode 100644 client/transport/spi/src/main/java/io/a2a/client/transport/spi/ClientTransportConfig.java create mode 100644 client/transport/spi/src/main/java/io/a2a/client/transport/spi/ClientTransportConfigBuilder.java rename {client-config/src/main/java/io/a2a/client/config => client/transport/spi/src/main/java/io/a2a/client/transport/spi/interceptors}/ClientCallContext.java (92%) rename client/{config/src/main/java/io/a2a/client/config => transport/spi/src/main/java/io/a2a/client/transport/spi/interceptors}/ClientCallInterceptor.java (95%) rename {client-config/src/main/java/io/a2a/client/config => client/transport/spi/src/main/java/io/a2a/client/transport/spi/interceptors}/PayloadAndHeaders.java (89%) rename {client/base/src/main/java/io/a2a/client => http-client/src/main/java/io/a2a/client/http}/A2ACardResolver.java (96%) rename {client/base/src/test/java/io/a2a/client => http-client/src/test/java/io/a2a/client/http}/A2ACardResolverTest.java (98%) rename {client/base/src/test/java/io/a2a/client => http-client/src/test/java/io/a2a/client/http}/JsonMessages.java (99%) diff --git a/README.md b/README.md index 54b66a8f0..2dbd99558 100644 --- a/README.md +++ b/README.md @@ -243,21 +243,14 @@ that you can use to create your A2A `Client`. ### 2. Add one or more dependencies on the A2A Java SDK Client Transport(s) you'd like to use -You need to add a dependency on at least one of the following client transport modules: +By default, the sdk-client is coming with the JSONRPC transport. + +If you want to use another transport (such as GRPC or HTTP+JSON), you'll need to add a relevant dependency: ---- > *⚠️ The `io.github.a2asdk` `groupId` below is temporary and will likely change for future releases.* ---- -```xml - - io.github.a2asdk - a2a-java-sdk-client-transport-jsonrpc - - ${io.a2a.sdk.version} - -``` - ```xml io.github.a2asdk @@ -305,14 +298,17 @@ Consumer errorHandler = error -> { ... }; -// Create the client using ClientFactory -ClientFactory clientFactory = new ClientFactory(clientConfig); -Client client = clientFactory.create(agentCard, consumers, errorHandler); +// Create the client using the builder +Client client = Client + .from(finalAgentCard) + .addStreamConsumers(consumers) + .streamErrorHandler(streamingErrorHandler) + .build(); ``` #### Configuring Transport-Specific Settings -Different transport protocols can be configured with specific settings using `ClientTransportConfig` implementations. The A2A Java SDK provides `JSONRPCTransportConfig` for the JSON-RPC transport and `GrpcTransportConfig` for the gRPC transport. +Different transport protocols can be configured with specific settings using specific `ClientTransportConfig` implementations. The A2A Java SDK provides `JSONRPCTransportConfig` for the JSON-RPC transport and `GrpcTransportConfig` for the gRPC transport. ##### JSON-RPC Transport Configuration @@ -327,10 +323,15 @@ A2AHttpClient customHttpClient = ... // Create JSON-RPC transport configuration JSONRPCTransportConfig jsonrpcConfig = new JSONRPCTransportConfig(customHttpClient); -// Configure the client with transport-specific settings +// Configure the client settings ClientConfig clientConfig = new ClientConfig.Builder() .setAcceptedOutputModes(List.of("text")) - .setClientTransportConfigs(List.of(jsonrpcConfig)) + .build(); + +Client client = Client + .from(agentCard) + .withJsonRpcTransport(new JSONRPCTransportConfigBuilder() + .httpClient(customHttpClient).build()) .build(); ``` @@ -339,12 +340,8 @@ ClientConfig clientConfig = new ClientConfig.Builder() For the gRPC transport, you must configure a channel factory: ```java -// Create a channel factory function that takes the agent URL and returns a Channel -Function channelFactory = agentUrl -> { - return ManagedChannelBuilder.forTarget(agentUrl) - ... - .build(); -}; +// Create a channel from agent URL +Channel channel = ManagedChannelBuilder.forTarget(agentUrl).build(); // Create gRPC transport configuration GrpcTransportConfig grpcConfig = new GrpcTransportConfig(channelFactory); @@ -352,7 +349,12 @@ GrpcTransportConfig grpcConfig = new GrpcTransportConfig(channelFactory); // Configure the client with transport-specific settings ClientConfig clientConfig = new ClientConfig.Builder() .setAcceptedOutputModes(List.of("text")) - .setClientTransportConfigs(List.of(grpcConfig)) + .build(); + +Client client = Client + .from(agentCard) + .withTransport(GrpcTransport.class, new GrpcTransportConfigBuilder() + .channel(channel).build()) .build(); ``` @@ -363,15 +365,11 @@ will be used based on the selected transport: ```java // Configure both JSON-RPC and gRPC transports -List transportConfigs = List.of( - new JSONRPCTransportConfig(...), - new GrpcTransportConfig(...) -); - -ClientConfig clientConfig = new ClientConfig.Builder() - .setAcceptedOutputModes(List.of("text")) - .setClientTransportConfigs(transportConfigs) - .build(); +Client client = Client + .from(agentCard) + .withTransport(GrpcTransport.class, new GrpcTransportConfigBuilder().build()) + .withJsonRpcTransport(new JSONRPCTransportConfigBuilder().build()) + .build(); ``` #### Send a message to the A2A server agent diff --git a/client-config/pom.xml b/client-config/pom.xml deleted file mode 100644 index 3d85221fb..000000000 --- a/client-config/pom.xml +++ /dev/null @@ -1,47 +0,0 @@ - - - 4.0.0 - - - io.github.a2asdk - a2a-java-sdk-parent - 0.2.6.Beta1-SNAPSHOT - - a2a-java-sdk-client-config - - jar - - Java SDK A2A Client Configuration - Java SDK for the Agent2Agent Protocol (A2A) - Client Configuration - - - - ${project.groupId} - a2a-java-sdk-client-http - ${project.version} - - - ${project.groupId} - a2a-java-sdk-spec - ${project.version} - - - org.junit.jupiter - junit-jupiter-api - test - - - - org.mock-server - mockserver-netty - test - - - io.grpc - grpc-api - - - - \ No newline at end of file diff --git a/client-config/src/main/java/io/a2a/client/config/ClientCallInterceptor.java b/client-config/src/main/java/io/a2a/client/config/ClientCallInterceptor.java deleted file mode 100644 index 631cd8353..000000000 --- a/client-config/src/main/java/io/a2a/client/config/ClientCallInterceptor.java +++ /dev/null @@ -1,26 +0,0 @@ -package io.a2a.client.config; - -import java.util.Map; - -import io.a2a.spec.AgentCard; - -/** - * An abstract base class for client-side call interceptors. - * Interceptors can inspect and modify requests before they are sent, - * which is ideal for concerns like authentication, logging, or tracing. - */ -public abstract class ClientCallInterceptor { - - /** - * Intercept a client call before the request is sent. - * - * @param methodName the name of the protocol method (e.g., 'message/send') - * @param payload the request payload - * @param headers the headers to use - * @param agentCard the agent card (may be {@code null}) - * @param clientCallContext the {@code ClientCallContext} for this call (may be {@code null}) - * @return the potentially modified payload and headers - */ - public abstract PayloadAndHeaders intercept(String methodName, Object payload, Map headers, - AgentCard agentCard, ClientCallContext clientCallContext); -} diff --git a/client-config/src/main/java/io/a2a/client/config/ClientConfig.java b/client-config/src/main/java/io/a2a/client/config/ClientConfig.java deleted file mode 100644 index ef8cc948d..000000000 --- a/client-config/src/main/java/io/a2a/client/config/ClientConfig.java +++ /dev/null @@ -1,150 +0,0 @@ -package io.a2a.client.config; - -import java.util.List; -import java.util.Map; - -import io.a2a.client.http.A2AHttpClient; -import io.a2a.spec.PushNotificationConfig; -import io.grpc.Channel; - -/** - * Configuration for the A2A client factory. - */ -public class ClientConfig { - - private final Boolean streaming; - private final Boolean polling; - private final A2AHttpClient httpClient; - private final Channel channel; - private final List supportedTransports; - private final Boolean useClientPreference; - private final List acceptedOutputModes; - private final PushNotificationConfig pushNotificationConfig; - private final Integer historyLength; - private final Map metadata; - - public ClientConfig(Boolean streaming, Boolean polling, A2AHttpClient httpClient, Channel channel, - List supportedTransports, Boolean useClientPreference, - List acceptedOutputModes, PushNotificationConfig pushNotificationConfig, - Integer historyLength, Map metadata) { - this.streaming = streaming == null ? true : streaming; - this.polling = polling == null ? false : polling; - this.httpClient = httpClient; - this.channel = channel; - this.supportedTransports = supportedTransports; - this.useClientPreference = useClientPreference == null ? false : useClientPreference; - this.acceptedOutputModes = acceptedOutputModes; - this.pushNotificationConfig = pushNotificationConfig; - this.historyLength = historyLength; - this.metadata = metadata; - } - - public boolean isStreaming() { - return streaming; - } - - public boolean isPolling() { - return polling; - } - - public A2AHttpClient getHttpClient() { - return httpClient; - } - - public Channel getChannel() { - return channel; - } - - public List getSupportedTransports() { - return supportedTransports; - } - - public boolean isUseClientPreference() { - return useClientPreference; - } - - public List getAcceptedOutputModes() { - return acceptedOutputModes; - } - - public PushNotificationConfig getPushNotificationConfig() { - return pushNotificationConfig; - } - - public Integer getHistoryLength() { - return historyLength; - } - - public Map getMetadata() { - return metadata; - } - - public static class Builder { - private Boolean streaming; - private Boolean polling; - private A2AHttpClient httpClient; - private Channel channel; - private List supportedTransports; - private Boolean useClientPreference; - private List acceptedOutputModes; - private PushNotificationConfig pushNotificationConfig; - private Integer historyLength; - private Map metadata; - - public Builder setStreaming(Boolean streaming) { - this.streaming = streaming; - return this; - } - - public Builder setPolling(Boolean polling) { - this.polling = polling; - return this; - } - - public Builder setHttpClient(A2AHttpClient httpClient) { - this.httpClient = httpClient; - return this; - } - - public Builder setChannel(Channel channel) { - this.channel = channel; - return this; - } - - public Builder setSupportedTransports(List supportedTransports) { - this.supportedTransports = supportedTransports; - return this; - } - - public Builder setUseClientPreference(Boolean useClientPreference) { - this.useClientPreference = useClientPreference; - return this; - } - - public Builder setAcceptedOutputModes(List acceptedOutputModes) { - this.acceptedOutputModes = acceptedOutputModes; - return this; - } - - public Builder setPushNotificationConfig(PushNotificationConfig pushNotificationConfig) { - this.pushNotificationConfig = pushNotificationConfig; - return this; - } - - public Builder setHistoryLength(Integer historyLength) { - this.historyLength = historyLength; - return this; - } - - public Builder setMetadata(Map metadata) { - this.metadata = metadata; - return this; - } - - public ClientConfig build() { - return new ClientConfig(streaming, polling, httpClient, channel, - supportedTransports, useClientPreference, acceptedOutputModes, - pushNotificationConfig, historyLength, metadata); - } - } -} \ No newline at end of file diff --git a/client-transport/grpc/src/main/resources/META-INF/services/io.a2a.client.transport.spi.ClientTransportProvider b/client-transport/grpc/src/main/resources/META-INF/services/io.a2a.client.transport.spi.ClientTransportProvider deleted file mode 100644 index 86d4fa7e5..000000000 --- a/client-transport/grpc/src/main/resources/META-INF/services/io.a2a.client.transport.spi.ClientTransportProvider +++ /dev/null @@ -1 +0,0 @@ -io.a2a.client.transport.grpc.GrpcTransportProvider \ No newline at end of file diff --git a/client-transport/jsonrpc/src/main/resources/META-INF/services/io.a2a.client.transport.spi.ClientTransportProvider b/client-transport/jsonrpc/src/main/resources/META-INF/services/io.a2a.client.transport.spi.ClientTransportProvider deleted file mode 100644 index b2904cb45..000000000 --- a/client-transport/jsonrpc/src/main/resources/META-INF/services/io.a2a.client.transport.spi.ClientTransportProvider +++ /dev/null @@ -1 +0,0 @@ -io.a2a.client.transport.jsonrpc.JSONRPCTransportProvider \ No newline at end of file diff --git a/client-transport/spi/src/main/java/io/a2a/client/transport/spi/ClientTransportProvider.java b/client-transport/spi/src/main/java/io/a2a/client/transport/spi/ClientTransportProvider.java deleted file mode 100644 index 5ebed06a9..000000000 --- a/client-transport/spi/src/main/java/io/a2a/client/transport/spi/ClientTransportProvider.java +++ /dev/null @@ -1,32 +0,0 @@ -package io.a2a.client.transport.spi; - -import java.util.List; - -import io.a2a.client.config.ClientCallInterceptor; -import io.a2a.client.config.ClientConfig; -import io.a2a.spec.AgentCard; - -/** - * Client transport provider interface. - */ -public interface ClientTransportProvider { - - /** - * Create a client transport. - * - * @param clientConfig the client config to use - * @param agentCard the agent card for the remote agent - * @param agentUrl the remote agent's URL - * @param interceptors the optional interceptors to use for a client call (may be {@code null}) - * @return the client transport - */ - ClientTransport create(ClientConfig clientConfig, AgentCard agentCard, - String agentUrl, List interceptors); - - /** - * Get the name of the client transport. - */ - String getTransportProtocol(); - -} - diff --git a/client/base/pom.xml b/client/base/pom.xml index 5b74deb8a..08ed589e1 100644 --- a/client/base/pom.xml +++ b/client/base/pom.xml @@ -20,15 +20,24 @@ ${project.groupId} - a2a-java-sdk-client-config + a2a-java-sdk-http-client + ${project.version} ${project.groupId} - a2a-java-sdk-http-client + a2a-java-sdk-client-transport-spi + ${project.version} ${project.groupId} - a2a-java-sdk-client-transport-spi + a2a-java-sdk-client-transport-jsonrpc + ${project.version} + + + ${project.groupId} + a2a-java-sdk-client-transport-grpc + ${project.version} + test ${project.groupId} @@ -49,10 +58,6 @@ mockserver-netty test - - io.grpc - grpc-api - \ No newline at end of file diff --git a/client/base/src/main/java/io/a2a/A2A.java b/client/base/src/main/java/io/a2a/A2A.java index 46b9c11ad..f72945677 100644 --- a/client/base/src/main/java/io/a2a/A2A.java +++ b/client/base/src/main/java/io/a2a/A2A.java @@ -3,7 +3,7 @@ import java.util.Collections; import java.util.Map; -import io.a2a.client.A2ACardResolver; +import io.a2a.client.http.A2ACardResolver; import io.a2a.client.http.A2AHttpClient; import io.a2a.client.http.JdkA2AHttpClient; import io.a2a.spec.A2AClientError; diff --git a/client/base/src/main/java/io/a2a/client/AbstractClient.java b/client/base/src/main/java/io/a2a/client/AbstractClient.java index 93ed8dca9..526b83e01 100644 --- a/client/base/src/main/java/io/a2a/client/AbstractClient.java +++ b/client/base/src/main/java/io/a2a/client/AbstractClient.java @@ -7,7 +7,7 @@ import java.util.function.BiConsumer; import java.util.function.Consumer; -import io.a2a.client.config.ClientCallContext; +import io.a2a.client.transport.spi.interceptors.ClientCallContext; import io.a2a.spec.A2AClientException; import io.a2a.spec.AgentCard; import io.a2a.spec.DeleteTaskPushNotificationConfigParams; @@ -45,11 +45,7 @@ public AbstractClient(List> consumers, Consum * Send a message to the remote agent. This method will automatically use * the streaming or non-streaming approach as determined by the server's * agent card and the client configuration. The configured client consumers -<<<<<<< HEAD:client/base/src/main/java/io/a2a/client/AbstractClient.java * will be used to handle messages, tasks, and update events received -======= - * and will be used to handle messages, tasks, and update events received ->>>>>>> 5955029 (feat: Update the ClientTransport interface, introducing ClientCallContext, ClientConfig, and ClientCallInterceptor similar to the Python SDK. Introduce a ClientTransportProvider and update the JSONRPC and gRPC transport implementations. Introduce a new Client and ClientFactory implementations.):client/src/main/java/io/a2a/client/AbstractClient.java * from the remote agent. The configured streaming error handler will be used * if an error occurs during streaming. The configured client push notification * configuration will get used for streaming. @@ -79,7 +75,6 @@ public void sendMessage(Message request) throws A2AClientException { /** * Send a message to the remote agent. This method will automatically use * the streaming or non-streaming approach as determined by the server's -<<<<<<< HEAD:client/base/src/main/java/io/a2a/client/AbstractClient.java * agent card and the client configuration. The specified client consumers * will be used to handle messages, tasks, and update events received * from the remote agent. The specified streaming error handler will be used @@ -120,8 +115,6 @@ public abstract void sendMessage(Message request, /** * Send a message to the remote agent. This method will automatically use * the streaming or non-streaming approach as determined by the server's -======= ->>>>>>> 5955029 (feat: Update the ClientTransport interface, introducing ClientCallContext, ClientConfig, and ClientCallInterceptor similar to the Python SDK. Introduce a ClientTransportProvider and update the JSONRPC and gRPC transport implementations. Introduce a new Client and ClientFactory implementations.):client/src/main/java/io/a2a/client/AbstractClient.java * agent card and the client configuration. The configured client consumers * will be used to handle messages, tasks, and update events received from * the remote agent. The configured streaming error handler will be used @@ -295,12 +288,9 @@ public abstract void deleteTaskPushNotificationConfigurations( /** * Resubscribe to a task's event stream. * This is only available if both the client and server support streaming. -<<<<<<< HEAD:client/base/src/main/java/io/a2a/client/AbstractClient.java * The configured client consumers will be used to handle messages, tasks, * and update events received from the remote agent. The configured streaming * error handler will be used if an error occurs during streaming. -======= ->>>>>>> 5955029 (feat: Update the ClientTransport interface, introducing ClientCallContext, ClientConfig, and ClientCallInterceptor similar to the Python SDK. Introduce a ClientTransportProvider and update the JSONRPC and gRPC transport implementations. Introduce a new Client and ClientFactory implementations.):client/src/main/java/io/a2a/client/AbstractClient.java * * @param request the parameters specifying which task's notification configs to delete * @throws A2AClientException if resubscribing fails for any reason @@ -323,7 +313,6 @@ public void resubscribe(TaskIdParams request) throws A2AClientException { public abstract void resubscribe(TaskIdParams request, ClientCallContext context) throws A2AClientException; /** -<<<<<<< HEAD:client/base/src/main/java/io/a2a/client/AbstractClient.java * Resubscribe to a task's event stream. * This is only available if both the client and server support streaming. * The specified client consumers will be used to handle messages, tasks, and @@ -367,8 +356,6 @@ public AgentCard getAgentCard() throws A2AClientException { } /** -======= ->>>>>>> 5955029 (feat: Update the ClientTransport interface, introducing ClientCallContext, ClientConfig, and ClientCallInterceptor similar to the Python SDK. Introduce a ClientTransportProvider and update the JSONRPC and gRPC transport implementations. Introduce a new Client and ClientFactory implementations.):client/src/main/java/io/a2a/client/AbstractClient.java * Retrieve the AgentCard. * * @param context optional client call context for the request (may be {@code null}) @@ -385,11 +372,7 @@ public AgentCard getAgentCard() throws A2AClientException { /** * Process the event using all configured consumers. */ -<<<<<<< HEAD:client/base/src/main/java/io/a2a/client/AbstractClient.java void consume(ClientEvent clientEventOrMessage, AgentCard agentCard) { -======= - public void consume(ClientEvent clientEventOrMessage, AgentCard agentCard) { ->>>>>>> 5955029 (feat: Update the ClientTransport interface, introducing ClientCallContext, ClientConfig, and ClientCallInterceptor similar to the Python SDK. Introduce a ClientTransportProvider and update the JSONRPC and gRPC transport implementations. Introduce a new Client and ClientFactory implementations.):client/src/main/java/io/a2a/client/AbstractClient.java for (BiConsumer consumer : consumers) { consumer.accept(clientEventOrMessage, agentCard); } @@ -404,4 +387,4 @@ public Consumer getStreamingErrorHandler() { return streamingErrorHandler; } -} +} \ No newline at end of file diff --git a/client/base/src/main/java/io/a2a/client/Client.java b/client/base/src/main/java/io/a2a/client/Client.java index 2d941faad..347fe081e 100644 --- a/client/base/src/main/java/io/a2a/client/Client.java +++ b/client/base/src/main/java/io/a2a/client/Client.java @@ -5,8 +5,7 @@ import java.util.function.BiConsumer; import java.util.function.Consumer; -import io.a2a.client.config.ClientCallContext; -import io.a2a.client.config.ClientConfig; +import io.a2a.client.transport.spi.interceptors.ClientCallContext; import io.a2a.client.transport.spi.ClientTransport; import io.a2a.spec.A2AClientError; import io.a2a.spec.A2AClientException; @@ -28,24 +27,30 @@ import io.a2a.spec.TaskQueryParams; import io.a2a.spec.TaskStatusUpdateEvent; +import static io.a2a.util.Assert.checkNotNullParam; + public class Client extends AbstractClient { private final ClientConfig clientConfig; private final ClientTransport clientTransport; private AgentCard agentCard; - public Client(AgentCard agentCard, ClientConfig clientConfig, ClientTransport clientTransport, + Client(AgentCard agentCard, ClientConfig clientConfig, ClientTransport clientTransport, List> consumers, Consumer streamingErrorHandler) { super(consumers, streamingErrorHandler); + checkNotNullParam("agentCard", agentCard); + this.agentCard = agentCard; this.clientConfig = clientConfig; this.clientTransport = clientTransport; } + public static ClientBuilder from(AgentCard agentCard) { + return new ClientBuilder(agentCard); + } @Override public void sendMessage(Message request, ClientCallContext context) throws A2AClientException { -<<<<<<< HEAD:client/base/src/main/java/io/a2a/client/Client.java MessageSendParams messageSendParams = getMessageSendParams(request, clientConfig); sendMessage(messageSendParams, null, null, context); } @@ -55,22 +60,6 @@ public void sendMessage(Message request, List Consumer streamingErrorHandler, ClientCallContext context) throws A2AClientException { MessageSendParams messageSendParams = getMessageSendParams(request, clientConfig); sendMessage(messageSendParams, consumers, streamingErrorHandler, context); -======= - MessageSendConfiguration messageSendConfiguration = new MessageSendConfiguration.Builder() - .acceptedOutputModes(clientConfig.getAcceptedOutputModes()) - .blocking(clientConfig.isPolling()) - .historyLength(clientConfig.getHistoryLength()) - .pushNotification(clientConfig.getPushNotificationConfig()) - .build(); - - MessageSendParams messageSendParams = new MessageSendParams.Builder() - .message(request) - .configuration(messageSendConfiguration) - .metadata(clientConfig.getMetadata()) - .build(); - - sendMessage(messageSendParams, context); ->>>>>>> 5955029 (feat: Update the ClientTransport interface, introducing ClientCallContext, ClientConfig, and ClientCallInterceptor similar to the Python SDK. Introduce a ClientTransportProvider and update the JSONRPC and gRPC transport implementations. Introduce a new Client and ClientFactory implementations.):client/src/main/java/io/a2a/client/Client.java } @Override @@ -89,11 +78,7 @@ public void sendMessage(Message request, PushNotificationConfig pushNotification .metadata(metatadata) .build(); -<<<<<<< HEAD:client/base/src/main/java/io/a2a/client/Client.java sendMessage(messageSendParams, null, null, context); -======= - sendMessage(messageSendParams, context); ->>>>>>> 5955029 (feat: Update the ClientTransport interface, introducing ClientCallContext, ClientConfig, and ClientCallInterceptor similar to the Python SDK. Introduce a ClientTransportProvider and update the JSONRPC and gRPC transport implementations. Introduce a new Client and ClientFactory implementations.):client/src/main/java/io/a2a/client/Client.java } @Override @@ -132,29 +117,13 @@ public void deleteTaskPushNotificationConfigurations( @Override public void resubscribe(TaskIdParams request, ClientCallContext context) throws A2AClientException { -<<<<<<< HEAD:client/base/src/main/java/io/a2a/client/Client.java - resubscribeToTask(request, null, null, context); + resubscribeToTask(request, null, null, context); } @Override public void resubscribe(TaskIdParams request, List> consumers, Consumer streamingErrorHandler, ClientCallContext context) throws A2AClientException { resubscribeToTask(request, consumers, streamingErrorHandler, context); -======= - if (! clientConfig.isStreaming() || ! agentCard.capabilities().streaming()) { - throw new A2AClientException("Client and/or server does not support resubscription"); - } - ClientTaskManager tracker = new ClientTaskManager(); - Consumer eventHandler = event -> { - try { - ClientEvent clientEvent = getClientEvent(event, tracker); - consume(clientEvent, agentCard); - } catch (A2AClientError e) { - getStreamingErrorHandler().accept(e); - } - }; - clientTransport.resubscribe(request, eventHandler, getStreamingErrorHandler(), context); ->>>>>>> 5955029 (feat: Update the ClientTransport interface, introducing ClientCallContext, ClientConfig, and ClientCallInterceptor similar to the Python SDK. Introduce a ClientTransportProvider and update the JSONRPC and gRPC transport implementations. Introduce a new Client and ClientFactory implementations.):client/src/main/java/io/a2a/client/Client.java } @Override @@ -185,12 +154,8 @@ private ClientEvent getClientEvent(StreamingEventKind event, ClientTaskManager t } } -<<<<<<< HEAD:client/base/src/main/java/io/a2a/client/Client.java private void sendMessage(MessageSendParams messageSendParams, List> consumers, Consumer errorHandler, ClientCallContext context) throws A2AClientException { -======= - private void sendMessage(MessageSendParams messageSendParams, ClientCallContext context) throws A2AClientException { ->>>>>>> 5955029 (feat: Update the ClientTransport interface, introducing ClientCallContext, ClientConfig, and ClientCallInterceptor similar to the Python SDK. Introduce a ClientTransportProvider and update the JSONRPC and gRPC transport implementations. Introduce a new Client and ClientFactory implementations.):client/src/main/java/io/a2a/client/Client.java if (! clientConfig.isStreaming() || ! agentCard.capabilities().streaming()) { EventKind eventKind = clientTransport.sendMessage(messageSendParams, context); ClientEvent clientEvent; @@ -200,7 +165,6 @@ private void sendMessage(MessageSendParams messageSendParams, ClientCallContext // must be a message clientEvent = new MessageEvent((Message) eventKind); } -<<<<<<< HEAD:client/base/src/main/java/io/a2a/client/Client.java consume(clientEvent, agentCard, consumers); } else { ClientTaskManager tracker = new ClientTaskManager(); @@ -273,20 +237,4 @@ private MessageSendParams getMessageSendParams(Message request, ClientConfig cli .metadata(clientConfig.getMetadata()) .build(); } -======= - consume(clientEvent, agentCard); - } else { - ClientTaskManager tracker = new ClientTaskManager(); - Consumer eventHandler = event -> { - try { - ClientEvent clientEvent = getClientEvent(event, tracker); - consume(clientEvent, agentCard); - } catch (A2AClientError e) { - getStreamingErrorHandler().accept(e); - } - }; - clientTransport.sendMessageStreaming(messageSendParams, eventHandler, getStreamingErrorHandler(), context); - } - } ->>>>>>> 5955029 (feat: Update the ClientTransport interface, introducing ClientCallContext, ClientConfig, and ClientCallInterceptor similar to the Python SDK. Introduce a ClientTransportProvider and update the JSONRPC and gRPC transport implementations. Introduce a new Client and ClientFactory implementations.):client/src/main/java/io/a2a/client/Client.java -} +} \ No newline at end of file diff --git a/client/base/src/main/java/io/a2a/client/ClientBuilder.java b/client/base/src/main/java/io/a2a/client/ClientBuilder.java new file mode 100644 index 000000000..b6d58233b --- /dev/null +++ b/client/base/src/main/java/io/a2a/client/ClientBuilder.java @@ -0,0 +1,179 @@ +package io.a2a.client; + +import io.a2a.client.transport.jsonrpc.JSONRPCTransport; +import io.a2a.client.transport.jsonrpc.JSONRPCTransportConfig; +import io.a2a.client.transport.jsonrpc.JSONRPCTransportConfigBuilder; +import io.a2a.client.transport.spi.ClientTransport; +import io.a2a.client.transport.spi.ClientTransportConfig; +import io.a2a.client.transport.spi.ClientTransportConfigBuilder; +import io.a2a.client.transport.spi.ClientTransportProvider; +import io.a2a.spec.A2AClientException; +import io.a2a.spec.AgentCard; +import io.a2a.spec.AgentInterface; +import io.a2a.spec.TransportProtocol; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.ServiceLoader; +import java.util.Set; +import java.util.function.BiConsumer; +import java.util.function.Consumer; + +/** + * @author David BRASSELY (david.brassely at graviteesource.com) + * @author GraviteeSource Team + */ +public class ClientBuilder { + + private static final Map>> transportProviderRegistry = new HashMap<>(); + private static final Map, String> transportProtocolMapping = new HashMap<>(); + + static { + ServiceLoader loader = ServiceLoader.load(ClientTransportProvider.class); + for (ClientTransportProvider transport : loader) { + transportProviderRegistry.put(transport.getTransportProtocol(), transport); + transportProtocolMapping.put(transport.getTransportProtocolClass(), transport.getTransportProtocol()); + } + } + + private final AgentCard agentCard; + private boolean useClientPreference; + + private final List> consumers = new ArrayList<>(); + private Consumer streamErrorHandler; + private ClientConfig clientConfig; + + private final Map, ClientTransportConfig> clientTransports = new HashMap<>(); + + ClientBuilder(AgentCard agentCard) { + this.agentCard = agentCard; + } + + public ClientBuilder withTransport(Class clazz, ClientTransportConfigBuilder, ?> configBuilder) { + return withTransport(clazz, configBuilder.build()); + } + + public ClientBuilder withTransport(Class clazz, ClientTransportConfig config) { + clientTransports.put(clazz, config); + + return this; + } + + public ClientBuilder useClientPreference(boolean useClientPreference) { + this.useClientPreference = useClientPreference; + return this; + } + + public ClientBuilder withJsonRpcTransport(JSONRPCTransportConfigBuilder configBuilder) { + return withTransport(JSONRPCTransport.class, configBuilder.build()); + } + + public ClientBuilder withJsonRpcTransport(JSONRPCTransportConfig config) { + return withTransport(JSONRPCTransport.class, config); + } + + public ClientBuilder withJsonRpcTransport() { + return withTransport(JSONRPCTransport.class, new JSONRPCTransportConfigBuilder()); + } + + public ClientBuilder addStreamConsumer(BiConsumer consumer) { + this.consumers.add(consumer); + return this; + } + + public ClientBuilder addStreamConsumers(List> consumers) { + this.consumers.addAll(consumers); + return this; + } + + public ClientBuilder streamErrorHandler(Consumer streamErrorHandler) { + this.streamErrorHandler = streamErrorHandler; + return this; + } + + public ClientBuilder clientConfig(ClientConfig clientConfig) { + this.clientConfig = clientConfig; + return this; + } + + public Client build() throws A2AClientException { + ClientTransport clientTransport = buildClientTransport(); + + return new Client(agentCard, clientConfig, clientTransport, consumers, streamErrorHandler); + } + + @SuppressWarnings("unchecked") + private ClientTransport buildClientTransport() throws A2AClientException { + // Get the preferred transport + AgentInterface agentInterface = findBestClientTransport(); + Class transportProtocolClass = transportProviderRegistry.get(agentInterface.transport()).getTransportProtocolClass(); + + // Get the transport provider associated to the protocol + ClientTransportProvider clientTransportProvider = transportProviderRegistry.get(agentInterface.transport()); + + // Retrieve the configuration associated to the preferred transport + ClientTransportConfig clientTransportConfig = clientTransports.get(transportProtocolClass); + + return clientTransportProvider.create(clientTransportConfig, agentCard, agentInterface.url()); + } + + private Map getServerPreferredTransports() { + Map serverPreferredTransports = new LinkedHashMap<>(); + serverPreferredTransports.put(agentCard.preferredTransport(), agentCard.url()); + for (AgentInterface agentInterface : agentCard.additionalInterfaces()) { + serverPreferredTransports.putIfAbsent(agentInterface.transport(), agentInterface.url()); + } + return serverPreferredTransports; + } + + private List getClientPreferredTransports() { + List supportedClientTransports = new ArrayList<>(); + + if (clientTransports.isEmpty()) { + // default to JSONRPC if not specified + supportedClientTransports.add(TransportProtocol.JSONRPC.asString()); + } else { + clientTransports.forEach((aClass, clientTransportConfig) -> supportedClientTransports.add(transportProtocolMapping.get(aClass))); + } + return supportedClientTransports; + } + + private AgentInterface findBestClientTransport() throws A2AClientException { + // Retrieve transport supported by the A2A server + Map serverPreferredTransports = getServerPreferredTransports(); + + // Retrieve transport configured for this client (using withTransport methods) + List clientPreferredTransports = getClientPreferredTransports(); + + String transportProtocol = null; + String transportUrl = null; + if (useClientPreference) { + for (String clientPreferredTransport : clientPreferredTransports) { + if (serverPreferredTransports.containsKey(clientPreferredTransport)) { + transportProtocol = clientPreferredTransport; + transportUrl = serverPreferredTransports.get(transportProtocol); + break; + } + } + } else { + for (Map.Entry transport : serverPreferredTransports.entrySet()) { + if (clientPreferredTransports.contains(transport.getKey())) { + transportProtocol = transport.getKey(); + transportUrl = transport.getValue(); + break; + } + } + } + if (transportProtocol == null || transportUrl == null) { + throw new A2AClientException("No compatible transport found"); + } + if (! transportProviderRegistry.containsKey(transportProtocol)) { + throw new A2AClientException("No client available for " + transportProtocol); + } + + return new AgentInterface(transportProtocol, transportUrl); + } +} diff --git a/client/config/src/main/java/io/a2a/client/config/ClientConfig.java b/client/base/src/main/java/io/a2a/client/ClientConfig.java similarity index 83% rename from client/config/src/main/java/io/a2a/client/config/ClientConfig.java rename to client/base/src/main/java/io/a2a/client/ClientConfig.java index 913495175..d7fa1ca23 100644 --- a/client/config/src/main/java/io/a2a/client/config/ClientConfig.java +++ b/client/base/src/main/java/io/a2a/client/ClientConfig.java @@ -1,4 +1,4 @@ -package io.a2a.client.config; +package io.a2a.client; import java.util.List; import java.util.Map; @@ -12,7 +12,6 @@ public class ClientConfig { private final Boolean streaming; private final Boolean polling; - private final List clientTransportConfigs; private final List supportedTransports; private final Boolean useClientPreference; private final List acceptedOutputModes; @@ -20,13 +19,12 @@ public class ClientConfig { private final Integer historyLength; private final Map metadata; - public ClientConfig(Boolean streaming, Boolean polling, List clientTransportConfigs, + public ClientConfig(Boolean streaming, Boolean polling, List supportedTransports, Boolean useClientPreference, List acceptedOutputModes, PushNotificationConfig pushNotificationConfig, Integer historyLength, Map metadata) { this.streaming = streaming == null ? true : streaming; this.polling = polling == null ? false : polling; - this.clientTransportConfigs = clientTransportConfigs; this.supportedTransports = supportedTransports; this.useClientPreference = useClientPreference == null ? false : useClientPreference; this.acceptedOutputModes = acceptedOutputModes; @@ -43,10 +41,6 @@ public boolean isPolling() { return polling; } - public List getClientTransportConfigs() { - return clientTransportConfigs; - } - public List getSupportedTransports() { return supportedTransports; } @@ -74,7 +68,6 @@ public Map getMetadata() { public static class Builder { private Boolean streaming; private Boolean polling; - private List clientTransportConfigs; private List supportedTransports; private Boolean useClientPreference; private List acceptedOutputModes; @@ -92,11 +85,6 @@ public Builder setPolling(Boolean polling) { return this; } - public Builder setClientTransportConfigs(List clientTransportConfigs) { - this.clientTransportConfigs = clientTransportConfigs; - return this; - } - public Builder setSupportedTransports(List supportedTransports) { this.supportedTransports = supportedTransports; return this; @@ -128,7 +116,7 @@ public Builder setMetadata(Map metadata) { } public ClientConfig build() { - return new ClientConfig(streaming, polling, clientTransportConfigs, + return new ClientConfig(streaming, polling, supportedTransports, useClientPreference, acceptedOutputModes, pushNotificationConfig, historyLength, metadata); } diff --git a/client/base/src/main/java/io/a2a/client/ClientFactory.java b/client/base/src/main/java/io/a2a/client/ClientFactory.java deleted file mode 100644 index 8035761ab..000000000 --- a/client/base/src/main/java/io/a2a/client/ClientFactory.java +++ /dev/null @@ -1,151 +0,0 @@ -package io.a2a.client; - -import static io.a2a.util.Assert.checkNotNullParam; - -import java.util.ArrayList; -import java.util.HashMap; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; -import java.util.ServiceLoader; -import java.util.function.BiConsumer; -import java.util.function.Consumer; - -import io.a2a.client.config.ClientCallInterceptor; -import io.a2a.client.config.ClientConfig; -import io.a2a.client.transport.spi.ClientTransport; -import io.a2a.client.transport.spi.ClientTransportProvider; -import io.a2a.spec.A2AClientException; -import io.a2a.spec.AgentCard; -import io.a2a.spec.AgentInterface; -import io.a2a.spec.TransportProtocol; - -/** - * Used to generate the appropriate client for the agent. - */ -public class ClientFactory { - - private final ClientConfig clientConfig; - private final Map transportProviderRegistry = new HashMap<>(); - - /** - * Create a client factory used to generate the appropriate client for the agent. - * - * @param clientConfig the client config to use - */ - public ClientFactory(ClientConfig clientConfig) { - checkNotNullParam("clientConfig", clientConfig); - this.clientConfig = clientConfig; - ServiceLoader loader = ServiceLoader.load(ClientTransportProvider.class); - for (ClientTransportProvider transport : loader) { - this.transportProviderRegistry.put(transport.getTransportProtocol(), transport); - } - } - - /** - * Create a new A2A client for the given agent card. - * - * @param agentCard the agent card for the remote agent - * @param consumers a list of consumers to pass responses from the remote agent to - * @param streamingErrorHandler an error handler that should be used for the streaming case if an error occurs -<<<<<<< HEAD:client/base/src/main/java/io/a2a/client/ClientFactory.java - * @return the client to use - * @throws A2AClientException if the client cannot be created for any reason - */ - public Client create(AgentCard agentCard, List> consumers, - Consumer streamingErrorHandler) throws A2AClientException { -======= - * @throws A2AClientException if the client cannot be created for any reason - */ - public AbstractClient create(AgentCard agentCard, List> consumers, - Consumer streamingErrorHandler) throws A2AClientException { ->>>>>>> 5955029 (feat: Update the ClientTransport interface, introducing ClientCallContext, ClientConfig, and ClientCallInterceptor similar to the Python SDK. Introduce a ClientTransportProvider and update the JSONRPC and gRPC transport implementations. Introduce a new Client and ClientFactory implementations.):client/src/main/java/io/a2a/client/ClientFactory.java - return create(agentCard, consumers, streamingErrorHandler, null); - } - - /** - * Create a new A2A client for the given agent card. - * - * @param agentCard the agent card for the remote agent - * @param consumers a list of consumers to pass responses from the remote agent to - * @param streamingErrorHandler an error handler that should be used for the streaming case if an error occurs - * @param interceptors the optional list of client call interceptors (may be {@code null}) -<<<<<<< HEAD:client/base/src/main/java/io/a2a/client/ClientFactory.java - * @return the client to use - * @throws A2AClientException if the client cannot be created for any reason - */ - public Client create(AgentCard agentCard, List> consumers, - Consumer streamingErrorHandler, List interceptors) throws A2AClientException { -======= - * @throws A2AClientException if the client cannot be created for any reason - */ - public AbstractClient create(AgentCard agentCard, List> consumers, - Consumer streamingErrorHandler, List interceptors) throws A2AClientException { ->>>>>>> 5955029 (feat: Update the ClientTransport interface, introducing ClientCallContext, ClientConfig, and ClientCallInterceptor similar to the Python SDK. Introduce a ClientTransportProvider and update the JSONRPC and gRPC transport implementations. Introduce a new Client and ClientFactory implementations.):client/src/main/java/io/a2a/client/ClientFactory.java - checkNotNullParam("agentCard", agentCard); - checkNotNullParam("consumers", consumers); - LinkedHashMap serverPreferredTransports = getServerPreferredTransports(agentCard); - List clientPreferredTransports = getClientPreferredTransports(); - ClientTransport clientTransport = getClientTransport(clientPreferredTransports, serverPreferredTransports, - agentCard, interceptors); - return new Client(agentCard, clientConfig, clientTransport, consumers, streamingErrorHandler); - } - - private static LinkedHashMap getServerPreferredTransports(AgentCard agentCard) { - LinkedHashMap serverPreferredTransports = new LinkedHashMap<>(); - serverPreferredTransports.put(agentCard.preferredTransport(), agentCard.url()); - if (agentCard.additionalInterfaces() != null) { - for (AgentInterface agentInterface : agentCard.additionalInterfaces()) { - serverPreferredTransports.putIfAbsent(agentInterface.transport(), agentInterface.url()); - } - } - return serverPreferredTransports; - } - - private List getClientPreferredTransports() { - List preferredClientTransports = clientConfig.getSupportedTransports(); - if (preferredClientTransports == null) { - preferredClientTransports = new ArrayList<>(); - } - if (preferredClientTransports.isEmpty()) { - // default to JSONRPC if not specified - preferredClientTransports.add(TransportProtocol.JSONRPC.asString()); - } - return preferredClientTransports; - } - - private ClientTransport getClientTransport(List clientPreferredTransports, - LinkedHashMap serverPreferredTransports, - AgentCard agentCard, - List interceptors) throws A2AClientException { - String transportProtocol = null; - String transportUrl = null; - if (clientConfig.isUseClientPreference()) { - for (String clientPreferredTransport : clientPreferredTransports) { - if (serverPreferredTransports.containsKey(clientPreferredTransport)) { - transportProtocol = clientPreferredTransport; - transportUrl = serverPreferredTransports.get(transportProtocol); - break; - } - } - } else { - for (Map.Entry transport : serverPreferredTransports.entrySet()) { - if (clientPreferredTransports.contains(transport.getKey())) { - transportProtocol = transport.getKey(); - transportUrl = transport.getValue(); - break; - } - } - } - if (transportProtocol == null || transportUrl == null) { - throw new A2AClientException("No compatible transports found"); - } - if (! transportProviderRegistry.containsKey(transportProtocol)) { - throw new A2AClientException("No client available for " + transportProtocol); - } - - ClientTransportProvider clientTransportProvider = transportProviderRegistry.get(transportProtocol); - return clientTransportProvider.create(clientConfig, agentCard, transportUrl, interceptors); - } - -} diff --git a/client/base/src/main/java/io/a2a/client/ClientTaskManager.java b/client/base/src/main/java/io/a2a/client/ClientTaskManager.java index 78dc235c2..591e70ae4 100644 --- a/client/base/src/main/java/io/a2a/client/ClientTaskManager.java +++ b/client/base/src/main/java/io/a2a/client/ClientTaskManager.java @@ -70,28 +70,15 @@ public Task saveTaskEvent(TaskStatusUpdateEvent taskStatusUpdateEvent) throws A2 if (task.getHistory() == null) { taskBuilder.history(taskStatusUpdateEvent.getStatus().message()); } else { -<<<<<<< HEAD:client/base/src/main/java/io/a2a/client/ClientTaskManager.java List history = new ArrayList<>(task.getHistory()); -======= - List history = task.getHistory(); ->>>>>>> 5955029 (feat: Update the ClientTransport interface, introducing ClientCallContext, ClientConfig, and ClientCallInterceptor similar to the Python SDK. Introduce a ClientTransportProvider and update the JSONRPC and gRPC transport implementations. Introduce a new Client and ClientFactory implementations.):client/src/main/java/io/a2a/client/ClientTaskManager.java history.add(taskStatusUpdateEvent.getStatus().message()); taskBuilder.history(history); } } if (taskStatusUpdateEvent.getMetadata() != null) { -<<<<<<< HEAD:client/base/src/main/java/io/a2a/client/ClientTaskManager.java Map newMetadata = task.getMetadata() != null ? new HashMap<>(task.getMetadata()) : new HashMap<>(); newMetadata.putAll(taskStatusUpdateEvent.getMetadata()); taskBuilder.metadata(newMetadata); -======= - Map metadata = taskStatusUpdateEvent.getMetadata(); - if (metadata == null) { - metadata = new HashMap<>(); - } - metadata.putAll(taskStatusUpdateEvent.getMetadata()); - taskBuilder.metadata(metadata); ->>>>>>> 5955029 (feat: Update the ClientTransport interface, introducing ClientCallContext, ClientConfig, and ClientCallInterceptor similar to the Python SDK. Introduce a ClientTransportProvider and update the JSONRPC and gRPC transport implementations. Introduce a new Client and ClientFactory implementations.):client/src/main/java/io/a2a/client/ClientTaskManager.java } taskBuilder.status(taskStatusUpdateEvent.getStatus()); currentTask = taskBuilder.build(); @@ -148,4 +135,4 @@ private void saveTask(Task task) { contextId = currentTask.getContextId(); } } -} +} \ No newline at end of file diff --git a/client/base/src/test/java/io/a2a/client/ClientBuilderTest.java b/client/base/src/test/java/io/a2a/client/ClientBuilderTest.java new file mode 100644 index 000000000..e9ac28790 --- /dev/null +++ b/client/base/src/test/java/io/a2a/client/ClientBuilderTest.java @@ -0,0 +1,88 @@ +package io.a2a.client; + +import io.a2a.client.transport.grpc.GrpcTransport; +import io.a2a.client.transport.grpc.GrpcTransportConfigBuilder; +import io.a2a.client.transport.jsonrpc.JSONRPCTransport; +import io.a2a.client.transport.jsonrpc.JSONRPCTransportConfig; +import io.a2a.client.transport.jsonrpc.JSONRPCTransportConfigBuilder; +import io.a2a.spec.A2AClientException; +import io.a2a.spec.AgentCapabilities; +import io.a2a.spec.AgentCard; +import io.a2a.spec.AgentInterface; +import io.a2a.spec.AgentSkill; +import io.a2a.spec.TransportProtocol; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.util.Collections; +import java.util.List; + +/** + * @author David BRASSELY (david.brassely at graviteesource.com) + * @author GraviteeSource Team + */ +public class ClientBuilderTest { + + private AgentCard card = new AgentCard.Builder() + .name("Hello World Agent") + .description("Just a hello world agent") + .url("http://localhost:9999") + .version("1.0.0") + .documentationUrl("http://example.com/docs") + .capabilities(new AgentCapabilities.Builder() + .streaming(true) + .pushNotifications(true) + .stateTransitionHistory(true) + .build()) + .defaultInputModes(Collections.singletonList("text")) + .defaultOutputModes(Collections.singletonList("text")) + .skills(Collections.singletonList(new AgentSkill.Builder() + .id("hello_world") + .name("Returns hello world") + .description("just returns hello world") + .tags(Collections.singletonList("hello world")) + .examples(List.of("hi", "hello world")) + .build())) + .protocolVersion("0.3.0") + .additionalInterfaces(List.of( + new AgentInterface(TransportProtocol.JSONRPC.asString(), "http://localhost:9999"))) + .build(); + + @Test() + public void shouldNotFindCompatibleTransport() throws A2AClientException { + A2AClientException exception = Assertions.assertThrows(A2AClientException.class, + () -> Client + .from(card) + .useClientPreference(true) + .withTransport(GrpcTransport.class, new GrpcTransportConfigBuilder() + .channel(null)) + .build()); + + Assertions.assertTrue(exception.getMessage().contains("No compatible transport found")); + } + + @Test + public void shouldCreateJSONRPCClient() throws A2AClientException { + Client client = Client + .from(card) + .useClientPreference(true) + .withTransport(JSONRPCTransport.class, new JSONRPCTransportConfigBuilder() + .addInterceptor(null) + .httpClient(null)) + .build(); + + Assertions.assertNotNull(client); + } + + @Test + public void shouldCreateClient_differentConfigurations() throws A2AClientException { + Client client = Client + .from(card) + .withJsonRpcTransport(new JSONRPCTransportConfigBuilder()) + .withJsonRpcTransport(new JSONRPCTransportConfig()) + .withJsonRpcTransport() + .build(); + + Assertions.assertNotNull(client); + } +} diff --git a/client/config/pom.xml b/client/config/pom.xml deleted file mode 100644 index db7afdc30..000000000 --- a/client/config/pom.xml +++ /dev/null @@ -1,38 +0,0 @@ - - - 4.0.0 - - - io.github.a2asdk - a2a-java-sdk-parent - 0.3.0.Beta1-SNAPSHOT - ../../pom.xml - - a2a-java-sdk-client-config - - jar - - Java SDK A2A Client Configuration - Java SDK for the Agent2Agent Protocol (A2A) - Client Configuration - - - - ${project.groupId} - a2a-java-sdk-spec - - - org.junit.jupiter - junit-jupiter-api - test - - - - org.mock-server - mockserver-netty - test - - - - \ No newline at end of file diff --git a/client/config/src/main/java/io/a2a/client/config/ClientCallContext.java b/client/config/src/main/java/io/a2a/client/config/ClientCallContext.java deleted file mode 100644 index 0cfff4d65..000000000 --- a/client/config/src/main/java/io/a2a/client/config/ClientCallContext.java +++ /dev/null @@ -1,27 +0,0 @@ -package io.a2a.client.config; - -import java.util.Map; - -/** - * A context passed with each client call, allowing for call-specific. - * configuration and data passing. Such as authentication details or - * request deadlines. - */ -public class ClientCallContext { - - private final Map state; - private final Map headers; - - public ClientCallContext(Map state, Map headers) { - this.state = state; - this.headers = headers; - } - - public Map getState() { - return state; - } - - public Map getHeaders() { - return headers; - } -} diff --git a/client/config/src/main/java/io/a2a/client/config/ClientTransportConfig.java b/client/config/src/main/java/io/a2a/client/config/ClientTransportConfig.java deleted file mode 100644 index 560b69afb..000000000 --- a/client/config/src/main/java/io/a2a/client/config/ClientTransportConfig.java +++ /dev/null @@ -1,7 +0,0 @@ -package io.a2a.client.config; - -/** - * Configuration for an A2A client transport. - */ -public interface ClientTransportConfig { -} \ No newline at end of file diff --git a/client/config/src/main/java/io/a2a/client/config/PayloadAndHeaders.java b/client/config/src/main/java/io/a2a/client/config/PayloadAndHeaders.java deleted file mode 100644 index 2146a5547..000000000 --- a/client/config/src/main/java/io/a2a/client/config/PayloadAndHeaders.java +++ /dev/null @@ -1,22 +0,0 @@ -package io.a2a.client.config; - -import java.util.Map; - -public class PayloadAndHeaders { - - private final Object payload; - private final Map headers; - - public PayloadAndHeaders(Object payload, Map headers) { - this.payload = payload; - this.headers = headers; - } - - public Object getPayload() { - return payload; - } - - public Map getHeaders() { - return headers; - } -} diff --git a/client/transport/grpc/pom.xml b/client/transport/grpc/pom.xml index 758130291..b910d6ac7 100644 --- a/client/transport/grpc/pom.xml +++ b/client/transport/grpc/pom.xml @@ -17,10 +17,6 @@ Java SDK for the Agent2Agent Protocol (A2A) - gRPC Client Transport - - ${project.groupId} - a2a-java-sdk-client-config - ${project.groupId} a2a-java-sdk-common diff --git a/client/transport/grpc/src/main/java/io/a2a/client/transport/grpc/GrpcTransport.java b/client/transport/grpc/src/main/java/io/a2a/client/transport/grpc/GrpcTransport.java index f709ed362..b313fb43a 100644 --- a/client/transport/grpc/src/main/java/io/a2a/client/transport/grpc/GrpcTransport.java +++ b/client/transport/grpc/src/main/java/io/a2a/client/transport/grpc/GrpcTransport.java @@ -10,7 +10,7 @@ import java.util.function.Consumer; import java.util.stream.Collectors; -import io.a2a.client.config.ClientCallContext; +import io.a2a.client.transport.spi.interceptors.ClientCallContext; import io.a2a.client.transport.spi.ClientTransport; import io.a2a.grpc.A2AServiceGrpc; import io.a2a.grpc.CancelTaskRequest; diff --git a/client/transport/grpc/src/main/java/io/a2a/client/transport/grpc/GrpcTransportConfig.java b/client/transport/grpc/src/main/java/io/a2a/client/transport/grpc/GrpcTransportConfig.java index 5717f59b7..4a5ef21df 100644 --- a/client/transport/grpc/src/main/java/io/a2a/client/transport/grpc/GrpcTransportConfig.java +++ b/client/transport/grpc/src/main/java/io/a2a/client/transport/grpc/GrpcTransportConfig.java @@ -1,20 +1,17 @@ package io.a2a.client.transport.grpc; -import java.util.function.Function; - -import io.a2a.client.config.ClientTransportConfig; +import io.a2a.client.transport.spi.ClientTransportConfig; import io.grpc.Channel; -public class GrpcTransportConfig implements ClientTransportConfig { +public class GrpcTransportConfig extends ClientTransportConfig { - private final Function channelFactory; + private final Channel channel; - public GrpcTransportConfig(Function channelFactory) { - this.channelFactory = channelFactory; + public GrpcTransportConfig(Channel channel) { + this.channel = channel; } - public Function getChannelFactory() { - return channelFactory; + public Channel getChannel() { + return channel; } - } \ No newline at end of file diff --git a/client/transport/grpc/src/main/java/io/a2a/client/transport/grpc/GrpcTransportConfigBuilder.java b/client/transport/grpc/src/main/java/io/a2a/client/transport/grpc/GrpcTransportConfigBuilder.java new file mode 100644 index 000000000..6cdc0280e --- /dev/null +++ b/client/transport/grpc/src/main/java/io/a2a/client/transport/grpc/GrpcTransportConfigBuilder.java @@ -0,0 +1,20 @@ +package io.a2a.client.transport.grpc; + +import io.a2a.client.transport.spi.ClientTransportConfigBuilder; +import io.grpc.Channel; + +public class GrpcTransportConfigBuilder extends ClientTransportConfigBuilder { + + private Channel channel; + + public GrpcTransportConfigBuilder channel(Channel channel) { + this.channel = channel; + + return this; + } + + @Override + public GrpcTransportConfig build() { + return new GrpcTransportConfig(channel); + } +} \ No newline at end of file diff --git a/client/transport/grpc/src/main/java/io/a2a/client/transport/grpc/GrpcTransportProvider.java b/client/transport/grpc/src/main/java/io/a2a/client/transport/grpc/GrpcTransportProvider.java index 7d941f5b1..63ddcdb9a 100644 --- a/client/transport/grpc/src/main/java/io/a2a/client/transport/grpc/GrpcTransportProvider.java +++ b/client/transport/grpc/src/main/java/io/a2a/client/transport/grpc/GrpcTransportProvider.java @@ -1,49 +1,28 @@ package io.a2a.client.transport.grpc; -import java.util.List; - -import io.a2a.client.config.ClientCallInterceptor; -import io.a2a.client.config.ClientConfig; -<<<<<<< HEAD:client/transport/grpc/src/main/java/io/a2a/client/transport/grpc/GrpcTransportProvider.java -import io.a2a.client.config.ClientTransportConfig; -import io.a2a.client.transport.spi.ClientTransport; import io.a2a.client.transport.spi.ClientTransportProvider; -import io.a2a.spec.A2AClientException; import io.a2a.spec.AgentCard; import io.a2a.spec.TransportProtocol; import io.grpc.Channel; -======= -import io.a2a.client.transport.spi.ClientTransport; -import io.a2a.client.transport.spi.ClientTransportProvider; -import io.a2a.spec.AgentCard; -import io.a2a.spec.TransportProtocol; ->>>>>>> 5955029 (feat: Update the ClientTransport interface, introducing ClientCallContext, ClientConfig, and ClientCallInterceptor similar to the Python SDK. Introduce a ClientTransportProvider and update the JSONRPC and gRPC transport implementations. Introduce a new Client and ClientFactory implementations.):client-transport/grpc/src/main/java/io/a2a/client/transport/grpc/GrpcTransportProvider.java +import io.grpc.ManagedChannelBuilder; /** * Provider for gRPC transport implementation. */ -public class GrpcTransportProvider implements ClientTransportProvider { +public class GrpcTransportProvider implements ClientTransportProvider { @Override - public ClientTransport create(ClientConfig clientConfig, AgentCard agentCard, -<<<<<<< HEAD:client/transport/grpc/src/main/java/io/a2a/client/transport/grpc/GrpcTransportProvider.java - String agentUrl, List interceptors) throws A2AClientException { + public GrpcTransport create(GrpcTransportConfig grpcTransportConfig, AgentCard agentCard, String agentUrl) { // not making use of the interceptors for gRPC for now - List clientTransportConfigs = clientConfig.getClientTransportConfigs(); - if (clientTransportConfigs != null) { - for (ClientTransportConfig clientTransportConfig : clientTransportConfigs) { - if (clientTransportConfig instanceof GrpcTransportConfig grpcTransportConfig) { - Channel channel = grpcTransportConfig.getChannelFactory().apply(agentUrl); - return new GrpcTransport(channel, agentCard); - } - } + + Channel channel = grpcTransportConfig.getChannel(); + + // no channel factory configured + if (channel == null) { + channel = ManagedChannelBuilder.forTarget(agentUrl).build(); } - throw new A2AClientException("Missing required GrpcTransportConfig"); -======= - String agentUrl, List interceptors) { - // not making use of the interceptors for gRPC for now - return new GrpcTransport(clientConfig.getChannel(), agentCard); ->>>>>>> 5955029 (feat: Update the ClientTransport interface, introducing ClientCallContext, ClientConfig, and ClientCallInterceptor similar to the Python SDK. Introduce a ClientTransportProvider and update the JSONRPC and gRPC transport implementations. Introduce a new Client and ClientFactory implementations.):client-transport/grpc/src/main/java/io/a2a/client/transport/grpc/GrpcTransportProvider.java + + return new GrpcTransport(channel, agentCard); } @Override @@ -51,4 +30,8 @@ public String getTransportProtocol() { return TransportProtocol.GRPC.asString(); } + @Override + public Class getTransportProtocolClass() { + return GrpcTransport.class; + } } diff --git a/client/transport/jsonrpc/pom.xml b/client/transport/jsonrpc/pom.xml index 66010fec3..172c243a3 100644 --- a/client/transport/jsonrpc/pom.xml +++ b/client/transport/jsonrpc/pom.xml @@ -17,14 +17,6 @@ Java SDK for the Agent2Agent Protocol (A2A) - JSONRPC Client Transport - - ${project.groupId} - a2a-java-sdk-client - - - ${project.groupId} - a2a-java-sdk-client-config - ${project.groupId} a2a-java-sdk-http-client diff --git a/client/transport/jsonrpc/src/main/java/io/a2a/client/transport/jsonrpc/JSONRPCTransport.java b/client/transport/jsonrpc/src/main/java/io/a2a/client/transport/jsonrpc/JSONRPCTransport.java index d3d93e463..8464911f5 100644 --- a/client/transport/jsonrpc/src/main/java/io/a2a/client/transport/jsonrpc/JSONRPCTransport.java +++ b/client/transport/jsonrpc/src/main/java/io/a2a/client/transport/jsonrpc/JSONRPCTransport.java @@ -10,10 +10,10 @@ import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.core.type.TypeReference; -import io.a2a.client.A2ACardResolver; -import io.a2a.client.config.ClientCallContext; -import io.a2a.client.config.ClientCallInterceptor; -import io.a2a.client.config.PayloadAndHeaders; +import io.a2a.client.http.A2ACardResolver; +import io.a2a.client.transport.spi.interceptors.ClientCallContext; +import io.a2a.client.transport.spi.interceptors.ClientCallInterceptor; +import io.a2a.client.transport.spi.interceptors.PayloadAndHeaders; import io.a2a.client.http.A2AHttpClient; import io.a2a.client.http.A2AHttpResponse; import io.a2a.client.http.JdkA2AHttpClient; diff --git a/client/transport/jsonrpc/src/main/java/io/a2a/client/transport/jsonrpc/JSONRPCTransportConfig.java b/client/transport/jsonrpc/src/main/java/io/a2a/client/transport/jsonrpc/JSONRPCTransportConfig.java index 1627b9090..efd3bbdf9 100644 --- a/client/transport/jsonrpc/src/main/java/io/a2a/client/transport/jsonrpc/JSONRPCTransportConfig.java +++ b/client/transport/jsonrpc/src/main/java/io/a2a/client/transport/jsonrpc/JSONRPCTransportConfig.java @@ -1,12 +1,16 @@ package io.a2a.client.transport.jsonrpc; -import io.a2a.client.config.ClientTransportConfig; +import io.a2a.client.transport.spi.ClientTransportConfig; import io.a2a.client.http.A2AHttpClient; -public class JSONRPCTransportConfig implements ClientTransportConfig { +public class JSONRPCTransportConfig extends ClientTransportConfig { private final A2AHttpClient httpClient; + public JSONRPCTransportConfig() { + this.httpClient = null; + } + public JSONRPCTransportConfig(A2AHttpClient httpClient) { this.httpClient = httpClient; } diff --git a/client/transport/jsonrpc/src/main/java/io/a2a/client/transport/jsonrpc/JSONRPCTransportConfigBuilder.java b/client/transport/jsonrpc/src/main/java/io/a2a/client/transport/jsonrpc/JSONRPCTransportConfigBuilder.java new file mode 100644 index 000000000..64153620f --- /dev/null +++ b/client/transport/jsonrpc/src/main/java/io/a2a/client/transport/jsonrpc/JSONRPCTransportConfigBuilder.java @@ -0,0 +1,28 @@ +package io.a2a.client.transport.jsonrpc; + +import io.a2a.client.http.A2AHttpClient; +import io.a2a.client.http.JdkA2AHttpClient; +import io.a2a.client.transport.spi.ClientTransportConfigBuilder; + +public class JSONRPCTransportConfigBuilder extends ClientTransportConfigBuilder { + + private A2AHttpClient httpClient; + + public JSONRPCTransportConfigBuilder httpClient(A2AHttpClient httpClient) { + this.httpClient = httpClient; + + return this; + } + + @Override + public JSONRPCTransportConfig build() { + // No HTTP client provided, fallback to the default one (JDK-based implementation) + if (httpClient == null) { + httpClient = new JdkA2AHttpClient(); + } + + JSONRPCTransportConfig config = new JSONRPCTransportConfig(httpClient); + config.setInterceptors(this.interceptors); + return config; + } +} \ No newline at end of file diff --git a/client/transport/jsonrpc/src/main/java/io/a2a/client/transport/jsonrpc/JSONRPCTransportProvider.java b/client/transport/jsonrpc/src/main/java/io/a2a/client/transport/jsonrpc/JSONRPCTransportProvider.java index c6e7824af..2ad1da5fe 100644 --- a/client/transport/jsonrpc/src/main/java/io/a2a/client/transport/jsonrpc/JSONRPCTransportProvider.java +++ b/client/transport/jsonrpc/src/main/java/io/a2a/client/transport/jsonrpc/JSONRPCTransportProvider.java @@ -1,47 +1,28 @@ package io.a2a.client.transport.jsonrpc; -import java.util.List; - -import io.a2a.client.config.ClientCallInterceptor; -import io.a2a.client.config.ClientConfig; -<<<<<<< HEAD:client/transport/jsonrpc/src/main/java/io/a2a/client/transport/jsonrpc/JSONRPCTransportProvider.java -import io.a2a.client.config.ClientTransportConfig; -import io.a2a.client.http.A2AHttpClient; -import io.a2a.client.transport.spi.ClientTransport; -import io.a2a.client.transport.spi.ClientTransportProvider; -import io.a2a.spec.A2AClientException; -======= -import io.a2a.client.transport.spi.ClientTransport; +import io.a2a.client.http.JdkA2AHttpClient; import io.a2a.client.transport.spi.ClientTransportProvider; ->>>>>>> 5955029 (feat: Update the ClientTransport interface, introducing ClientCallContext, ClientConfig, and ClientCallInterceptor similar to the Python SDK. Introduce a ClientTransportProvider and update the JSONRPC and gRPC transport implementations. Introduce a new Client and ClientFactory implementations.):client-transport/jsonrpc/src/main/java/io/a2a/client/transport/jsonrpc/JSONRPCTransportProvider.java import io.a2a.spec.AgentCard; import io.a2a.spec.TransportProtocol; -public class JSONRPCTransportProvider implements ClientTransportProvider { +public class JSONRPCTransportProvider implements ClientTransportProvider { @Override - public ClientTransport create(ClientConfig clientConfig, AgentCard agentCard, -<<<<<<< HEAD:client/transport/jsonrpc/src/main/java/io/a2a/client/transport/jsonrpc/JSONRPCTransportProvider.java - String agentUrl, List interceptors) throws A2AClientException { - A2AHttpClient httpClient = null; - List clientTransportConfigs = clientConfig.getClientTransportConfigs(); - if (clientTransportConfigs != null) { - for (ClientTransportConfig clientTransportConfig : clientTransportConfigs) { - if (clientTransportConfig instanceof JSONRPCTransportConfig jsonrpcTransportConfig) { - httpClient = jsonrpcTransportConfig.getHttpClient(); - break; - } - } + public JSONRPCTransport create(JSONRPCTransportConfig clientTransportConfig, AgentCard agentCard, String agentUrl) { + if (clientTransportConfig == null) { + clientTransportConfig = new JSONRPCTransportConfig(new JdkA2AHttpClient()); } - return new JSONRPCTransport(httpClient, agentCard, agentUrl, interceptors); -======= - String agentUrl, List interceptors) { - return new JSONRPCTransport(clientConfig.getHttpClient(), agentCard, agentUrl, interceptors); ->>>>>>> 5955029 (feat: Update the ClientTransport interface, introducing ClientCallContext, ClientConfig, and ClientCallInterceptor similar to the Python SDK. Introduce a ClientTransportProvider and update the JSONRPC and gRPC transport implementations. Introduce a new Client and ClientFactory implementations.):client-transport/jsonrpc/src/main/java/io/a2a/client/transport/jsonrpc/JSONRPCTransportProvider.java + + return new JSONRPCTransport(clientTransportConfig.getHttpClient(), agentCard, agentUrl, clientTransportConfig.getInterceptors()); } @Override public String getTransportProtocol() { return TransportProtocol.JSONRPC.asString(); } + + @Override + public Class getTransportProtocolClass() { + return JSONRPCTransport.class; + } } diff --git a/client/transport/jsonrpc/src/test/java/io/a2a/client/transport/jsonrpc/JSONRPCTransportStreamingTest.java b/client/transport/jsonrpc/src/test/java/io/a2a/client/transport/jsonrpc/JSONRPCTransportStreamingTest.java index 0ba3063e0..66091532e 100644 --- a/client/transport/jsonrpc/src/test/java/io/a2a/client/transport/jsonrpc/JSONRPCTransportStreamingTest.java +++ b/client/transport/jsonrpc/src/test/java/io/a2a/client/transport/jsonrpc/JSONRPCTransportStreamingTest.java @@ -18,7 +18,6 @@ import java.util.concurrent.atomic.AtomicReference; import java.util.function.Consumer; -import io.a2a.client.transport.jsonrpc.JSONRPCTransport; import io.a2a.spec.Artifact; import io.a2a.spec.Message; import io.a2a.spec.MessageSendConfiguration; @@ -61,17 +60,17 @@ public void testSendStreamingMessageParams() { .contextId("context-test") .messageId("message-test") .build(); - + MessageSendConfiguration configuration = new MessageSendConfiguration.Builder() .acceptedOutputModes(List.of("text")) .blocking(false) .build(); - + MessageSendParams params = new MessageSendParams.Builder() .message(message) .configuration(configuration) .build(); - + assertNotNull(params); assertEquals(message, params.message()); assertEquals(configuration, params.configuration()); diff --git a/client/transport/jsonrpc/src/test/java/io/a2a/client/transport/jsonrpc/JSONRPCTransportTest.java b/client/transport/jsonrpc/src/test/java/io/a2a/client/transport/jsonrpc/JSONRPCTransportTest.java index 1e871441c..afb6cc29b 100644 --- a/client/transport/jsonrpc/src/test/java/io/a2a/client/transport/jsonrpc/JSONRPCTransportTest.java +++ b/client/transport/jsonrpc/src/test/java/io/a2a/client/transport/jsonrpc/JSONRPCTransportTest.java @@ -65,6 +65,7 @@ import io.a2a.spec.TaskQueryParams; import io.a2a.spec.TaskState; import io.a2a.spec.TextPart; +import io.a2a.spec.TransportProtocol; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; @@ -357,7 +358,7 @@ public void testA2AClientGetAgentCard() throws Exception { this.server.when( request() .withMethod("GET") - .withPath("/.well-known/agent.json") + .withPath("/.well-known/agent-card.json") ) .respond( response() @@ -418,7 +419,16 @@ public void testA2AClientGetAgentCard() throws Exception { assertEquals(outputModes, skills.get(1).outputModes()); assertFalse(agentCard.supportsAuthenticatedExtendedCard()); assertEquals("https://georoute-agent.example.com/icon.png", agentCard.iconUrl()); - assertEquals("0.2.5", agentCard.protocolVersion()); + assertEquals("0.2.9", agentCard.protocolVersion()); + assertEquals("JSONRPC", agentCard.preferredTransport()); + List additionalInterfaces = agentCard.additionalInterfaces(); + assertEquals(3, additionalInterfaces.size()); + AgentInterface jsonrpc = new AgentInterface(TransportProtocol.JSONRPC.asString(), "https://georoute-agent.example.com/a2a/v1"); + AgentInterface grpc = new AgentInterface(TransportProtocol.GRPC.asString(), "https://georoute-agent.example.com/a2a/grpc"); + AgentInterface httpJson = new AgentInterface(TransportProtocol.HTTP_JSON.asString(), "https://georoute-agent.example.com/a2a/json"); + assertEquals(jsonrpc, additionalInterfaces.get(0)); + assertEquals(grpc, additionalInterfaces.get(1)); + assertEquals(httpJson, additionalInterfaces.get(2)); } @Test @@ -442,17 +452,7 @@ public void testA2AClientGetAuthenticatedExtendedAgentCard() throws Exception { .respond( response() .withStatusCode(200) - .withBody(AGENT_CARD_SUPPORTS_EXTENDED) - ); - this.server.when( - request() - .withMethod("GET") - .withPath("/agent/authenticatedExtendedCard") - ) - .respond( - response() - .withStatusCode(200) - .withBody(AUTHENTICATION_EXTENDED_AGENT_CARD) + .withBody(GET_AUTHENTICATED_EXTENDED_AGENT_CARD_RESPONSE) ); JSONRPCTransport client = new JSONRPCTransport("http://localhost:4001"); diff --git a/client/transport/jsonrpc/src/test/java/io/a2a/client/transport/jsonrpc/JsonMessages.java b/client/transport/jsonrpc/src/test/java/io/a2a/client/transport/jsonrpc/JsonMessages.java index 84c28628d..390aa1899 100644 --- a/client/transport/jsonrpc/src/test/java/io/a2a/client/transport/jsonrpc/JsonMessages.java +++ b/client/transport/jsonrpc/src/test/java/io/a2a/client/transport/jsonrpc/JsonMessages.java @@ -8,7 +8,6 @@ public class JsonMessages { static final String AGENT_CARD = """ { -<<<<<<< HEAD:client/transport/jsonrpc/src/test/java/io/a2a/client/transport/jsonrpc/JsonMessages.java "protocolVersion": "0.2.9", "name": "GeoSpatial Route Planner Agent", "description": "Provides advanced route planning, traffic analysis, and custom map generation services. This agent can calculate optimal routes, estimate travel times considering real-time traffic, and create personalized maps with points of interest.", @@ -83,136 +82,6 @@ public class JsonMessages { } ] }"""; -======= - "name": "GeoSpatial Route Planner Agent", - "description": "Provides advanced route planning, traffic analysis, and custom map generation services. This agent can calculate optimal routes, estimate travel times considering real-time traffic, and create personalized maps with points of interest.", - "url": "https://georoute-agent.example.com/a2a/v1", - "provider": { - "organization": "Example Geo Services Inc.", - "url": "https://www.examplegeoservices.com" - }, - "iconUrl": "https://georoute-agent.example.com/icon.png", - "version": "1.2.0", - "documentationUrl": "https://docs.examplegeoservices.com/georoute-agent/api", - "capabilities": { - "streaming": true, - "pushNotifications": true, - "stateTransitionHistory": false - }, - "securitySchemes": { - "google": { - "type": "openIdConnect", - "openIdConnectUrl": "https://accounts.google.com/.well-known/openid-configuration" - } - }, - "security": [{ "google": ["openid", "profile", "email"] }], - "defaultInputModes": ["application/json", "text/plain"], - "defaultOutputModes": ["application/json", "image/png"], - "skills": [ - { - "id": "route-optimizer-traffic", - "name": "Traffic-Aware Route Optimizer", - "description": "Calculates the optimal driving route between two or more locations, taking into account real-time traffic conditions, road closures, and user preferences (e.g., avoid tolls, prefer highways).", - "tags": ["maps", "routing", "navigation", "directions", "traffic"], - "examples": [ - "Plan a route from '1600 Amphitheatre Parkway, Mountain View, CA' to 'San Francisco International Airport' avoiding tolls.", - "{\\"origin\\": {\\"lat\\": 37.422, \\"lng\\": -122.084}, \\"destination\\": {\\"lat\\": 37.7749, \\"lng\\": -122.4194}, \\"preferences\\": [\\"avoid_ferries\\"]}" - ], - "inputModes": ["application/json", "text/plain"], - "outputModes": [ - "application/json", - "application/vnd.geo+json", - "text/html" - ] - }, - { - "id": "custom-map-generator", - "name": "Personalized Map Generator", - "description": "Creates custom map images or interactive map views based on user-defined points of interest, routes, and style preferences. Can overlay data layers.", - "tags": ["maps", "customization", "visualization", "cartography"], - "examples": [ - "Generate a map of my upcoming road trip with all planned stops highlighted.", - "Show me a map visualizing all coffee shops within a 1-mile radius of my current location." - ], - "inputModes": ["application/json"], - "outputModes": [ - "image/png", - "image/jpeg", - "application/json", - "text/html" - ] - } - ], - "supportsAuthenticatedExtendedCard": false, - "protocolVersion": "0.2.5" - }"""; - - static final String AGENT_CARD_SUPPORTS_EXTENDED = """ - { - "name": "GeoSpatial Route Planner Agent", - "description": "Provides advanced route planning, traffic analysis, and custom map generation services. This agent can calculate optimal routes, estimate travel times considering real-time traffic, and create personalized maps with points of interest.", - "url": "https://georoute-agent.example.com/a2a/v1", - "provider": { - "organization": "Example Geo Services Inc.", - "url": "https://www.examplegeoservices.com" - }, - "iconUrl": "https://georoute-agent.example.com/icon.png", - "version": "1.2.0", - "documentationUrl": "https://docs.examplegeoservices.com/georoute-agent/api", - "capabilities": { - "streaming": true, - "pushNotifications": true, - "stateTransitionHistory": false - }, - "securitySchemes": { - "google": { - "type": "openIdConnect", - "openIdConnectUrl": "https://accounts.google.com/.well-known/openid-configuration" - } - }, - "security": [{ "google": ["openid", "profile", "email"] }], - "defaultInputModes": ["application/json", "text/plain"], - "defaultOutputModes": ["application/json", "image/png"], - "skills": [ - { - "id": "route-optimizer-traffic", - "name": "Traffic-Aware Route Optimizer", - "description": "Calculates the optimal driving route between two or more locations, taking into account real-time traffic conditions, road closures, and user preferences (e.g., avoid tolls, prefer highways).", - "tags": ["maps", "routing", "navigation", "directions", "traffic"], - "examples": [ - "Plan a route from '1600 Amphitheatre Parkway, Mountain View, CA' to 'San Francisco International Airport' avoiding tolls.", - "{\\"origin\\": {\\"lat\\": 37.422, \\"lng\\": -122.084}, \\"destination\\": {\\"lat\\": 37.7749, \\"lng\\": -122.4194}, \\"preferences\\": [\\"avoid_ferries\\"]}" - ], - "inputModes": ["application/json", "text/plain"], - "outputModes": [ - "application/json", - "application/vnd.geo+json", - "text/html" - ] - }, - { - "id": "custom-map-generator", - "name": "Personalized Map Generator", - "description": "Creates custom map images or interactive map views based on user-defined points of interest, routes, and style preferences. Can overlay data layers.", - "tags": ["maps", "customization", "visualization", "cartography"], - "examples": [ - "Generate a map of my upcoming road trip with all planned stops highlighted.", - "Show me a map visualizing all coffee shops within a 1-mile radius of my current location." - ], - "inputModes": ["application/json"], - "outputModes": [ - "image/png", - "image/jpeg", - "application/json", - "text/html" - ] - } - ], - "supportsAuthenticatedExtendedCard": true, - "protocolVersion": "0.2.5" - }"""; - ->>>>>>> 5955029 (feat: Update the ClientTransport interface, introducing ClientCallContext, ClientConfig, and ClientCallInterceptor similar to the Python SDK. Introduce a ClientTransportProvider and update the JSONRPC and gRPC transport implementations. Introduce a new Client and ClientFactory implementations.):client-transport/jsonrpc/src/test/java/io/a2a/client/transport/jsonrpc/JsonMessages.java static final String AUTHENTICATION_EXTENDED_AGENT_CARD = """ { @@ -282,7 +151,6 @@ public class JsonMessages { } ], "supportsAuthenticatedExtendedCard": true, -<<<<<<< HEAD:client/transport/jsonrpc/src/test/java/io/a2a/client/transport/jsonrpc/JsonMessages.java "protocolVersion": "0.2.9", "signatures": [ { @@ -292,13 +160,6 @@ public class JsonMessages { ] }"""; -<<<<<<<< HEAD:client/transport/jsonrpc/src/test/java/io/a2a/client/transport/jsonrpc/JsonMessages.java -======= - "protocolVersion": "0.2.5" - }"""; - - ->>>>>>> 5955029 (feat: Update the ClientTransport interface, introducing ClientCallContext, ClientConfig, and ClientCallInterceptor similar to the Python SDK. Introduce a ClientTransportProvider and update the JSONRPC and gRPC transport implementations. Introduce a new Client and ClientFactory implementations.):client-transport/jsonrpc/src/test/java/io/a2a/client/transport/jsonrpc/JsonMessages.java static final String SEND_MESSAGE_TEST_REQUEST = """ { "jsonrpc": "2.0", @@ -753,7 +614,6 @@ public class JsonMessages { } }"""; -<<<<<<< HEAD:client/transport/jsonrpc/src/test/java/io/a2a/client/transport/jsonrpc/JsonMessages.java static final String GET_AUTHENTICATED_EXTENDED_AGENT_CARD_REQUEST = """ { "jsonrpc": "2.0", @@ -906,11 +766,4 @@ public class JsonMessages { "supportsAuthenticatedExtendedCard": true, "protocolVersion": "0.2.5" }"""; -} -======== - -} ->>>>>>>> 5955029 (feat: Update the ClientTransport interface, introducing ClientCallContext, ClientConfig, and ClientCallInterceptor similar to the Python SDK. Introduce a ClientTransportProvider and update the JSONRPC and gRPC transport implementations. Introduce a new Client and ClientFactory implementations.):client/src/test/java/io/a2a/client/JsonMessages.java -======= -} ->>>>>>> 5955029 (feat: Update the ClientTransport interface, introducing ClientCallContext, ClientConfig, and ClientCallInterceptor similar to the Python SDK. Introduce a ClientTransportProvider and update the JSONRPC and gRPC transport implementations. Introduce a new Client and ClientFactory implementations.):client-transport/jsonrpc/src/test/java/io/a2a/client/transport/jsonrpc/JsonMessages.java +} \ No newline at end of file diff --git a/client/transport/spi/pom.xml b/client/transport/spi/pom.xml index b83d5adaf..99b183289 100644 --- a/client/transport/spi/pom.xml +++ b/client/transport/spi/pom.xml @@ -17,10 +17,6 @@ Java SDK for the Agent2Agent Protocol (A2A) - Client Transport SPI - - io.github.a2asdk - a2a-java-sdk-client-config - io.github.a2asdk a2a-java-sdk-spec diff --git a/client/transport/spi/src/main/java/io/a2a/client/transport/spi/ClientTransport.java b/client/transport/spi/src/main/java/io/a2a/client/transport/spi/ClientTransport.java index 163dd6582..8084d8195 100644 --- a/client/transport/spi/src/main/java/io/a2a/client/transport/spi/ClientTransport.java +++ b/client/transport/spi/src/main/java/io/a2a/client/transport/spi/ClientTransport.java @@ -3,7 +3,7 @@ import java.util.List; import java.util.function.Consumer; -import io.a2a.client.config.ClientCallContext; +import io.a2a.client.transport.spi.interceptors.ClientCallContext; import io.a2a.spec.A2AClientException; import io.a2a.spec.AgentCard; import io.a2a.spec.DeleteTaskPushNotificationConfigParams; diff --git a/client/transport/spi/src/main/java/io/a2a/client/transport/spi/ClientTransportConfig.java b/client/transport/spi/src/main/java/io/a2a/client/transport/spi/ClientTransportConfig.java new file mode 100644 index 000000000..657971383 --- /dev/null +++ b/client/transport/spi/src/main/java/io/a2a/client/transport/spi/ClientTransportConfig.java @@ -0,0 +1,21 @@ +package io.a2a.client.transport.spi; + +import io.a2a.client.transport.spi.interceptors.ClientCallInterceptor; + +import java.util.List; + +/** + * Configuration for an A2A client transport. + */ +public abstract class ClientTransportConfig { + + protected List interceptors; + + public void setInterceptors(List interceptors) { + this.interceptors = interceptors; + } + + public List getInterceptors() { + return interceptors; + } +} \ No newline at end of file diff --git a/client/transport/spi/src/main/java/io/a2a/client/transport/spi/ClientTransportConfigBuilder.java b/client/transport/spi/src/main/java/io/a2a/client/transport/spi/ClientTransportConfigBuilder.java new file mode 100644 index 000000000..f08144cc9 --- /dev/null +++ b/client/transport/spi/src/main/java/io/a2a/client/transport/spi/ClientTransportConfigBuilder.java @@ -0,0 +1,24 @@ +package io.a2a.client.transport.spi; + +import io.a2a.client.transport.spi.interceptors.ClientCallInterceptor; + +import java.util.ArrayList; +import java.util.List; + +/** + * @author David BRASSELY (david.brassely at graviteesource.com) + * @author GraviteeSource Team + */ +public abstract class ClientTransportConfigBuilder, + B extends ClientTransportConfigBuilder> { + + protected List interceptors = new ArrayList<>(); + + public B addInterceptor(ClientCallInterceptor interceptor) { + this.interceptors.add(interceptor); + + return (B) this; + } + + public abstract T build(); +} diff --git a/client/transport/spi/src/main/java/io/a2a/client/transport/spi/ClientTransportProvider.java b/client/transport/spi/src/main/java/io/a2a/client/transport/spi/ClientTransportProvider.java index e4127ff82..b81c3b49f 100644 --- a/client/transport/spi/src/main/java/io/a2a/client/transport/spi/ClientTransportProvider.java +++ b/client/transport/spi/src/main/java/io/a2a/client/transport/spi/ClientTransportProvider.java @@ -2,33 +2,30 @@ import java.util.List; -import io.a2a.client.config.ClientCallInterceptor; -import io.a2a.client.config.ClientConfig; -import io.a2a.spec.A2AClientException; +import io.a2a.client.transport.spi.interceptors.ClientCallInterceptor; import io.a2a.spec.AgentCard; /** * Client transport provider interface. */ -public interface ClientTransportProvider { +public interface ClientTransportProvider> { /** * Create a client transport. * - * @param clientConfig the client config to use - * @param agentCard the agent card for the remote agent + * @param clientTransportConfig the client transport config to use * @param agentUrl the remote agent's URL - * @param interceptors the optional interceptors to use for a client call (may be {@code null}) * @return the client transport * @throws io.a2a.spec.A2AClientException if an error occurs trying to create the client */ - ClientTransport create(ClientConfig clientConfig, AgentCard agentCard, - String agentUrl, List interceptors) throws A2AClientException; + T create(C clientTransportConfig, AgentCard agentCard, + String agentUrl); /** * Get the name of the client transport. */ String getTransportProtocol(); + Class getTransportProtocolClass(); } diff --git a/client-config/src/main/java/io/a2a/client/config/ClientCallContext.java b/client/transport/spi/src/main/java/io/a2a/client/transport/spi/interceptors/ClientCallContext.java similarity index 92% rename from client-config/src/main/java/io/a2a/client/config/ClientCallContext.java rename to client/transport/spi/src/main/java/io/a2a/client/transport/spi/interceptors/ClientCallContext.java index 0cfff4d65..288d7b54a 100644 --- a/client-config/src/main/java/io/a2a/client/config/ClientCallContext.java +++ b/client/transport/spi/src/main/java/io/a2a/client/transport/spi/interceptors/ClientCallContext.java @@ -1,4 +1,4 @@ -package io.a2a.client.config; +package io.a2a.client.transport.spi.interceptors; import java.util.Map; diff --git a/client/config/src/main/java/io/a2a/client/config/ClientCallInterceptor.java b/client/transport/spi/src/main/java/io/a2a/client/transport/spi/interceptors/ClientCallInterceptor.java similarity index 95% rename from client/config/src/main/java/io/a2a/client/config/ClientCallInterceptor.java rename to client/transport/spi/src/main/java/io/a2a/client/transport/spi/interceptors/ClientCallInterceptor.java index 631cd8353..3a44ff70b 100644 --- a/client/config/src/main/java/io/a2a/client/config/ClientCallInterceptor.java +++ b/client/transport/spi/src/main/java/io/a2a/client/transport/spi/interceptors/ClientCallInterceptor.java @@ -1,4 +1,4 @@ -package io.a2a.client.config; +package io.a2a.client.transport.spi.interceptors; import java.util.Map; diff --git a/client-config/src/main/java/io/a2a/client/config/PayloadAndHeaders.java b/client/transport/spi/src/main/java/io/a2a/client/transport/spi/interceptors/PayloadAndHeaders.java similarity index 89% rename from client-config/src/main/java/io/a2a/client/config/PayloadAndHeaders.java rename to client/transport/spi/src/main/java/io/a2a/client/transport/spi/interceptors/PayloadAndHeaders.java index 2146a5547..73983a096 100644 --- a/client-config/src/main/java/io/a2a/client/config/PayloadAndHeaders.java +++ b/client/transport/spi/src/main/java/io/a2a/client/transport/spi/interceptors/PayloadAndHeaders.java @@ -1,4 +1,4 @@ -package io.a2a.client.config; +package io.a2a.client.transport.spi.interceptors; import java.util.Map; diff --git a/examples/helloworld/client/pom.xml b/examples/helloworld/client/pom.xml index cdfeef28d..3aaa5c221 100644 --- a/examples/helloworld/client/pom.xml +++ b/examples/helloworld/client/pom.xml @@ -20,10 +20,6 @@ io.github.a2asdk a2a-java-sdk-client - - io.github.a2asdk - a2a-java-sdk-client-transport-jsonrpc - diff --git a/examples/helloworld/client/src/main/java/io/a2a/examples/helloworld/HelloWorldClient.java b/examples/helloworld/client/src/main/java/io/a2a/examples/helloworld/HelloWorldClient.java index e2303ac30..f59d98e49 100644 --- a/examples/helloworld/client/src/main/java/io/a2a/examples/helloworld/HelloWorldClient.java +++ b/examples/helloworld/client/src/main/java/io/a2a/examples/helloworld/HelloWorldClient.java @@ -10,12 +10,11 @@ import com.fasterxml.jackson.databind.ObjectMapper; import io.a2a.A2A; -import io.a2a.client.A2ACardResolver; + import io.a2a.client.Client; import io.a2a.client.ClientEvent; -import io.a2a.client.ClientFactory; import io.a2a.client.MessageEvent; -import io.a2a.client.config.ClientConfig; +import io.a2a.client.http.A2ACardResolver; import io.a2a.spec.AgentCard; import io.a2a.spec.Message; import io.a2a.spec.Part; @@ -81,8 +80,11 @@ public static void main(String[] args) { messageResponse.completeExceptionally(error); }; - ClientFactory clientFactory = new ClientFactory(new ClientConfig.Builder().build()); - Client client = clientFactory.create(finalAgentCard, consumers, streamingErrorHandler); + Client client = Client + .from(finalAgentCard) + .addStreamConsumers(consumers) + .streamErrorHandler(streamingErrorHandler).build(); + Message message = A2A.toUserMessage(MESSAGE_TEXT); // the message ID will be automatically generated for you System.out.println("Sending message: " + MESSAGE_TEXT); diff --git a/examples/helloworld/pom.xml b/examples/helloworld/pom.xml index a9579b75c..4cb7c0c3e 100644 --- a/examples/helloworld/pom.xml +++ b/examples/helloworld/pom.xml @@ -31,16 +31,6 @@ a2a-java-sdk-client ${project.version} - - io.github.a2asdk - a2a-java-sdk-client-transport-jsonrpc - ${project.version} - - - io.github.a2asdk - a2a-java-sdk-reference-jsonrpc - ${project.version} - diff --git a/examples/helloworld/server/pom.xml b/examples/helloworld/server/pom.xml index 4ff3752bb..cbc4ee401 100644 --- a/examples/helloworld/server/pom.xml +++ b/examples/helloworld/server/pom.xml @@ -18,7 +18,12 @@ io.github.a2asdk - a2a-java-sdk-reference-jsonrpc + a2a-java-sdk-client + + + io.github.a2asdk + a2a-java-sdk-server-common + ${project.version} io.quarkus @@ -34,10 +39,6 @@ jakarta.ws.rs jakarta.ws.rs-api - - io.github.a2asdk - a2a-java-sdk-client-transport-jsonrpc - diff --git a/http-client/pom.xml b/http-client/pom.xml index f331fba11..f14e7cb1e 100644 --- a/http-client/pom.xml +++ b/http-client/pom.xml @@ -17,6 +17,12 @@ Java SDK for the Agent2Agent Protocol (A2A) - HTTP Client + + ${project.groupId} + a2a-java-sdk-spec + ${project.version} + + org.junit.jupiter junit-jupiter-api diff --git a/client/base/src/main/java/io/a2a/client/A2ACardResolver.java b/http-client/src/main/java/io/a2a/client/http/A2ACardResolver.java similarity index 96% rename from client/base/src/main/java/io/a2a/client/A2ACardResolver.java rename to http-client/src/main/java/io/a2a/client/http/A2ACardResolver.java index f5de4746e..f510cd2ac 100644 --- a/client/base/src/main/java/io/a2a/client/A2ACardResolver.java +++ b/http-client/src/main/java/io/a2a/client/http/A2ACardResolver.java @@ -1,4 +1,4 @@ -package io.a2a.client; +package io.a2a.client.http; import static io.a2a.util.Utils.unmarshalFrom; @@ -9,9 +9,6 @@ import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.core.type.TypeReference; -import io.a2a.client.http.A2AHttpClient; -import io.a2a.client.http.A2AHttpResponse; -import io.a2a.client.http.JdkA2AHttpClient; import io.a2a.spec.A2AClientError; import io.a2a.spec.A2AClientJSONError; import io.a2a.spec.AgentCard; diff --git a/client/base/src/test/java/io/a2a/client/A2ACardResolverTest.java b/http-client/src/test/java/io/a2a/client/http/A2ACardResolverTest.java similarity index 98% rename from client/base/src/test/java/io/a2a/client/A2ACardResolverTest.java rename to http-client/src/test/java/io/a2a/client/http/A2ACardResolverTest.java index aef8cca9c..0b855007b 100644 --- a/client/base/src/test/java/io/a2a/client/A2ACardResolverTest.java +++ b/http-client/src/test/java/io/a2a/client/http/A2ACardResolverTest.java @@ -1,4 +1,4 @@ -package io.a2a.client; +package io.a2a.client.http; import static io.a2a.util.Utils.OBJECT_MAPPER; import static io.a2a.util.Utils.unmarshalFrom; @@ -11,8 +11,6 @@ import java.util.function.Consumer; import com.fasterxml.jackson.core.type.TypeReference; -import io.a2a.client.http.A2AHttpClient; -import io.a2a.client.http.A2AHttpResponse; import io.a2a.spec.A2AClientError; import io.a2a.spec.A2AClientJSONError; import io.a2a.spec.AgentCard; diff --git a/client/base/src/test/java/io/a2a/client/JsonMessages.java b/http-client/src/test/java/io/a2a/client/http/JsonMessages.java similarity index 99% rename from client/base/src/test/java/io/a2a/client/JsonMessages.java rename to http-client/src/test/java/io/a2a/client/http/JsonMessages.java index b99da3623..f5fd0426a 100644 --- a/client/base/src/test/java/io/a2a/client/JsonMessages.java +++ b/http-client/src/test/java/io/a2a/client/http/JsonMessages.java @@ -1,4 +1,4 @@ -package io.a2a.client; +package io.a2a.client.http; /** * Request and response messages used by the tests. These have been created following examples from diff --git a/pom.xml b/pom.xml index a242b91c2..9a3e30c23 100644 --- a/pom.xml +++ b/pom.xml @@ -315,7 +315,6 @@ client/base - client/config client/transport/grpc client/transport/jsonrpc client/transport/spi diff --git a/reference/grpc/src/test/java/io/a2a/server/grpc/quarkus/QuarkusA2AGrpcTest.java b/reference/grpc/src/test/java/io/a2a/server/grpc/quarkus/QuarkusA2AGrpcTest.java index 959188408..2be51bf61 100644 --- a/reference/grpc/src/test/java/io/a2a/server/grpc/quarkus/QuarkusA2AGrpcTest.java +++ b/reference/grpc/src/test/java/io/a2a/server/grpc/quarkus/QuarkusA2AGrpcTest.java @@ -1,11 +1,10 @@ package io.a2a.server.grpc.quarkus; -import java.util.ArrayList; -import java.util.List; import java.util.concurrent.TimeUnit; -import io.a2a.client.config.ClientTransportConfig; -import io.a2a.client.transport.grpc.GrpcTransportConfig; +import io.a2a.client.ClientBuilder; +import io.a2a.client.transport.grpc.GrpcTransport; +import io.a2a.client.transport.grpc.GrpcTransportConfigBuilder; import io.a2a.server.apps.common.AbstractA2AServerTest; import io.a2a.spec.TransportProtocol; import io.grpc.ManagedChannel; @@ -34,14 +33,9 @@ protected String getTransportUrl() { } @Override - protected List getClientTransportConfigs() { - List transportConfigs = new ArrayList<>(); - transportConfigs.add(new GrpcTransportConfig( - target -> { - channel = ManagedChannelBuilder.forTarget(target).usePlaintext().build(); - return channel; - })); - return transportConfigs; + protected void configureTransport(ClientBuilder builder) { + channel = ManagedChannelBuilder.forTarget(getTransportUrl()).usePlaintext().build(); + builder.withTransport(GrpcTransport.class, new GrpcTransportConfigBuilder().channel(channel)); } @AfterAll diff --git a/reference/jsonrpc/src/test/java/io/a2a/server/apps/quarkus/QuarkusA2AJSONRPCTest.java b/reference/jsonrpc/src/test/java/io/a2a/server/apps/quarkus/QuarkusA2AJSONRPCTest.java index acebe4cb2..c4affe3b2 100644 --- a/reference/jsonrpc/src/test/java/io/a2a/server/apps/quarkus/QuarkusA2AJSONRPCTest.java +++ b/reference/jsonrpc/src/test/java/io/a2a/server/apps/quarkus/QuarkusA2AJSONRPCTest.java @@ -1,5 +1,6 @@ package io.a2a.server.apps.quarkus; +import io.a2a.client.ClientBuilder; import io.a2a.server.apps.common.AbstractA2AServerTest; import io.a2a.spec.TransportProtocol; import io.quarkus.test.junit.QuarkusTest; diff --git a/tests/server-common/src/test/java/io/a2a/server/apps/common/AbstractA2AServerTest.java b/tests/server-common/src/test/java/io/a2a/server/apps/common/AbstractA2AServerTest.java index 0cbee983f..c25343060 100644 --- a/tests/server-common/src/test/java/io/a2a/server/apps/common/AbstractA2AServerTest.java +++ b/tests/server-common/src/test/java/io/a2a/server/apps/common/AbstractA2AServerTest.java @@ -10,6 +10,12 @@ import static org.wildfly.common.Assert.assertNotNull; import static org.wildfly.common.Assert.assertTrue; +import io.a2a.client.Client; +import io.a2a.client.ClientBuilder; +import io.a2a.client.ClientConfig; +import io.a2a.client.ClientEvent; +import io.a2a.client.MessageEvent; +import io.a2a.client.TaskUpdateEvent; import jakarta.ws.rs.core.MediaType; import java.io.EOFException; @@ -33,13 +39,6 @@ import com.fasterxml.jackson.core.JsonProcessingException; -import io.a2a.client.Client; -import io.a2a.client.ClientEvent; -import io.a2a.client.ClientFactory; -import io.a2a.client.MessageEvent; -import io.a2a.client.TaskUpdateEvent; -import io.a2a.client.config.ClientConfig; -import io.a2a.client.config.ClientTransportConfig; import io.a2a.spec.A2AClientException; import io.a2a.spec.AgentCard; import io.a2a.spec.AgentCapabilities; @@ -137,8 +136,9 @@ protected AbstractA2AServerTest(int serverPort) { /** * Get the transport configs to use for this test. */ - protected List getClientTransportConfigs() { - return new ArrayList<>(); + protected void configureTransport(ClientBuilder builder) { + // Include jsonRPC transport by default + builder.withJsonRpcTransport(); } @Test @@ -1265,11 +1265,15 @@ protected Client getNonStreamingClient() throws A2AClientException { private Client createClient(boolean streaming) throws A2AClientException { AgentCard agentCard = createTestAgentCard(); ClientConfig clientConfig = createClientConfig(streaming); - ClientFactory clientFactory = new ClientFactory(clientConfig); - return clientFactory.create(agentCard, List.of(), null, null); - } + ClientBuilder clientBuilder = Client + .from(agentCard) + .clientConfig(clientConfig); + + configureTransport(clientBuilder); + return clientBuilder.build(); + } /** * Create a test agent card with the appropriate transport configuration. @@ -1299,19 +1303,11 @@ private AgentCard createTestAgentCard() { * Create client configuration with transport-specific settings. */ private ClientConfig createClientConfig(boolean streaming) { - ClientConfig.Builder builder = new ClientConfig.Builder() + return new ClientConfig.Builder() .setStreaming(streaming) .setSupportedTransports(List.of(getTransportProtocol())) - .setAcceptedOutputModes(List.of("text")); - - // Set transport-specific configuration - List transportConfigs = getClientTransportConfigs(); - - if (!transportConfigs.isEmpty()) { - builder.setClientTransportConfigs(transportConfigs); - } - - return builder.build(); + .setAcceptedOutputModes(List.of("text")) + .build(); } } \ No newline at end of file