diff --git a/README.md b/README.md
index 0b528adf0..2dbd99558 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
@@ -211,8 +219,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 +241,220 @@ To make use of the Java `A2AClient`, simply add the following dependency:
```
+### 2. Add one or more dependencies on the A2A Java SDK Client Transport(s) you'd like to use
+
+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-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
-// 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");
+// 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 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 specific `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 settings
+ClientConfig clientConfig = new ClientConfig.Builder()
+ .setAcceptedOutputModes(List.of("text"))
+ .build();
+
+Client client = Client
+ .from(agentCard)
+ .withJsonRpcTransport(new JSONRPCTransportConfigBuilder()
+ .httpClient(customHttpClient).build())
+ .build();
+```
+
+##### gRPC Transport Configuration
+
+For the gRPC transport, you must configure a channel factory:
+
+```java
+// Create a channel from agent URL
+Channel channel = 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"))
+ .build();
+
+Client client = Client
+ .from(agentCard)
+ .withTransport(GrpcTransport.class, new GrpcTransportConfigBuilder()
+ .channel(channel).build())
+ .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
+Client client = Client
+ .from(agentCard)
+ .withTransport(GrpcTransport.class, new GrpcTransportConfigBuilder().build())
+ .withJsonRpcTransport(new JSONRPCTransportConfigBuilder().build())
+ .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 +465,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 +534,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
@@ -412,7 +575,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/).
diff --git a/client/base/pom.xml b/client/base/pom.xml
new file mode 100644
index 000000000..08ed589e1
--- /dev/null
+++ b/client/base/pom.xml
@@ -0,0 +1,63 @@
+
+
+ 4.0.0
+
+
+ io.github.a2asdk
+ a2a-java-sdk-parent
+ 0.3.0.Beta1-SNAPSHOT
+ ../../pom.xml
+
+ a2a-java-sdk-client
+
+ jar
+
+ Java SDK A2A Client
+ Java SDK for the Agent2Agent Protocol (A2A) - Client
+
+
+
+ ${project.groupId}
+ a2a-java-sdk-http-client
+ ${project.version}
+
+
+ ${project.groupId}
+ a2a-java-sdk-client-transport-spi
+ ${project.version}
+
+
+ ${project.groupId}
+ a2a-java-sdk-client-transport-jsonrpc
+ ${project.version}
+
+
+ ${project.groupId}
+ a2a-java-sdk-client-transport-grpc
+ ${project.version}
+ test
+
+
+ ${project.groupId}
+ a2a-java-sdk-common
+
+
+ ${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/src/main/java/io/a2a/A2A.java b/client/base/src/main/java/io/a2a/A2A.java
similarity index 97%
rename from client/src/main/java/io/a2a/A2A.java
rename to client/base/src/main/java/io/a2a/A2A.java
index 1d1f6260e..f72945677 100644
--- a/client/src/main/java/io/a2a/A2A.java
+++ b/client/base/src/main/java/io/a2a/A2A.java
@@ -3,9 +3,9 @@
import java.util.Collections;
import java.util.Map;
-import io.a2a.client.A2ACardResolver;
-import io.a2a.http.A2AHttpClient;
-import io.a2a.http.JdkA2AHttpClient;
+import io.a2a.client.http.A2ACardResolver;
+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/base/src/main/java/io/a2a/client/AbstractClient.java b/client/base/src/main/java/io/a2a/client/AbstractClient.java
new file mode 100644
index 000000000..526b83e01
--- /dev/null
+++ b/client/base/src/main/java/io/a2a/client/AbstractClient.java
@@ -0,0 +1,390 @@
+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.transport.spi.interceptors.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
+ * 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
+ * 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
+ * @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 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
+ * 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
+ * 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 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.
+ *
+ * @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
+ * @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.
+ *
+ * @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
+ * @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.
+ *
+ * @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
+ * @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.
+ *
+ * @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
+ * @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.
+ *
+ * @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
+ * @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.
+ *
+ * @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.
+ * 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.
+ * 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})
+ * @throws A2AClientException if resubscribing fails for any reason
+ */
+ 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.
+ * 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.
+ *
+ * @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.
+ *
+ * @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.
+ */
+ 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;
+ }
+
+}
\ 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
new file mode 100644
index 000000000..347fe081e
--- /dev/null
+++ b/client/base/src/main/java/io/a2a/client/Client.java
@@ -0,0 +1,240 @@
+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.transport.spi.interceptors.ClientCallContext;
+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;
+
+import static io.a2a.util.Assert.checkNotNullParam;
+
+public class Client extends AbstractClient {
+
+ private final ClientConfig clientConfig;
+ private final ClientTransport clientTransport;
+ private AgentCard agentCard;
+
+ 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 {
+ MessageSendParams messageSendParams = getMessageSendParams(request, clientConfig);
+ sendMessage(messageSendParams, null, null, context);
+ }
+
+ @Override
+ public void sendMessage(Message request, List> consumers,
+ Consumer streamingErrorHandler, ClientCallContext context) throws A2AClientException {
+ MessageSendParams messageSendParams = getMessageSendParams(request, clientConfig);
+ sendMessage(messageSendParams, consumers, streamingErrorHandler, 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, null, null, 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 {
+ 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
+ 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, List> consumers,
+ Consumer errorHandler, 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, consumers);
+ } else {
+ 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.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);
+ }
+ }
+
+ private MessageSendParams getMessageSendParams(Message request, ClientConfig clientConfig) {
+ MessageSendConfiguration messageSendConfiguration = new MessageSendConfiguration.Builder()
+ .acceptedOutputModes(clientConfig.getAcceptedOutputModes())
+ .blocking(clientConfig.isPolling())
+ .historyLength(clientConfig.getHistoryLength())
+ .pushNotification(clientConfig.getPushNotificationConfig())
+ .build();
+
+ return new MessageSendParams.Builder()
+ .message(request)
+ .configuration(messageSendConfiguration)
+ .metadata(clientConfig.getMetadata())
+ .build();
+ }
+}
\ 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 extends ClientTransport>> clientTransports = new HashMap<>();
+
+ ClientBuilder(AgentCard agentCard) {
+ this.agentCard = agentCard;
+ }
+
+ public ClientBuilder withTransport(Class clazz, ClientTransportConfigBuilder extends ClientTransportConfig, ?> 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 extends ClientTransport> 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 extends ClientTransport> 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/base/src/main/java/io/a2a/client/ClientConfig.java b/client/base/src/main/java/io/a2a/client/ClientConfig.java
new file mode 100644
index 000000000..d7fa1ca23
--- /dev/null
+++ b/client/base/src/main/java/io/a2a/client/ClientConfig.java
@@ -0,0 +1,124 @@
+package io.a2a.client;
+
+import java.util.List;
+import java.util.Map;
+
+import io.a2a.spec.PushNotificationConfig;
+
+/**
+ * Configuration for the A2A client factory.
+ */
+public class ClientConfig {
+
+ private final Boolean streaming;
+ private final Boolean polling;
+ 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,
+ 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.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 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 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 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,
+ supportedTransports, useClientPreference, acceptedOutputModes,
+ pushNotificationConfig, historyLength, metadata);
+ }
+ }
+}
\ No newline at end of file
diff --git a/client/base/src/main/java/io/a2a/client/ClientEvent.java b/client/base/src/main/java/io/a2a/client/ClientEvent.java
new file mode 100644
index 000000000..dcaae9495
--- /dev/null
+++ b/client/base/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/base/src/main/java/io/a2a/client/ClientTaskManager.java b/client/base/src/main/java/io/a2a/client/ClientTaskManager.java
new file mode 100644
index 000000000..591e70ae4
--- /dev/null
+++ b/client/base/src/main/java/io/a2a/client/ClientTaskManager.java
@@ -0,0 +1,138 @@
+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 = new ArrayList<>(task.getHistory());
+ history.add(taskStatusUpdateEvent.getStatus().message());
+ taskBuilder.history(history);
+ }
+ }
+ if (taskStatusUpdateEvent.getMetadata() != null) {
+ 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();
+ 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();
+ }
+ }
+}
\ No newline at end of file
diff --git a/client/base/src/main/java/io/a2a/client/MessageEvent.java b/client/base/src/main/java/io/a2a/client/MessageEvent.java
new file mode 100644
index 000000000..b5970ab78
--- /dev/null
+++ b/client/base/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/base/src/main/java/io/a2a/client/TaskEvent.java b/client/base/src/main/java/io/a2a/client/TaskEvent.java
new file mode 100644
index 000000000..a18392841
--- /dev/null
+++ b/client/base/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/base/src/main/java/io/a2a/client/TaskUpdateEvent.java b/client/base/src/main/java/io/a2a/client/TaskUpdateEvent.java
new file mode 100644
index 000000000..c45650822
--- /dev/null
+++ b/client/base/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/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/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/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/pom.xml b/client/transport/grpc/pom.xml
similarity index 75%
rename from client/pom.xml
rename to client/transport/grpc/pom.xml
index fa381e65e..b910d6ac7 100644
--- a/client/pom.xml
+++ b/client/transport/grpc/pom.xml
@@ -8,31 +8,31 @@
io.github.a2asdk
a2a-java-sdk-parent
0.3.0.Beta1-SNAPSHOT
+ ../../../pom.xml
- a2a-java-sdk-client
-
+ a2a-java-sdk-client-transport-grpc
jar
- Java SDK A2A Client
- Java SDK for the Agent2Agent Protocol (A2A) - Client
+ 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
+
org.junit.jupiter
junit-jupiter-api
diff --git a/client/src/main/java/io/a2a/client/sse/SSEStreamObserver.java b/client/transport/grpc/src/main/java/io/a2a/client/transport/grpc/EventStreamObserver.java
similarity index 69%
rename from client/src/main/java/io/a2a/client/sse/SSEStreamObserver.java
rename to client/transport/grpc/src/main/java/io/a2a/client/transport/grpc/EventStreamObserver.java
index adc721f42..627286607 100644
--- a/client/src/main/java/io/a2a/client/sse/SSEStreamObserver.java
+++ b/client/transport/grpc/src/main/java/io/a2a/client/transport/grpc/EventStreamObserver.java
@@ -1,22 +1,22 @@
-package io.a2a.client.sse;
+package io.a2a.client.transport.grpc;
-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;
}
@@ -47,7 +47,14 @@ public void onNext(StreamResponse response) {
@Override
public void onError(Throwable t) {
- errorHandler.accept(t);
+ if (errorHandler != null) {
+ // 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);
+ }
+ }
}
@Override
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
new file mode 100644
index 000000000..b313fb43a
--- /dev/null
+++ b/client/transport/grpc/src/main/java/io/a2a/client/transport/grpc/GrpcTransport.java
@@ -0,0 +1,255 @@
+package io.a2a.client.transport.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.transport.spi.interceptors.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.StreamResponse;
+import io.a2a.grpc.TaskSubscriptionRequest;
+
+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;
+import io.grpc.Channel;
+
+import io.grpc.StatusRuntimeException;
+import io.grpc.stub.StreamObserver;
+
+public class GrpcTransport implements ClientTransport {
+
+ private final A2AServiceBlockingV2Stub blockingStub;
+ private final A2AServiceStub asyncStub;
+ private AgentCard agentCard;
+
+ public GrpcTransport(Channel channel, AgentCard agentCard) {
+ checkNotNullParam("channel", channel);
+ this.asyncStub = A2AServiceGrpc.newStub(channel);
+ this.blockingStub = A2AServiceGrpc.newBlockingV2Stub(channel);
+ this.agentCard = agentCard;
+ }
+
+ @Override
+ public EventKind sendMessage(MessageSendParams request, ClientCallContext context) throws A2AClientException {
+ checkNotNullParam("request", request);
+
+ SendMessageRequest sendMessageRequest = createGrpcSendMessageRequest(request, context);
+
+ try {
+ SendMessageResponse response = blockingStub.sendMessage(sendMessageRequest);
+ if (response.hasMsg()) {
+ return FromProto.message(response.getMsg());
+ } else if (response.hasTask()) {
+ return FromProto.task(response.getTask());
+ } else {
+ throw new A2AClientException("Server response did not contain a message or task");
+ }
+ } catch (StatusRuntimeException e) {
+ throw GrpcErrorMapper.mapGrpcError(e, "Failed to send message: ");
+ }
+ }
+
+ @Override
+ 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 GrpcErrorMapper.mapGrpcError(e, "Failed to send streaming message request: ");
+ }
+ }
+
+ @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 FromProto.task(blockingStub.getTask(getTaskRequest));
+ } catch (StatusRuntimeException e) {
+ throw GrpcErrorMapper.mapGrpcError(e, "Failed to get task: ");
+ }
+ }
+
+ @Override
+ public Task cancelTask(TaskIdParams request, ClientCallContext context) throws A2AClientException {
+ checkNotNullParam("request", request);
+
+ CancelTaskRequest cancelTaskRequest = CancelTaskRequest.newBuilder()
+ .setName("tasks/" + request.id())
+ .build();
+
+ try {
+ return FromProto.task(blockingStub.cancelTask(cancelTaskRequest));
+ } catch (StatusRuntimeException e) {
+ throw GrpcErrorMapper.mapGrpcError(e, "Failed to cancel task: ");
+ }
+ }
+
+ @Override
+ 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 : request.taskId())
+ .build();
+
+ try {
+ return FromProto.taskPushNotificationConfig(blockingStub.createTaskPushNotificationConfig(grpcRequest));
+ } catch (StatusRuntimeException e) {
+ throw GrpcErrorMapper.mapGrpcError(e, "Failed to create task push notification config: ");
+ }
+ }
+
+ @Override
+ public TaskPushNotificationConfig getTaskPushNotificationConfiguration(
+ GetTaskPushNotificationConfigParams request,
+ ClientCallContext context) throws A2AClientException {
+ checkNotNullParam("request", request);
+
+ GetTaskPushNotificationConfigRequest grpcRequest = GetTaskPushNotificationConfigRequest.newBuilder()
+ .setName(getTaskPushNotificationConfigName(request))
+ .build();
+
+ try {
+ return FromProto.taskPushNotificationConfig(blockingStub.getTaskPushNotificationConfig(grpcRequest));
+ } catch (StatusRuntimeException e) {
+ throw GrpcErrorMapper.mapGrpcError(e, "Failed to get task push notification config: ");
+ }
+ }
+
+ @Override
+ 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 GrpcErrorMapper.mapGrpcError(e, "Failed to list task push notification config: ");
+ }
+ }
+
+ @Override
+ 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 GrpcErrorMapper.mapGrpcError(e, "Failed to delete task push notification config: ");
+ }
+ }
+
+ @Override
+ 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.taskSubscription(grpcRequest, streamObserver);
+ } catch (StatusRuntimeException e) {
+ throw GrpcErrorMapper.mapGrpcError(e, "Failed to resubscribe task push notification config: ");
+ }
+ }
+
+ @Override
+ 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 createGrpcSendMessageRequest(MessageSendParams messageSendParams, ClientCallContext context) {
+ 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 params) {
+ return getTaskPushNotificationConfigName(params.id(), params.pushNotificationConfigId());
+ }
+
+ private String getTaskPushNotificationConfigName(String taskId, String pushNotificationConfigId) {
+ StringBuilder name = new StringBuilder();
+ name.append("tasks/");
+ name.append(taskId);
+ if (pushNotificationConfigId != null) {
+ 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();
+ }
+
+}
\ 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..4a5ef21df
--- /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.transport.spi.ClientTransportConfig;
+import io.grpc.Channel;
+
+public class GrpcTransportConfig extends ClientTransportConfig {
+
+ private final Channel channel;
+
+ public GrpcTransportConfig(Channel channel) {
+ this.channel = channel;
+ }
+
+ 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
new file mode 100644
index 000000000..63ddcdb9a
--- /dev/null
+++ b/client/transport/grpc/src/main/java/io/a2a/client/transport/grpc/GrpcTransportProvider.java
@@ -0,0 +1,37 @@
+package io.a2a.client.transport.grpc;
+
+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.
+ */
+public class GrpcTransportProvider implements ClientTransportProvider {
+
+ @Override
+ public GrpcTransport create(GrpcTransportConfig grpcTransportConfig, AgentCard agentCard, String agentUrl) {
+ // not making use of the interceptors for gRPC for now
+
+ Channel channel = grpcTransportConfig.getChannel();
+
+ // no channel factory configured
+ if (channel == null) {
+ channel = ManagedChannelBuilder.forTarget(agentUrl).build();
+ }
+
+ return new GrpcTransport(channel, agentCard);
+ }
+
+ @Override
+ public String getTransportProtocol() {
+ return TransportProtocol.GRPC.asString();
+ }
+
+ @Override
+ public Class getTransportProtocolClass() {
+ return GrpcTransport.class;
+ }
+}
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
new file mode 100644
index 000000000..172c243a3
--- /dev/null
+++ b/client/transport/jsonrpc/pom.xml
@@ -0,0 +1,49 @@
+
+
+ 4.0.0
+
+
+ io.github.a2asdk
+ a2a-java-sdk-parent
+ 0.3.0.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-http-client
+
+
+ ${project.groupId}
+ a2a-java-sdk-client-transport-spi
+
+
+ ${project.groupId}
+ a2a-java-sdk-common
+
+
+ ${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/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
new file mode 100644
index 000000000..8464911f5
--- /dev/null
+++ b/client/transport/jsonrpc/src/main/java/io/a2a/client/transport/jsonrpc/JSONRPCTransport.java
@@ -0,0 +1,420 @@
+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.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;
+import io.a2a.client.transport.spi.ClientTransport;
+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.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;
+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.DeleteTaskPushNotificationConfigRequest;
+import io.a2a.spec.DeleteTaskPushNotificationConfigResponse;
+import io.a2a.spec.MessageSendParams;
+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.client.transport.jsonrpc.sse.SSEEventListener;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.atomic.AtomicReference;
+
+import io.a2a.util.Utils;
+
+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<>() {};
+ 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 final List interceptors;
+ private AgentCard agentCard;
+ private boolean needsExtendedCard = false;
+
+ public JSONRPCTransport(String agentUrl) {
+ this(null, null, agentUrl, null);
+ }
+
+ 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.interceptors = interceptors;
+ this.needsExtendedCard = agentCard == null || agentCard.supportsAuthenticatedExtendedCard();
+ }
+
+ @Override
+ 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(request)
+ .build(); // id will be randomly generated
+
+ PayloadAndHeaders payloadAndHeaders = applyInterceptors(SendMessageRequest.METHOD, sendMessageRequest,
+ agentCard, context);
+
+ try {
+ 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 A2AClientException("Failed to send message: " + e, e);
+ }
+ }
+
+ @Override
+ 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(SendStreamingMessageRequest.METHOD)
+ .params(request)
+ .build(); // id will be randomly generated
+
+ PayloadAndHeaders payloadAndHeaders = applyInterceptors(SendStreamingMessageRequest.METHOD,
+ sendStreamingMessageRequest, agentCard, context);
+
+ AtomicReference> ref = new AtomicReference<>();
+ SSEEventListener sseEventListener = new SSEEventListener(eventConsumer, errorConsumer);
+
+ try {
+ 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 getTask(TaskQueryParams request, ClientCallContext context) throws A2AClientException {
+ checkNotNullParam("request", request);
+ GetTaskRequest getTaskRequest = new GetTaskRequest.Builder()
+ .jsonrpc(JSONRPCMessage.JSONRPC_VERSION)
+ .method(GetTaskRequest.METHOD)
+ .params(request)
+ .build(); // id will be randomly generated
+
+ PayloadAndHeaders payloadAndHeaders = applyInterceptors(GetTaskRequest.METHOD, getTaskRequest,
+ agentCard, context);
+
+ try {
+ 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 A2AClientException("Failed to get task: " + e, e);
+ }
+ }
+
+ @Override
+ public Task cancelTask(TaskIdParams request, ClientCallContext context) throws A2AClientException {
+ checkNotNullParam("request", request);
+ CancelTaskRequest cancelTaskRequest = new CancelTaskRequest.Builder()
+ .jsonrpc(JSONRPCMessage.JSONRPC_VERSION)
+ .method(CancelTaskRequest.METHOD)
+ .params(request)
+ .build(); // id will be randomly generated
+
+ PayloadAndHeaders payloadAndHeaders = applyInterceptors(CancelTaskRequest.METHOD, cancelTaskRequest,
+ agentCard, context);
+
+ try {
+ 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 A2AClientException("Failed to cancel task: " + e, e);
+ }
+ }
+
+ @Override
+ 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(request)
+ .build(); // id will be randomly generated
+
+ PayloadAndHeaders payloadAndHeaders = applyInterceptors(SetTaskPushNotificationConfigRequest.METHOD,
+ setTaskPushNotificationRequest, agentCard, context);
+
+ 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);
+ }
+ }
+
+ @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(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 A2AClientException("Failed to get task push notification config: " + e, e);
+ }
+ }
+
+ @Override
+ 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(request)
+ .build(); // id will be randomly generated
+
+ PayloadAndHeaders payloadAndHeaders = applyInterceptors(ListTaskPushNotificationConfigRequest.METHOD,
+ listTaskPushNotificationRequest, agentCard, context);
+
+ try {
+ 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 A2AClientException("Failed to list task push notification configs: " + e, e);
+ }
+ }
+
+ @Override
+ 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(request)
+ .build(); // id will be randomly generated
+
+ PayloadAndHeaders payloadAndHeaders = applyInterceptors(DeleteTaskPushNotificationConfigRequest.METHOD,
+ deleteTaskPushNotificationRequest, agentCard, context);
+
+ try {
+ String httpResponseBody = sendPostRequest(payloadAndHeaders);
+ unmarshalResponse(httpResponseBody, DELETE_TASK_PUSH_NOTIFICATION_CONFIG_RESPONSE_REFERENCE);
+ } catch (A2AClientException e) {
+ throw e;
+ } catch (IOException | InterruptedException e) {
+ throw new A2AClientException("Failed to delete task push notification configs: " + e, e);
+ }
+ }
+
+ @Override
+ 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(TaskResubscriptionRequest.METHOD)
+ .params(request)
+ .build(); // id will be randomly generated
+
+ PayloadAndHeaders payloadAndHeaders = applyInterceptors(TaskResubscriptionRequest.METHOD,
+ taskResubscriptionRequest, agentCard, context);
+
+ AtomicReference> ref = new AtomicReference<>();
+ SSEEventListener sseEventListener = new SSEEventListener(eventConsumer, errorConsumer);
+
+ try {
+ 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 task resubscription request: " + e, e);
+ } catch (InterruptedException e) {
+ throw new A2AClientException("Task resubscription request timed out: " + e, e);
+ }
+ }
+
+ @Override
+ public AgentCard getAgentCard(ClientCallContext context) throws A2AClientException {
+ A2ACardResolver resolver;
+ try {
+ if (agentCard == null) {
+ resolver = new A2ACardResolver(httpClient, agentUrl, null, getHttpHeaders(context));
+ agentCard = resolver.getAgentCard();
+ needsExtendedCard = agentCard.supportsAuthenticatedExtendedCard();
+ }
+ if (!needsExtendedCard) {
+ return agentCard;
+ }
+
+ 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_AUTHENTICATED_EXTENDED_CARD_RESPONSE_REFERENCE);
+ agentCard = response.getResult();
+ needsExtendedCard = false;
+ return agentCard;
+ } catch (IOException | InterruptedException e) {
+ throw new A2AClientException("Failed to get authenticated extended agent card: " + e, e);
+ }
+ } catch(A2AClientError e){
+ throw new A2AClientException("Failed to get agent card: " + e, e);
+ }
+ }
+
+ @Override
+ public void close() {
+ // no-op
+ }
+
+ 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(PayloadAndHeaders payloadAndHeaders) throws IOException, InterruptedException {
+ A2AHttpClient.PostBuilder builder = createPostBuilder(payloadAndHeaders);
+ A2AHttpResponse response = builder.post();
+ if (!response.success()) {
+ throw new IOException("Request failed " + response.status());
+ }
+ return response.body();
+ }
+
+ 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(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 A2AClientException, JsonProcessingException {
+ T value = Utils.unmarshalFrom(response, typeReference);
+ JSONRPCError error = value.getError();
+ if (error != null) {
+ 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/JSONRPCTransportConfig.java b/client/transport/jsonrpc/src/main/java/io/a2a/client/transport/jsonrpc/JSONRPCTransportConfig.java
new file mode 100644
index 000000000..efd3bbdf9
--- /dev/null
+++ b/client/transport/jsonrpc/src/main/java/io/a2a/client/transport/jsonrpc/JSONRPCTransportConfig.java
@@ -0,0 +1,21 @@
+package io.a2a.client.transport.jsonrpc;
+
+import io.a2a.client.transport.spi.ClientTransportConfig;
+import io.a2a.client.http.A2AHttpClient;
+
+public class JSONRPCTransportConfig extends ClientTransportConfig {
+
+ private final A2AHttpClient httpClient;
+
+ public JSONRPCTransportConfig() {
+ this.httpClient = null;
+ }
+
+ 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/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
new file mode 100644
index 000000000..2ad1da5fe
--- /dev/null
+++ b/client/transport/jsonrpc/src/main/java/io/a2a/client/transport/jsonrpc/JSONRPCTransportProvider.java
@@ -0,0 +1,28 @@
+package io.a2a.client.transport.jsonrpc;
+
+import io.a2a.client.http.JdkA2AHttpClient;
+import io.a2a.client.transport.spi.ClientTransportProvider;
+import io.a2a.spec.AgentCard;
+import io.a2a.spec.TransportProtocol;
+
+public class JSONRPCTransportProvider implements ClientTransportProvider {
+
+ @Override
+ public JSONRPCTransport create(JSONRPCTransportConfig clientTransportConfig, AgentCard agentCard, String agentUrl) {
+ if (clientTransportConfig == null) {
+ clientTransportConfig = new JSONRPCTransportConfig(new JdkA2AHttpClient());
+ }
+
+ 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/src/main/java/io/a2a/client/sse/SSEEventListener.java b/client/transport/jsonrpc/src/main/java/io/a2a/client/transport/jsonrpc/sse/SSEEventListener.java
similarity index 85%
rename from client/src/main/java/io/a2a/client/sse/SSEEventListener.java
rename to client/transport/jsonrpc/src/main/java/io/a2a/client/transport/jsonrpc/sse/SSEEventListener.java
index 8ed0e9aa3..99ca546c4 100644
--- a/client/src/main/java/io/a2a/client/sse/SSEEventListener.java
+++ b/client/transport/jsonrpc/src/main/java/io/a2a/client/transport/jsonrpc/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.client.transport.jsonrpc.sse;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode;
@@ -12,16 +6,21 @@
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;
- 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..66091532e 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;
@@ -19,7 +19,6 @@
import java.util.function.Consumer;
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 +36,7 @@
import org.mockserver.matchers.MatchType;
import org.mockserver.model.JsonBody;
-public class A2AClientStreamingTest {
+public class JSONRPCTransportStreamingTest {
private ClientAndServer server;
@@ -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());
@@ -85,7 +84,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 +94,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 +116,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 +130,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 +140,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 +149,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 79%
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..afb6cc29b 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,30 @@
-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_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;
+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 +39,18 @@
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,8 +59,6 @@
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;
@@ -79,7 +74,7 @@
import org.mockserver.matchers.MatchType;
import org.mockserver.model.JsonBody;
-public class A2AClientTest {
+public class JSONRPCTransportTest {
private ClientAndServer server;
@@ -99,7 +94,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 +103,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 +119,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 +142,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 +151,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 +167,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 +184,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 +193,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 +210,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 +223,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 +232,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 +276,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 +285,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 +300,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 +309,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 +327,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 +336,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));
}
@@ -397,8 +366,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,7 +417,7 @@ 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());
@@ -464,12 +433,21 @@ public void testA2AClientGetAgentCard() throws Exception {
@Test
public void testA2AClientGetAuthenticatedExtendedAgentCard() throws Exception {
+ this.server.when(
+ request()
+ .withMethod("GET")
+ .withPath("/.well-known/agent-card.json")
+ )
+ .respond(
+ response()
+ .withStatusCode(200)
+ .withBody(AGENT_CARD_SUPPORTS_EXTENDED)
+ );
this.server.when(
request()
.withMethod("POST")
.withPath("/")
- .withBody(JsonBody.json(GET_AUTHENTICATED_EXTENDED_AGENT_CARD_REQUEST, MatchType.STRICT))
-
+ .withBody(JsonBody.json(GET_AUTHENTICATED_EXTENDED_AGENT_CARD_REQUEST, MatchType.ONLY_MATCHING_FIELDS))
)
.respond(
response()
@@ -477,9 +455,8 @@ public void testA2AClientGetAuthenticatedExtendedAgentCard() throws Exception {
.withBody(GET_AUTHENTICATED_EXTENDED_AGENT_CARD_RESPONSE)
);
- 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 +512,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 +521,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 +530,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 +549,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 +572,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 +581,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 +607,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 +630,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 +639,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 +665,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/src/test/java/io/a2a/client/JsonMessages.java b/client/transport/jsonrpc/src/test/java/io/a2a/client/transport/jsonrpc/JsonMessages.java
similarity index 73%
rename from client/src/test/java/io/a2a/client/JsonMessages.java
rename to client/transport/jsonrpc/src/test/java/io/a2a/client/transport/jsonrpc/JsonMessages.java
index d59ee0146..390aa1899 100644
--- a/client/src/test/java/io/a2a/client/JsonMessages.java
+++ b/client/transport/jsonrpc/src/test/java/io/a2a/client/transport/jsonrpc/JsonMessages.java
@@ -1,4 +1,4 @@
-package io.a2a.client;
+package io.a2a.client.transport.jsonrpc;
/**
* Request and response messages used by the tests. These have been created following examples from
@@ -74,7 +74,7 @@ public class JsonMessages {
]
}
],
- "supportsAuthenticatedExtendedCard": true,
+ "supportsAuthenticatedExtendedCard": false,
"signatures": [
{
"protected": "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpPU0UiLCJraWQiOiJrZXktMSIsImprdSI6Imh0dHBzOi8vZXhhbXBsZS5jb20vYWdlbnQvandrcy5qc29uIn0",
@@ -160,11 +160,9 @@ public class JsonMessages {
]
}""";
-
static final String SEND_MESSAGE_TEST_REQUEST = """
{
"jsonrpc": "2.0",
- "id": "request-1234",
"method": "message/send",
"params": {
"message": {
@@ -189,7 +187,6 @@ public class JsonMessages {
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",
@@ -216,7 +213,6 @@ public class JsonMessages {
static final String SEND_MESSAGE_TEST_REQUEST_WITH_MESSAGE_RESPONSE = """
{
"jsonrpc": "2.0",
- "id": "request-1234-with-message-response",
"method": "message/send",
"params": {
"message": {
@@ -259,7 +255,6 @@ public class JsonMessages {
static final String SEND_MESSAGE_WITH_ERROR_TEST_REQUEST = """
{
"jsonrpc": "2.0",
- "id": "request-1234-with-error",
"method": "message/send",
"params": {
"message": {
@@ -294,7 +289,6 @@ public class JsonMessages {
static final String GET_TASK_TEST_REQUEST = """
{
"jsonrpc": "2.0",
- "id": "request-1234",
"method": "tasks/get",
"params": {
"id": "de38c76d-d54c-436c-8b9f-4c2703648d64",
@@ -306,7 +300,6 @@ public class JsonMessages {
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",
@@ -360,7 +353,6 @@ public class JsonMessages {
static final String CANCEL_TASK_TEST_REQUEST = """
{
"jsonrpc": "2.0",
- "id": "request-1234",
"method": "tasks/cancel",
"params": {
"id": "de38c76d-d54c-436c-8b9f-4c2703648d64",
@@ -372,7 +364,6 @@ public class JsonMessages {
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",
@@ -388,7 +379,6 @@ public class JsonMessages {
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",
@@ -400,7 +390,6 @@ public class JsonMessages {
static final String GET_TASK_PUSH_NOTIFICATION_CONFIG_TEST_RESPONSE = """
{
"jsonrpc": "2.0",
- "id": 1,
"result": {
"taskId": "de38c76d-d54c-436c-8b9f-4c2703648d64",
"pushNotificationConfig": {
@@ -416,7 +405,6 @@ public class JsonMessages {
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",
@@ -432,7 +420,6 @@ public class JsonMessages {
static final String SET_TASK_PUSH_NOTIFICATION_CONFIG_TEST_RESPONSE = """
{
"jsonrpc": "2.0",
- "id": 1,
"result": {
"taskId": "de38c76d-d54c-436c-8b9f-4c2703648d64",
"pushNotificationConfig": {
@@ -448,7 +435,6 @@ public class JsonMessages {
static final String SEND_MESSAGE_WITH_FILE_PART_TEST_REQUEST = """
{
"jsonrpc": "2.0",
- "id": "request-1234-with-file",
"method": "message/send",
"params": {
"message": {
@@ -480,7 +466,6 @@ public class JsonMessages {
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",
@@ -507,7 +492,6 @@ public class JsonMessages {
static final String SEND_MESSAGE_WITH_DATA_PART_TEST_REQUEST = """
{
"jsonrpc": "2.0",
- "id": "request-1234-with-data",
"method": "message/send",
"params": {
"message": {
@@ -541,7 +525,6 @@ public class JsonMessages {
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",
@@ -568,7 +551,6 @@ public class JsonMessages {
static final String SEND_MESSAGE_WITH_MIXED_PARTS_TEST_REQUEST = """
{
"jsonrpc": "2.0",
- "id": "request-1234-with-mixed",
"method": "message/send",
"params": {
"message": {
@@ -609,7 +591,6 @@ public class JsonMessages {
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",
@@ -636,18 +617,153 @@ public class JsonMessages {
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 +
- """
- }
- """;
-}
+ "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"
+ }""";
+}
\ No newline at end of file
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/src/test/java/io/a2a/client/sse/SSEEventListenerTest.java b/client/transport/jsonrpc/src/test/java/io/a2a/client/transport/jsonrpc/sse/SSEEventListenerTest.java
similarity index 91%
rename from client/src/test/java/io/a2a/client/sse/SSEEventListenerTest.java
rename to client/transport/jsonrpc/src/test/java/io/a2a/client/transport/jsonrpc/sse/SSEEventListenerTest.java
index 1fca0ff9c..8c4c1495e 100644
--- a/client/src/test/java/io/a2a/client/sse/SSEEventListenerTest.java
+++ b/client/transport/jsonrpc/src/test/java/io/a2a/client/transport/jsonrpc/sse/SSEEventListenerTest.java
@@ -1,7 +1,8 @@
-package io.a2a.client.sse;
+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;
@@ -12,7 +13,7 @@
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicReference;
-import io.a2a.client.JsonStreamingMessages;
+import io.a2a.client.transport.jsonrpc.JsonStreamingMessages;
import io.a2a.spec.Artifact;
import io.a2a.spec.JSONRPCError;
import io.a2a.spec.Message;
@@ -34,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(
@@ -59,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(
@@ -87,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(
@@ -113,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(
@@ -142,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(
@@ -157,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
@@ -167,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();
@@ -193,8 +196,8 @@ public void testFinalTaskStatusUpdateEventCancels() {
AtomicReference receivedEvent = new AtomicReference<>();
SSEEventListener listener = new SSEEventListener(
event -> receivedEvent.set(event),
- error -> {},
- () -> {});
+ error -> {}
+ );
}
@@ -205,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
new file mode 100644
index 000000000..99b183289
--- /dev/null
+++ b/client/transport/spi/pom.xml
@@ -0,0 +1,26 @@
+
+
+ 4.0.0
+
+
+ io.github.a2asdk
+ a2a-java-sdk-parent
+ 0.3.0.Beta1-SNAPSHOT
+ ../../../pom.xml
+
+ a2a-java-sdk-client-transport-spi
+ jar
+
+ Java SDK A2A Client Transport: SPI
+ Java SDK for the Agent2Agent Protocol (A2A) - Client Transport SPI
+
+
+
+ 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
new file mode 100644
index 000000000..8084d8195
--- /dev/null
+++ b/client/transport/spi/src/main/java/io/a2a/client/transport/spi/ClientTransport.java
@@ -0,0 +1,140 @@
+package io.a2a.client.transport.spi;
+
+import java.util.List;
+import java.util.function.Consumer;
+
+import io.a2a.client.transport.spi.interceptors.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 non-streaming message request to the agent.
+ *
+ * @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(MessageSendParams request, ClientCallContext context)
+ throws A2AClientException;
+
+ /**
+ * Send a streaming message request to the agent and receive responses as they arrive.
+ *
+ * @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
+ */
+ void sendMessageStreaming(MessageSendParams request, Consumer eventConsumer,
+ Consumer errorConsumer, 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
+ */
+ 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
+ */
+ 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
+ */
+ 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
+ */
+ 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
+ */
+ 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
+ */
+ void deleteTaskPushNotificationConfigurations(
+ DeleteTaskPushNotificationConfigParams request,
+ ClientCallContext context) throws A2AClientException;
+
+ /**
+ * Reconnect to get task updates for an existing task.
+ *
+ * @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 close();
+
+}
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
new file mode 100644
index 000000000..b81c3b49f
--- /dev/null
+++ b/client/transport/spi/src/main/java/io/a2a/client/transport/spi/ClientTransportProvider.java
@@ -0,0 +1,31 @@
+package io.a2a.client.transport.spi;
+
+import java.util.List;
+
+import io.a2a.client.transport.spi.interceptors.ClientCallInterceptor;
+import io.a2a.spec.AgentCard;
+
+/**
+ * Client transport provider interface.
+ */
+public interface ClientTransportProvider> {
+
+ /**
+ * Create a client transport.
+ *
+ * @param clientTransportConfig the client transport config to use
+ * @param agentUrl the remote agent's URL
+ * @return the client transport
+ * @throws io.a2a.spec.A2AClientException if an error occurs trying to create the client
+ */
+ T create(C clientTransportConfig, AgentCard agentCard,
+ String agentUrl);
+
+ /**
+ * Get the name of the client transport.
+ */
+ String getTransportProtocol();
+
+ Class getTransportProtocolClass();
+}
+
diff --git a/client/transport/spi/src/main/java/io/a2a/client/transport/spi/interceptors/ClientCallContext.java b/client/transport/spi/src/main/java/io/a2a/client/transport/spi/interceptors/ClientCallContext.java
new file mode 100644
index 000000000..288d7b54a
--- /dev/null
+++ b/client/transport/spi/src/main/java/io/a2a/client/transport/spi/interceptors/ClientCallContext.java
@@ -0,0 +1,27 @@
+package io.a2a.client.transport.spi.interceptors;
+
+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/transport/spi/src/main/java/io/a2a/client/transport/spi/interceptors/ClientCallInterceptor.java b/client/transport/spi/src/main/java/io/a2a/client/transport/spi/interceptors/ClientCallInterceptor.java
new file mode 100644
index 000000000..3a44ff70b
--- /dev/null
+++ b/client/transport/spi/src/main/java/io/a2a/client/transport/spi/interceptors/ClientCallInterceptor.java
@@ -0,0 +1,26 @@
+package io.a2a.client.transport.spi.interceptors;
+
+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/transport/spi/src/main/java/io/a2a/client/transport/spi/interceptors/PayloadAndHeaders.java b/client/transport/spi/src/main/java/io/a2a/client/transport/spi/interceptors/PayloadAndHeaders.java
new file mode 100644
index 000000000..73983a096
--- /dev/null
+++ b/client/transport/spi/src/main/java/io/a2a/client/transport/spi/interceptors/PayloadAndHeaders.java
@@ -0,0 +1,22 @@
+package io.a2a.client.transport.spi.interceptors;
+
+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/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 94e595228..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
@@ -1,16 +1,22 @@
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.client.A2AClient;
import io.a2a.A2A;
+
+import io.a2a.client.Client;
+import io.a2a.client.ClientEvent;
+import io.a2a.client.MessageEvent;
+import io.a2a.client.http.A2ACardResolver;
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;
@@ -27,7 +33,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).");
@@ -46,25 +52,50 @@ 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);
- 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();
- 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);
+ };
+
+ 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);
+ 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 a60b123d9..4cb7c0c3e 100644
--- a/examples/helloworld/pom.xml
+++ b/examples/helloworld/pom.xml
@@ -31,11 +31,6 @@
a2a-java-sdk-client
${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 2b0b15652..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
diff --git a/http-client/pom.xml b/http-client/pom.xml
new file mode 100644
index 000000000..f14e7cb1e
--- /dev/null
+++ b/http-client/pom.xml
@@ -0,0 +1,39 @@
+
+
+ 4.0.0
+
+
+ io.github.a2asdk
+ a2a-java-sdk-parent
+ 0.3.0.Beta1-SNAPSHOT
+
+ a2a-java-sdk-http-client
+
+ jar
+
+ Java SDK A2A HTTP Client
+ Java SDK for the Agent2Agent Protocol (A2A) - HTTP Client
+
+
+
+ ${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/client/src/main/java/io/a2a/client/A2ACardResolver.java b/http-client/src/main/java/io/a2a/client/http/A2ACardResolver.java
similarity index 89%
rename from client/src/main/java/io/a2a/client/A2ACardResolver.java
rename to http-client/src/main/java/io/a2a/client/http/A2ACardResolver.java
index 8ec181647..f510cd2ac 100644
--- a/client/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,8 +9,6 @@
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.spec.A2AClientError;
import io.a2a.spec.A2AClientJSONError;
import io.a2a.spec.AgentCard;
@@ -25,6 +23,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/src/main/java/io/a2a/http/A2AHttpClient.java b/http-client/src/main/java/io/a2a/client/http/A2AHttpClient.java
similarity index 97%
rename from client/src/main/java/io/a2a/http/A2AHttpClient.java
rename to http-client/src/main/java/io/a2a/client/http/A2AHttpClient.java
index 7a246843a..f59e079f2 100644
--- a/client/src/main/java/io/a2a/http/A2AHttpClient.java
+++ b/http-client/src/main/java/io/a2a/client/http/A2AHttpClient.java
@@ -1,4 +1,4 @@
-package io.a2a.http;
+package io.a2a.client.http;
import java.io.IOException;
import java.util.concurrent.CompletableFuture;
diff --git a/client/src/main/java/io/a2a/http/A2AHttpResponse.java b/http-client/src/main/java/io/a2a/client/http/A2AHttpResponse.java
similarity index 78%
rename from client/src/main/java/io/a2a/http/A2AHttpResponse.java
rename to http-client/src/main/java/io/a2a/client/http/A2AHttpResponse.java
index d6973a5dc..171fceebd 100644
--- a/client/src/main/java/io/a2a/http/A2AHttpResponse.java
+++ b/http-client/src/main/java/io/a2a/client/http/A2AHttpResponse.java
@@ -1,4 +1,4 @@
-package io.a2a.http;
+package io.a2a.client.http;
public interface A2AHttpResponse {
int status();
diff --git a/client/src/main/java/io/a2a/http/JdkA2AHttpClient.java b/http-client/src/main/java/io/a2a/client/http/JdkA2AHttpClient.java
similarity index 99%
rename from client/src/main/java/io/a2a/http/JdkA2AHttpClient.java
rename to http-client/src/main/java/io/a2a/client/http/JdkA2AHttpClient.java
index c3d5907a2..2cdbb2d37 100644
--- a/client/src/main/java/io/a2a/http/JdkA2AHttpClient.java
+++ b/http-client/src/main/java/io/a2a/client/http/JdkA2AHttpClient.java
@@ -1,4 +1,4 @@
-package io.a2a.http;
+package io.a2a.client.http;
import java.io.IOException;
import java.net.URI;
diff --git a/client/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/src/test/java/io/a2a/client/A2ACardResolverTest.java
rename to http-client/src/test/java/io/a2a/client/http/A2ACardResolverTest.java
index c9ce509d3..0b855007b 100644
--- a/client/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.http.A2AHttpClient;
-import io.a2a.http.A2AHttpResponse;
import io.a2a.spec.A2AClientError;
import io.a2a.spec.A2AClientJSONError;
import io.a2a.spec.AgentCard;
diff --git a/http-client/src/test/java/io/a2a/client/http/JsonMessages.java b/http-client/src/test/java/io/a2a/client/http/JsonMessages.java
new file mode 100644
index 000000000..f5fd0426a
--- /dev/null
+++ b/http-client/src/test/java/io/a2a/client/http/JsonMessages.java
@@ -0,0 +1,164 @@
+package io.a2a.client.http;
+
+/**
+ * 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 = """
+ {
+ "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": true,
+ "signatures": [
+ {
+ "protected": "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpPU0UiLCJraWQiOiJrZXktMSIsImprdSI6Imh0dHBzOi8vZXhhbXBsZS5jb20vYWdlbnQvandrcy5qc29uIn0",
+ "signature": "QFdkNLNszlGj3z3u0YQGt_T9LixY3qtdQpZmsTdDHDe3fXV9y9-B3m2-XgCpzuhiLt8E0tV6HXoZKHv4GtHgKQ"
+ }
+ ]
+ }""";
+
+ 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.9",
+ "signatures": [
+ {
+ "protected": "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpPU0UiLCJraWQiOiJrZXktMSIsImprdSI6Imh0dHBzOi8vZXhhbXBsZS5jb20vYWdlbnQvandrcy5qc29uIn0",
+ "signature": "QFdkNLNszlGj3z3u0YQGt_T9LixY3qtdQpZmsTdDHDe3fXV9y9-B3m2-XgCpzuhiLt8E0tV6HXoZKHv4GtHgKQ"
+ }
+ ]
+ }""";
+
+
+}
\ No newline at end of file
diff --git a/pom.xml b/pom.xml
index 22e44da39..9a3e30c23 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
@@ -279,9 +314,13 @@
- client
+ client/base
+ client/transport/grpc
+ client/transport/jsonrpc
+ client/transport/spi
common
examples/helloworld
+ http-client
reference/common
reference/grpc
reference/jsonrpc
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/main/java/io/a2a/server/grpc/quarkus/QuarkusGrpcHandler.java b/reference/grpc/src/main/java/io/a2a/server/grpc/quarkus/QuarkusGrpcHandler.java
index 4a98ace48..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.grpc.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 25758da9c..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.grpc.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/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..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,1245 +1,50 @@
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.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;
+import io.grpc.ManagedChannelBuilder;
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());
- }
- }
-
- @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());
- }
- }
-
- @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());
- }
- }
-
- @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.
- }
-
- @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"));
- }
- }
+import org.junit.jupiter.api.AfterAll;
+@QuarkusTest
+public class QuarkusA2AGrpcTest extends AbstractA2AServerTest {
- 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();
+ private static ManagedChannel channel;
- 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()));
- }
+ public QuarkusA2AGrpcTest() {
+ super(8081); // HTTP server port for utility endpoints
}
- 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);
+ @Override
+ protected String getTransportProtocol() {
+ return TransportProtocol.GRPC.asString();
}
- 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());
- }
+ @Override
+ protected String getTransportUrl() {
+ return "localhost:9001"; // gRPC server runs on port 9001
}
- 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()));
- }
+ @Override
+ protected void configureTransport(ClientBuilder builder) {
+ channel = ManagedChannelBuilder.forTarget(getTransportUrl()).usePlaintext().build();
+ builder.withTransport(GrpcTransport.class, new GrpcTransportConfigBuilder().channel(channel));
}
- 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();
+ @AfterAll
+ public static void closeChannel() {
+ channel.shutdownNow();
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);
+ channel.awaitTermination(10, TimeUnit.SECONDS);
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
}
}
-
- 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/main/java/io/a2a/server/apps/quarkus/A2AServerRoutes.java b/reference/jsonrpc/src/main/java/io/a2a/server/apps/quarkus/A2AServerRoutes.java
index 2cd411409..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.jsonrpc.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/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..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,6 +1,8 @@
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;
@QuarkusTest
@@ -9,4 +11,14 @@ 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";
+ }
}
diff --git a/server-common/pom.xml b/server-common/pom.xml
index 5677d4d02..9364fa595 100644
--- a/server-common/pom.xml
+++ b/server-common/pom.xml
@@ -24,7 +24,17 @@
${project.groupId}
- a2a-java-sdk-client
+ a2a-java-sdk-http-client
+ ${project.version}
+
+
+ ${project.groupId}
+ a2a-java-sdk-client-transport-jsonrpc
+ ${project.version}
+
+
+ ${project.groupId}
+ a2a-java-sdk-client-transport-jsonrpc
${project.version}
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/BasePushNotificationSender.java b/server-common/src/main/java/io/a2a/server/tasks/BasePushNotificationSender.java
index 33ac4445c..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.http.A2AHttpClient;
-import io.a2a.http.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/main/java/io/a2a/server/tasks/InMemoryPushNotificationConfigStore.java b/server-common/src/main/java/io/a2a/server/tasks/InMemoryPushNotificationConfigStore.java
index e66fc1669..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
@@ -27,10 +27,10 @@ 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) {
+ if (notificationConfig.id() == null || notificationConfig.id().isEmpty()) {
builder.id(taskId);
}
notificationConfig = builder.build();
@@ -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/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/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..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.http.A2AHttpClient;
-import io.a2a.http.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/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..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
@@ -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();
}
@@ -686,18 +687,32 @@ 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);
}
@@ -705,11 +720,15 @@ 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[] 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);
}
@@ -761,10 +780,13 @@ 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(),
- authenticationInfo(pushNotification.getAuthentication()),
+ pushNotification.getToken().isEmpty() ? null : pushNotification.getToken(),
+ pushNotification.hasAuthentication() ? authenticationInfo(pushNotification.getAuthentication()) : null,
pushNotification.getId().isEmpty() ? configId : pushNotification.getId()
);
}
@@ -795,6 +817,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 +891,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/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..fb6cbc5db 100644
--- a/tests/server-common/pom.xml
+++ b/tests/server-common/pom.xml
@@ -53,6 +53,18 @@
quarkus-arc
test
+
+ io.github.a2asdk
+ a2a-java-sdk-client-transport-jsonrpc
+ ${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 481767a2e..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
@@ -1,14 +1,23 @@
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;
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 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;
import java.io.IOException;
import java.net.URI;
@@ -16,53 +25,40 @@
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.function.BiConsumer;
+import java.util.function.Consumer;
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.A2AClientException;
import io.a2a.spec.AgentCard;
+import io.a2a.spec.AgentCapabilities;
+import io.a2a.spec.AgentInterface;
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.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.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.ListTaskPushNotificationConfigResponse;
+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.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;
@@ -70,15 +66,13 @@
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.TransportProtocol;
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;
@@ -90,7 +84,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))
@@ -114,19 +108,37 @@ 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;
- private A2AClient client;
+ protected final int serverPort;
+ private Client client;
+ private Client nonStreamingClient;
protected AbstractA2AServerTest(int serverPort) {
this.serverPort = serverPort;
- this.client = new A2AClient("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();
+
+ /**
+ * Get the transport configs to use for this test.
+ */
+ protected void configureTransport(ClientBuilder builder) {
+ // Include jsonRPC transport by default
+ builder.withJsonRpcTransport();
}
@Test
@@ -155,25 +167,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()));
+ 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());
}
@@ -182,44 +181,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"));
+ 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()));
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());
}
@@ -229,22 +209,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()));
+ 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());
}
@@ -252,22 +221,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"));
+ 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
@@ -277,18 +237,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);
+
+ 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);
@@ -304,24 +277,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);
+ 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());
}
@@ -334,22 +319,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);
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());
@@ -364,35 +338,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);
+ assertNotNull(setResult);
+
+ TaskPushNotificationConfig config = getClient().getTaskPushNotificationConfiguration(
+ new GetTaskPushNotificationConfigParams(MINIMAL_TASK.getId()));
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());
@@ -400,195 +354,39 @@ 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);
+
+ // 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();
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());
assertTrue(agentCard.capabilities().streaming());
assertTrue(agentCard.capabilities().stateTransitionHistory());
assertTrue(agentCard.skills().isEmpty());
+ assertFalse(agentCard.supportsAuthenticatedExtendedCard());
}
- @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());
- }
-
- @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);
@@ -597,48 +395,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);
+
+ 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());
}
@@ -647,221 +443,147 @@ public void testSendMessageStreamExistingTaskSuccess() throws Exception {
@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<>();
-
- // 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);
+ // Create error handler
+ Consumer errorHandler = error -> {
+ if (!isStreamClosedError(error)) {
+ errorRef.set(error);
}
- } catch (InterruptedException e) {
- Thread.currentThread().interrupt();
- }
+ eventLatch.countDown();
+ };
- // wait for the client to receive the responses
- taskResubscriptionResponseReceived.await();
+ // 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
+ getClient().resubscribe(new TaskIdParams(MINIMAL_TASK.getId()), List.of(consumer), errorHandler);
+
+ // 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);
deleteTaskInTaskStore(MINIMAL_TASK.getId());
- executorService.shutdown();
- if (!executorService.awaitTermination(10, TimeUnit.SECONDS)) {
- executorService.shutdownNow();
- }
}
}
@Test
public void testResubscribeNoExistingTaskError() throws Exception {
- TaskResubscriptionRequest request = new TaskResubscriptionRequest("1", new TaskIdParams("non-existent-task"));
-
- CompletableFuture>> responseFuture = initialiseStreamingRequest(request, 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) {
- 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();
- }
- } 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 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;
- });
-
+ CountDownLatch errorLatch = new CountDownLatch(1);
+ AtomicReference errorRef = new AtomicReference<>();
- boolean dataRead = latch.await(20, TimeUnit.SECONDS);
- Assertions.assertTrue(dataRead);
- Assertions.assertNull(errorRef.get());
+ // Create error handler to capture the TaskNotFoundError
+ Consumer errorHandler = error -> {
+ if (!isStreamClosedError(error)) {
+ errorRef.set(error);
+ }
+ errorLatch.countDown();
+ };
+ try {
+ 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);
+
+ 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");
+ }
+ }
+ } else {
+ fail("Expected error for non-existent task resubscription");
+ }
+ } catch (A2AClientException e) {
+ fail("Expected error for non-existent task resubscription");
+ }
}
@Test
@@ -881,11 +603,11 @@ public void testListPushNotificationConfigWithConfigId() throws Exception {
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 = getClient().listTaskPushNotificationConfigurations(
+ 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));
} catch (Exception e) {
fail();
} finally {
@@ -911,16 +633,16 @@ public void testListPushNotificationConfigWithoutConfigId() throws Exception {
// 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 = getClient().listTaskPushNotificationConfigurations(
+ new ListTaskPushNotificationConfigParams(MINIMAL_TASK.getId()));
+ 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 {
@@ -932,9 +654,10 @@ public void testListPushNotificationConfigWithoutConfigId() throws Exception {
@Test
public void testListPushNotificationConfigTaskNotFound() {
try {
- client.listTaskPushNotificationConfig("111", "non-existent-task");
+ List result = getClient().listTaskPushNotificationConfigurations(
+ new ListTaskPushNotificationConfigParams("non-existent-task"));
fail();
- } catch (A2AServerException e) {
+ } catch (A2AClientException e) {
assertInstanceOf(TaskNotFoundError.class, e.getCause());
}
}
@@ -943,11 +666,11 @@ public void testListPushNotificationConfigTaskNotFound() {
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 = getClient().listTaskPushNotificationConfigurations(
+ new ListTaskPushNotificationConfigParams(MINIMAL_TASK.getId()));
+ assertEquals(0, result.size());
} catch (Exception e) {
- fail();
+ fail(e.getMessage());
} finally {
deleteTaskInTaskStore(MINIMAL_TASK.getId());
}
@@ -978,20 +701,20 @@ public void testDeletePushNotificationConfigWithValidConfigId() throws Exception
try {
// specify the config ID to delete
- DeleteTaskPushNotificationConfigResponse deleteResponse = client.deleteTaskPushNotificationConfig(MINIMAL_TASK.getId(),
- "config1");
- assertNull(deleteResponse.getError());
- assertNull(deleteResponse.getResult());
+ getClient().deleteTaskPushNotificationConfigurations(
+ new DeleteTaskPushNotificationConfigParams(MINIMAL_TASK.getId(), "config1"));
// should now be 1 left
- ListTaskPushNotificationConfigResponse listResponse = client.listTaskPushNotificationConfig(MINIMAL_TASK.getId());
- assertEquals(1, listResponse.getResult().size());
+ List result = getClient().listTaskPushNotificationConfigurations(
+ new ListTaskPushNotificationConfigParams(MINIMAL_TASK.getId()));
+ assertEquals(1, result.size());
// should remain unchanged, this is a different task
- listResponse = client.listTaskPushNotificationConfig("task-456");
- assertEquals(1, listResponse.getResult().size());
+ result = getClient().listTaskPushNotificationConfigurations(
+ new ListTaskPushNotificationConfigParams("task-456"));
+ assertEquals(1, result.size());
} catch (Exception e) {
- fail();
+ fail(e.getMessage());
} finally {
deletePushNotificationConfigInStore(MINIMAL_TASK.getId(), "config1");
deletePushNotificationConfigInStore(MINIMAL_TASK.getId(), "config2");
@@ -1018,14 +741,13 @@ public void testDeletePushNotificationConfigWithNonExistingConfigId() throws Exc
savePushNotificationConfigInStore(MINIMAL_TASK.getId(), notificationConfig2);
try {
- DeleteTaskPushNotificationConfigResponse deleteResponse = client.deleteTaskPushNotificationConfig(MINIMAL_TASK.getId(),
- "non-existent-config-id");
- assertNull(deleteResponse.getError());
- assertNull(deleteResponse.getResult());
+ getClient().deleteTaskPushNotificationConfigurations(
+ new DeleteTaskPushNotificationConfigParams(MINIMAL_TASK.getId(), "non-existent-config-id"));
// should remain unchanged
- ListTaskPushNotificationConfigResponse listResponse = client.listTaskPushNotificationConfig(MINIMAL_TASK.getId());
- assertEquals(2, listResponse.getResult().size());
+ List result = getClient().listTaskPushNotificationConfigurations(
+ new ListTaskPushNotificationConfigParams(MINIMAL_TASK.getId()));
+ assertEquals(2, result.size());
} catch (Exception e) {
fail();
} finally {
@@ -1038,9 +760,11 @@ public void testDeletePushNotificationConfigWithNonExistingConfigId() throws Exc
@Test
public void testDeletePushNotificationConfigTaskNotFound() {
try {
- client.deleteTaskPushNotificationConfig("non-existent-task", "non-existent-config-id");
+ getClient().deleteTaskPushNotificationConfigurations(
+ new DeleteTaskPushNotificationConfigParams("non-existent-task",
+ "non-existent-config-id"));
fail();
- } catch (A2AServerException e) {
+ } catch (A2AClientException e) {
assertInstanceOf(TaskNotFoundError.class, e.getCause());
}
}
@@ -1062,14 +786,13 @@ public void testDeletePushNotificationConfigSetWithoutConfigId() throws Exceptio
savePushNotificationConfigInStore(MINIMAL_TASK.getId(), notificationConfig2);
try {
- DeleteTaskPushNotificationConfigResponse deleteResponse = client.deleteTaskPushNotificationConfig(MINIMAL_TASK.getId(),
- MINIMAL_TASK.getId());
- assertNull(deleteResponse.getError());
- assertNull(deleteResponse.getResult());
+ getClient().deleteTaskPushNotificationConfigurations(
+ new DeleteTaskPushNotificationConfigParams(MINIMAL_TASK.getId(), MINIMAL_TASK.getId()));
// should now be 0
- ListTaskPushNotificationConfigResponse listResponse = client.listTaskPushNotificationConfig(MINIMAL_TASK.getId());
- assertEquals(0, listResponse.getResult().size());
+ List result = getClient().listTaskPushNotificationConfigurations(
+ new ListTaskPushNotificationConfigParams(MINIMAL_TASK.getId()), null);
+ assertEquals(0, result.size());
} catch (Exception e) {
fail();
} finally {
@@ -1078,33 +801,224 @@ 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;
+ @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());
}
- private static String extractSseData(String line) {
- if (line.startsWith("data:")) {
- line = line.substring(5).trim();
- return line;
- }
- return null;
+ @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 boolean isStreamClosedError(Throwable throwable) {
- // Unwrap the CompletionException
- Throwable cause = throwable;
+ 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());
+ }
- while (cause != null) {
- if (cause instanceof EOFException) {
- return true;
+ @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": {}
}
- cause = cause.getCause();
- }
- return false;
+ """;
+ 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(
@@ -1130,6 +1044,40 @@ private CompletableFuture>> initialiseStreamingReque
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;
+
+ while (cause != null) {
+ 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;
+ }
+
protected void saveTaskInTaskStore(Task task) throws Exception {
HttpClient client = HttpClient.newBuilder()
.version(HttpClient.Version.HTTP_2)
@@ -1142,7 +1090,7 @@ protected void saveTaskInTaskStore(Task task) throws Exception {
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()));
}
}
@@ -1160,7 +1108,7 @@ protected Task getTaskFromTaskStore(String taskId) throws Exception {
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);
}
@@ -1189,7 +1137,7 @@ 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()));
}
}
@@ -1291,7 +1239,75 @@ protected void savePushNotificationConfigInStore(String taskId, PushNotification
}
}
- private static class BreakException extends RuntimeException {
+ /**
+ * 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);
+
+ ClientBuilder clientBuilder = Client
+ .from(agentCard)
+ .clientConfig(clientConfig);
+
+ configureTransport(clientBuilder);
+
+ return clientBuilder.build();
+ }
+
+ /**
+ * 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) {
+ return new ClientConfig.Builder()
+ .setStreaming(streaming)
+ .setSupportedTransports(List.of(getTransportProtocol()))
+ .setAcceptedOutputModes(List.of("text"))
+ .build();
}
-}
+
+}
\ No newline at end of file
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..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.http.A2AHttpClient;
-import io.a2a.http.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/grpc/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/grpc/handler/GrpcHandler.java
rename to transport/grpc/src/main/java/io/a2a/transport/grpc/handler/GrpcHandler.java
index 69f9d2c1d..b32ccc23f 100644
--- a/transport/grpc/src/main/java/io/a2a/grpc/handler/GrpcHandler.java
+++ b/transport/grpc/src/main/java/io/a2a/transport/grpc/handler/GrpcHandler.java
@@ -1,4 +1,4 @@
-package io.a2a.grpc.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 1cfeb6626..e5aba9097 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;
@@ -51,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;
@@ -739,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)
diff --git a/transport/jsonrpc/src/main/java/io/a2a/jsonrpc/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/jsonrpc/handler/JSONRPCHandler.java
rename to transport/jsonrpc/src/main/java/io/a2a/transport/jsonrpc/handler/JSONRPCHandler.java
index e4e11f294..e75f6f2d3 100644
--- a/transport/jsonrpc/src/main/java/io/a2a/jsonrpc/handler/JSONRPCHandler.java
+++ b/transport/jsonrpc/src/main/java/io/a2a/transport/jsonrpc/handler/JSONRPCHandler.java
@@ -1,4 +1,4 @@
-package io.a2a.jsonrpc.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/jsonrpc/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/jsonrpc/handler/JSONRPCHandlerTest.java
rename to transport/jsonrpc/src/test/java/io/a2a/transport/jsonrpc/handler/JSONRPCHandlerTest.java
index a645c9b37..0754cd055 100644
--- a/transport/jsonrpc/src/test/java/io/a2a/jsonrpc/handler/JSONRPCHandlerTest.java
+++ b/transport/jsonrpc/src/test/java/io/a2a/transport/jsonrpc/handler/JSONRPCHandlerTest.java
@@ -1,4 +1,4 @@
-package io.a2a.jsonrpc.handler;
+package io.a2a.transport.jsonrpc.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.handler.JSONRPCHandler;
import mutiny.zero.ZeroPublisher;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Disabled;
@@ -556,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