diff --git a/.claude/architecture/compatibility_0.3.md b/.claude/architecture/compatibility_0.3.md index f9dfcccdf..aec1482c6 100644 --- a/.claude/architecture/compatibility_0.3.md +++ b/.claude/architecture/compatibility_0.3.md @@ -16,7 +16,8 @@ The A2A protocol evolved from v0.3 to v1.0 with significant breaking changes. Ex - Dedicated `compat-0.3` Maven module structure containing **only** 0.3-specific code - gRPC code generation from the v0.3 `a2a.proto` -- Dedicated v0.3 client (`Client_v0_3`) exposing only features available in v0.3 +- Unified v1.0 client compatibility adapters that allow the normal `Client` API to communicate with v0.3 agents +- Legacy v0.3 client (`Client_v0_3`) exposing only features available in v0.3 - Server-side conversion layer (`Convert_v0_3_To10RequestHandler`) that accepts v0.3 requests and delegates to v1.0 server-common - Server-side transport handlers for v0.3 (JSON-RPC, gRPC, REST) - Bidirectional mapping layer between v0.3 and v1.0 domain objects @@ -30,7 +31,7 @@ The A2A protocol evolved from v0.3 to v1.0 with significant breaking changes. Ex ### Out of Scope - Changes to existing v1.0 modules (no regressions, no API changes) -- Automatic protocol version detection (client must explicitly choose API version) +- Implicit protocol downgrade or automatic version selection (the unified client requires the caller to explicitly request supported protocol versions during agent-card discovery) - Extras modules (OpenTelemetry, JPA stores, etc.) for v0.3 - Serving a separate v0.3-format agent card (the v1.0 card is served, with optional v0.3-compatible fields added by the user) @@ -107,13 +108,21 @@ All compat-0.3 classes use a `_v0_3` suffix to avoid naming conflicts with v1.0 ### Dedicated v0.3 Client -The compat layer exposes a **dedicated `Client_v0_3`** that only provides features available in v0.3: +The compat layer retains a **dedicated `Client_v0_3`** for applications that already use the v0.3 API directly. It only provides features available in v0.3: - No `listTasks()` method (absent in v0.3) - Method names reflect v0.3 semantics where they differ - The client is a standalone API, not a wrapper around the v1.0 `Client` -Users must explicitly check the `protocolVersion` field from the agent card and instantiate the correct client accordingly. No automatic version detection. +This is the legacy path. New applications should use the unified v1.0 `Client` path below instead. + +### Unified v1.0 Client with v0.3 Compatibility + +The recommended path for new applications is the normal v1.0 `Client` API with optional v0.3 compatibility artifacts. Applications continue to use v1.0 `AgentCard`, request, response, event, configuration, context, and interceptor types; they do not need to import `Client_v0_3` or v0.3 domain types. + +The caller explicitly requests the protocol versions it is willing to use during agent-card discovery. If a v0.3 interface is selected, the compatibility parser projects its card into a v1.0 `AgentCard`, and a version-aware transport adapter converts calls between v1.0 and v0.3. The unified client does not silently downgrade when v0.3 was not requested. + +The compatibility parser and the adapter for the selected binding are optional dependencies. The adapter rejects v1.0 operations that have no v0.3 equivalent, such as `listTasks`, before sending a request. If the requested compatibility parser or binding adapter is absent, discovery or client construction reports the missing artifact. ### Server-Side Conversion Layer @@ -231,9 +240,12 @@ compat-0.3/ │ └── ListTaskPushNotificationConfigsResultMapper_v0_3.java ├── tests/ # Test infrastructure │ └── server-common/ # Shared test base classes (AgentExecutorProducer_v0_3) -├── client/ # v0.3-compatible client -│ ├── base/ # Client_v0_3 — dedicated 0.3 API -│ │ └── pom.xml +├── client/ # Client compatibility support +│ ├── base/ # Client_v0_3 — legacy dedicated 0.3 API +│ ├── adapter/ # Unified v1.0 Client compatibility adapter +│ ├── adapter-jsonrpc/ # Unified JSON-RPC client adapter +│ ├── adapter-rest/ # Unified REST client adapter +│ ├── adapter-grpc/ # Unified gRPC client adapter │ └── transport/ │ ├── spi/ # Transport SPI │ │ └── pom.xml @@ -427,7 +439,38 @@ The `server-conversion` module produces a test-jar containing shared test infras ### Client: Talking to a v0.3 Agent -**1. Add the compat client dependency:** +New applications should use the unified v1.0 `Client` API. Add the compatibility parser and the adapter for the desired binding: + +```xml + + org.a2aproject.sdk + a2a-java-sdk-compat-0.3-client-adapter + + + + org.a2aproject.sdk + a2a-java-sdk-compat-0.3-client-adapter-jsonrpc + +``` + +Request the versions explicitly and build the ordinary client: + +```java +AgentCard agentCard = A2A.getAgentCard( + "http://localhost:1234", Set.of("1.0", "0.3")); + +Client client = Client.builder(agentCard) + .withTransport(JSONRPCTransport.class, new JSONRPCTransportConfigBuilder() + .httpClient(A2AHttpClientFactory.create()) + .build()) + .build(); +``` + +The returned card contains a v1.0 `AgentInterface` marked with protocol version `"0.3"`. The ordinary builder selects the matching compatibility adapter, while a native v1.0 interface continues to use the normal transport. + +Existing applications that use v0.3 domain types directly can continue using the legacy API: + +**1. Add the legacy compat client dependency:** ```xml @@ -441,7 +484,7 @@ The `server-conversion` module produces a test-jar containing shared test infras ``` -**2. Find the v0.3 interface and create the client:** +**2. Find the v0.3 interface and create the legacy client:** ```java AgentCard card = // ... fetch agent card from /.well-known/agent-card.json @@ -452,13 +495,13 @@ AgentInterface v03Interface = card.supportedInterfaces().stream() .findFirst() .orElseThrow(); -// Create the v0.3 compatibility client +// Create the legacy v0.3 client Client_v0_3 client = ClientBuilder_v0_3.forUrl(v03Interface.url()) .withTransport(JSONRPCTransport_v0_3.class, new JSONRPCTransportConfigBuilder_v0_3()) .build(); ``` -`Client_v0_3` exposes only operations available in v0.3. Return types are v0.3 `org.a2aproject.sdk.compat03.spec` domain objects. +`Client_v0_3` exposes only operations available in v0.3, and its return types are v0.3 `org.a2aproject.sdk.compat03.spec` domain objects. This path is useful for existing integrations but requires applications to use the legacy API and types directly. ### Server: Serving v0.3 Clients @@ -540,7 +583,8 @@ For JSON-RPC and REST, multi-version convenience modules are also available that | `Convert_v0_3_To10RequestHandler` | Integration tests | Via transport handler tests using real v1.0 backend | | Transport handlers | Unit + Integration | Handler-level tests + end-to-end via reference servers | | Client transports | Unit tests | Mocked v0.3 endpoints | -| `Client_v0_3` | Unit tests | API coverage, absence of v1.0-only methods | +| Unified v1.0 `Client` compatibility adapters | Unit + integration tests | v1.0 client API against v0.3 endpoints, including synchronous, streaming, resubscription, push configuration, authentication, and error behavior | +| `Client_v0_3` | Unit + integration tests | Legacy API coverage, absence of v1.0-only methods, and direct v0.3 request/response behavior | | Reference servers | Integration tests | Full request/response cycle with v0.3 client | | TCK | Conformance tests | Protocol conformance against v0.3 spec | diff --git a/AGENTS.md b/AGENTS.md index aed63a2fd..743701445 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -122,7 +122,7 @@ For detailed architectural documentation: - **[Request Flows](.claude/architecture/eventqueue/FLOWS.md)**: Non-streaming vs streaming, cleanup patterns - **[Usage Scenarios](.claude/architecture/eventqueue/SCENARIOS.md)**: Real-world patterns and common pitfalls - **Compatibility with previous protocol versions**: - - 0.3 protocol compatibility layer: `.claude/architecture/compatibility_0.3.md` + - 0.3 protocol compatibility layer, including the unified client adapters and legacy `Client_v0_3` API: `.claude/architecture/compatibility_0.3.md` > 💡 Deep-dive docs are loaded on-demand when working in related areas. diff --git a/boms/sdk/pom.xml b/boms/sdk/pom.xml index 7fd26cac6..6d0ebf1e5 100644 --- a/boms/sdk/pom.xml +++ b/boms/sdk/pom.xml @@ -120,6 +120,33 @@ ${project.version} + + + ${project.groupId} + a2a-java-sdk-compat-0.3-conversion + ${project.version} + + + ${project.groupId} + a2a-java-sdk-compat-0.3-client-adapter + ${project.version} + + + ${project.groupId} + a2a-java-sdk-compat-0.3-client-adapter-jsonrpc + ${project.version} + + + ${project.groupId} + a2a-java-sdk-compat-0.3-client-adapter-rest + ${project.version} + + + ${project.groupId} + a2a-java-sdk-compat-0.3-client-adapter-grpc + ${project.version} + + ${project.groupId} diff --git a/boms/sdk/src/it/sdk-usage-test/pom.xml b/boms/sdk/src/it/sdk-usage-test/pom.xml index dc8a23965..bb11137a0 100644 --- a/boms/sdk/src/it/sdk-usage-test/pom.xml +++ b/boms/sdk/src/it/sdk-usage-test/pom.xml @@ -113,6 +113,28 @@ a2a-java-sdk-compat-0.3-spec-grpc + + + org.a2aproject.sdk + a2a-java-sdk-compat-0.3-conversion + + + org.a2aproject.sdk + a2a-java-sdk-compat-0.3-client-adapter + + + org.a2aproject.sdk + a2a-java-sdk-compat-0.3-client-adapter-jsonrpc + + + org.a2aproject.sdk + a2a-java-sdk-compat-0.3-client-adapter-rest + + + org.a2aproject.sdk + a2a-java-sdk-compat-0.3-client-adapter-grpc + + org.a2aproject.sdk diff --git a/client/base/src/main/java/org/a2aproject/sdk/A2A.java b/client/base/src/main/java/org/a2aproject/sdk/A2A.java index d5643d305..e0b9d08b3 100644 --- a/client/base/src/main/java/org/a2aproject/sdk/A2A.java +++ b/client/base/src/main/java/org/a2aproject/sdk/A2A.java @@ -3,6 +3,7 @@ import java.util.Collections; import java.util.List; import java.util.Map; +import java.util.Set; import org.a2aproject.sdk.client.http.A2ACardResolver; import org.a2aproject.sdk.client.http.A2AHttpClient; @@ -289,6 +290,16 @@ public static AgentCard getAgentCard(String agentUrl) throws A2AClientError, A2A return getAgentCard(A2AHttpClientFactory.create(), agentUrl); } + /** + * Retrieves an agent card while explicitly selecting the protocol versions to accept. + * The set must be non-empty; v0.3 discovery additionally requires the optional compatibility + * parser and a matching binding adapter. + */ + public static AgentCard getAgentCard(String agentUrl, Set supportedProtocolVersions) + throws A2AClientError, A2AClientJSONError { + return getAgentCard(A2AHttpClientFactory.create(), agentUrl, supportedProtocolVersions); + } + /** * Retrieve the agent card using a custom HTTP client. *

@@ -315,6 +326,14 @@ public static AgentCard getAgentCard(A2AHttpClient httpClient, String agentUrl) return getAgentCard(httpClient, agentUrl, null, null); } + /** + * Retrieves an agent card with a custom HTTP client and explicit protocol-version policy. + */ + public static AgentCard getAgentCard(A2AHttpClient httpClient, String agentUrl, + Set supportedProtocolVersions) throws A2AClientError, A2AClientJSONError { + return getAgentCard(httpClient, agentUrl, null, null, supportedProtocolVersions); + } + /** * Retrieve the agent card with custom path and authentication. *

@@ -360,6 +379,16 @@ public static AgentCard getAgentCard(String agentUrl, String relativeCardPath, M return getAgentCard(A2AHttpClientFactory.create(), agentUrl, relativeCardPath, authHeaders); } + /** + * Retrieves an agent card with custom endpoint/authentication settings and explicit protocol versions. + */ + public static AgentCard getAgentCard(String agentUrl, String relativeCardPath, + Map authHeaders, Set supportedProtocolVersions) + throws A2AClientError, A2AClientJSONError { + return getAgentCard(A2AHttpClientFactory.create(), agentUrl, relativeCardPath, authHeaders, + supportedProtocolVersions); + } + /** * Retrieve the agent card with full customization options. *

@@ -401,4 +430,20 @@ public static AgentCard getAgentCard(A2AHttpClient httpClient, String agentUrl, .build(); return resolver.getAgentCard(); } + + /** + * Retrieves an agent card with full HTTP, endpoint, authentication, and protocol-version settings. + */ + public static AgentCard getAgentCard(A2AHttpClient httpClient, String agentUrl, String relativeCardPath, + Map authHeaders, Set supportedProtocolVersions) + throws A2AClientError, A2AClientJSONError { + A2ACardResolver resolver = A2ACardResolver.builder() + .httpClient(httpClient) + .baseUrl(agentUrl) + .agentCardPath(relativeCardPath) + .authHeaders(authHeaders) + .supportedProtocolVersions(supportedProtocolVersions) + .build(); + return resolver.getAgentCard(); + } } diff --git a/client/base/src/main/java/org/a2aproject/sdk/client/ClientBuilder.java b/client/base/src/main/java/org/a2aproject/sdk/client/ClientBuilder.java index 42614c262..1e3d24d3b 100644 --- a/client/base/src/main/java/org/a2aproject/sdk/client/ClientBuilder.java +++ b/client/base/src/main/java/org/a2aproject/sdk/client/ClientBuilder.java @@ -12,6 +12,7 @@ import java.util.stream.Collectors; import org.a2aproject.sdk.client.config.ClientConfig; +import org.a2aproject.sdk.client.http.A2ACardResolver; import org.a2aproject.sdk.client.transport.spi.ClientTransport; import org.a2aproject.sdk.client.transport.spi.ClientTransportConfig; import org.a2aproject.sdk.client.transport.spi.ClientTransportConfigBuilder; @@ -102,6 +103,7 @@ public class ClientBuilder { private static final Map>> transportProviderRegistry = new HashMap<>(); private static final Map, String> transportProtocolMapping = new HashMap<>(); + private static final Map versionedTransportProviderRegistry = new HashMap<>(); private static final Logger LOGGER = LoggerFactory.getLogger(ClientBuilder.class); static { @@ -110,6 +112,14 @@ public class ClientBuilder { transportProviderRegistry.put(transport.getTransportProtocol(), transport); transportProtocolMapping.put(transport.getTransportProtocolClass(), transport.getTransportProtocol()); } + ServiceLoader versionedLoader = ServiceLoader.load(VersionedClientTransportProvider.class); + for (VersionedClientTransportProvider provider : versionedLoader) { + VersionKey key = new VersionKey(provider.protocolBinding(), + A2ACardResolver.normalizeSupportedProtocolVersion(provider.protocolVersion())); + if (versionedTransportProviderRegistry.putIfAbsent(key, provider) != null) { + throw new IllegalStateException("Duplicate versioned client transport provider for " + key); + } + } } private final AgentCard agentCard; @@ -170,6 +180,9 @@ public ClientBuilder withTransport(Class clazz, C * @return this builder for method chaining */ public ClientBuilder withTransport(Class clazz, ClientTransportConfig config) { + if (!transportProtocolMapping.containsKey(clazz)) { + throw new IllegalArgumentException("No ClientTransportProvider is registered for " + clazz.getName()); + } clientTransports.put(clazz, config); return this; @@ -286,8 +299,10 @@ public ClientBuilder clientConfig(@NonNull ClientConfig clientConfig) { *

    *
  1. If {@link ClientConfig#isUseClientPreference()} is {@code true}, iterate through * client transports in registration order and select the first one the server supports
  2. - *
  3. Otherwise, iterate through server interfaces in preference order (first entry - * in {@link AgentCard#supportedInterfaces()}) and select the first one the client supports
  4. + *
  5. Otherwise, iterate through server interfaces in their declared preference order (first entry + * in {@link AgentCard#supportedInterfaces()}) and select the first one the client supports. + * This order is preserved across protocol versions; a 1.0 interface does not supersede an + * earlier compatible legacy interface.
  6. *
*

* Important: At least one transport must be configured via {@link #withTransport}, @@ -311,34 +326,69 @@ private ClientTransport buildClientTransport() throws A2AClientException { // Get the preferred transport AgentInterface agentInterface = findBestClientTransport(); - // Get the transport provider associated with the protocol - ClientTransportProvider clientTransportProvider = transportProviderRegistry.get(agentInterface.protocolBinding()); - if (clientTransportProvider == null) { - throw new A2AClientException("No client available for " + agentInterface.protocolBinding()); - } - Class transportProtocolClass = clientTransportProvider.getTransportProtocolClass(); - - // Retrieve the configuration associated with the preferred transport - ClientTransportConfig clientTransportConfig = clientTransports.get(transportProtocolClass); - - if (clientTransportConfig == null) { - throw new A2AClientException("Missing required TransportConfig for " + agentInterface.protocolBinding()); + String protocolVersion = normalizeInterfaceVersionForClient(agentInterface); + Class transportProtocolClass; + ClientTransportConfig clientTransportConfig; + ClientTransport transport; + if ("1.0".equals(protocolVersion)) { + ClientTransportProvider clientTransportProvider = transportProviderRegistry.get(agentInterface.protocolBinding()); + if (clientTransportProvider == null) { + throw new A2AClientException("No client available for " + agentInterface.protocolBinding()); + } + transportProtocolClass = clientTransportProvider.getTransportProtocolClass(); + clientTransportConfig = clientTransports.get(transportProtocolClass); + if (clientTransportConfig == null) { + throw new A2AClientException("Missing required TransportConfig for " + agentInterface.protocolBinding()); + } + transport = clientTransportProvider.create(clientTransportConfig, agentCard, agentInterface); + } else { + VersionedClientTransportProvider provider = versionedTransportProviderRegistry.get( + new VersionKey(agentInterface.protocolBinding(), protocolVersion)); + if (provider == null) { + throw new A2AClientException("No client available for " + agentInterface.protocolBinding() + + " protocol version " + protocolVersion); + } + transportProtocolClass = provider.configuredTransportClass(); + clientTransportConfig = clientTransports.get(transportProtocolClass); + if (clientTransportConfig == null) { + throw new A2AClientException("Missing required TransportConfig for " + agentInterface.protocolBinding()); + } + transport = provider.create(clientTransportConfig, agentCard, agentInterface); } - return wrap(clientTransportProvider.create(clientTransportConfig, agentCard, agentInterface), clientTransportConfig); + return wrap(transport, clientTransportConfig); } - private Map getServerInterfacesMap() throws A2AClientException { + /** + * Returns supported interfaces in the AgentCard's declared order, omitting unsupported versions + * and duplicate binding/version pairs. Preserving this order is required for server-preference + * negotiation. + */ + private List getServerInterfaces() throws A2AClientException { List serverInterfaces = agentCard.supportedInterfaces(); if (serverInterfaces == null || serverInterfaces.isEmpty()) { throw new A2AClientException("No server interface available in the AgentCard"); } - // If there are multiple interfaces with the same protocol binding, only the first is considered - Map serverInterfacesMap = new LinkedHashMap<>(); + List ordered = new ArrayList<>(); for (AgentInterface iface : serverInterfaces) { - serverInterfacesMap.putIfAbsent(iface.protocolBinding(), iface); + final String version; + try { + version = normalizeInterfaceVersion(iface); + } catch (IllegalArgumentException e) { + LOGGER.debug("Ignoring unsupported protocol version '{}' for {}", iface.protocolVersion(), + iface.protocolBinding()); + continue; + } + if (ordered.stream().noneMatch(existing -> existing.protocolBinding().equals(iface.protocolBinding()) + && normalizeInterfaceVersion(existing).equals(version))) { + ordered.add(iface); + } + } + if (ordered.isEmpty()) { + throw new A2AClientException( + "AgentCard does not advertise a supported protocol version (supported: 1.0 and 0.3)"); } - return serverInterfacesMap; + return ordered; } private List getClientPreferredTransports() { @@ -355,22 +405,36 @@ private List getClientPreferredTransports() { // Package-private for testing AgentInterface findBestClientTransport() throws A2AClientException { - Map serverInterfacesMap = getServerInterfacesMap(); + final List serverInterfaces; + try { + serverInterfaces = getServerInterfaces(); + } catch (IllegalArgumentException e) { + throw new A2AClientException("Unsupported protocol version in AgentCard", e); + } List clientPreferredTransports = getClientPreferredTransports(); AgentInterface matchedInterface = null; if (clientConfig.isUseClientPreference()) { // Client preference: iterate client transports first, find first server match + List nativeInterfaces = serverInterfaces.stream() + .filter(iface -> "1.0".equals(normalizeInterfaceVersion(iface)) + && clientPreferredTransports.contains(iface.protocolBinding()) + && hasTransportProvider(iface)) + .toList(); + List preferredInterfaces = nativeInterfaces.isEmpty() ? serverInterfaces : nativeInterfaces; for (String clientPreferredTransport : clientPreferredTransports) { - if (serverInterfacesMap.containsKey(clientPreferredTransport)) { - matchedInterface = serverInterfacesMap.get(clientPreferredTransport); - break; + for (AgentInterface iface : preferredInterfaces) { + if (clientPreferredTransport.equals(iface.protocolBinding()) && hasTransportProvider(iface)) { + matchedInterface = iface; + break; + } } + if (matchedInterface != null) break; } } else { // Server preference: iterate server interfaces first, find first client match - for (AgentInterface iface : serverInterfacesMap.values()) { - if (clientPreferredTransports.contains(iface.protocolBinding())) { + for (AgentInterface iface : serverInterfaces) { + if (clientPreferredTransports.contains(iface.protocolBinding()) && hasTransportProvider(iface)) { matchedInterface = iface; break; } @@ -378,15 +442,76 @@ AgentInterface findBestClientTransport() throws A2AClientException { } if (matchedInterface == null) { + for (AgentInterface iface : serverInterfaces) { + String adapter = missingCompatibilityAdapter(iface); + if (adapter != null) { + throw new A2AClientException(iface.protocolBinding() + " " + + normalizeInterfaceVersion(iface) + " requires " + + adapter); + } + } throw new A2AClientException("No compatible transport found"); } - if (!transportProviderRegistry.containsKey(matchedInterface.protocolBinding())) { - throw new A2AClientException("No client available for " + matchedInterface.protocolBinding()); + String version = normalizeInterfaceVersionForClient(matchedInterface); + if ("1.0".equals(version)) { + if (!transportProviderRegistry.containsKey(matchedInterface.protocolBinding())) { + throw new A2AClientException("No client available for " + matchedInterface.protocolBinding()); + } + } else if (!versionedTransportProviderRegistry.containsKey( + new VersionKey(matchedInterface.protocolBinding(), version))) { + throw new A2AClientException("No client available for " + matchedInterface.protocolBinding() + + " protocol version " + version); } return matchedInterface; } + private static String normalizeInterfaceVersion(AgentInterface agentInterface) { + return A2ACardResolver.normalizeSupportedProtocolVersion(agentInterface.protocolVersion()); + } + + private static boolean hasTransportProvider(AgentInterface agentInterface) { + String version = normalizeInterfaceVersion(agentInterface); + return "1.0".equals(version) + ? transportProviderRegistry.containsKey(agentInterface.protocolBinding()) + : versionedTransportProviderRegistry.containsKey( + new VersionKey(agentInterface.protocolBinding(), version)); + } + + private @Nullable String missingCompatibilityAdapter(AgentInterface agentInterface) { + String version = normalizeInterfaceVersion(agentInterface); + if ("1.0".equals(version) + || hasTransportProvider(agentInterface) + || !isConfiguredBinding(agentInterface.protocolBinding())) { + return null; + } + return switch (agentInterface.protocolBinding()) { + case "JSONRPC" -> "a2a-java-sdk-compat-0.3-client-adapter-jsonrpc"; + case "HTTP+JSON" -> "a2a-java-sdk-compat-0.3-client-adapter-rest"; + case "GRPC" -> "a2a-java-sdk-compat-0.3-client-adapter-grpc"; + default -> "a2a-java-sdk-compat-0.3-client-adapter-" + agentInterface.protocolBinding().toLowerCase(); + }; + } + + private boolean isConfiguredBinding(String binding) { + if (clientTransports.isEmpty() && TransportProtocol.JSONRPC.asString().equals(binding)) { + return true; + } + return clientTransports.keySet().stream() + .anyMatch(clazz -> binding.equals(transportProtocolMapping.get(clazz))); + } + + private static String normalizeInterfaceVersionForClient(AgentInterface agentInterface) throws A2AClientException { + try { + return normalizeInterfaceVersion(agentInterface); + } catch (IllegalArgumentException e) { + throw new A2AClientException("Unsupported protocol version '" + agentInterface.protocolVersion() + "'", e); + } + } + + private record VersionKey(String binding, String version) { + } + /** * Wraps the transport with all available transport wrappers discovered via ServiceLoader. * Wrappers are applied in reverse priority order (lowest priority first) to build a stack diff --git a/client/base/src/main/java/org/a2aproject/sdk/client/VersionedClientTransportProvider.java b/client/base/src/main/java/org/a2aproject/sdk/client/VersionedClientTransportProvider.java new file mode 100644 index 000000000..9f3650829 --- /dev/null +++ b/client/base/src/main/java/org/a2aproject/sdk/client/VersionedClientTransportProvider.java @@ -0,0 +1,45 @@ +package org.a2aproject.sdk.client; + +import org.a2aproject.sdk.client.transport.spi.ClientTransport; +import org.a2aproject.sdk.client.transport.spi.ClientTransportConfig; +import org.a2aproject.sdk.spec.A2AClientException; +import org.a2aproject.sdk.spec.AgentCard; +import org.a2aproject.sdk.spec.AgentInterface; + +/** + * Provider SPI for client transports targeting a protocol version other than 1.0. + */ +public interface VersionedClientTransportProvider { + /** + * Returns the protocol binding supported by this provider. + * + * @return the protocol binding name + */ + String protocolBinding(); + + /** + * Returns the protocol version supported by this provider. + * + * @return the protocol version + */ + String protocolVersion(); + + /** + * Returns the native transport class whose configuration this provider consumes. + * + * @return the configured transport class + */ + Class configuredTransportClass(); + + /** + * Creates a transport for the supplied agent interface. + * + * @param config the native transport configuration + * @param card the agent card + * @param agentInterface the selected agent interface + * @return the configured transport + * @throws A2AClientException if the configuration or agent interface is invalid + */ + ClientTransport create(ClientTransportConfig config, AgentCard card, AgentInterface agentInterface) + throws A2AClientException; +} diff --git a/client/base/src/test/java/org/a2aproject/sdk/client/ClientBuilderTest.java b/client/base/src/test/java/org/a2aproject/sdk/client/ClientBuilderTest.java index 3f1c120cc..ed708b7a8 100644 --- a/client/base/src/test/java/org/a2aproject/sdk/client/ClientBuilderTest.java +++ b/client/base/src/test/java/org/a2aproject/sdk/client/ClientBuilderTest.java @@ -7,6 +7,8 @@ import org.a2aproject.sdk.client.http.A2AHttpClientFactory; import org.a2aproject.sdk.client.transport.grpc.GrpcTransport; import org.a2aproject.sdk.client.transport.grpc.GrpcTransportConfigBuilder; +import org.a2aproject.sdk.client.transport.spi.ClientTransport; +import org.a2aproject.sdk.client.transport.spi.ClientTransportConfig; import org.a2aproject.sdk.client.transport.jsonrpc.JSONRPCTransport; import org.a2aproject.sdk.client.transport.jsonrpc.JSONRPCTransportConfig; import org.a2aproject.sdk.client.transport.jsonrpc.JSONRPCTransportConfigBuilder; @@ -78,6 +80,16 @@ public void shouldNotFindConfigurationTransport() throws A2AClientException { Assertions.assertTrue(exception.getMessage() != null && exception.getMessage().startsWith("Missing required TransportConfig for")); } + @Test + public void shouldRejectUnregisteredTransportAtConfigurationTime() { + IllegalArgumentException exception = Assertions.assertThrows(IllegalArgumentException.class, + () -> Client.builder(card).withTransport(UnregisteredTransport.class, + new ClientTransportConfig() { })); + + Assertions.assertTrue(exception.getMessage().contains(UnregisteredTransport.class.getName())); + Assertions.assertTrue(exception.getMessage().contains("No ClientTransportProvider is registered")); + } + @Test public void shouldCreateJSONRPCClient() throws A2AClientException { Client client = Client @@ -172,4 +184,7 @@ public void shouldHaveNullTenantWhenNotSet() throws A2AClientException { Assertions.assertNull(selectedInterface.tenant()); } + + private abstract static class UnregisteredTransport implements ClientTransport { + } } diff --git a/client/base/src/test/java/org/a2aproject/sdk/client/VersionedClientTransportProviderTest.java b/client/base/src/test/java/org/a2aproject/sdk/client/VersionedClientTransportProviderTest.java new file mode 100644 index 000000000..34fc14ea3 --- /dev/null +++ b/client/base/src/test/java/org/a2aproject/sdk/client/VersionedClientTransportProviderTest.java @@ -0,0 +1,170 @@ +package org.a2aproject.sdk.client; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.lang.reflect.Proxy; +import java.util.List; + +import org.a2aproject.sdk.client.config.ClientConfig; +import org.a2aproject.sdk.client.transport.jsonrpc.JSONRPCTransport; +import org.a2aproject.sdk.client.transport.jsonrpc.JSONRPCTransportConfigBuilder; +import org.a2aproject.sdk.client.transport.grpc.GrpcTransport; +import org.a2aproject.sdk.client.transport.grpc.GrpcTransportConfigBuilder; +import org.a2aproject.sdk.client.transport.spi.ClientTransport; +import org.a2aproject.sdk.client.transport.spi.ClientTransportConfig; +import org.a2aproject.sdk.spec.A2AClientException; +import org.a2aproject.sdk.spec.AgentCapabilities; +import org.a2aproject.sdk.spec.AgentCard; +import org.a2aproject.sdk.spec.AgentInterface; +import org.a2aproject.sdk.spec.AgentSkill; +import org.junit.jupiter.api.Test; + +class VersionedClientTransportProviderTest { + @Test + void selectsVersionedProviderForPatchFormAndUsesOrdinaryConfig() throws Exception { + AgentCard card = AgentCard.builder() + .name("agent").description("agent").version("1") + .capabilities(AgentCapabilities.builder().build()) + .defaultInputModes(List.of("text")).defaultOutputModes(List.of("text")) + .skills(List.of(AgentSkill.builder().id("skill").name("skill").description("skill") + .tags(List.of("tag")).build())) + .supportedInterfaces(List.of(new AgentInterface("JSONRPC", "http://example.test", null, "0.3.0"))) + .build(); + + ClientBuilder builder = Client.builder(card) + .clientConfig(new ClientConfig.Builder().setUseClientPreference(true).build()) + .withTransport(JSONRPCTransport.class, new JSONRPCTransportConfigBuilder()); + + assertEquals("0.3.0", builder.findBestClientTransport().protocolVersion()); + assertNotNull(builder.build()); + } + + @Test + void rejectsUnknownProtocolVersionBeforeNativeFallback() { + AgentCard card = AgentCard.builder() + .name("agent").description("agent").version("1") + .capabilities(AgentCapabilities.builder().build()) + .defaultInputModes(List.of("text")).defaultOutputModes(List.of("text")) + .skills(List.of(AgentSkill.builder().id("skill").name("skill").description("skill") + .tags(List.of("tag")).build())) + .supportedInterfaces(List.of(new AgentInterface("JSONRPC", "http://example.test", null, "0.2.9"))) + .build(); + + assertThrows(A2AClientException.class, () -> Client.builder(card).findBestClientTransport()); + } + + @Test + void preservesAgentCardOrderAcrossProtocolVersionsWithServerPreference() throws Exception { + AgentCard card = AgentCard.builder() + .name("agent").description("agent").version("1") + .capabilities(AgentCapabilities.builder().build()) + .defaultInputModes(List.of("text")).defaultOutputModes(List.of("text")) + .skills(List.of(AgentSkill.builder().id("skill").name("skill").description("skill") + .tags(List.of("tag")).build())) + .supportedInterfaces(List.of( + new AgentInterface("JSONRPC", "http://legacy.example", null, "0.3"), + new AgentInterface("GRPC", "http://native.example", null, "1.0"))) + .build(); + + ClientBuilder builder = Client.builder(card) + .withTransport(JSONRPCTransport.class, new JSONRPCTransportConfigBuilder()) + .withTransport(GrpcTransport.class, new GrpcTransportConfigBuilder().channelFactory(target -> null)); + + assertEquals("JSONRPC", builder.findBestClientTransport().protocolBinding()); + } + + @Test + void selectsNativeInterfaceBeforeLegacyInterfaceWithClientPreference() throws Exception { + AgentCard card = AgentCard.builder() + .name("agent").description("agent").version("1") + .capabilities(AgentCapabilities.builder().build()) + .defaultInputModes(List.of("text")).defaultOutputModes(List.of("text")) + .skills(List.of(AgentSkill.builder().id("skill").name("skill").description("skill") + .tags(List.of("tag")).build())) + .supportedInterfaces(List.of( + new AgentInterface("JSONRPC", "http://legacy.example", null, "0.3"), + new AgentInterface("GRPC", "http://native.example", null, "1.0"))) + .build(); + + ClientBuilder builder = Client.builder(card) + .clientConfig(new ClientConfig.Builder().setUseClientPreference(true).build()) + .withTransport(JSONRPCTransport.class, new JSONRPCTransportConfigBuilder()) + .withTransport(GrpcTransport.class, new GrpcTransportConfigBuilder().channelFactory(target -> null)); + + assertEquals("GRPC", builder.findBestClientTransport().protocolBinding()); + } + + @Test + void fallsBackToLegacyInterfaceWhenNoNativeInterfaceUsesAConfiguredTransport() throws Exception { + AgentCard card = AgentCard.builder() + .name("agent").description("agent").version("1") + .capabilities(AgentCapabilities.builder().build()) + .defaultInputModes(List.of("text")).defaultOutputModes(List.of("text")) + .skills(List.of(AgentSkill.builder().id("skill").name("skill").description("skill") + .tags(List.of("tag")).build())) + .supportedInterfaces(List.of( + new AgentInterface("GRPC", "http://native.example", null, "1.0"), + new AgentInterface("JSONRPC", "http://legacy.example", null, "0.3"))) + .build(); + + ClientBuilder builder = Client.builder(card) + .clientConfig(new ClientConfig.Builder().setUseClientPreference(true).build()) + .withTransport(JSONRPCTransport.class, new JSONRPCTransportConfigBuilder()); + + assertEquals("JSONRPC", builder.findBestClientTransport().protocolBinding()); + assertEquals("0.3", builder.findBestClientTransport().protocolVersion()); + } + + @Test + void skipsLegacyInterfaceWhoseBindingAdapterIsNotInstalled() throws Exception { + AgentCard card = AgentCard.builder() + .name("agent").description("agent").version("1") + .capabilities(AgentCapabilities.builder().build()) + .defaultInputModes(List.of("text")).defaultOutputModes(List.of("text")) + .skills(List.of(AgentSkill.builder().id("skill").name("skill").description("skill") + .tags(List.of("tag")).build())) + .supportedInterfaces(List.of( + new AgentInterface("GRPC", "http://grpc.example", null, "0.3"), + new AgentInterface("JSONRPC", "http://jsonrpc.example", null, "0.3"))) + .build(); + + ClientBuilder builder = Client.builder(card) + .withTransport(GrpcTransport.class, new GrpcTransportConfigBuilder().channelFactory(target -> null)) + .withTransport(JSONRPCTransport.class, new JSONRPCTransportConfigBuilder()); + + assertEquals("JSONRPC", builder.findBestClientTransport().protocolBinding()); + } + + @Test + void ignoresUnknownInterfaceVersionWhenACompatibleInterfaceExists() throws Exception { + AgentCard card = AgentCard.builder() + .name("agent").description("agent").version("1") + .capabilities(AgentCapabilities.builder().build()) + .defaultInputModes(List.of("text")).defaultOutputModes(List.of("text")) + .skills(List.of(AgentSkill.builder().id("skill").name("skill").description("skill") + .tags(List.of("tag")).build())) + .supportedInterfaces(List.of( + new AgentInterface("JSONRPC", "http://future.example", null, "2.0"), + new AgentInterface("GRPC", "http://grpc.example", null, "1.0"))) + .build(); + + ClientBuilder builder = Client.builder(card) + .withTransport(JSONRPCTransport.class, new JSONRPCTransportConfigBuilder()) + .withTransport(GrpcTransport.class, new GrpcTransportConfigBuilder().channelFactory(target -> null)); + + assertEquals("GRPC", builder.findBestClientTransport().protocolBinding()); + } + + public static final class FakeVersionedProvider implements VersionedClientTransportProvider { + @Override public String protocolBinding() { return "JSONRPC"; } + @Override public String protocolVersion() { return "0.3"; } + @Override public Class configuredTransportClass() { return JSONRPCTransport.class; } + @Override public ClientTransport create(ClientTransportConfig config, AgentCard card, + AgentInterface agentInterface) throws A2AClientException { + return (ClientTransport) Proxy.newProxyInstance(ClientTransport.class.getClassLoader(), + new Class[] {ClientTransport.class}, (proxy, method, args) -> null); + } + } +} diff --git a/client/base/src/test/resources/META-INF/services/org.a2aproject.sdk.client.VersionedClientTransportProvider b/client/base/src/test/resources/META-INF/services/org.a2aproject.sdk.client.VersionedClientTransportProvider new file mode 100644 index 000000000..7d9ea7bb8 --- /dev/null +++ b/client/base/src/test/resources/META-INF/services/org.a2aproject.sdk.client.VersionedClientTransportProvider @@ -0,0 +1 @@ +org.a2aproject.sdk.client.VersionedClientTransportProviderTest$FakeVersionedProvider diff --git a/compat-0.3/client/adapter-grpc/pom.xml b/compat-0.3/client/adapter-grpc/pom.xml new file mode 100644 index 000000000..e822a08a9 --- /dev/null +++ b/compat-0.3/client/adapter-grpc/pom.xml @@ -0,0 +1,46 @@ + + + 4.0.0 + + org.a2aproject.sdk + a2a-java-sdk-compat-0.3-parent + 1.4.1.Final-SNAPSHOT + ../../pom.xml + + a2a-java-sdk-compat-0.3-client-adapter-grpc + Java SDK A2A Compat 0.3 Client Adapter: gRPC + + + ${project.groupId} + a2a-java-sdk-compat-0.3-client-adapter + + + ${project.groupId} + a2a-java-sdk-client + + + ${project.groupId} + a2a-java-sdk-client-transport-grpc + + + ${project.groupId} + a2a-java-sdk-compat-0.3-client-transport-grpc + + + ${project.groupId} + a2a-java-sdk-spec-grpc + + + org.junit.jupiter + junit-jupiter-api + test + + + io.grpc + grpc-inprocess + test + + + diff --git a/compat-0.3/client/adapter-grpc/src/main/java/org/a2aproject/sdk/compat03/client/adapter/grpc/GrpcCompat03ClientTransport.java b/compat-0.3/client/adapter-grpc/src/main/java/org/a2aproject/sdk/compat03/client/adapter/grpc/GrpcCompat03ClientTransport.java new file mode 100644 index 000000000..4b7e21127 --- /dev/null +++ b/compat-0.3/client/adapter-grpc/src/main/java/org/a2aproject/sdk/compat03/client/adapter/grpc/GrpcCompat03ClientTransport.java @@ -0,0 +1,186 @@ +package org.a2aproject.sdk.compat03.client.adapter.grpc; + +import java.util.List; +import java.util.Map; +import java.util.function.Consumer; + +import org.a2aproject.sdk.client.transport.spi.interceptors.ClientCallContext; +import org.a2aproject.sdk.client.transport.spi.interceptors.ClientCallInterceptor; +import org.a2aproject.sdk.client.transport.spi.interceptors.PayloadAndHeaders; +import org.a2aproject.sdk.compat03.client.adapter.Compat03ClientCallContextMapper; +import org.a2aproject.sdk.compat03.client.adapter.Compat03ClientTransportBase; +import org.a2aproject.sdk.compat03.client.adapter.Compat03ClientTransportSupport; +import org.a2aproject.sdk.compat03.client.adapter.Compat03InterceptorSupport; +import org.a2aproject.sdk.compat03.client.transport.grpc.GrpcTransport_v0_3; +import org.a2aproject.sdk.compat03.conversion.mappers.domain.TaskMapper_v0_3; +import org.a2aproject.sdk.grpc.utils.ProtoUtils; +import org.a2aproject.sdk.spec.A2AMethods; +import org.a2aproject.sdk.spec.AgentCard; +import org.a2aproject.sdk.spec.CancelTaskParams; +import org.a2aproject.sdk.spec.DeleteTaskPushNotificationConfigParams; +import org.a2aproject.sdk.spec.EventKind; +import org.a2aproject.sdk.spec.GetTaskPushNotificationConfigParams; +import org.a2aproject.sdk.spec.ListTaskPushNotificationConfigsParams; +import org.a2aproject.sdk.spec.ListTaskPushNotificationConfigsResult; +import org.a2aproject.sdk.spec.MessageSendParams; +import org.a2aproject.sdk.spec.StreamingEventKind; +import org.a2aproject.sdk.spec.Task; +import org.a2aproject.sdk.spec.TaskIdParams; +import org.a2aproject.sdk.spec.TaskPushNotificationConfig; +import org.a2aproject.sdk.spec.TaskQueryParams; +import org.jspecify.annotations.Nullable; + +/** 1.0 client facade backed by the legacy gRPC transport. */ +public final class GrpcCompat03ClientTransport extends Compat03ClientTransportBase { + public GrpcCompat03ClientTransport(GrpcTransport_v0_3 delegate, AgentCard card, + List interceptors) { + super(delegate, card, interceptors); + } + + @Override + public EventKind sendMessage(MessageSendParams request, @Nullable ClientCallContext context) { + Compat03ClientTransportSupport.validateProtobufMessageSend(request); + PayloadAndHeaders payload = apply(A2AMethods.SEND_MESSAGE_METHOD, + ProtoUtils.ToProto.sendMessageRequest(request), org.a2aproject.sdk.grpc.SendMessageRequest.class, context); + return Compat03ClientTransportSupport.call(() -> Compat03ClientTransportSupport.toV10(delegate.sendMessage( + Compat03ClientTransportSupport.toV03Protobuf(ProtoUtils.FromProto.messageSendParams(payloadPayload(payload))), + legacyContext(context, payload)))); + } + + @Override + public void sendMessageStreaming(MessageSendParams request, Consumer events, + Consumer errors, @Nullable ClientCallContext context) { + Compat03ClientTransportSupport.validateProtobufMessageSend(request); + PayloadAndHeaders payload = apply(A2AMethods.SEND_STREAMING_MESSAGE_METHOD, + ProtoUtils.ToProto.sendMessageRequest(request), org.a2aproject.sdk.grpc.SendMessageRequest.class, context); + Compat03ClientTransportSupport.run(() -> delegate.sendMessageStreaming( + Compat03ClientTransportSupport.toV03Protobuf(ProtoUtils.FromProto.messageSendParams(payloadPayload(payload))), + event -> events.accept(Compat03ClientTransportSupport.toV10(event)), + Compat03ClientTransportSupport.mapAsyncError(errors), legacyContext(context, payload))); + } + + @Override + public Task getTask(TaskQueryParams request, @Nullable ClientCallContext context) { + Compat03ClientTransportSupport.validateTaskQuery(request); + PayloadAndHeaders payload = apply(A2AMethods.GET_TASK_METHOD, ProtoUtils.ToProto.getTaskRequest(request), + org.a2aproject.sdk.grpc.GetTaskRequest.class, context); + return Compat03ClientTransportSupport.call(() -> TaskMapper_v0_3.INSTANCE.toV10(delegate.getTask( + Compat03ClientTransportSupport.toV03(ProtoUtils.FromProto.taskQueryParams( + taskQueryPayload(payload))), legacyContext(context, payload)))); + } + + @Override + public Task cancelTask(CancelTaskParams request, @Nullable ClientCallContext context) { + Compat03ClientTransportSupport.validateProtobufCancel(request); + PayloadAndHeaders payload = apply(A2AMethods.CANCEL_TASK_METHOD, ProtoUtils.ToProto.cancelTaskRequest(request), + org.a2aproject.sdk.grpc.CancelTaskRequest.class, context); + return Compat03ClientTransportSupport.call(() -> TaskMapper_v0_3.INSTANCE.toV10(delegate.cancelTask( + Compat03ClientTransportSupport.toV03Protobuf(ProtoUtils.FromProto.cancelTaskParams(cancelPayload(payload))), + legacyContext(context, payload)))); + } + + @Override + public TaskPushNotificationConfig createTaskPushNotificationConfiguration(TaskPushNotificationConfig request, + @Nullable ClientCallContext context) { + Compat03ClientTransportSupport.validatePushConfig(request); + PayloadAndHeaders payload = apply(A2AMethods.SET_TASK_PUSH_NOTIFICATION_CONFIG_METHOD, + ProtoUtils.ToProto.taskPushNotificationConfig(request), org.a2aproject.sdk.grpc.TaskPushNotificationConfig.class, context); + return Compat03ClientTransportSupport.call(() -> Compat03ClientTransportSupport.toV10( + delegate.setTaskPushNotificationConfiguration(Compat03ClientTransportSupport.toV03( + ProtoUtils.FromProto.taskPushNotificationConfig(pushConfigPayload(payload))), + legacyContext(context, payload)))); + } + + @Override + public TaskPushNotificationConfig getTaskPushNotificationConfiguration(GetTaskPushNotificationConfigParams request, + @Nullable ClientCallContext context) { + Compat03ClientTransportSupport.validateGetPush(request); + PayloadAndHeaders payload = apply(A2AMethods.GET_TASK_PUSH_NOTIFICATION_CONFIG_METHOD, + ProtoUtils.ToProto.getTaskPushNotificationConfigRequest(request), + org.a2aproject.sdk.grpc.GetTaskPushNotificationConfigRequest.class, context); + return Compat03ClientTransportSupport.call(() -> Compat03ClientTransportSupport.toV10( + delegate.getTaskPushNotificationConfiguration(Compat03ClientTransportSupport.toV03( + ProtoUtils.FromProto.getTaskPushNotificationConfigParams(getPushPayload(payload))), + legacyContext(context, payload)))); + } + + @Override + public ListTaskPushNotificationConfigsResult listTaskPushNotificationConfigurations( + ListTaskPushNotificationConfigsParams request, @Nullable ClientCallContext context) { + Compat03ClientTransportSupport.validatePushList(request); + PayloadAndHeaders payload = apply(A2AMethods.LIST_TASK_PUSH_NOTIFICATION_CONFIG_METHOD, + ProtoUtils.ToProto.listTaskPushNotificationConfigsRequest(request), + org.a2aproject.sdk.grpc.ListTaskPushNotificationConfigsRequest.class, context); + return Compat03ClientTransportSupport.call(() -> Compat03ClientTransportSupport.toV10PushList( + delegate.listTaskPushNotificationConfigurations(Compat03ClientTransportSupport.toV03( + ProtoUtils.FromProto.listTaskPushNotificationConfigsParams(listPayload(payload))), + legacyContext(context, payload)))); + } + + @Override + public void deleteTaskPushNotificationConfigurations(DeleteTaskPushNotificationConfigParams request, + @Nullable ClientCallContext context) { + Compat03ClientTransportSupport.validateDeletePush(request); + PayloadAndHeaders payload = apply(A2AMethods.DELETE_TASK_PUSH_NOTIFICATION_CONFIG_METHOD, + ProtoUtils.ToProto.deleteTaskPushNotificationConfigRequest(request), + org.a2aproject.sdk.grpc.DeleteTaskPushNotificationConfigRequest.class, context); + Compat03ClientTransportSupport.run(() -> delegate.deleteTaskPushNotificationConfigurations( + Compat03ClientTransportSupport.toV03(ProtoUtils.FromProto.deleteTaskPushNotificationConfigParams( + deletePayload(payload))), legacyContext(context, payload))); + } + + @Override + public void subscribeToTask(TaskIdParams request, Consumer events, + Consumer errors, @Nullable ClientCallContext context) { + Compat03ClientTransportSupport.validateTenant("subscribeToTask", request.tenant()); + PayloadAndHeaders payload = apply(A2AMethods.SUBSCRIBE_TO_TASK_METHOD, + ProtoUtils.ToProto.subscribeToTaskRequest(request), org.a2aproject.sdk.grpc.SubscribeToTaskRequest.class, context); + Compat03ClientTransportSupport.run(() -> delegate.resubscribe( + Compat03ClientTransportSupport.toV03(ProtoUtils.FromProto.taskIdParams(subscribePayload(payload))), + event -> events.accept(Compat03ClientTransportSupport.toV10(event)), + Compat03ClientTransportSupport.mapAsyncError(errors), legacyContext(context, payload))); + } + + private PayloadAndHeaders apply(String method, Object payload, Class expected, + @Nullable ClientCallContext context) { + return Compat03InterceptorSupport.apply(interceptors, method, payload, agentCard, context, expected); + } + + private static org.a2aproject.sdk.compat03.client.transport.spi.interceptors.ClientCallContext_v0_3 legacyContext( + @Nullable ClientCallContext original, PayloadAndHeaders payload) { + return Compat03ClientCallContextMapper.toV03( + new ClientCallContext(original == null ? Map.of() : original.getState(), payload.getHeaders())); + } + + private static org.a2aproject.sdk.grpc.SendMessageRequest payloadPayload(PayloadAndHeaders payload) { + return (org.a2aproject.sdk.grpc.SendMessageRequest) payload.getPayload(); + } + + private static org.a2aproject.sdk.grpc.GetTaskRequest taskQueryPayload(PayloadAndHeaders payload) { + return (org.a2aproject.sdk.grpc.GetTaskRequest) payload.getPayload(); + } + + private static org.a2aproject.sdk.grpc.CancelTaskRequest cancelPayload(PayloadAndHeaders payload) { + return (org.a2aproject.sdk.grpc.CancelTaskRequest) payload.getPayload(); + } + + private static org.a2aproject.sdk.grpc.TaskPushNotificationConfig pushConfigPayload(PayloadAndHeaders payload) { + return (org.a2aproject.sdk.grpc.TaskPushNotificationConfig) payload.getPayload(); + } + + private static org.a2aproject.sdk.grpc.GetTaskPushNotificationConfigRequest getPushPayload(PayloadAndHeaders payload) { + return (org.a2aproject.sdk.grpc.GetTaskPushNotificationConfigRequest) payload.getPayload(); + } + + private static org.a2aproject.sdk.grpc.ListTaskPushNotificationConfigsRequest listPayload(PayloadAndHeaders payload) { + return (org.a2aproject.sdk.grpc.ListTaskPushNotificationConfigsRequest) payload.getPayload(); + } + + private static org.a2aproject.sdk.grpc.DeleteTaskPushNotificationConfigRequest deletePayload(PayloadAndHeaders payload) { + return (org.a2aproject.sdk.grpc.DeleteTaskPushNotificationConfigRequest) payload.getPayload(); + } + + private static org.a2aproject.sdk.grpc.SubscribeToTaskRequest subscribePayload(PayloadAndHeaders payload) { + return (org.a2aproject.sdk.grpc.SubscribeToTaskRequest) payload.getPayload(); + } +} diff --git a/compat-0.3/client/adapter-grpc/src/main/java/org/a2aproject/sdk/compat03/client/adapter/grpc/GrpcCompat03ClientTransportProvider.java b/compat-0.3/client/adapter-grpc/src/main/java/org/a2aproject/sdk/compat03/client/adapter/grpc/GrpcCompat03ClientTransportProvider.java new file mode 100644 index 000000000..17e4b6ed0 --- /dev/null +++ b/compat-0.3/client/adapter-grpc/src/main/java/org/a2aproject/sdk/compat03/client/adapter/grpc/GrpcCompat03ClientTransportProvider.java @@ -0,0 +1,48 @@ +package org.a2aproject.sdk.compat03.client.adapter.grpc; + +import java.util.Objects; + +import org.a2aproject.sdk.client.VersionedClientTransportProvider; +import org.a2aproject.sdk.client.transport.grpc.GrpcTransport; +import org.a2aproject.sdk.client.transport.grpc.GrpcTransportConfig; +import org.a2aproject.sdk.client.transport.spi.ClientTransport; +import org.a2aproject.sdk.client.transport.spi.ClientTransportConfig; +import org.a2aproject.sdk.compat03.client.adapter.Compat03ClientTransportBase; +import org.a2aproject.sdk.compat03.client.adapter.Compat03ClientTransportSupport; +import org.a2aproject.sdk.compat03.client.transport.grpc.GrpcTransport_v0_3; +import org.a2aproject.sdk.spec.A2AClientException; +import org.a2aproject.sdk.spec.AgentCard; +import org.a2aproject.sdk.spec.AgentInterface; + +/** ServiceLoader provider for the optional gRPC 0.3 adapter. */ +public final class GrpcCompat03ClientTransportProvider implements VersionedClientTransportProvider { + @Override + public String protocolBinding() { + return "GRPC"; + } + + @Override + public String protocolVersion() { + return "0.3"; + } + + @Override + public Class configuredTransportClass() { + return GrpcTransport.class; + } + + @Override + public ClientTransport create(ClientTransportConfig config, AgentCard card, AgentInterface agentInterface) + throws A2AClientException { + Compat03ClientTransportSupport.validateAgentInterfaceTenant(agentInterface.tenant()); + if (!(config instanceof GrpcTransportConfig grpcConfig)) { + throw new A2AClientException("Expected GrpcTransportConfig for the gRPC 0.3 adapter"); + } + Compat03ClientTransportSupport.validateConfig(grpcConfig); + GrpcTransport_v0_3 legacy = new GrpcTransport_v0_3( + Objects.requireNonNull(grpcConfig.getChannelFactory().apply(agentInterface.url()), + "channelFactory returned null"), + Compat03ClientTransportBase.legacyCard(card)); + return new GrpcCompat03ClientTransport(legacy, card, grpcConfig.getInterceptors()); + } +} diff --git a/compat-0.3/client/adapter-grpc/src/main/resources/META-INF/services/org.a2aproject.sdk.client.VersionedClientTransportProvider b/compat-0.3/client/adapter-grpc/src/main/resources/META-INF/services/org.a2aproject.sdk.client.VersionedClientTransportProvider new file mode 100644 index 000000000..78db0eb42 --- /dev/null +++ b/compat-0.3/client/adapter-grpc/src/main/resources/META-INF/services/org.a2aproject.sdk.client.VersionedClientTransportProvider @@ -0,0 +1 @@ +org.a2aproject.sdk.compat03.client.adapter.grpc.GrpcCompat03ClientTransportProvider diff --git a/compat-0.3/client/adapter-grpc/src/test/java/org/a2aproject/sdk/compat03/client/adapter/grpc/GrpcCompat03ClientTransportProviderTest.java b/compat-0.3/client/adapter-grpc/src/test/java/org/a2aproject/sdk/compat03/client/adapter/grpc/GrpcCompat03ClientTransportProviderTest.java new file mode 100644 index 000000000..134d0a6e8 --- /dev/null +++ b/compat-0.3/client/adapter-grpc/src/test/java/org/a2aproject/sdk/compat03/client/adapter/grpc/GrpcCompat03ClientTransportProviderTest.java @@ -0,0 +1,196 @@ +package org.a2aproject.sdk.compat03.client.adapter.grpc; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import java.util.Map; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; + +import io.grpc.ManagedChannel; +import io.grpc.Server; +import io.grpc.Status; +import io.grpc.inprocess.InProcessChannelBuilder; +import io.grpc.inprocess.InProcessServerBuilder; +import io.grpc.stub.StreamObserver; +import org.a2aproject.sdk.client.transport.grpc.GrpcTransport; +import org.a2aproject.sdk.client.transport.grpc.GrpcTransportConfigBuilder; +import org.a2aproject.sdk.client.transport.spi.ClientTransport; +import org.a2aproject.sdk.client.transport.spi.interceptors.ClientCallContext; +import org.a2aproject.sdk.client.transport.spi.interceptors.ClientCallInterceptor; +import org.a2aproject.sdk.client.transport.spi.interceptors.PayloadAndHeaders; +import org.a2aproject.sdk.compat03.grpc.A2AServiceGrpc; +import org.a2aproject.sdk.compat03.grpc.Message; +import org.a2aproject.sdk.compat03.grpc.Part; +import org.a2aproject.sdk.compat03.grpc.Role; +import org.a2aproject.sdk.compat03.grpc.SendMessageResponse; +import org.a2aproject.sdk.spec.AgentCapabilities; +import org.a2aproject.sdk.spec.AgentCard; +import org.a2aproject.sdk.spec.AgentInterface; +import org.a2aproject.sdk.spec.AgentSkill; +import org.a2aproject.sdk.spec.GetTaskPushNotificationConfigParams; +import org.a2aproject.sdk.spec.MessageSendParams; +import org.a2aproject.sdk.spec.TextPart; +import org.a2aproject.sdk.spec.TransportProtocol; +import org.junit.jupiter.api.Test; + +class GrpcCompat03ClientTransportProviderTest { + @Test + void sendsCompletePushConfigurationResourceNamesIncludingDefaultAndClearedIds() throws Exception { + String name = InProcessServerBuilder.generateName(); + AtomicReference receivedName = new AtomicReference<>(); + Server server = InProcessServerBuilder.forName(name).directExecutor() + .addService(new A2AServiceGrpc.A2AServiceImplBase() { + @Override + public void getTaskPushNotificationConfig( + org.a2aproject.sdk.compat03.grpc.GetTaskPushNotificationConfigRequest request, + StreamObserver observer) { + receivedName.set(request.getName()); + // Validate the contract directly rather than using the permissive reference parser. + if (!request.getName().matches("tasks/task-123/pushNotificationConfigs/(task-123|specific)")) { + observer.onError(Status.INVALID_ARGUMENT.withDescription("Invalid config resource name") + .asRuntimeException()); + return; + } + String configId = request.getName().substring(request.getName().lastIndexOf('/') + 1); + observer.onNext(org.a2aproject.sdk.compat03.grpc.TaskPushNotificationConfig.newBuilder() + .setName(request.getName()) + .setPushNotificationConfig(org.a2aproject.sdk.compat03.grpc.PushNotificationConfig.newBuilder() + .setId(configId).setUrl("https://example.test/callback")) + .build()); + observer.onCompleted(); + } + }).build().start(); + ManagedChannel channel = InProcessChannelBuilder.forName(name).directExecutor().build(); + try { + AgentCard card = card(name); + var provider = new GrpcCompat03ClientTransportProvider(); + ClientTransport transport = provider.create( + new GrpcTransportConfigBuilder().channelFactory(ignored -> channel).build(), card, + card.supportedInterfaces().get(0)); + try { + assertEquals("task-123", transport.getTaskPushNotificationConfiguration( + new GetTaskPushNotificationConfigParams("task-123"), null).id()); + assertEquals("tasks/task-123/pushNotificationConfigs/task-123", receivedName.get()); + assertEquals("specific", transport.getTaskPushNotificationConfiguration( + new GetTaskPushNotificationConfigParams("task-123", "specific"), null).id()); + assertEquals("tasks/task-123/pushNotificationConfigs/specific", receivedName.get()); + } finally { + transport.close(); + } + + ClientCallInterceptor clearingInterceptor = new ClientCallInterceptor() { + @Override + public PayloadAndHeaders intercept(String method, Object payload, Map headers, + AgentCard agentCard, ClientCallContext context) { + var request = (org.a2aproject.sdk.grpc.GetTaskPushNotificationConfigRequest) payload; + return new PayloadAndHeaders(request.toBuilder().clearId().build(), headers); + } + }; + ClientTransport intercepted = provider.create(new GrpcTransportConfigBuilder() + .channelFactory(ignored -> channel).addInterceptor(clearingInterceptor).build(), card, + card.supportedInterfaces().get(0)); + try { + assertEquals("task-123", intercepted.getTaskPushNotificationConfiguration( + new GetTaskPushNotificationConfigParams("task-123", "specific"), null).id()); + assertEquals("tasks/task-123/pushNotificationConfigs/task-123", receivedName.get()); + } finally { + intercepted.close(); + } + } finally { + channel.shutdownNow(); + server.shutdownNow(); + assertTrue(channel.awaitTermination(5, TimeUnit.SECONDS)); + assertTrue(server.awaitTermination(5, TimeUnit.SECONDS)); + } + } + + @Test + void providerTargetsLegacyGrpcBindingAndVersion() { + GrpcCompat03ClientTransportProvider provider = new GrpcCompat03ClientTransportProvider(); + + assertEquals(TransportProtocol.GRPC.asString(), provider.protocolBinding()); + assertEquals("0.3", provider.protocolVersion()); + assertEquals(GrpcTransport.class, provider.configuredTransportClass()); + } + + @Test + void sendsThroughLegacyServiceAndDoesNotOwnCallerChannel() throws Exception { + String name = InProcessServerBuilder.generateName(); + AtomicBoolean called = new AtomicBoolean(); + Server server = InProcessServerBuilder.forName(name).directExecutor() + .addService(new A2AServiceGrpc.A2AServiceImplBase() { + @Override + public void sendMessage(org.a2aproject.sdk.compat03.grpc.SendMessageRequest request, + StreamObserver responseObserver) { + called.set(true); + responseObserver.onNext(SendMessageResponse.newBuilder().setMsg(Message.newBuilder() + .setMessageId("response") + .setRole(Role.ROLE_AGENT) + .addContent(Part.newBuilder().setText("hello").build()) + .build()).build()); + responseObserver.onCompleted(); + } + }).build().start(); + ManagedChannel channel = InProcessChannelBuilder.forName(name).directExecutor().build(); + AgentCard card = card(name); + ClientTransport transport = new GrpcCompat03ClientTransportProvider().create( + new GrpcTransportConfigBuilder().channelFactory(ignored -> channel).build(), card, + card.supportedInterfaces().get(0)); + try { + var result = transport.sendMessage(new MessageSendParams( + new org.a2aproject.sdk.spec.Message(org.a2aproject.sdk.spec.Message.Role.ROLE_USER, + List.of(new TextPart("hello")), "request", null, null, null, null, null), + null, null, null), null); + assertEquals("response", ((org.a2aproject.sdk.spec.Message) result).messageId()); + assertFalse(channel.isShutdown()); + assertEquals(true, called.get()); + } finally { + transport.close(); + channel.shutdownNow(); + server.shutdownNow(); + } + } + + @Test + void rejectsTenantOnLegacyAgentInterface() { + AgentCard card = cardWithInterfaceTenant(); + + org.a2aproject.sdk.spec.A2AClientException exception = assertThrows(org.a2aproject.sdk.spec.A2AClientException.class, + () -> new GrpcCompat03ClientTransportProvider().create(null, card, card.supportedInterfaces().get(0))); + + assertTrue(exception.getMessage().contains("tenant")); + } + + private static AgentCard card(String endpoint) { + return AgentCard.builder() + .name("legacy") + .description("legacy") + .version("1") + .capabilities(AgentCapabilities.builder().build()) + .defaultInputModes(List.of("text")) + .defaultOutputModes(List.of("text")) + .skills(List.of(AgentSkill.builder().id("skill").name("skill").description("skill").tags(List.of()).build())) + .url(endpoint) + .preferredTransport(TransportProtocol.GRPC.asString()) + .supportedInterfaces(List.of(new AgentInterface(TransportProtocol.GRPC.asString(), endpoint, null, "0.3"))) + .build(); + } + + private static AgentCard cardWithInterfaceTenant() { + return AgentCard.builder() + .name("legacy") + .description("legacy") + .version("1") + .capabilities(AgentCapabilities.builder().build()) + .defaultInputModes(List.of("text")) + .defaultOutputModes(List.of("text")) + .skills(List.of()) + .supportedInterfaces(List.of(new AgentInterface(TransportProtocol.GRPC.asString(), "in-process", "tenant", "0.3"))) + .build(); + } +} diff --git a/compat-0.3/client/adapter-grpc/src/test/java/org/a2aproject/sdk/compat03/client/adapter/grpc/GrpcCompat03UnsupportedFieldsTest.java b/compat-0.3/client/adapter-grpc/src/test/java/org/a2aproject/sdk/compat03/client/adapter/grpc/GrpcCompat03UnsupportedFieldsTest.java new file mode 100644 index 000000000..de2cdad37 --- /dev/null +++ b/compat-0.3/client/adapter-grpc/src/test/java/org/a2aproject/sdk/compat03/client/adapter/grpc/GrpcCompat03UnsupportedFieldsTest.java @@ -0,0 +1,105 @@ +package org.a2aproject.sdk.compat03.client.adapter.grpc; + +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import java.util.Map; +import java.util.function.Consumer; + +import com.google.protobuf.Struct; +import com.google.protobuf.Value; +import io.grpc.ManagedChannel; +import io.grpc.inprocess.InProcessChannelBuilder; +import io.grpc.inprocess.InProcessServerBuilder; +import org.a2aproject.sdk.client.transport.spi.interceptors.ClientCallContext; +import org.a2aproject.sdk.client.transport.spi.interceptors.ClientCallInterceptor; +import org.a2aproject.sdk.client.transport.spi.interceptors.PayloadAndHeaders; +import org.a2aproject.sdk.compat03.client.adapter.Compat03ClientTransportBase; +import org.a2aproject.sdk.compat03.client.transport.grpc.GrpcTransport_v0_3; +import org.a2aproject.sdk.compat03.client.transport.spi.interceptors.ClientCallContext_v0_3; +import org.a2aproject.sdk.compat03.spec.EventKind_v0_3; +import org.a2aproject.sdk.compat03.spec.MessageSendParams_v0_3; +import org.a2aproject.sdk.compat03.spec.StreamingEventKind_v0_3; +import org.a2aproject.sdk.compat03.spec.TaskIdParams_v0_3; +import org.a2aproject.sdk.compat03.spec.Task_v0_3; +import org.a2aproject.sdk.spec.A2AClientException; +import org.a2aproject.sdk.spec.AgentCapabilities; +import org.a2aproject.sdk.spec.AgentCard; +import org.a2aproject.sdk.spec.AgentInterface; +import org.a2aproject.sdk.spec.CancelTaskParams; +import org.a2aproject.sdk.spec.Message; +import org.a2aproject.sdk.spec.MessageSendParams; +import org.a2aproject.sdk.spec.TextPart; +import org.a2aproject.sdk.spec.UnsupportedOperationError; +import org.jspecify.annotations.Nullable; +import org.junit.jupiter.api.Test; + +class GrpcCompat03UnsupportedFieldsTest { + @Test + void rejectsOriginalFieldsAndInterceptorMutationsBeforeDelegation() { + AgentCard card = AgentCard.builder().name("legacy").description("legacy").version("1") + .url("https://example.test").capabilities(AgentCapabilities.builder().build()) + .defaultInputModes(List.of("text")).defaultOutputModes(List.of("text")).skills(List.of()) + .supportedInterfaces(List.of(new AgentInterface("GRPC", "https://example.test", null, "0.3"))).build(); + ManagedChannel channel = InProcessChannelBuilder.forName(InProcessServerBuilder.generateName()) + .directExecutor().build(); + var delegate = new GrpcTransport_v0_3(channel, Compat03ClientTransportBase.legacyCard(card)) { + @Override + public EventKind_v0_3 sendMessage(MessageSendParams_v0_3 request, @Nullable ClientCallContext_v0_3 context) { + throw new AssertionError("Unsupported request reached the delegate"); + } + + @Override + public void sendMessageStreaming(MessageSendParams_v0_3 request, Consumer events, + Consumer errors, @Nullable ClientCallContext_v0_3 context) { + throw new AssertionError("Unsupported streaming request reached the delegate"); + } + + @Override + public Task_v0_3 cancelTask(TaskIdParams_v0_3 request, @Nullable ClientCallContext_v0_3 context) { + throw new AssertionError("Unsupported cancellation reached the delegate"); + } + }; + try { + for (boolean intercepted : List.of(false, true)) { + var transport = new GrpcCompat03ClientTransport(delegate, card, + intercepted ? List.of(unsupportedFieldsInterceptor()) : List.of()); + var request = new MessageSendParams(new Message(Message.Role.ROLE_USER, List.of(new TextPart("hello")), + "request", null, null, intercepted ? null : List.of("referenced-task"), null, null), null, null); + assertUnsupported("referenceTaskIds", () -> transport.sendMessage(request, null)); + assertUnsupported("referenceTaskIds", () -> transport.sendMessageStreaming(request, + event -> { }, error -> { throw new AssertionError(error); }, null)); + var cancel = new CancelTaskParams("task", null, intercepted ? Map.of() : Map.of("reason", "stop")); + assertUnsupported("metadata", () -> transport.cancelTask(cancel, null)); + } + } finally { + channel.shutdownNow(); + } + } + + private static void assertUnsupported(String field, Runnable call) { + A2AClientException error = assertThrows(A2AClientException.class, call::run); + assertInstanceOf(UnsupportedOperationError.class, error.getCause()); + assertTrue(error.getMessage().contains(field)); + } + + private static ClientCallInterceptor unsupportedFieldsInterceptor() { + return new ClientCallInterceptor() { + @Override + public PayloadAndHeaders intercept(String method, @Nullable Object payload, Map headers, + @Nullable AgentCard card, @Nullable ClientCallContext context) { + if (payload instanceof org.a2aproject.sdk.grpc.SendMessageRequest) { + var request = ((org.a2aproject.sdk.grpc.SendMessageRequest) payload).toBuilder(); + request.getMessageBuilder().addReferenceTaskIds("referenced-task"); + return new PayloadAndHeaders(request.build(), headers); + } + Struct metadata = Struct.newBuilder().putFields("reason", Value.newBuilder().setStringValue("stop").build()).build(); + var request = ((org.a2aproject.sdk.grpc.CancelTaskRequest) payload).toBuilder(); + request.setMetadata(metadata); + return new PayloadAndHeaders(request.build(), headers); + } + }; + } +} diff --git a/compat-0.3/client/adapter-jsonrpc/pom.xml b/compat-0.3/client/adapter-jsonrpc/pom.xml new file mode 100644 index 000000000..ee608d323 --- /dev/null +++ b/compat-0.3/client/adapter-jsonrpc/pom.xml @@ -0,0 +1,41 @@ + + + 4.0.0 + + org.a2aproject.sdk + a2a-java-sdk-compat-0.3-parent + 1.4.1.Final-SNAPSHOT + ../../pom.xml + + a2a-java-sdk-compat-0.3-client-adapter-jsonrpc + Java SDK A2A Compat 0.3 Client Adapter: JSON-RPC + + + ${project.groupId} + a2a-java-sdk-compat-0.3-client-adapter + + + ${project.groupId} + a2a-java-sdk-client + + + ${project.groupId} + a2a-java-sdk-client-transport-jsonrpc + + + ${project.groupId} + a2a-java-sdk-compat-0.3-client-transport-jsonrpc + + + ${project.groupId} + a2a-java-sdk-spec-grpc + + + org.junit.jupiter + junit-jupiter-api + test + + + diff --git a/compat-0.3/client/adapter-jsonrpc/src/main/java/org/a2aproject/sdk/compat03/client/adapter/jsonrpc/JSONRPCCompat03ClientTransport.java b/compat-0.3/client/adapter-jsonrpc/src/main/java/org/a2aproject/sdk/compat03/client/adapter/jsonrpc/JSONRPCCompat03ClientTransport.java new file mode 100644 index 000000000..0b5960f64 --- /dev/null +++ b/compat-0.3/client/adapter-jsonrpc/src/main/java/org/a2aproject/sdk/compat03/client/adapter/jsonrpc/JSONRPCCompat03ClientTransport.java @@ -0,0 +1,183 @@ +package org.a2aproject.sdk.compat03.client.adapter.jsonrpc; + +import java.util.List; +import java.util.Map; +import java.util.function.Consumer; + +import org.a2aproject.sdk.client.transport.spi.interceptors.ClientCallContext; +import org.a2aproject.sdk.client.transport.spi.interceptors.ClientCallInterceptor; +import org.a2aproject.sdk.client.transport.spi.interceptors.PayloadAndHeaders; +import org.a2aproject.sdk.compat03.client.adapter.Compat03ClientCallContextMapper; +import org.a2aproject.sdk.compat03.client.adapter.Compat03ClientTransportBase; +import org.a2aproject.sdk.compat03.client.adapter.Compat03ClientTransportSupport; +import org.a2aproject.sdk.compat03.client.adapter.Compat03InterceptorSupport; +import org.a2aproject.sdk.compat03.client.transport.spi.ClientTransport_v0_3; +import org.a2aproject.sdk.compat03.conversion.mappers.domain.TaskMapper_v0_3; +import org.a2aproject.sdk.compat03.conversion.mappers.params.MessageSendParamsMapper_v0_3; +import org.a2aproject.sdk.compat03.spec.MessageSendParams_v0_3; +import org.a2aproject.sdk.compat03.spec.TaskIdParams_v0_3; +import org.a2aproject.sdk.grpc.utils.ProtoUtils; +import org.a2aproject.sdk.spec.A2AMethods; +import org.a2aproject.sdk.spec.AgentCard; +import org.a2aproject.sdk.spec.CancelTaskParams; +import org.a2aproject.sdk.spec.DeleteTaskPushNotificationConfigParams; +import org.a2aproject.sdk.spec.EventKind; +import org.a2aproject.sdk.spec.GetTaskPushNotificationConfigParams; +import org.a2aproject.sdk.spec.ListTaskPushNotificationConfigsParams; +import org.a2aproject.sdk.spec.ListTaskPushNotificationConfigsResult; +import org.a2aproject.sdk.spec.MessageSendParams; +import org.a2aproject.sdk.spec.StreamingEventKind; +import org.a2aproject.sdk.spec.Task; +import org.a2aproject.sdk.spec.TaskIdParams; +import org.a2aproject.sdk.spec.TaskPushNotificationConfig; +import org.a2aproject.sdk.spec.TaskQueryParams; +import org.jspecify.annotations.Nullable; + +/** 1.0 client facade backed by the legacy JSON-RPC transport. */ +public class JSONRPCCompat03ClientTransport extends Compat03ClientTransportBase { + public JSONRPCCompat03ClientTransport(ClientTransport_v0_3 delegate, AgentCard card, + List interceptors) { + super(delegate, card, interceptors); + } + + @Override + public EventKind sendMessage(MessageSendParams request, @Nullable ClientCallContext context) { + MessageSendParams_v0_3 legacyRequest = Compat03ClientTransportSupport.toV03(request); + org.a2aproject.sdk.grpc.SendMessageRequest original = ProtoUtils.ToProto.sendMessageRequest(request); + PayloadAndHeaders payload = apply(A2AMethods.SEND_MESSAGE_METHOD, + original, org.a2aproject.sdk.grpc.SendMessageRequest.class, context); + return Compat03ClientTransportSupport.call(() -> Compat03ClientTransportSupport.toV10(delegate.sendMessage( + adaptMessage(legacyRequest, original, + (org.a2aproject.sdk.grpc.SendMessageRequest) payload.getPayload()), + Compat03ClientCallContextMapper.toV03(contextWithHeaders(context, payload))))); + } + + @Override + public void sendMessageStreaming(MessageSendParams request, Consumer events, + Consumer errors, @Nullable ClientCallContext context) { + MessageSendParams_v0_3 legacyRequest = Compat03ClientTransportSupport.toV03(request); + org.a2aproject.sdk.grpc.SendMessageRequest original = ProtoUtils.ToProto.sendMessageRequest(request); + PayloadAndHeaders payload = apply(A2AMethods.SEND_STREAMING_MESSAGE_METHOD, + original, org.a2aproject.sdk.grpc.SendMessageRequest.class, context); + Compat03ClientTransportSupport.run(() -> delegate.sendMessageStreaming( + adaptMessage(legacyRequest, original, + (org.a2aproject.sdk.grpc.SendMessageRequest) payload.getPayload()), + event -> events.accept(Compat03ClientTransportSupport.toV10(event)), + Compat03ClientTransportSupport.mapAsyncError(errors), + Compat03ClientCallContextMapper.toV03(contextWithHeaders(context, payload)))); + } + + @Override + public Task getTask(TaskQueryParams request, @Nullable ClientCallContext context) { + Compat03ClientTransportSupport.validateTaskQuery(request); + PayloadAndHeaders payload = apply(A2AMethods.GET_TASK_METHOD, ProtoUtils.ToProto.getTaskRequest(request), + org.a2aproject.sdk.grpc.GetTaskRequest.class, context); + return Compat03ClientTransportSupport.call(() -> TaskMapper_v0_3.INSTANCE.toV10(delegate.getTask( + Compat03ClientTransportSupport.toV03(ProtoUtils.FromProto.taskQueryParams( + (org.a2aproject.sdk.grpc.GetTaskRequest) payload.getPayload())), + Compat03ClientCallContextMapper.toV03(contextWithHeaders(context, payload))))); + } + + @Override + public Task cancelTask(CancelTaskParams request, @Nullable ClientCallContext context) { + Compat03ClientTransportSupport.validateCancel(request); + org.a2aproject.sdk.grpc.CancelTaskRequest original = ProtoUtils.ToProto.cancelTaskRequest(request); + PayloadAndHeaders payload = apply(A2AMethods.CANCEL_TASK_METHOD, original, + org.a2aproject.sdk.grpc.CancelTaskRequest.class, context); + return Compat03ClientTransportSupport.call(() -> TaskMapper_v0_3.INSTANCE.toV10(delegate.cancelTask( + adaptCancel(request, original, (org.a2aproject.sdk.grpc.CancelTaskRequest) payload.getPayload()), + Compat03ClientCallContextMapper.toV03(contextWithHeaders(context, payload))))); + } + + @Override + public TaskPushNotificationConfig createTaskPushNotificationConfiguration(TaskPushNotificationConfig request, + @Nullable ClientCallContext context) { + Compat03ClientTransportSupport.validatePushConfig(request); + PayloadAndHeaders payload = apply(A2AMethods.SET_TASK_PUSH_NOTIFICATION_CONFIG_METHOD, + ProtoUtils.ToProto.taskPushNotificationConfig(request), org.a2aproject.sdk.grpc.TaskPushNotificationConfig.class, context); + return Compat03ClientTransportSupport.call(() -> Compat03ClientTransportSupport.toV10( + delegate.setTaskPushNotificationConfiguration(Compat03ClientTransportSupport.toV03( + ProtoUtils.FromProto.taskPushNotificationConfig((org.a2aproject.sdk.grpc.TaskPushNotificationConfig) payload.getPayload())), + Compat03ClientCallContextMapper.toV03(contextWithHeaders(context, payload))))); + } + + @Override + public TaskPushNotificationConfig getTaskPushNotificationConfiguration(GetTaskPushNotificationConfigParams request, + @Nullable ClientCallContext context) { + Compat03ClientTransportSupport.validateGetPush(request); + PayloadAndHeaders payload = apply(A2AMethods.GET_TASK_PUSH_NOTIFICATION_CONFIG_METHOD, + ProtoUtils.ToProto.getTaskPushNotificationConfigRequest(request), org.a2aproject.sdk.grpc.GetTaskPushNotificationConfigRequest.class, context); + return Compat03ClientTransportSupport.call(() -> Compat03ClientTransportSupport.toV10( + delegate.getTaskPushNotificationConfiguration(Compat03ClientTransportSupport.toV03( + ProtoUtils.FromProto.getTaskPushNotificationConfigParams((org.a2aproject.sdk.grpc.GetTaskPushNotificationConfigRequest) payload.getPayload())), + Compat03ClientCallContextMapper.toV03(contextWithHeaders(context, payload))))); + } + + @Override + public ListTaskPushNotificationConfigsResult listTaskPushNotificationConfigurations( + ListTaskPushNotificationConfigsParams request, @Nullable ClientCallContext context) { + Compat03ClientTransportSupport.validatePushList(request); + PayloadAndHeaders payload = apply(A2AMethods.LIST_TASK_PUSH_NOTIFICATION_CONFIG_METHOD, + ProtoUtils.ToProto.listTaskPushNotificationConfigsRequest(request), org.a2aproject.sdk.grpc.ListTaskPushNotificationConfigsRequest.class, context); + return Compat03ClientTransportSupport.call(() -> Compat03ClientTransportSupport.toV10PushList( + delegate.listTaskPushNotificationConfigurations(Compat03ClientTransportSupport.toV03( + ProtoUtils.FromProto.listTaskPushNotificationConfigsParams((org.a2aproject.sdk.grpc.ListTaskPushNotificationConfigsRequest) payload.getPayload())), + Compat03ClientCallContextMapper.toV03(contextWithHeaders(context, payload))))); + } + + @Override + public void deleteTaskPushNotificationConfigurations(DeleteTaskPushNotificationConfigParams request, + @Nullable ClientCallContext context) { + Compat03ClientTransportSupport.validateDeletePush(request); + PayloadAndHeaders payload = apply(A2AMethods.DELETE_TASK_PUSH_NOTIFICATION_CONFIG_METHOD, + ProtoUtils.ToProto.deleteTaskPushNotificationConfigRequest(request), org.a2aproject.sdk.grpc.DeleteTaskPushNotificationConfigRequest.class, context); + Compat03ClientTransportSupport.run(() -> delegate.deleteTaskPushNotificationConfigurations( + Compat03ClientTransportSupport.toV03(ProtoUtils.FromProto.deleteTaskPushNotificationConfigParams( + (org.a2aproject.sdk.grpc.DeleteTaskPushNotificationConfigRequest) payload.getPayload())), + Compat03ClientCallContextMapper.toV03(contextWithHeaders(context, payload)))); + } + + @Override + public void subscribeToTask(TaskIdParams request, Consumer events, + Consumer errors, @Nullable ClientCallContext context) { + Compat03ClientTransportSupport.validateTenant("subscribeToTask", request.tenant()); + PayloadAndHeaders payload = apply(A2AMethods.SUBSCRIBE_TO_TASK_METHOD, + ProtoUtils.ToProto.subscribeToTaskRequest(request), org.a2aproject.sdk.grpc.SubscribeToTaskRequest.class, context); + Compat03ClientTransportSupport.run(() -> delegate.resubscribe( + Compat03ClientTransportSupport.toV03(ProtoUtils.FromProto.taskIdParams( + (org.a2aproject.sdk.grpc.SubscribeToTaskRequest) payload.getPayload())), + event -> events.accept(Compat03ClientTransportSupport.toV10(event)), + Compat03ClientTransportSupport.mapAsyncError(errors), + Compat03ClientCallContextMapper.toV03(contextWithHeaders(context, payload)))); + } + + private static MessageSendParams_v0_3 adaptMessage(MessageSendParams_v0_3 legacyRequest, + org.a2aproject.sdk.grpc.SendMessageRequest original, org.a2aproject.sdk.grpc.SendMessageRequest modified) { + if (original.equals(modified)) { + return legacyRequest; + } + return JSONRPCCompat03PayloadSupport.preserveUnchangedValues(legacyRequest, + MessageSendParamsMapper_v0_3.INSTANCE.fromV10(ProtoUtils.FromProto.messageSendParams(original)), + Compat03ClientTransportSupport.toV03(ProtoUtils.FromProto.messageSendParams(modified))); + } + + private static TaskIdParams_v0_3 adaptCancel(CancelTaskParams request, + org.a2aproject.sdk.grpc.CancelTaskRequest original, org.a2aproject.sdk.grpc.CancelTaskRequest modified) { + TaskIdParams_v0_3 legacy = Compat03ClientTransportSupport.toV03(request); + if (original.equals(modified)) { + return legacy; + } + return JSONRPCCompat03PayloadSupport.preserveUnchangedValues(legacy, + Compat03ClientTransportSupport.toV03(ProtoUtils.FromProto.cancelTaskParams(original)), + Compat03ClientTransportSupport.toV03(ProtoUtils.FromProto.cancelTaskParams(modified))); + } + + private PayloadAndHeaders apply(String method, Object payload, Class expected, + @Nullable ClientCallContext context) { + return Compat03InterceptorSupport.apply(interceptors, method, payload, agentCard, context, expected); + } + + private static ClientCallContext contextWithHeaders(@Nullable ClientCallContext original, PayloadAndHeaders payload) { + return new ClientCallContext(original == null ? Map.of() : original.getState(), payload.getHeaders()); + } +} diff --git a/compat-0.3/client/adapter-jsonrpc/src/main/java/org/a2aproject/sdk/compat03/client/adapter/jsonrpc/JSONRPCCompat03ClientTransportProvider.java b/compat-0.3/client/adapter-jsonrpc/src/main/java/org/a2aproject/sdk/compat03/client/adapter/jsonrpc/JSONRPCCompat03ClientTransportProvider.java new file mode 100644 index 000000000..6135b8937 --- /dev/null +++ b/compat-0.3/client/adapter-jsonrpc/src/main/java/org/a2aproject/sdk/compat03/client/adapter/jsonrpc/JSONRPCCompat03ClientTransportProvider.java @@ -0,0 +1,36 @@ +package org.a2aproject.sdk.compat03.client.adapter.jsonrpc; + +import org.a2aproject.sdk.client.VersionedClientTransportProvider; +import org.a2aproject.sdk.client.http.A2AHttpClient; +import org.a2aproject.sdk.client.transport.jsonrpc.JSONRPCTransport; +import org.a2aproject.sdk.client.transport.jsonrpc.JSONRPCTransportConfig; +import org.a2aproject.sdk.client.transport.spi.ClientTransport; +import org.a2aproject.sdk.client.transport.spi.ClientTransportConfig; +import org.a2aproject.sdk.compat03.client.adapter.Compat03ClientTransportBase; +import org.a2aproject.sdk.compat03.client.adapter.Compat03ClientTransportSupport; +import org.a2aproject.sdk.compat03.client.transport.jsonrpc.JSONRPCTransport_v0_3; +import org.a2aproject.sdk.compat03.client.transport.jsonrpc.JSONRPCTransportConfig_v0_3; +import org.a2aproject.sdk.spec.A2AClientException; +import org.a2aproject.sdk.spec.AgentCard; +import org.a2aproject.sdk.spec.AgentInterface; + +/** ServiceLoader provider for the optional JSON-RPC 0.3 adapter. */ +public final class JSONRPCCompat03ClientTransportProvider implements VersionedClientTransportProvider { + @Override public String protocolBinding() { return "JSONRPC"; } + @Override public String protocolVersion() { return "0.3"; } + @Override public Class configuredTransportClass() { return JSONRPCTransport.class; } + + @Override + public ClientTransport create(ClientTransportConfig config, AgentCard card, AgentInterface agentInterface) + throws A2AClientException { + Compat03ClientTransportSupport.validateAgentInterfaceTenant(agentInterface.tenant()); + if (!(config instanceof JSONRPCTransportConfig nativeConfig)) { + throw new A2AClientException("Expected JSONRPCTransportConfig for the JSON-RPC 0.3 adapter"); + } + Compat03ClientTransportSupport.validateConfig(nativeConfig); + A2AHttpClient httpClient = nativeConfig.getHttpClient(); + JSONRPCTransport_v0_3 legacy = new JSONRPCTransport_v0_3(httpClient, + Compat03ClientTransportBase.legacyCard(card), agentInterface.url(), java.util.List.of()); + return new JSONRPCCompat03ClientTransport(legacy, card, nativeConfig.getInterceptors()); + } +} diff --git a/compat-0.3/client/adapter-jsonrpc/src/main/java/org/a2aproject/sdk/compat03/client/adapter/jsonrpc/JSONRPCCompat03PayloadSupport.java b/compat-0.3/client/adapter-jsonrpc/src/main/java/org/a2aproject/sdk/compat03/client/adapter/jsonrpc/JSONRPCCompat03PayloadSupport.java new file mode 100644 index 000000000..8feabfd0b --- /dev/null +++ b/compat-0.3/client/adapter-jsonrpc/src/main/java/org/a2aproject/sdk/compat03/client/adapter/jsonrpc/JSONRPCCompat03PayloadSupport.java @@ -0,0 +1,148 @@ +package org.a2aproject.sdk.compat03.client.adapter.jsonrpc; + +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +import org.a2aproject.sdk.compat03.spec.DataPart_v0_3; +import org.a2aproject.sdk.compat03.spec.FilePart_v0_3; +import org.a2aproject.sdk.compat03.spec.MessageSendParams_v0_3; +import org.a2aproject.sdk.compat03.spec.Message_v0_3; +import org.a2aproject.sdk.compat03.spec.Part_v0_3; +import org.a2aproject.sdk.compat03.spec.TaskIdParams_v0_3; +import org.a2aproject.sdk.compat03.spec.TextPart_v0_3; +import org.a2aproject.sdk.spec.A2AClientException; +import org.jspecify.annotations.Nullable; + +/** Retains original JSON values in fields that native protobuf interceptors have not changed. */ +final class JSONRPCCompat03PayloadSupport { + private JSONRPCCompat03PayloadSupport() { + } + + static MessageSendParams_v0_3 preserveUnchangedValues(MessageSendParams_v0_3 original, + MessageSendParams_v0_3 before, MessageSendParams_v0_3 after) { + Message_v0_3 source = original.message(); + Message_v0_3 baseline = before.message(); + Message_v0_3 modified = after.message(); + List> parts = modified.parts(); + if (baseline.parts().equals(modified.parts())) { + parts = source.parts(); + } else if (source.parts().size() == 1 && baseline.parts().size() == 1 && modified.parts().size() == 1) { + parts = List.of(restorePart(source.parts().get(0), baseline.parts().get(0), modified.parts().get(0))); + } else if (hasLossyNumbers(source.parts(), baseline.parts())) { + throw ambiguousArray(); + } + Message_v0_3 message = new Message_v0_3(modified.role(), parts, modified.messageId(), modified.contextId(), + modified.taskId(), modified.referenceTaskIds(), + restoreMap(source.metadata(), baseline.metadata(), modified.metadata()), modified.extensions()); + return new MessageSendParams_v0_3(message, after.configuration(), + restoreMap(original.metadata(), before.metadata(), after.metadata())); + } + + static TaskIdParams_v0_3 preserveUnchangedValues(TaskIdParams_v0_3 original, + TaskIdParams_v0_3 before, TaskIdParams_v0_3 after) { + return new TaskIdParams_v0_3(after.id(), restoreMap(original.metadata(), before.metadata(), after.metadata())); + } + + private static Part_v0_3 restorePart(Part_v0_3 original, Part_v0_3 before, Part_v0_3 after) { + if (before.equals(after)) { + return original; + } + @Nullable Map metadata = restoreMap(partMetadata(original), partMetadata(before), partMetadata(after)); + if (after instanceof DataPart_v0_3 modified) { + Map data = modified.data(); + if (original instanceof DataPart_v0_3 source && before instanceof DataPart_v0_3 baseline) { + data = Objects.requireNonNull(restoreMap(source.data(), baseline.data(), modified.data())); + } + return new DataPart_v0_3(data, metadata); + } + if (after instanceof TextPart_v0_3 modified) { + return new TextPart_v0_3(modified.text(), metadata); + } + FilePart_v0_3 modified = (FilePart_v0_3) after; + return new FilePart_v0_3(modified.file(), metadata); + } + + private static @Nullable Map partMetadata(Part_v0_3 part) { + if (part instanceof DataPart_v0_3 data) { + return data.metadata(); + } + if (part instanceof TextPart_v0_3 text) { + return text.metadata(); + } + return ((FilePart_v0_3) part).metadata(); + } + + @SuppressWarnings("unchecked") + private static @Nullable Map restoreMap(@Nullable Map original, + @Nullable Map before, @Nullable Map after) { + return (Map) restore(original, before, after); + } + + private static @Nullable Object restore(@Nullable Object original, @Nullable Object before, @Nullable Object after) { + // Struct/Value uses doubles. Restore unchanged values directly from the original objects, + // avoiding another JSON parse that could itself round large integers or decimal numbers. + if (Objects.equals(before, after)) { + return original; + } + if (original instanceof Map source && before instanceof Map baseline + && after instanceof Map modified) { + Map result = new LinkedHashMap<>(); + modified.forEach((key, value) -> result.put(key, source.containsKey(key) && baseline.containsKey(key) + ? restore(source.get(key), baseline.get(key), value) : value)); + return result; + } + if (original instanceof List source && before instanceof List baseline && after instanceof List modified) { + if (source.size() == 1 && baseline.size() == 1 && modified.size() == 1) { + // Array slots have no identity. Only a singleton has an unambiguous slot. + List<@Nullable Object> result = new ArrayList<>(); + result.add(restore(source.get(0), baseline.get(0), modified.get(0))); + return result; + } + if (hasLossyNumbers(source, baseline)) { + throw ambiguousArray(); + } + } + return after; + } + + private static boolean hasLossyNumbers(@Nullable Object original, @Nullable Object projected) { + if (original instanceof Number source && projected instanceof Number baseline) { + return !Double.isFinite(baseline.doubleValue()) + || new BigDecimal(source.toString()).compareTo(new BigDecimal(baseline.toString())) != 0; + } + if (original instanceof DataPart_v0_3 source && projected instanceof DataPart_v0_3 baseline) { + return hasLossyNumbers(source.metadata(), baseline.metadata()) + || hasLossyNumbers(source.data(), baseline.data()); + } + if (original instanceof TextPart_v0_3 source && projected instanceof TextPart_v0_3 baseline) { + return hasLossyNumbers(source.metadata(), baseline.metadata()); + } + if (original instanceof FilePart_v0_3 source && projected instanceof FilePart_v0_3 baseline) { + return hasLossyNumbers(source.metadata(), baseline.metadata()); + } + if (original instanceof Map source && projected instanceof Map baseline) { + for (var entry : source.entrySet()) { + if (hasLossyNumbers(entry.getValue(), baseline.get(entry.getKey()))) { + return true; + } + } + } + if (original instanceof List source && projected instanceof List baseline) { + for (int i = 0; i < source.size() && i < baseline.size(); i++) { + if (hasLossyNumbers(source.get(i), baseline.get(i))) { + return true; + } + } + } + return false; + } + + private static A2AClientException ambiguousArray() { + return new A2AClientException("Cannot preserve exact JSON numbers after a protobuf interceptor edits " + + "a non-singleton array. Keep that array unchanged or use strings for exact numeric identifiers."); + } +} diff --git a/compat-0.3/client/adapter-jsonrpc/src/main/resources/META-INF/services/org.a2aproject.sdk.client.VersionedClientTransportProvider b/compat-0.3/client/adapter-jsonrpc/src/main/resources/META-INF/services/org.a2aproject.sdk.client.VersionedClientTransportProvider new file mode 100644 index 000000000..d85332a18 --- /dev/null +++ b/compat-0.3/client/adapter-jsonrpc/src/main/resources/META-INF/services/org.a2aproject.sdk.client.VersionedClientTransportProvider @@ -0,0 +1 @@ +org.a2aproject.sdk.compat03.client.adapter.jsonrpc.JSONRPCCompat03ClientTransportProvider diff --git a/compat-0.3/client/adapter-jsonrpc/src/test/java/org/a2aproject/sdk/compat03/client/adapter/jsonrpc/JSONRPCCompat03ClientTransportTest.java b/compat-0.3/client/adapter-jsonrpc/src/test/java/org/a2aproject/sdk/compat03/client/adapter/jsonrpc/JSONRPCCompat03ClientTransportTest.java new file mode 100644 index 000000000..fa43cbee4 --- /dev/null +++ b/compat-0.3/client/adapter-jsonrpc/src/test/java/org/a2aproject/sdk/compat03/client/adapter/jsonrpc/JSONRPCCompat03ClientTransportTest.java @@ -0,0 +1,441 @@ +package org.a2aproject.sdk.compat03.client.adapter.jsonrpc; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.math.BigDecimal; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicReference; + +import org.a2aproject.sdk.client.transport.jsonrpc.JSONRPCTransport; +import org.a2aproject.sdk.client.transport.spi.ClientTransportConfig; +import org.a2aproject.sdk.client.transport.spi.interceptors.ClientCallContext; +import org.a2aproject.sdk.client.transport.spi.interceptors.ClientCallInterceptor; +import org.a2aproject.sdk.client.transport.spi.interceptors.PayloadAndHeaders; +import org.a2aproject.sdk.compat03.client.transport.spi.ClientTransport_v0_3; +import org.a2aproject.sdk.compat03.client.transport.spi.interceptors.ClientCallContext_v0_3; +import org.a2aproject.sdk.compat03.json.JsonUtil_v0_3; +import org.a2aproject.sdk.compat03.spec.AgentCard_v0_3; +import org.a2aproject.sdk.compat03.spec.DeleteTaskPushNotificationConfigParams_v0_3; +import org.a2aproject.sdk.compat03.spec.EventKind_v0_3; +import org.a2aproject.sdk.compat03.spec.GetTaskPushNotificationConfigParams_v0_3; +import org.a2aproject.sdk.compat03.spec.ListTaskPushNotificationConfigParams_v0_3; +import org.a2aproject.sdk.compat03.spec.MessageSendParams_v0_3; +import org.a2aproject.sdk.compat03.spec.Message_v0_3; +import org.a2aproject.sdk.compat03.spec.StreamingEventKind_v0_3; +import org.a2aproject.sdk.compat03.spec.TaskIdParams_v0_3; +import org.a2aproject.sdk.compat03.spec.TaskPushNotificationConfig_v0_3; +import org.a2aproject.sdk.compat03.spec.TaskQueryParams_v0_3; +import org.a2aproject.sdk.spec.AgentCapabilities; +import org.a2aproject.sdk.spec.AgentCard; +import org.a2aproject.sdk.spec.AgentInterface; +import org.a2aproject.sdk.spec.CancelTaskParams; +import org.a2aproject.sdk.spec.DataPart; +import org.a2aproject.sdk.spec.FilePart; +import org.a2aproject.sdk.spec.FileWithBytes; +import org.a2aproject.sdk.spec.Message; +import org.a2aproject.sdk.spec.MessageSendParams; +import org.a2aproject.sdk.spec.TaskQueryParams; +import org.a2aproject.sdk.spec.TextPart; +import org.a2aproject.sdk.spec.UnsupportedOperationError; +import org.junit.jupiter.api.Test; + +class JSONRPCCompat03ClientTransportTest { + @Test + void preservesSingletonArrayNumbersAndAppliesMapAdditionsRemovalsAndReplacements() { + long id = 9007199254740993L; + double projectedId = (double) id; + var original = new TaskIdParams_v0_3("task", Map.of( + "items", List.of(Map.of("id", id, "counter", 1L)), "remove", "old", "replace", id)); + var before = new TaskIdParams_v0_3("task", Map.of( + "items", List.of(Map.of("id", projectedId, "counter", 1.0)), "remove", "old", "replace", projectedId)); + var after = new TaskIdParams_v0_3("task", Map.of( + "items", List.of(Map.of("id", projectedId, "counter", 2.0)), "add", true, "replace", 42.0)); + var result = JSONRPCCompat03PayloadSupport.preserveUnchangedValues(original, before, after); + assertEquals(Map.of("items", List.of(Map.of("id", id, "counter", 2.0)), + "add", true, "replace", 42.0), result.metadata()); + } + + @Test + void acceptsLosslessArrayEditsAndTreatsIdenticalProtobufNumbersAsUnchanged() { + var original = new TaskIdParams_v0_3("task", Map.of("ids", List.of(1L, 7L))); + var before = new TaskIdParams_v0_3("task", Map.of("ids", List.of(1.0, 7.0))); + var after = new TaskIdParams_v0_3("task", Map.of("ids", List.of(7.0))); + assertEquals(after, JSONRPCCompat03PayloadSupport.preserveUnchangedValues(original, before, after)); + + // The interceptor contract treats identical protobuf values as unchanged, even for replacements. + var exact = new TaskIdParams_v0_3("task", Map.of("id", 9007199254740993L)); + var projected = new TaskIdParams_v0_3("task", Map.of("id", 9007199254740992.0)); + assertEquals(exact, JSONRPCCompat03PayloadSupport.preserveUnchangedValues(exact, projected, projected)); + } + + @Test + void preservesMetadataWhenAnInterceptorChangesASingletonPartKind() { + ClientCallInterceptor interceptor = new ClientCallInterceptor() { + @Override + public PayloadAndHeaders intercept(String method, Object payload, Map headers, + AgentCard card, ClientCallContext context) { + var request = (org.a2aproject.sdk.grpc.SendMessageRequest) payload; + var part = request.getMessage().getParts(0).toBuilder().setData( + com.google.protobuf.Value.newBuilder().setStructValue( + com.google.protobuf.Struct.newBuilder().putFields("new", + com.google.protobuf.Value.newBuilder().setBoolValue(true).build()))); + return new PayloadAndHeaders(request.toBuilder().setMessage(request.getMessage().toBuilder() + .setParts(0, part)).build(), headers); + } + }; + RecordingDelegate delegate = new RecordingDelegate(); + delegate.response = new Message_v0_3.Builder().role(Message_v0_3.Role.AGENT) + .messageId("response").parts(List.of(new org.a2aproject.sdk.compat03.spec.TextPart_v0_3("ok"))).build(); + var transport = new JSONRPCCompat03ClientTransport(delegate, testCard(), List.of(interceptor)); + var request = new MessageSendParams(new Message(Message.Role.ROLE_USER, + List.of(new TextPart("original", Map.of("id", 9007199254740993L))), + "request", null, null, null, null, null), null, null); + transport.sendMessage(request, null); + var part = assertInstanceOf(org.a2aproject.sdk.compat03.spec.DataPart_v0_3.class, + delegate.sent.message().parts().get(0)); + assertEquals(Map.of("new", true), part.data()); + assertEquals(Map.of("id", 9007199254740993L), part.metadata()); + transport.sendMessageStreaming(request, event -> { }, failure -> { }, null); + part = assertInstanceOf(org.a2aproject.sdk.compat03.spec.DataPart_v0_3.class, + delegate.sent.message().parts().get(0)); + assertEquals(Map.of("id", 9007199254740993L), part.metadata()); + } + + @Test + void rejectsAmbiguousArrayEditsBeforeBlockingOrStreamingDelegation() { + // Removing A and editing B must neither round B's untouched ID nor restore A's stale ID. + assertAmbiguousMutationRejected( + List.of(new DataPart(Map.of("id", 1L, "counter", 1L)), + new DataPart(Map.of("id", 9007199254740993L, "counter", 1L))), + List.of(new DataPart(Map.of("id", 9007199254740992L, "counter", 2L)))); + assertAmbiguousMutationRejected( + List.of(new DataPart(Map.of("id", 9007199254740993L, "counter", 1L)), + new DataPart(Map.of("id", 7L, "counter", 1L))), + List.of(new DataPart(Map.of("id", 9007199254740992L, "counter", 2L)))); + // Both original integers project to the same double, so exact matching cannot identify the survivor. + assertAmbiguousMutationRejected( + List.of(new DataPart(Map.of("nested", List.of(9007199254740992L, 9007199254740993L)))), + List.of(new DataPart(Map.of("nested", List.of(9007199254740992L))))); + assertAmbiguousMutationRejected( + List.of(new DataPart(Map.of("id", 9007199254740993L)), new TextPart("move me")), + List.of(new TextPart("move me"), new DataPart(Map.of("id", 9007199254740992L)))); + } + + private static void assertAmbiguousMutationRejected(List> before, + List> after) { + Message message = new Message(Message.Role.ROLE_USER, before, "request", null, null, null, null, null); + Message replacement = Message.builder(message).parts(after).build(); + ClientCallInterceptor interceptor = new ClientCallInterceptor() { + @Override + public PayloadAndHeaders intercept(String method, Object payload, Map headers, + AgentCard card, ClientCallContext context) { + var request = (org.a2aproject.sdk.grpc.SendMessageRequest) payload; + return new PayloadAndHeaders(request.toBuilder() + .setMessage(org.a2aproject.sdk.grpc.utils.ProtoUtils.ToProto.message(replacement)).build(), headers); + } + }; + RecordingDelegate delegate = new RecordingDelegate(); + var transport = new JSONRPCCompat03ClientTransport(delegate, testCard(), List.of(interceptor)); + var request = new MessageSendParams(message, null, null); + var error = assertThrows(org.a2aproject.sdk.spec.A2AClientException.class, + () -> transport.sendMessage(request, null)); + assertTrue(error.getMessage().contains("non-singleton array")); + assertThrows(org.a2aproject.sdk.spec.A2AClientException.class, + () -> transport.sendMessageStreaming(request, event -> { }, failure -> { }, null)); + assertNull(delegate.sent); + assertFalse(delegate.called); + } + + @Test + void rejectsAmbiguousCancellationMetadataArrayEditsBeforeDelegation() { + ClientCallInterceptor interceptor = new ClientCallInterceptor() { + @Override + public PayloadAndHeaders intercept(String method, Object payload, Map headers, + AgentCard card, ClientCallContext context) { + var request = (org.a2aproject.sdk.grpc.CancelTaskRequest) payload; + var values = request.getMetadata().getFieldsOrThrow("ids").getListValue(); + var remaining = com.google.protobuf.Value.newBuilder().setListValue( + com.google.protobuf.ListValue.newBuilder().addValues(values.getValues(1))).build(); + return new PayloadAndHeaders(request.toBuilder().setMetadata(request.getMetadata().toBuilder() + .putFields("ids", remaining)).build(), headers); + } + }; + RecordingDelegate delegate = new RecordingDelegate(); + var transport = new JSONRPCCompat03ClientTransport(delegate, testCard(), List.of(interceptor)); + assertThrows(org.a2aproject.sdk.spec.A2AClientException.class, () -> transport.cancelTask( + new CancelTaskParams("task", null, Map.of("ids", List.of(9007199254740992L, 9007199254740993L))), null)); + assertNull(delegate.cancelled); + assertFalse(delegate.called); + } + + @Test + void preservesLargeIntegersForBlockingStreamingAndCancellation() throws Exception { + checkNumericPrecision(List.of(), false, false); + checkNumericPrecision(List.of(new ClientCallInterceptor() { + @Override + public PayloadAndHeaders intercept(String method, Object payload, Map headers, + AgentCard card, ClientCallContext context) { + return new PayloadAndHeaders(payload, Map.of("A2A-Extensions", "urn:example:extension")); + } + }), false, true); + checkNumericPrecision(List.of(new ClientCallInterceptor() { + @Override + public PayloadAndHeaders intercept(String method, Object payload, Map headers, + AgentCard card, ClientCallContext context) { + var value = com.google.protobuf.Value.newBuilder().setNumberValue(2).build(); + if (payload instanceof org.a2aproject.sdk.grpc.SendMessageRequest request) { + return new PayloadAndHeaders(request.toBuilder().setMessage(request.getMessage().toBuilder() + .setMessageId("changed")) + .setMetadata(request.getMetadata().toBuilder().putFields("counter", value)).build(), headers); + } + var request = (org.a2aproject.sdk.grpc.CancelTaskRequest) payload; + return new PayloadAndHeaders(request.toBuilder().setId("changed-task") + .setMetadata(request.getMetadata().toBuilder().putFields("counter", value)).build(), headers); + } + }), true, false); + } + + private static void checkNumericPrecision(List interceptors, + boolean changedPayload, boolean extensionHeader) throws Exception { + long id = 9007199254740993L; + Map metadata = Map.of("id", id); + BigDecimal decimal = new BigDecimal("0.12345678901234567890123456789"); + Map requestMetadata = Map.of("id", id, "counter", 1L, "decimal", decimal); + RecordingDelegate delegate = new RecordingDelegate(); + delegate.response = new Message_v0_3.Builder().role(Message_v0_3.Role.AGENT) + .messageId("response").parts(List.of(new org.a2aproject.sdk.compat03.spec.TextPart_v0_3("ok"))).build(); + var transport = new JSONRPCCompat03ClientTransport(delegate, testCard(), interceptors); + var message = new Message(Message.Role.ROLE_USER, + List.of(new DataPart(Map.of("nested", List.of(metadata)), metadata)), + "request", null, null, null, metadata, null); + var request = new MessageSendParams(message, null, requestMetadata); + + transport.sendMessage(request, null); + assertExactNumbers(delegate.sent, id); + assertEquals(changedPayload ? "changed" : "request", delegate.sent.message().messageId()); + assertEquals(changedPayload ? 2.0 : 1.0, ((Number) delegate.sent.metadata().get("counter")).doubleValue()); + assertEquals(decimal, delegate.sent.metadata().get("decimal")); + transport.sendMessageStreaming(request, event -> { }, error -> { throw new AssertionError(error); }, null); + assertExactNumbers(delegate.sent, id); + assertEquals(changedPayload ? "changed" : "request", delegate.sent.message().messageId()); + assertEquals(changedPayload ? 2.0 : 1.0, ((Number) delegate.sent.metadata().get("counter")).doubleValue()); + assertEquals(decimal, delegate.sent.metadata().get("decimal")); + transport.cancelTask(new CancelTaskParams("task", null, requestMetadata), null); + assertEquals(id, delegate.cancelled.metadata().get("id")); + assertEquals(decimal, delegate.cancelled.metadata().get("decimal")); + assertEquals(changedPayload ? 2.0 : 1.0, ((Number) delegate.cancelled.metadata().get("counter")).doubleValue()); + assertTrue(JsonUtil_v0_3.toJson(delegate.cancelled).contains("9007199254740993")); + assertEquals(changedPayload ? "changed-task" : "task", delegate.cancelled.id()); + if (extensionHeader) { + assertEquals("urn:example:extension", delegate.context.getHeaders().get("X-A2A-Extensions")); + } + assertFalse(delegate.context.getHeaders().containsKey("A2A-Extensions")); + } + + private static void assertExactNumbers(MessageSendParams_v0_3 request, long id) throws Exception { + assertEquals(id, request.metadata().get("id")); + assertEquals(Map.of("id", id), request.message().metadata()); + var part = assertInstanceOf(org.a2aproject.sdk.compat03.spec.DataPart_v0_3.class, request.message().parts().get(0)); + assertEquals(Map.of("id", id), part.metadata()); + assertEquals(Map.of("nested", List.of(Map.of("id", id))), part.data()); + assertTrue(JsonUtil_v0_3.toJson(request).contains("9007199254740993")); + } + + @Test + void providerTargetsJsonRpcAndOrdinaryJsonRpcConfiguration() { + JSONRPCCompat03ClientTransportProvider provider = new JSONRPCCompat03ClientTransportProvider(); + + assertEquals("JSONRPC", provider.protocolBinding()); + assertEquals("0.3", provider.protocolVersion()); + assertEquals(JSONRPCTransport.class, provider.configuredTransportClass()); + assertTrue(provider.getClass().getPackageName().contains("adapter.jsonrpc")); + } + + @Test + void rejectsTenantBeforeCallingLegacyDelegate() { + RecordingDelegate delegate = new RecordingDelegate(); + JSONRPCCompat03ClientTransport transport = new JSONRPCCompat03ClientTransport( + delegate, testCard(), List.of()); + + assertThrows(org.a2aproject.sdk.spec.A2AClientException.class, + () -> transport.subscribeToTask(new org.a2aproject.sdk.spec.TaskIdParams("task", "tenant"), + event -> { }, error -> { }, (ClientCallContext) null)); + assertFalse(delegate.called); + } + + @Test + void providerRejectsTenantOnLegacyAgentInterface() { + AgentCard card = cardWithInterfaceTenant(); + + org.a2aproject.sdk.spec.A2AClientException exception = assertThrows(org.a2aproject.sdk.spec.A2AClientException.class, + () -> new JSONRPCCompat03ClientTransportProvider().create(null, card, card.supportedInterfaces().get(0))); + + assertTrue(exception.getMessage().contains("tenant")); + } + + @Test + void providerRejectsWrongConfigurationType() { + org.a2aproject.sdk.spec.A2AClientException exception = assertThrows( + org.a2aproject.sdk.spec.A2AClientException.class, + () -> new JSONRPCCompat03ClientTransportProvider().create( + new ClientTransportConfig() { }, + testCard(), testCard().supportedInterfaces().get(0))); + + assertTrue(exception.getMessage().contains("JSONRPCTransportConfig")); + } + + @Test + void providerRejectsNullConfiguration() { + org.a2aproject.sdk.spec.A2AClientException exception = assertThrows( + org.a2aproject.sdk.spec.A2AClientException.class, + () -> new JSONRPCCompat03ClientTransportProvider().create( + null, testCard(), testCard().supportedInterfaces().get(0))); + + assertTrue(exception.getMessage().contains("JSONRPCTransportConfig")); + } + + @Test + void receivesInlineFilesWithoutMimeTypeForBlockingAndStreamingSends() throws Exception { + RecordingDelegate delegate = new RecordingDelegate(); + delegate.response = JsonUtil_v0_3.fromJson(""" + {"kind":"message","role":"agent","messageId":"response", + "parts":[{"kind":"file","file":{"bytes":"aGVsbG8="}}]} + """, Message_v0_3.class); + JSONRPCCompat03ClientTransport transport = new JSONRPCCompat03ClientTransport( + delegate, testCard(), List.of()); + MessageSendParams request = new MessageSendParams( + new Message(Message.Role.ROLE_USER, List.of(new TextPart("hello")), "request", + null, null, null, null, null), null, null); + + Message blocking = assertInstanceOf(Message.class, transport.sendMessage(request, null)); + AtomicReference received = new AtomicReference<>(); + AtomicReference error = new AtomicReference<>(); + transport.sendMessageStreaming(request, received::set, error::set, null); + + assertNull(error.get()); + Message streaming = assertInstanceOf(Message.class, received.get()); + for (Message response : List.of(blocking, streaming)) { + assertEquals("response", response.messageId()); + FilePart part = assertInstanceOf(FilePart.class, response.parts().get(0)); + FileWithBytes file = assertInstanceOf(FileWithBytes.class, part.file()); + assertEquals("", file.mimeType()); + assertEquals("", file.name()); + assertEquals("aGVsbG8=", file.bytes()); + } + } + + @Test + void rejectsNonObjectDataIntroducedByAnInterceptorBeforeDelegation() { + ClientCallInterceptor interceptor = new ClientCallInterceptor() { + @Override + public PayloadAndHeaders intercept(String method, Object payload, java.util.Map headers, + AgentCard card, ClientCallContext context) { + Message replacement = new Message(Message.Role.ROLE_USER, List.of(new DataPart(List.of("item"))), + "request", null, null, null, null, null); + org.a2aproject.sdk.grpc.SendMessageRequest request = + (org.a2aproject.sdk.grpc.SendMessageRequest) payload; + return new PayloadAndHeaders(request.toBuilder() + .setMessage(org.a2aproject.sdk.grpc.utils.ProtoUtils.ToProto.message(replacement)).build(), headers); + } + }; + RecordingDelegate delegate = new RecordingDelegate(); + JSONRPCCompat03ClientTransport transport = new JSONRPCCompat03ClientTransport( + delegate, testCard(), List.of(interceptor)); + MessageSendParams request = new MessageSendParams( + new Message(Message.Role.ROLE_USER, List.of(new TextPart("hello")), "request", + null, null, null, null, null), null, null); + + var blocking = assertThrows(org.a2aproject.sdk.spec.A2AClientException.class, + () -> transport.sendMessage(request, null)); + var streaming = assertThrows(org.a2aproject.sdk.spec.A2AClientException.class, + () -> transport.sendMessageStreaming(request, event -> { }, error -> { }, null)); + + assertInstanceOf(UnsupportedOperationError.class, blocking.getCause()); + assertInstanceOf(UnsupportedOperationError.class, streaming.getCause()); + assertFalse(delegate.called); + } + + @Test + void rejectsUnsupportedHistoryLengthIntroducedByAnInterceptor() { + ClientCallInterceptor interceptor = new ClientCallInterceptor() { + @Override + public PayloadAndHeaders intercept(String method, Object payload, java.util.Map headers, + AgentCard card, ClientCallContext context) { + org.a2aproject.sdk.grpc.GetTaskRequest request = + (org.a2aproject.sdk.grpc.GetTaskRequest) payload; + return new PayloadAndHeaders(request.toBuilder().setHistoryLength(0).build(), headers); + } + }; + RecordingDelegate delegate = new RecordingDelegate(); + JSONRPCCompat03ClientTransport transport = new JSONRPCCompat03ClientTransport( + delegate, testCard(), List.of(interceptor)); + + assertThrows(org.a2aproject.sdk.spec.A2AClientException.class, + () -> transport.getTask(new TaskQueryParams("task", 1), null)); + assertFalse(delegate.called); + } + + private static AgentCard testCard() { + return AgentCard.builder().name("agent").description("description").version("1") + .capabilities(new AgentCapabilities(false, false, false, null)) + .defaultInputModes(List.of("text")).defaultOutputModes(List.of("text")) + .skills(List.of()).supportedInterfaces(List.of(new AgentInterface("JSONRPC", "https://example.test", null, "0.3"))) + .build(); + } + + private static AgentCard cardWithInterfaceTenant() { + return AgentCard.builder().name("agent").description("description").version("1") + .capabilities(new AgentCapabilities(false, false, false, null)) + .defaultInputModes(List.of("text")).defaultOutputModes(List.of("text")) + .skills(List.of()).supportedInterfaces(List.of( + new AgentInterface("JSONRPC", "https://example.test", "tenant", "0.3"))) + .build(); + } + + private static final class RecordingDelegate implements ClientTransport_v0_3 { + boolean called; + Message_v0_3 response; + MessageSendParams_v0_3 sent; + TaskIdParams_v0_3 cancelled; + ClientCallContext_v0_3 context; + + @Override public EventKind_v0_3 sendMessage(MessageSendParams_v0_3 request, ClientCallContext_v0_3 context) { + called = true; + sent = request; + this.context = context; + assertNotNull(response); + return response; + } + @Override public void sendMessageStreaming(MessageSendParams_v0_3 request, java.util.function.Consumer events, + java.util.function.Consumer errors, ClientCallContext_v0_3 context) { + called = true; + sent = request; + this.context = context; + assertNotNull(response); + events.accept(response); + } + @Override public org.a2aproject.sdk.compat03.spec.Task_v0_3 getTask(TaskQueryParams_v0_3 request, ClientCallContext_v0_3 context) { called = true; return null; } + @Override public org.a2aproject.sdk.compat03.spec.Task_v0_3 cancelTask(TaskIdParams_v0_3 request, ClientCallContext_v0_3 context) { + cancelled = request; + this.context = context; + return new org.a2aproject.sdk.compat03.spec.Task_v0_3(request.id(), "context", + new org.a2aproject.sdk.compat03.spec.TaskStatus_v0_3(org.a2aproject.sdk.compat03.spec.TaskState_v0_3.CANCELED), + List.of(), List.of(), null); + } + @Override public TaskPushNotificationConfig_v0_3 setTaskPushNotificationConfiguration(TaskPushNotificationConfig_v0_3 request, ClientCallContext_v0_3 context) { return null; } + @Override public TaskPushNotificationConfig_v0_3 getTaskPushNotificationConfiguration(GetTaskPushNotificationConfigParams_v0_3 request, ClientCallContext_v0_3 context) { return null; } + @Override public List listTaskPushNotificationConfigurations(ListTaskPushNotificationConfigParams_v0_3 request, ClientCallContext_v0_3 context) { return List.of(); } + @Override public void deleteTaskPushNotificationConfigurations(DeleteTaskPushNotificationConfigParams_v0_3 request, ClientCallContext_v0_3 context) { } + @Override public void resubscribe(TaskIdParams_v0_3 request, java.util.function.Consumer events, + java.util.function.Consumer errors, ClientCallContext_v0_3 context) { called = true; } + @Override public AgentCard_v0_3 getAgentCard(ClientCallContext_v0_3 context) { return null; } + @Override public void close() { } + } +} diff --git a/compat-0.3/client/adapter-rest/pom.xml b/compat-0.3/client/adapter-rest/pom.xml new file mode 100644 index 000000000..dd09554af --- /dev/null +++ b/compat-0.3/client/adapter-rest/pom.xml @@ -0,0 +1,41 @@ + + + 4.0.0 + + org.a2aproject.sdk + a2a-java-sdk-compat-0.3-parent + 1.4.1.Final-SNAPSHOT + ../../pom.xml + + a2a-java-sdk-compat-0.3-client-adapter-rest + Java SDK A2A Compat 0.3 Client Adapter: REST + + + ${project.groupId} + a2a-java-sdk-compat-0.3-client-adapter + + + ${project.groupId} + a2a-java-sdk-client + + + ${project.groupId} + a2a-java-sdk-client-transport-rest + + + ${project.groupId} + a2a-java-sdk-compat-0.3-client-transport-rest + + + ${project.groupId} + a2a-java-sdk-spec-grpc + + + org.junit.jupiter + junit-jupiter-api + test + + + diff --git a/compat-0.3/client/adapter-rest/src/main/java/org/a2aproject/sdk/compat03/client/adapter/rest/RestCompat03ClientTransport.java b/compat-0.3/client/adapter-rest/src/main/java/org/a2aproject/sdk/compat03/client/adapter/rest/RestCompat03ClientTransport.java new file mode 100644 index 000000000..8a9c880d3 --- /dev/null +++ b/compat-0.3/client/adapter-rest/src/main/java/org/a2aproject/sdk/compat03/client/adapter/rest/RestCompat03ClientTransport.java @@ -0,0 +1,172 @@ +package org.a2aproject.sdk.compat03.client.adapter.rest; + +import java.util.List; +import java.util.Map; +import java.util.function.Consumer; + +import org.a2aproject.sdk.client.transport.spi.interceptors.ClientCallContext; +import org.a2aproject.sdk.client.transport.spi.interceptors.ClientCallInterceptor; +import org.a2aproject.sdk.client.transport.spi.interceptors.PayloadAndHeaders; +import org.a2aproject.sdk.compat03.client.adapter.Compat03ClientCallContextMapper; +import org.a2aproject.sdk.compat03.client.adapter.Compat03ClientTransportBase; +import org.a2aproject.sdk.compat03.client.adapter.Compat03ClientTransportSupport; +import org.a2aproject.sdk.compat03.client.adapter.Compat03InterceptorSupport; +import org.a2aproject.sdk.compat03.client.transport.rest.RestTransport_v0_3; +import org.a2aproject.sdk.compat03.conversion.mappers.domain.TaskMapper_v0_3; +import org.a2aproject.sdk.grpc.utils.ProtoUtils; +import org.a2aproject.sdk.spec.A2AMethods; +import org.a2aproject.sdk.spec.AgentCard; +import org.a2aproject.sdk.spec.CancelTaskParams; +import org.a2aproject.sdk.spec.DeleteTaskPushNotificationConfigParams; +import org.a2aproject.sdk.spec.EventKind; +import org.a2aproject.sdk.spec.GetTaskPushNotificationConfigParams; +import org.a2aproject.sdk.spec.ListTaskPushNotificationConfigsParams; +import org.a2aproject.sdk.spec.ListTaskPushNotificationConfigsResult; +import org.a2aproject.sdk.spec.MessageSendParams; +import org.a2aproject.sdk.spec.StreamingEventKind; +import org.a2aproject.sdk.spec.Task; +import org.a2aproject.sdk.spec.TaskIdParams; +import org.a2aproject.sdk.spec.TaskPushNotificationConfig; +import org.a2aproject.sdk.spec.TaskQueryParams; +import org.jspecify.annotations.Nullable; + +/** 1.0 client facade backed by the legacy REST transport. */ +public final class RestCompat03ClientTransport extends Compat03ClientTransportBase { + public RestCompat03ClientTransport(RestTransport_v0_3 delegate, AgentCard card, + List interceptors) { + super(delegate, card, interceptors); + } + + @Override + public EventKind sendMessage(MessageSendParams request, @Nullable ClientCallContext context) { + Compat03ClientTransportSupport.validateProtobufMessageSend(request); + PayloadAndHeaders payload = apply(A2AMethods.SEND_MESSAGE_METHOD, + org.a2aproject.sdk.grpc.SendMessageRequest.newBuilder(ProtoUtils.ToProto.sendMessageRequest(request)), + org.a2aproject.sdk.grpc.SendMessageRequest.Builder.class, context); + return Compat03ClientTransportSupport.call(() -> Compat03ClientTransportSupport.toV10(delegate.sendMessage( + Compat03ClientTransportSupport.toV03Protobuf(ProtoUtils.FromProto.messageSendParams( + ((org.a2aproject.sdk.grpc.SendMessageRequest.Builder) payload.getPayload()).build())), + Compat03ClientCallContextMapper.toV03(contextWithHeaders(context, payload))))); + } + + @Override + public void sendMessageStreaming(MessageSendParams request, Consumer events, + Consumer errors, @Nullable ClientCallContext context) { + Compat03ClientTransportSupport.validateProtobufMessageSend(request); + PayloadAndHeaders payload = apply(A2AMethods.SEND_STREAMING_MESSAGE_METHOD, + org.a2aproject.sdk.grpc.SendMessageRequest.newBuilder(ProtoUtils.ToProto.sendMessageRequest(request)), + org.a2aproject.sdk.grpc.SendMessageRequest.Builder.class, context); + Compat03ClientTransportSupport.run(() -> delegate.sendMessageStreaming( + Compat03ClientTransportSupport.toV03Protobuf(ProtoUtils.FromProto.messageSendParams( + ((org.a2aproject.sdk.grpc.SendMessageRequest.Builder) payload.getPayload()).build())), + event -> events.accept(Compat03ClientTransportSupport.toV10(event)), + Compat03ClientTransportSupport.mapAsyncError(errors), + Compat03ClientCallContextMapper.toV03(contextWithHeaders(context, payload)))); + } + + @Override + public Task getTask(TaskQueryParams request, @Nullable ClientCallContext context) { + Compat03ClientTransportSupport.validateTaskQuery(request); + PayloadAndHeaders payload = apply(A2AMethods.GET_TASK_METHOD, + org.a2aproject.sdk.grpc.GetTaskRequest.newBuilder(ProtoUtils.ToProto.getTaskRequest(request)), + org.a2aproject.sdk.grpc.GetTaskRequest.Builder.class, context); + return Compat03ClientTransportSupport.call(() -> TaskMapper_v0_3.INSTANCE.toV10(delegate.getTask( + Compat03ClientTransportSupport.toV03(ProtoUtils.FromProto.taskQueryParams( + ((org.a2aproject.sdk.grpc.GetTaskRequest.Builder) payload.getPayload()).build())), + Compat03ClientCallContextMapper.toV03(contextWithHeaders(context, payload))))); + } + + @Override + public Task cancelTask(CancelTaskParams request, @Nullable ClientCallContext context) { + Compat03ClientTransportSupport.validateProtobufCancel(request); + PayloadAndHeaders payload = apply(A2AMethods.CANCEL_TASK_METHOD, + org.a2aproject.sdk.grpc.CancelTaskRequest.newBuilder(ProtoUtils.ToProto.cancelTaskRequest(request)), + org.a2aproject.sdk.grpc.CancelTaskRequest.Builder.class, context); + return Compat03ClientTransportSupport.call(() -> TaskMapper_v0_3.INSTANCE.toV10(delegate.cancelTask( + Compat03ClientTransportSupport.toV03Protobuf(ProtoUtils.FromProto.cancelTaskParams( + ((org.a2aproject.sdk.grpc.CancelTaskRequest.Builder) payload.getPayload()).build())), + Compat03ClientCallContextMapper.toV03(contextWithHeaders(context, payload))))); + } + + @Override + public TaskPushNotificationConfig createTaskPushNotificationConfiguration(TaskPushNotificationConfig request, + @Nullable ClientCallContext context) { + Compat03ClientTransportSupport.validatePushConfig(request); + PayloadAndHeaders payload = apply(A2AMethods.SET_TASK_PUSH_NOTIFICATION_CONFIG_METHOD, + org.a2aproject.sdk.grpc.TaskPushNotificationConfig.newBuilder(ProtoUtils.ToProto.taskPushNotificationConfig(request)), + org.a2aproject.sdk.grpc.TaskPushNotificationConfig.Builder.class, context); + return Compat03ClientTransportSupport.call(() -> Compat03ClientTransportSupport.toV10( + delegate.setTaskPushNotificationConfiguration(Compat03ClientTransportSupport.toV03( + ProtoUtils.FromProto.taskPushNotificationConfig( + ((org.a2aproject.sdk.grpc.TaskPushNotificationConfig.Builder) payload.getPayload()).build())), + Compat03ClientCallContextMapper.toV03(contextWithHeaders(context, payload))))); + } + + @Override + public TaskPushNotificationConfig getTaskPushNotificationConfiguration(GetTaskPushNotificationConfigParams request, + @Nullable ClientCallContext context) { + Compat03ClientTransportSupport.validateGetPush(request); + PayloadAndHeaders payload = apply(A2AMethods.GET_TASK_PUSH_NOTIFICATION_CONFIG_METHOD, + org.a2aproject.sdk.grpc.GetTaskPushNotificationConfigRequest.newBuilder( + ProtoUtils.ToProto.getTaskPushNotificationConfigRequest(request)), + org.a2aproject.sdk.grpc.GetTaskPushNotificationConfigRequest.Builder.class, context); + return Compat03ClientTransportSupport.call(() -> Compat03ClientTransportSupport.toV10( + delegate.getTaskPushNotificationConfiguration(Compat03ClientTransportSupport.toV03( + ProtoUtils.FromProto.getTaskPushNotificationConfigParams( + ((org.a2aproject.sdk.grpc.GetTaskPushNotificationConfigRequest.Builder) payload.getPayload()).build())), + Compat03ClientCallContextMapper.toV03(contextWithHeaders(context, payload))))); + } + + @Override + public ListTaskPushNotificationConfigsResult listTaskPushNotificationConfigurations( + ListTaskPushNotificationConfigsParams request, @Nullable ClientCallContext context) { + Compat03ClientTransportSupport.validatePushList(request); + PayloadAndHeaders payload = apply(A2AMethods.LIST_TASK_PUSH_NOTIFICATION_CONFIG_METHOD, + org.a2aproject.sdk.grpc.ListTaskPushNotificationConfigsRequest.newBuilder( + ProtoUtils.ToProto.listTaskPushNotificationConfigsRequest(request)), + org.a2aproject.sdk.grpc.ListTaskPushNotificationConfigsRequest.Builder.class, context); + return Compat03ClientTransportSupport.call(() -> Compat03ClientTransportSupport.toV10PushList( + delegate.listTaskPushNotificationConfigurations(Compat03ClientTransportSupport.toV03( + ProtoUtils.FromProto.listTaskPushNotificationConfigsParams( + ((org.a2aproject.sdk.grpc.ListTaskPushNotificationConfigsRequest.Builder) payload.getPayload()).build())), + Compat03ClientCallContextMapper.toV03(contextWithHeaders(context, payload))))); + } + + @Override + public void deleteTaskPushNotificationConfigurations(DeleteTaskPushNotificationConfigParams request, + @Nullable ClientCallContext context) { + Compat03ClientTransportSupport.validateDeletePush(request); + PayloadAndHeaders payload = apply(A2AMethods.DELETE_TASK_PUSH_NOTIFICATION_CONFIG_METHOD, + org.a2aproject.sdk.grpc.DeleteTaskPushNotificationConfigRequest.newBuilder( + ProtoUtils.ToProto.deleteTaskPushNotificationConfigRequest(request)), + org.a2aproject.sdk.grpc.DeleteTaskPushNotificationConfigRequest.Builder.class, context); + Compat03ClientTransportSupport.run(() -> delegate.deleteTaskPushNotificationConfigurations( + Compat03ClientTransportSupport.toV03(ProtoUtils.FromProto.deleteTaskPushNotificationConfigParams( + ((org.a2aproject.sdk.grpc.DeleteTaskPushNotificationConfigRequest.Builder) payload.getPayload()).build())), + Compat03ClientCallContextMapper.toV03(contextWithHeaders(context, payload)))); + } + + @Override + public void subscribeToTask(TaskIdParams request, Consumer events, + Consumer errors, @Nullable ClientCallContext context) { + Compat03ClientTransportSupport.validateTenant("subscribeToTask", request.tenant()); + PayloadAndHeaders payload = apply(A2AMethods.SUBSCRIBE_TO_TASK_METHOD, + org.a2aproject.sdk.grpc.SubscribeToTaskRequest.newBuilder(ProtoUtils.ToProto.subscribeToTaskRequest(request)), + org.a2aproject.sdk.grpc.SubscribeToTaskRequest.Builder.class, context); + Compat03ClientTransportSupport.run(() -> delegate.resubscribe( + Compat03ClientTransportSupport.toV03(ProtoUtils.FromProto.taskIdParams( + ((org.a2aproject.sdk.grpc.SubscribeToTaskRequest.Builder) payload.getPayload()).build())), + event -> events.accept(Compat03ClientTransportSupport.toV10(event)), + Compat03ClientTransportSupport.mapAsyncError(errors), + Compat03ClientCallContextMapper.toV03(contextWithHeaders(context, payload)))); + } + + private PayloadAndHeaders apply(String method, Object payload, Class expected, + @Nullable ClientCallContext context) { + return Compat03InterceptorSupport.apply(interceptors, method, payload, agentCard, context, expected); + } + + private static ClientCallContext contextWithHeaders(@Nullable ClientCallContext original, PayloadAndHeaders payload) { + return new ClientCallContext(original == null ? Map.of() : original.getState(), payload.getHeaders()); + } +} diff --git a/compat-0.3/client/adapter-rest/src/main/java/org/a2aproject/sdk/compat03/client/adapter/rest/RestCompat03ClientTransportProvider.java b/compat-0.3/client/adapter-rest/src/main/java/org/a2aproject/sdk/compat03/client/adapter/rest/RestCompat03ClientTransportProvider.java new file mode 100644 index 000000000..cddae9e27 --- /dev/null +++ b/compat-0.3/client/adapter-rest/src/main/java/org/a2aproject/sdk/compat03/client/adapter/rest/RestCompat03ClientTransportProvider.java @@ -0,0 +1,35 @@ +package org.a2aproject.sdk.compat03.client.adapter.rest; + +import org.a2aproject.sdk.client.VersionedClientTransportProvider; +import org.a2aproject.sdk.client.http.A2AHttpClient; +import org.a2aproject.sdk.client.transport.rest.RestTransport; +import org.a2aproject.sdk.client.transport.rest.RestTransportConfig; +import org.a2aproject.sdk.client.transport.spi.ClientTransport; +import org.a2aproject.sdk.client.transport.spi.ClientTransportConfig; +import org.a2aproject.sdk.compat03.client.adapter.Compat03ClientTransportBase; +import org.a2aproject.sdk.compat03.client.adapter.Compat03ClientTransportSupport; +import org.a2aproject.sdk.compat03.client.transport.rest.RestTransport_v0_3; +import org.a2aproject.sdk.spec.A2AClientException; +import org.a2aproject.sdk.spec.AgentCard; +import org.a2aproject.sdk.spec.AgentInterface; + +/** ServiceLoader provider for the optional REST 0.3 adapter. */ +public final class RestCompat03ClientTransportProvider implements VersionedClientTransportProvider { + @Override public String protocolBinding() { return "HTTP+JSON"; } + @Override public String protocolVersion() { return "0.3"; } + @Override public Class configuredTransportClass() { return RestTransport.class; } + + @Override + public ClientTransport create(ClientTransportConfig config, AgentCard card, AgentInterface agentInterface) + throws A2AClientException { + Compat03ClientTransportSupport.validateAgentInterfaceTenant(agentInterface.tenant()); + if (!(config instanceof RestTransportConfig nativeConfig)) { + throw new A2AClientException("Expected RestTransportConfig for the REST 0.3 adapter"); + } + Compat03ClientTransportSupport.validateConfig(nativeConfig); + A2AHttpClient httpClient = nativeConfig.getHttpClient(); + RestTransport_v0_3 legacy = new RestTransport_v0_3(httpClient, + Compat03ClientTransportBase.legacyCard(card), agentInterface.url(), java.util.List.of()); + return new RestCompat03ClientTransport(legacy, card, nativeConfig.getInterceptors()); + } +} diff --git a/compat-0.3/client/adapter-rest/src/main/resources/META-INF/services/org.a2aproject.sdk.client.VersionedClientTransportProvider b/compat-0.3/client/adapter-rest/src/main/resources/META-INF/services/org.a2aproject.sdk.client.VersionedClientTransportProvider new file mode 100644 index 000000000..7eb59dcfa --- /dev/null +++ b/compat-0.3/client/adapter-rest/src/main/resources/META-INF/services/org.a2aproject.sdk.client.VersionedClientTransportProvider @@ -0,0 +1 @@ +org.a2aproject.sdk.compat03.client.adapter.rest.RestCompat03ClientTransportProvider diff --git a/compat-0.3/client/adapter-rest/src/test/java/org/a2aproject/sdk/compat03/client/adapter/rest/RestCompat03ClientTransportTest.java b/compat-0.3/client/adapter-rest/src/test/java/org/a2aproject/sdk/compat03/client/adapter/rest/RestCompat03ClientTransportTest.java new file mode 100644 index 000000000..c944916d8 --- /dev/null +++ b/compat-0.3/client/adapter-rest/src/test/java/org/a2aproject/sdk/compat03/client/adapter/rest/RestCompat03ClientTransportTest.java @@ -0,0 +1,71 @@ +package org.a2aproject.sdk.compat03.client.adapter.rest; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.a2aproject.sdk.client.transport.rest.RestTransport; +import org.a2aproject.sdk.client.transport.spi.ClientTransportConfig; +import org.a2aproject.sdk.spec.A2AClientException; +import org.a2aproject.sdk.spec.AgentCapabilities; +import org.a2aproject.sdk.spec.AgentCard; +import org.a2aproject.sdk.spec.AgentInterface; +import org.junit.jupiter.api.Test; + +class RestCompat03ClientTransportTest { + @Test + void providerTargetsHttpJsonAndOrdinaryRestConfiguration() { + RestCompat03ClientTransportProvider provider = new RestCompat03ClientTransportProvider(); + + assertEquals("HTTP+JSON", provider.protocolBinding()); + assertEquals("0.3", provider.protocolVersion()); + assertEquals(RestTransport.class, provider.configuredTransportClass()); + } + + @Test + void rejectsTenantOnLegacyAgentInterface() { + AgentCard card = cardWithTenant(); + + A2AClientException exception = assertThrows(A2AClientException.class, + () -> new RestCompat03ClientTransportProvider().create(null, card, card.supportedInterfaces().get(0))); + + assertTrue(exception.getMessage().contains("tenant")); + } + + @Test + void providerRejectsWrongConfigurationType() { + AgentCard card = card(); + A2AClientException exception = assertThrows(A2AClientException.class, + () -> new RestCompat03ClientTransportProvider().create( + new ClientTransportConfig() { }, card, card.supportedInterfaces().get(0))); + + assertTrue(exception.getMessage().contains("RestTransportConfig")); + } + + @Test + void providerRejectsNullConfiguration() { + AgentCard card = card(); + A2AClientException exception = assertThrows(A2AClientException.class, + () -> new RestCompat03ClientTransportProvider().create(null, card, card.supportedInterfaces().get(0))); + + assertTrue(exception.getMessage().contains("RestTransportConfig")); + } + + private static AgentCard card() { + return AgentCard.builder().name("agent").description("description").version("1") + .capabilities(AgentCapabilities.builder().build()) + .defaultInputModes(java.util.List.of("text")).defaultOutputModes(java.util.List.of("text")) + .skills(java.util.List.of()) + .supportedInterfaces(java.util.List.of(new AgentInterface("HTTP+JSON", "https://example.test", null, "0.3"))) + .build(); + } + + private static AgentCard cardWithTenant() { + return AgentCard.builder().name("agent").description("description").version("1") + .capabilities(AgentCapabilities.builder().build()) + .defaultInputModes(java.util.List.of("text")).defaultOutputModes(java.util.List.of("text")) + .skills(java.util.List.of()) + .supportedInterfaces(java.util.List.of(new AgentInterface("HTTP+JSON", "https://example.test", "tenant", "0.3"))) + .build(); + } +} diff --git a/compat-0.3/client/adapter-rest/src/test/java/org/a2aproject/sdk/compat03/client/adapter/rest/RestCompat03UnsupportedFieldsTest.java b/compat-0.3/client/adapter-rest/src/test/java/org/a2aproject/sdk/compat03/client/adapter/rest/RestCompat03UnsupportedFieldsTest.java new file mode 100644 index 000000000..2e0493829 --- /dev/null +++ b/compat-0.3/client/adapter-rest/src/test/java/org/a2aproject/sdk/compat03/client/adapter/rest/RestCompat03UnsupportedFieldsTest.java @@ -0,0 +1,96 @@ +package org.a2aproject.sdk.compat03.client.adapter.rest; + +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import java.util.Map; +import java.util.function.Consumer; + +import com.google.protobuf.Struct; +import com.google.protobuf.Value; +import org.a2aproject.sdk.client.transport.spi.interceptors.ClientCallContext; +import org.a2aproject.sdk.client.transport.spi.interceptors.ClientCallInterceptor; +import org.a2aproject.sdk.client.transport.spi.interceptors.PayloadAndHeaders; +import org.a2aproject.sdk.compat03.client.adapter.Compat03ClientTransportBase; +import org.a2aproject.sdk.compat03.client.transport.rest.RestTransport_v0_3; +import org.a2aproject.sdk.compat03.client.transport.spi.interceptors.ClientCallContext_v0_3; +import org.a2aproject.sdk.compat03.spec.EventKind_v0_3; +import org.a2aproject.sdk.compat03.spec.MessageSendParams_v0_3; +import org.a2aproject.sdk.compat03.spec.StreamingEventKind_v0_3; +import org.a2aproject.sdk.compat03.spec.TaskIdParams_v0_3; +import org.a2aproject.sdk.compat03.spec.Task_v0_3; +import org.a2aproject.sdk.spec.A2AClientException; +import org.a2aproject.sdk.spec.AgentCapabilities; +import org.a2aproject.sdk.spec.AgentCard; +import org.a2aproject.sdk.spec.AgentInterface; +import org.a2aproject.sdk.spec.CancelTaskParams; +import org.a2aproject.sdk.spec.Message; +import org.a2aproject.sdk.spec.MessageSendParams; +import org.a2aproject.sdk.spec.TextPart; +import org.a2aproject.sdk.spec.UnsupportedOperationError; +import org.jspecify.annotations.Nullable; +import org.junit.jupiter.api.Test; + +class RestCompat03UnsupportedFieldsTest { + @Test + void rejectsOriginalFieldsAndInterceptorMutationsBeforeDelegation() { + AgentCard card = AgentCard.builder().name("legacy").description("legacy").version("1") + .url("https://example.test").capabilities(AgentCapabilities.builder().build()) + .defaultInputModes(List.of("text")).defaultOutputModes(List.of("text")).skills(List.of()) + .supportedInterfaces(List.of(new AgentInterface("HTTP+JSON", "https://example.test", null, "0.3"))).build(); + var delegate = new RestTransport_v0_3(null, Compat03ClientTransportBase.legacyCard(card), "https://example.test", List.of()) { + @Override + public EventKind_v0_3 sendMessage(MessageSendParams_v0_3 request, @Nullable ClientCallContext_v0_3 context) { + throw new AssertionError("Unsupported request reached the delegate"); + } + + @Override + public void sendMessageStreaming(MessageSendParams_v0_3 request, Consumer events, + Consumer errors, @Nullable ClientCallContext_v0_3 context) { + throw new AssertionError("Unsupported streaming request reached the delegate"); + } + + @Override + public Task_v0_3 cancelTask(TaskIdParams_v0_3 request, @Nullable ClientCallContext_v0_3 context) { + throw new AssertionError("Unsupported cancellation reached the delegate"); + } + }; + for (boolean intercepted : List.of(false, true)) { + var transport = new RestCompat03ClientTransport(delegate, card, + intercepted ? List.of(unsupportedFieldsInterceptor()) : List.of()); + var request = new MessageSendParams(new Message(Message.Role.ROLE_USER, List.of(new TextPart("hello")), + "request", null, null, intercepted ? null : List.of("referenced-task"), null, null), null, null); + assertUnsupported("referenceTaskIds", () -> transport.sendMessage(request, null)); + assertUnsupported("referenceTaskIds", () -> transport.sendMessageStreaming(request, + event -> { }, error -> { throw new AssertionError(error); }, null)); + var cancel = new CancelTaskParams("task", null, intercepted ? Map.of() : Map.of("reason", "stop")); + assertUnsupported("metadata", () -> transport.cancelTask(cancel, null)); + } + } + + private static void assertUnsupported(String field, Runnable call) { + A2AClientException error = assertThrows(A2AClientException.class, call::run); + assertInstanceOf(UnsupportedOperationError.class, error.getCause()); + assertTrue(error.getMessage().contains(field)); + } + + private static ClientCallInterceptor unsupportedFieldsInterceptor() { + return new ClientCallInterceptor() { + @Override + public PayloadAndHeaders intercept(String method, @Nullable Object payload, Map headers, + @Nullable AgentCard card, @Nullable ClientCallContext context) { + if (payload instanceof org.a2aproject.sdk.grpc.SendMessageRequest.Builder) { + var request = (org.a2aproject.sdk.grpc.SendMessageRequest.Builder) payload; + request.getMessageBuilder().addReferenceTaskIds("referenced-task"); + return new PayloadAndHeaders(request, headers); + } + Struct metadata = Struct.newBuilder().putFields("reason", Value.newBuilder().setStringValue("stop").build()).build(); + var request = (org.a2aproject.sdk.grpc.CancelTaskRequest.Builder) payload; + request.setMetadata(metadata); + return new PayloadAndHeaders(request, headers); + } + }; + } +} diff --git a/compat-0.3/client/adapter/pom.xml b/compat-0.3/client/adapter/pom.xml new file mode 100644 index 000000000..dc0360677 --- /dev/null +++ b/compat-0.3/client/adapter/pom.xml @@ -0,0 +1,46 @@ + + + 4.0.0 + + org.a2aproject.sdk + a2a-java-sdk-compat-0.3-parent + 1.4.1.Final-SNAPSHOT + ../.. + + a2a-java-sdk-compat-0.3-client-adapter + Java SDK A2A Compat 0.3 Client Adapter + Optional 0.3 agent-card parser and client adapter support + + + ${project.groupId} + a2a-java-sdk-compat-0.3-conversion + + + ${project.groupId} + a2a-java-sdk-spec + + + ${project.groupId} + a2a-java-sdk-compat-0.3-spec + + + ${project.groupId} + a2a-java-sdk-http-client + + + ${project.groupId} + a2a-java-sdk-client-transport-spi + + + ${project.groupId} + a2a-java-sdk-compat-0.3-client-transport-spi + + + org.junit.jupiter + junit-jupiter-api + test + + + diff --git a/compat-0.3/client/adapter/src/main/java/org/a2aproject/sdk/compat03/client/adapter/Compat03AgentCardCompatibilityParser.java b/compat-0.3/client/adapter/src/main/java/org/a2aproject/sdk/compat03/client/adapter/Compat03AgentCardCompatibilityParser.java new file mode 100644 index 000000000..13971dccb --- /dev/null +++ b/compat-0.3/client/adapter/src/main/java/org/a2aproject/sdk/compat03/client/adapter/Compat03AgentCardCompatibilityParser.java @@ -0,0 +1,43 @@ +package org.a2aproject.sdk.compat03.client.adapter; + +import java.util.Optional; +import java.util.Set; + +import org.a2aproject.sdk.client.http.A2ACardResolver; +import org.a2aproject.sdk.client.http.AgentCardCompatibilityParser; +import org.a2aproject.sdk.compat03.conversion.mappers.domain.AgentCardMapper_v0_3; +import org.a2aproject.sdk.compat03.json.JsonProcessingException_v0_3; +import org.a2aproject.sdk.compat03.json.JsonUtil_v0_3; +import org.a2aproject.sdk.compat03.spec.AgentCard_v0_3; +import org.a2aproject.sdk.spec.A2AClientJSONError; +import org.a2aproject.sdk.spec.AgentCard; +import org.jspecify.annotations.Nullable; + +/** Parses a legacy 0.3 JSON card and projects it into the public 1.0 card model. */ +public final class Compat03AgentCardCompatibilityParser implements AgentCardCompatibilityParser { + @Override + public String supportedProtocolVersion() { + return "0.3"; + } + + @Override + public Optional parse(String rawCardJson, @Nullable AgentCard parsedV10Card, + Set requestedProtocolVersions) { + final AgentCard_v0_3 legacyCard; + try { + legacyCard = JsonUtil_v0_3.fromJson(rawCardJson, AgentCard_v0_3.class); + } catch (JsonProcessingException_v0_3 e) { + throw new A2AClientJSONError("Could not convert A2A 0.3 agent card to the unified client model", e); + } + final String version; + try { + version = A2ACardResolver.normalizeSupportedProtocolVersion(legacyCard.protocolVersion()); + } catch (IllegalArgumentException e) { + return Optional.empty(); + } + if (!"0.3".equals(version)) { + return Optional.empty(); + } + return Optional.of(AgentCardMapper_v0_3.INSTANCE.toV10(legacyCard)); + } +} diff --git a/compat-0.3/client/adapter/src/main/java/org/a2aproject/sdk/compat03/client/adapter/Compat03ClientCallContextMapper.java b/compat-0.3/client/adapter/src/main/java/org/a2aproject/sdk/compat03/client/adapter/Compat03ClientCallContextMapper.java new file mode 100644 index 000000000..c3eb74691 --- /dev/null +++ b/compat-0.3/client/adapter/src/main/java/org/a2aproject/sdk/compat03/client/adapter/Compat03ClientCallContextMapper.java @@ -0,0 +1,37 @@ +package org.a2aproject.sdk.compat03.client.adapter; + +import java.util.HashMap; +import java.util.Map; + +import org.a2aproject.sdk.client.transport.spi.interceptors.ClientCallContext; +import org.a2aproject.sdk.common.A2AHeaders; +import org.a2aproject.sdk.compat03.common.A2AHeaders_v0_3; +import org.a2aproject.sdk.compat03.client.transport.spi.interceptors.ClientCallContext_v0_3; +import org.jspecify.annotations.Nullable; + +/** Converts public 1.0 call contexts to the equivalent 0.3 context. */ +public final class Compat03ClientCallContextMapper { + + private Compat03ClientCallContextMapper() { + } + + public static @Nullable ClientCallContext_v0_3 toV03(@Nullable ClientCallContext context) { + if (context == null) { + return null; + } + Map headers = new HashMap<>(); + context.getHeaders().forEach((name, value) -> { + if (!A2AHeaders.A2A_EXTENSIONS.equalsIgnoreCase(name)) { + headers.put(A2AHeaders_v0_3.X_A2A_EXTENSIONS.equalsIgnoreCase(name) + ? A2AHeaders_v0_3.X_A2A_EXTENSIONS : name, value); + } + }); + // The native header, including interceptor replacements, takes precedence over a legacy alias. + context.getHeaders().forEach((name, value) -> { + if (A2AHeaders.A2A_EXTENSIONS.equalsIgnoreCase(name)) { + headers.put(A2AHeaders_v0_3.X_A2A_EXTENSIONS, value); + } + }); + return new ClientCallContext_v0_3(Map.copyOf(context.getState()), Map.copyOf(headers)); + } +} diff --git a/compat-0.3/client/adapter/src/main/java/org/a2aproject/sdk/compat03/client/adapter/Compat03ClientErrorMapper.java b/compat-0.3/client/adapter/src/main/java/org/a2aproject/sdk/compat03/client/adapter/Compat03ClientErrorMapper.java new file mode 100644 index 000000000..ba30a8c55 --- /dev/null +++ b/compat-0.3/client/adapter/src/main/java/org/a2aproject/sdk/compat03/client/adapter/Compat03ClientErrorMapper.java @@ -0,0 +1,89 @@ +package org.a2aproject.sdk.compat03.client.adapter; + +import java.io.IOException; +import java.util.LinkedHashMap; +import java.util.Map; + +import org.a2aproject.sdk.compat03.spec.A2AClientException_v0_3; +import org.a2aproject.sdk.compat03.spec.A2AClientHTTPError_v0_3; +import org.a2aproject.sdk.compat03.spec.A2AErrorCodes_v0_3; +import org.a2aproject.sdk.compat03.spec.JSONRPCError_v0_3; +import org.a2aproject.sdk.spec.A2AClientException; +import org.a2aproject.sdk.spec.A2AClientHTTPError; +import org.a2aproject.sdk.spec.A2AError; +import org.a2aproject.sdk.spec.ContentTypeNotSupportedError; +import org.a2aproject.sdk.spec.ExtendedAgentCardNotConfiguredError; +import org.a2aproject.sdk.spec.InternalError; +import org.a2aproject.sdk.spec.InvalidAgentResponseError; +import org.a2aproject.sdk.spec.InvalidParamsError; +import org.a2aproject.sdk.spec.InvalidRequestError; +import org.a2aproject.sdk.spec.JSONParseError; +import org.a2aproject.sdk.spec.MethodNotFoundError; +import org.a2aproject.sdk.spec.PushNotificationNotSupportedError; +import org.a2aproject.sdk.spec.TaskNotCancelableError; +import org.a2aproject.sdk.spec.TaskNotFoundError; +import org.a2aproject.sdk.spec.UnsupportedOperationError; + +/** Maps errors from a 0.3 delegate into the public 1.0 exception hierarchy. */ +public final class Compat03ClientErrorMapper { + + private Compat03ClientErrorMapper() { + } + + public static A2AClientException toV10(A2AClientException_v0_3 exception) { + Throwable cause = exception.getCause(); + String message = exception.getMessage() == null ? "A2A 0.3 client operation failed" : exception.getMessage(); + if (cause instanceof A2AClientHTTPError_v0_3 error) { + return new A2AClientException(message, new A2AClientHTTPError(error.getCode(), error.getMessage(), + error.getResponseBody(), error.getResponseHeaders())); + } + if (cause instanceof IOException && cause.getCause() instanceof A2AClientHTTPError error) { + return new A2AClientException(message, error); + } + if (cause instanceof JSONRPCError_v0_3 error) { + return new A2AClientException(message, toV10(error)); + } + return cause == null ? new A2AClientException(message) : new A2AClientException(message, cause); + } + + public static A2AError toV10(JSONRPCError_v0_3 error) { + Integer code = error.getCode(); + String message = error.getMessage(); + Map details = details(error.getData()); + return switch (code) { + case A2AErrorCodes_v0_3.JSON_PARSE_ERROR_CODE -> new JSONParseError(code, message, details); + case A2AErrorCodes_v0_3.INVALID_REQUEST_ERROR_CODE -> new InvalidRequestError(code, message, details); + case A2AErrorCodes_v0_3.METHOD_NOT_FOUND_ERROR_CODE -> new MethodNotFoundError(code, message, details); + case A2AErrorCodes_v0_3.INVALID_PARAMS_ERROR_CODE -> new InvalidParamsError(code, message, details); + case A2AErrorCodes_v0_3.INTERNAL_ERROR_CODE -> new InternalError(code, message, details); + case A2AErrorCodes_v0_3.TASK_NOT_FOUND_ERROR_CODE -> new TaskNotFoundError(message, details); + case A2AErrorCodes_v0_3.TASK_NOT_CANCELABLE_ERROR_CODE -> new TaskNotCancelableError(code, message, details); + case A2AErrorCodes_v0_3.PUSH_NOTIFICATION_NOT_SUPPORTED_ERROR_CODE -> + new PushNotificationNotSupportedError(code, message, details); + case A2AErrorCodes_v0_3.UNSUPPORTED_OPERATION_ERROR_CODE -> new UnsupportedOperationError(code, message, details); + case A2AErrorCodes_v0_3.CONTENT_TYPE_NOT_SUPPORTED_ERROR_CODE -> + new ContentTypeNotSupportedError(code, message, details); + case A2AErrorCodes_v0_3.INVALID_AGENT_RESPONSE_ERROR_CODE -> + new InvalidAgentResponseError(code, message, details); + case A2AErrorCodes_v0_3.AUTHENTICATED_EXTENDED_CARD_NOT_CONFIGURED_ERROR_CODE -> + new ExtendedAgentCardNotConfiguredError(code, message, details); + default -> new A2AError(code, message, details); + }; + } + + private static Map details(Object data) { + if (data == null) { + return Map.of(); + } + if (!(data instanceof Map rawDetails)) { + return Map.of("data", data); + } + Map details = new LinkedHashMap<>(); + rawDetails.forEach((key, value) -> { + if (key instanceof String stringKey) { + details.put(stringKey, value); + } + }); + return details; + } +} diff --git a/compat-0.3/client/adapter/src/main/java/org/a2aproject/sdk/compat03/client/adapter/Compat03ClientTransportBase.java b/compat-0.3/client/adapter/src/main/java/org/a2aproject/sdk/compat03/client/adapter/Compat03ClientTransportBase.java new file mode 100644 index 000000000..0c06cfa06 --- /dev/null +++ b/compat-0.3/client/adapter/src/main/java/org/a2aproject/sdk/compat03/client/adapter/Compat03ClientTransportBase.java @@ -0,0 +1,148 @@ +package org.a2aproject.sdk.compat03.client.adapter; + +import java.util.List; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.Consumer; + +import org.a2aproject.sdk.client.transport.spi.ClientTransport; +import org.a2aproject.sdk.client.transport.spi.interceptors.ClientCallContext; +import org.a2aproject.sdk.client.transport.spi.interceptors.ClientCallInterceptor; +import org.a2aproject.sdk.compat03.client.transport.spi.ClientTransport_v0_3; +import org.a2aproject.sdk.compat03.spec.AgentCard_v0_3; +import org.a2aproject.sdk.compat03.spec.EventKind_v0_3; +import org.a2aproject.sdk.compat03.spec.StreamingEventKind_v0_3; +import org.a2aproject.sdk.compat03.spec.Task_v0_3; +import org.a2aproject.sdk.spec.AgentCard; +import org.a2aproject.sdk.spec.CancelTaskParams; +import org.a2aproject.sdk.spec.DeleteTaskPushNotificationConfigParams; +import org.a2aproject.sdk.spec.EventKind; +import org.a2aproject.sdk.spec.GetExtendedAgentCardParams; +import org.a2aproject.sdk.spec.GetTaskPushNotificationConfigParams; +import org.a2aproject.sdk.spec.ListTaskPushNotificationConfigsParams; +import org.a2aproject.sdk.spec.ListTaskPushNotificationConfigsResult; +import org.a2aproject.sdk.spec.ListTasksParams; +import org.a2aproject.sdk.spec.MessageSendParams; +import org.a2aproject.sdk.spec.StreamingEventKind; +import org.a2aproject.sdk.spec.Task; +import org.a2aproject.sdk.spec.TaskIdParams; +import org.a2aproject.sdk.spec.TaskPushNotificationConfig; +import org.a2aproject.sdk.spec.TaskQueryParams; +import org.a2aproject.sdk.jsonrpc.common.wrappers.ListTasksResult; +import org.jspecify.annotations.Nullable; + +/** Common public 1.0 transport facade over a legacy 0.3 transport. */ +public abstract class Compat03ClientTransportBase implements ClientTransport { + protected final ClientTransport_v0_3 delegate; + protected final AgentCard agentCard; + protected final List interceptors; + private final AtomicBoolean closed = new AtomicBoolean(); + + protected Compat03ClientTransportBase(ClientTransport_v0_3 delegate, AgentCard agentCard, + List interceptors) { + this.delegate = delegate; + this.agentCard = agentCard; + this.interceptors = List.copyOf(interceptors); + } + + @Override + public EventKind sendMessage(MessageSendParams request, @Nullable ClientCallContext context) { + Compat03ClientTransportSupport.validateMessageSend(request); + return Compat03ClientTransportSupport.call(() -> + Compat03ClientTransportSupport.toV10(delegate.sendMessage( + Compat03ClientTransportSupport.toV03(request), Compat03ClientTransportSupport.toV03Context(context)))); + } + + @Override + public void sendMessageStreaming(MessageSendParams request, Consumer events, + Consumer errors, @Nullable ClientCallContext context) { + Compat03ClientTransportSupport.validateMessageSend(request); + Compat03ClientTransportSupport.run(() -> delegate.sendMessageStreaming( + Compat03ClientTransportSupport.toV03(request), + event -> events.accept(Compat03ClientTransportSupport.toV10(event)), + Compat03ClientTransportSupport.mapAsyncError(errors), + Compat03ClientTransportSupport.toV03Context(context))); + } + + @Override + public Task getTask(TaskQueryParams request, @Nullable ClientCallContext context) { + Compat03ClientTransportSupport.validateTaskQuery(request); + return Compat03ClientTransportSupport.call(() -> + org.a2aproject.sdk.compat03.conversion.mappers.domain.TaskMapper_v0_3.INSTANCE.toV10(delegate.getTask( + Compat03ClientTransportSupport.toV03(request), Compat03ClientTransportSupport.toV03Context(context)))); + } + + @Override + public Task cancelTask(CancelTaskParams request, @Nullable ClientCallContext context) { + Compat03ClientTransportSupport.validateCancel(request); + return Compat03ClientTransportSupport.call(() -> + org.a2aproject.sdk.compat03.conversion.mappers.domain.TaskMapper_v0_3.INSTANCE.toV10(delegate.cancelTask( + Compat03ClientTransportSupport.toV03(request), Compat03ClientTransportSupport.toV03Context(context)))); + } + + @Override + public ListTasksResult listTasks(ListTasksParams request, @Nullable ClientCallContext context) { + return Compat03ClientTransportSupport.unsupportedOperation("listTasks"); + } + + @Override + public TaskPushNotificationConfig createTaskPushNotificationConfiguration(TaskPushNotificationConfig request, + @Nullable ClientCallContext context) { + Compat03ClientTransportSupport.validatePushConfig(request); + return Compat03ClientTransportSupport.call(() -> Compat03ClientTransportSupport.toV10( + delegate.setTaskPushNotificationConfiguration(Compat03ClientTransportSupport.toV03(request), + Compat03ClientTransportSupport.toV03Context(context)))); + } + + @Override + public TaskPushNotificationConfig getTaskPushNotificationConfiguration(GetTaskPushNotificationConfigParams request, + @Nullable ClientCallContext context) { + Compat03ClientTransportSupport.validateGetPush(request); + return Compat03ClientTransportSupport.call(() -> Compat03ClientTransportSupport.toV10( + delegate.getTaskPushNotificationConfiguration(Compat03ClientTransportSupport.toV03(request), + Compat03ClientTransportSupport.toV03Context(context)))); + } + + @Override + public org.a2aproject.sdk.spec.ListTaskPushNotificationConfigsResult listTaskPushNotificationConfigurations( + ListTaskPushNotificationConfigsParams request, @Nullable ClientCallContext context) { + Compat03ClientTransportSupport.validatePushList(request); + return Compat03ClientTransportSupport.call(() -> Compat03ClientTransportSupport.toV10PushList( + delegate.listTaskPushNotificationConfigurations(Compat03ClientTransportSupport.toV03(request), + Compat03ClientTransportSupport.toV03Context(context)))); + } + + @Override + public void deleteTaskPushNotificationConfigurations(DeleteTaskPushNotificationConfigParams request, + @Nullable ClientCallContext context) { + Compat03ClientTransportSupport.validateDeletePush(request); + Compat03ClientTransportSupport.run(() -> delegate.deleteTaskPushNotificationConfigurations( + Compat03ClientTransportSupport.toV03(request), Compat03ClientTransportSupport.toV03Context(context))); + } + + @Override + public void subscribeToTask(TaskIdParams request, Consumer events, + Consumer errors, @Nullable ClientCallContext context) { + Compat03ClientTransportSupport.validateTenant("subscribeToTask", request.tenant()); + Compat03ClientTransportSupport.run(() -> delegate.resubscribe( + Compat03ClientTransportSupport.toV03(request), + event -> events.accept(Compat03ClientTransportSupport.toV10(event)), + Compat03ClientTransportSupport.mapAsyncError(errors), + Compat03ClientTransportSupport.toV03Context(context))); + } + + @Override + public AgentCard getExtendedAgentCard(GetExtendedAgentCardParams params, @Nullable ClientCallContext context) { + return Compat03ClientTransportSupport.unsupportedOperation("getExtendedAgentCard"); + } + + @Override + public void close() { + if (closed.compareAndSet(false, true)) { + delegate.close(); + } + } + + public static AgentCard_v0_3 legacyCard(AgentCard card) { + return org.a2aproject.sdk.compat03.conversion.mappers.domain.AgentCardMapper_v0_3.INSTANCE.fromV10(card); + } +} diff --git a/compat-0.3/client/adapter/src/main/java/org/a2aproject/sdk/compat03/client/adapter/Compat03ClientTransportSupport.java b/compat-0.3/client/adapter/src/main/java/org/a2aproject/sdk/compat03/client/adapter/Compat03ClientTransportSupport.java new file mode 100644 index 000000000..18e3b7b1d --- /dev/null +++ b/compat-0.3/client/adapter/src/main/java/org/a2aproject/sdk/compat03/client/adapter/Compat03ClientTransportSupport.java @@ -0,0 +1,268 @@ +package org.a2aproject.sdk.compat03.client.adapter; + +import java.util.List; +import java.util.Map; +import java.util.function.Consumer; + +import org.a2aproject.sdk.client.transport.spi.ClientTransportConfig; +import org.a2aproject.sdk.client.transport.spi.interceptors.ClientCallContext; +import org.a2aproject.sdk.compat03.client.transport.spi.interceptors.ClientCallContext_v0_3; +import org.a2aproject.sdk.compat03.conversion.mappers.domain.EventKindMapper_v0_3; +import org.a2aproject.sdk.compat03.conversion.mappers.domain.StreamingEventKindMapper_v0_3; +import org.a2aproject.sdk.compat03.conversion.mappers.domain.TaskPushNotificationConfigMapper_v0_3; +import org.a2aproject.sdk.compat03.conversion.mappers.params.CancelTaskParamsMapper_v0_3; +import org.a2aproject.sdk.compat03.conversion.mappers.params.MessageSendParamsMapper_v0_3; +import org.a2aproject.sdk.compat03.conversion.mappers.params.TaskIdParamsMapper_v0_3; +import org.a2aproject.sdk.compat03.conversion.mappers.params.TaskQueryParamsMapper_v0_3; +import org.a2aproject.sdk.compat03.conversion.mappers.result.ListTaskPushNotificationConfigsResultMapper_v0_3; +import org.a2aproject.sdk.compat03.spec.A2AClientException_v0_3; +import org.a2aproject.sdk.compat03.spec.DeleteTaskPushNotificationConfigParams_v0_3; +import org.a2aproject.sdk.compat03.spec.GetTaskPushNotificationConfigParams_v0_3; +import org.a2aproject.sdk.compat03.spec.JSONRPCError_v0_3; +import org.a2aproject.sdk.compat03.spec.ListTaskPushNotificationConfigParams_v0_3; +import org.a2aproject.sdk.compat03.spec.MessageSendParams_v0_3; +import org.a2aproject.sdk.compat03.spec.StreamingEventKind_v0_3; +import org.a2aproject.sdk.compat03.spec.TaskIdParams_v0_3; +import org.a2aproject.sdk.compat03.spec.TaskPushNotificationConfig_v0_3; +import org.a2aproject.sdk.compat03.spec.TaskQueryParams_v0_3; +import org.a2aproject.sdk.spec.A2AClientException; +import org.a2aproject.sdk.spec.CancelTaskParams; +import org.a2aproject.sdk.spec.DataPart; +import org.a2aproject.sdk.spec.ListTaskPushNotificationConfigsParams; +import org.a2aproject.sdk.spec.ListTaskPushNotificationConfigsResult; +import org.a2aproject.sdk.spec.ListTasksParams; +import org.a2aproject.sdk.spec.MessageSendParams; +import org.a2aproject.sdk.spec.StreamingEventKind; +import org.a2aproject.sdk.spec.TaskIdParams; +import org.a2aproject.sdk.spec.TaskPushNotificationConfig; +import org.a2aproject.sdk.spec.TaskQueryParams; +import org.jspecify.annotations.Nullable; + +/** Shared, binding-independent behavior for adapters from the 1.0 client to 0.3 transports. */ +public final class Compat03ClientTransportSupport { + + private Compat03ClientTransportSupport() { + } + + public static void validateParameters(Map parameters) { + if (!parameters.isEmpty()) { + throw new A2AClientException("0.3 client adapters do not support generic transport parameters"); + } + } + + public static void validateListTasks(@Nullable ListTasksParams request) { + throw unsupported("listTasks"); + } + + public static void validateExtendedAgentCard(@Nullable Object request) { + throw unsupported("getExtendedAgentCard"); + } + + public static T unsupportedOperation(String operation) { + throw unsupported(operation); + } + + public static void validateTenant(String operation, @Nullable String tenant) { + if (tenant != null && !tenant.isEmpty()) { + throw unsupported(operation + " with a tenant"); + } + } + + public static void validateMessageSend(MessageSendParams request) { + validateTenant("sendMessage", request.tenant()); + for (var part : request.message().parts()) { + if (part instanceof DataPart data && !(data.data() instanceof Map)) { + throw unsupported("sendMessage with non-object DataPart data"); + } + } + if (request.configuration() != null + && request.configuration().taskPushNotificationConfig() != null) { + validateTenant("sendMessage with a task push notification configuration", + request.configuration().taskPushNotificationConfig().tenant()); + } + if (request.configuration() != null && Integer.valueOf(0).equals(request.configuration().historyLength())) { + throw unsupported("sendMessage with historyLength 0, which A2A protocol 0.3 interprets as unlimited history"); + } + } + + /** REST uses the same 0.3 protobuf schema as gRPC. */ + public static void validateProtobufMessageSend(MessageSendParams request) { + validateMessageSend(request); + if (request.message().referenceTaskIds() != null && !request.message().referenceTaskIds().isEmpty()) { + throw unsupported("sendMessage with referenceTaskIds over REST or gRPC"); + } + } + + public static void validateProtobufCancel(CancelTaskParams request) { + validateCancel(request); + if (request.metadata() != null && !request.metadata().isEmpty()) { + throw unsupported("cancelTask with metadata over REST or gRPC"); + } + } + + public static void validateTaskQuery(TaskQueryParams request) { + validateTenant("getTask", request.tenant()); + if (Integer.valueOf(0).equals(request.historyLength())) { + throw unsupported("getTask with historyLength 0, which A2A protocol 0.3 interprets as unlimited history"); + } + } + + public static void validateCancel(CancelTaskParams request) { + validateTenant("cancelTask", request.tenant()); + } + + public static void validatePushConfig(TaskPushNotificationConfig request) { + validateTenant("createTaskPushNotificationConfiguration", request.tenant()); + } + + public static void validateGetPush(org.a2aproject.sdk.spec.GetTaskPushNotificationConfigParams request) { + validateTenant("getTaskPushNotificationConfiguration", request.tenant()); + } + + public static void validateDeletePush(org.a2aproject.sdk.spec.DeleteTaskPushNotificationConfigParams request) { + validateTenant("deleteTaskPushNotificationConfigurations", request.tenant()); + } + + public static void validatePushList(ListTaskPushNotificationConfigsParams request) { + validateTenant("listTaskPushNotificationConfigurations", request.tenant()); + if (request.pageSize() > 0 || (request.pageToken() != null && !request.pageToken().isEmpty())) { + throw unsupported("listTaskPushNotificationConfigurations pagination"); + } + } + + public static void validateConfig(ClientTransportConfig config) { + validateParameters(config.getParameters()); + } + + public static void validateAgentInterfaceTenant(@Nullable String tenant) { + validateTenant("AgentInterface", tenant); + } + + public static @Nullable ClientCallContext_v0_3 toV03Context(@Nullable ClientCallContext context) { + if (context == null) { + return null; + } + return Compat03ClientCallContextMapper.toV03(context); + } + + public static MessageSendParams_v0_3 toV03(MessageSendParams request) { + validateMessageSend(request); + return MessageSendParamsMapper_v0_3.INSTANCE.fromV10(request); + } + + public static MessageSendParams_v0_3 toV03Protobuf(MessageSendParams request) { + validateProtobufMessageSend(request); + return MessageSendParamsMapper_v0_3.INSTANCE.fromV10(request); + } + + public static TaskIdParams_v0_3 toV03Protobuf(CancelTaskParams request) { + validateProtobufCancel(request); + return CancelTaskParamsMapper_v0_3.INSTANCE.fromV10(request); + } + + public static TaskQueryParams_v0_3 toV03(TaskQueryParams request) { + validateTaskQuery(request); + return TaskQueryParamsMapper_v0_3.INSTANCE.fromV10(request); + } + + public static TaskIdParams_v0_3 toV03(TaskIdParams request) { + validateTenant("TaskIdParams", request.tenant()); + return TaskIdParamsMapper_v0_3.INSTANCE.fromV10(request); + } + + public static TaskIdParams_v0_3 toV03(org.a2aproject.sdk.spec.CancelTaskParams request) { + validateCancel(request); + return CancelTaskParamsMapper_v0_3.INSTANCE.fromV10(request); + } + + public static TaskPushNotificationConfig_v0_3 toV03(TaskPushNotificationConfig request) { + validatePushConfig(request); + return TaskPushNotificationConfigMapper_v0_3.INSTANCE.fromV10(request); + } + + public static GetTaskPushNotificationConfigParams_v0_3 toV03( + org.a2aproject.sdk.spec.GetTaskPushNotificationConfigParams request) { + validateGetPush(request); + @Nullable String id = request.id(); + // An omitted optional protobuf string is read back as empty after interceptors. + return new GetTaskPushNotificationConfigParams_v0_3(request.taskId(), + id == null || id.isEmpty() ? null : id); + } + + public static ListTaskPushNotificationConfigParams_v0_3 toV03( + ListTaskPushNotificationConfigsParams request) { + validatePushList(request); + return new ListTaskPushNotificationConfigParams_v0_3(request.id()); + } + + public static DeleteTaskPushNotificationConfigParams_v0_3 toV03( + org.a2aproject.sdk.spec.DeleteTaskPushNotificationConfigParams request) { + validateDeletePush(request); + return new DeleteTaskPushNotificationConfigParams_v0_3(request.taskId(), request.id()); + } + + public static org.a2aproject.sdk.spec.EventKind toV10( + org.a2aproject.sdk.compat03.spec.EventKind_v0_3 event) { + return EventKindMapper_v0_3.INSTANCE.toV10(event); + } + + public static StreamingEventKind toV10(StreamingEventKind_v0_3 event) { + return StreamingEventKindMapper_v0_3.INSTANCE.toV10(event); + } + + public static TaskPushNotificationConfig toV10(TaskPushNotificationConfig_v0_3 config) { + return TaskPushNotificationConfigMapper_v0_3.INSTANCE.toV10(config); + } + + public static ListTaskPushNotificationConfigsResult toV10PushList( + List configs) { + return ListTaskPushNotificationConfigsResultMapper_v0_3.INSTANCE.toV10(configs); + } + + public static A2AClientException mapLegacyException(A2AClientException_v0_3 exception) { + return Compat03ClientErrorMapper.toV10(exception); + } + + public static Consumer mapAsyncError(Consumer errors) { + return error -> { + if (error instanceof A2AClientException_v0_3 legacy) { + errors.accept(mapLegacyException(legacy)); + } else if (error instanceof JSONRPCError_v0_3 legacy) { + errors.accept(new A2AClientException(legacy.getMessage(), Compat03ClientErrorMapper.toV10(legacy))); + } else { + errors.accept(error); + } + }; + } + + public static T call(ThrowingSupplier delegate) { + try { + return delegate.get(); + } catch (A2AClientException_v0_3 exception) { + throw mapLegacyException(exception); + } + } + + public static void run(ThrowingRunnable delegate) { + try { + delegate.run(); + } catch (A2AClientException_v0_3 exception) { + throw mapLegacyException(exception); + } + } + + private static A2AClientException unsupported(String operation) { + return new A2AClientException(operation + " is not supported by A2A protocol 0.3", + new org.a2aproject.sdk.spec.UnsupportedOperationError()); + } + + @FunctionalInterface + public interface ThrowingSupplier { + T get() throws A2AClientException_v0_3; + } + + @FunctionalInterface + public interface ThrowingRunnable { + void run() throws A2AClientException_v0_3; + } +} diff --git a/compat-0.3/client/adapter/src/main/java/org/a2aproject/sdk/compat03/client/adapter/Compat03InterceptorSupport.java b/compat-0.3/client/adapter/src/main/java/org/a2aproject/sdk/compat03/client/adapter/Compat03InterceptorSupport.java new file mode 100644 index 000000000..9eb6efa9e --- /dev/null +++ b/compat-0.3/client/adapter/src/main/java/org/a2aproject/sdk/compat03/client/adapter/Compat03InterceptorSupport.java @@ -0,0 +1,54 @@ +package org.a2aproject.sdk.compat03.client.adapter; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.a2aproject.sdk.client.transport.spi.interceptors.ClientCallContext; +import org.a2aproject.sdk.client.transport.spi.interceptors.ClientCallInterceptor; +import org.a2aproject.sdk.client.transport.spi.interceptors.PayloadAndHeaders; +import org.a2aproject.sdk.common.A2AHeaders; +import org.a2aproject.sdk.spec.A2AClientException; +import org.a2aproject.sdk.spec.AgentCard; +import org.jspecify.annotations.Nullable; + +/** Applies ordinary 1.0 interceptors while enforcing the 0.3 routing contract. */ +public final class Compat03InterceptorSupport { + private Compat03InterceptorSupport() { + } + + public static PayloadAndHeaders apply(List interceptors, String method, + Object payload, AgentCard card, @Nullable ClientCallContext context, Class expectedType) { + Map headers = new HashMap<>(); + if (context != null) { + context.getHeaders().forEach((name, value) -> { + if (!A2AHeaders.A2A_VERSION.equalsIgnoreCase(name)) { + headers.put(name, value); + } + }); + } + ClientCallContext sanitizedContext = context == null ? null + : new ClientCallContext(context.getState(), headers); + PayloadAndHeaders result = new PayloadAndHeaders(payload, headers); + for (ClientCallInterceptor interceptor : interceptors) { + result = interceptor.intercept(method, result.getPayload(), result.getHeaders(), card, sanitizedContext); + if (result == null || result.getPayload() == null) { + throw new A2AClientException("0.3 interceptor returned a forbidden null payload for " + method); + } + if (!expectedType.equals(result.getPayload().getClass())) { + throw new A2AClientException("0.3 interceptor returned " + result.getPayload().getClass().getName() + + "; expected " + expectedType.getName() + " for " + method); + } + validateVersionHeader(result.getHeaders()); + } + return result; + } + + private static void validateVersionHeader(Map headers) { + for (String name : headers.keySet()) { + if (A2AHeaders.A2A_VERSION.equalsIgnoreCase(name)) { + throw new A2AClientException("0.3 client interceptors may not override A2A-Version"); + } + } + } +} diff --git a/compat-0.3/client/adapter/src/main/resources/META-INF/services/org.a2aproject.sdk.client.http.AgentCardCompatibilityParser b/compat-0.3/client/adapter/src/main/resources/META-INF/services/org.a2aproject.sdk.client.http.AgentCardCompatibilityParser new file mode 100644 index 000000000..f13cddd7b --- /dev/null +++ b/compat-0.3/client/adapter/src/main/resources/META-INF/services/org.a2aproject.sdk.client.http.AgentCardCompatibilityParser @@ -0,0 +1 @@ +org.a2aproject.sdk.compat03.client.adapter.Compat03AgentCardCompatibilityParser diff --git a/compat-0.3/client/adapter/src/test/java/org/a2aproject/sdk/compat03/client/adapter/Compat03AgentCardCompatibilityParserTest.java b/compat-0.3/client/adapter/src/test/java/org/a2aproject/sdk/compat03/client/adapter/Compat03AgentCardCompatibilityParserTest.java new file mode 100644 index 000000000..5f60e8f0c --- /dev/null +++ b/compat-0.3/client/adapter/src/test/java/org/a2aproject/sdk/compat03/client/adapter/Compat03AgentCardCompatibilityParserTest.java @@ -0,0 +1,156 @@ +package org.a2aproject.sdk.compat03.client.adapter; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.atomic.AtomicInteger; + +import com.sun.net.httpserver.HttpServer; +import org.a2aproject.sdk.client.http.A2ACardResolver; +import org.a2aproject.sdk.compat03.json.JsonUtil_v0_3; +import org.a2aproject.sdk.compat03.spec.AgentCapabilities_v0_3; +import org.a2aproject.sdk.compat03.spec.AgentCard_v0_3; +import org.a2aproject.sdk.compat03.spec.AgentInterface_v0_3; +import org.a2aproject.sdk.compat03.spec.AgentSkill_v0_3; +import org.a2aproject.sdk.compat03.spec.HTTPAuthSecurityScheme_v0_3; +import org.a2aproject.sdk.compat03.spec.ImplicitOAuthFlow_v0_3; +import org.a2aproject.sdk.compat03.spec.OAuth2SecurityScheme_v0_3; +import org.a2aproject.sdk.compat03.spec.OAuthFlows_v0_3; +import org.a2aproject.sdk.spec.A2AClientJSONError; +import org.a2aproject.sdk.spec.AgentCard; +import org.a2aproject.sdk.spec.HTTPAuthSecurityScheme; +import org.junit.jupiter.api.Test; + +class Compat03AgentCardCompatibilityParserTest { + @Test + void resolverUsesProductionParserWithARealLegacyCardAndOneFetch() throws Exception { + AtomicInteger fetches = new AtomicInteger(); + AgentCard card = resolveCard(JsonUtil_v0_3.toJson(primaryOnlyCard("rest")), fetches); + assertEquals("legacy", card.name()); + assertEquals("HTTP+JSON", card.supportedInterfaces().get(0).protocolBinding()); + assertEquals("0.3", card.supportedInterfaces().get(0).protocolVersion()); + assertEquals(1, fetches.get()); + } + + @Test + void resolverRejectsMalformedShapeAndUnrequestedLegacyVersion() throws Exception { + AtomicInteger fetches = new AtomicInteger(); + assertThrows(A2AClientJSONError.class, () -> resolveCard("{\"legacy\":true}", fetches)); + assertEquals(1, fetches.get()); + fetches.set(0); + String unsupported = JsonUtil_v0_3.toJson(primaryOnlyCard("rest")) + .replace("\"0.3.0\"", "\"0.2.0\""); + assertThrows(A2AClientJSONError.class, () -> resolveCard(unsupported, fetches)); + assertEquals(1, fetches.get()); + } + + private static AgentCard resolveCard(String rawCard, AtomicInteger fetches) throws Exception { + HttpServer server = HttpServer.create(new InetSocketAddress("localhost", 0), 0); + server.createContext("/.well-known/agent-card.json", exchange -> { + fetches.incrementAndGet(); + byte[] body = rawCard.getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().set("Content-Type", "application/json"); + exchange.sendResponseHeaders(200, body.length); + try (var output = exchange.getResponseBody()) { + output.write(body); + } finally { + exchange.close(); + } + }); + server.start(); + try { + return A2ACardResolver.builder().baseUrl("http://localhost:" + server.getAddress().getPort()) + .supportedProtocolVersions(Set.of("1.0", "0.3")).build().getAgentCard(); + } finally { + server.stop(0); + } + } + + @Test + void parsesDeclaredPatchVersionAndProjectsInterface() throws Exception { + AgentCard_v0_3 card = new AgentCard_v0_3.Builder() + .name("legacy") + .description("legacy") + .url("https://example.test/a2a") + .version("1") + .capabilities(new AgentCapabilities_v0_3.Builder().build()) + .defaultInputModes(List.of("text")) + .defaultOutputModes(List.of("text")) + .skills(List.of(new AgentSkill_v0_3.Builder().id("skill").name("skill") + .description("skill").tags(List.of("tag")).build())) + .additionalInterfaces(List.of(new AgentInterface_v0_3("JSONRPC", "https://example.test/a2a"))) + .protocolVersion("0.3.0") + .build(); + + var result = new Compat03AgentCardCompatibilityParser().parse( + JsonUtil_v0_3.toJson(card), null, Set.of("1.0", "0.3")); + + assertTrue(result.isPresent()); + assertEquals("0.3", result.orElseThrow().supportedInterfaces().get(0).protocolVersion()); + } + + @Test + void parsesPrimaryOnlyCardAndProjectsCanonicalInterface() throws Exception { + AgentCard_v0_3 card = primaryOnlyCard("rest"); + + var result = new Compat03AgentCardCompatibilityParser().parse( + JsonUtil_v0_3.toJson(card), null, Set.of("1.0", "0.3")); + + assertTrue(result.isPresent()); + AgentCard projected = result.orElseThrow(); + assertEquals(1, projected.supportedInterfaces().size()); + assertEquals("HTTP+JSON", projected.supportedInterfaces().get(0).protocolBinding()); + assertEquals("0.3", projected.supportedInterfaces().get(0).protocolVersion()); + } + + @Test + void parsesPrimaryOnlyCardWithHttpAuthScheme() throws Exception { + AgentCard_v0_3 card = primaryOnlyCard("http", Map.of("basicAuth", new HTTPAuthSecurityScheme_v0_3.Builder() + .scheme("basic").bearerFormat("none").description("HTTP Basic authentication").build())); + + var result = new Compat03AgentCardCompatibilityParser().parse( + JsonUtil_v0_3.toJson(card), null, Set.of("1.0", "0.3")); + + assertTrue(result.isPresent()); + HTTPAuthSecurityScheme projectedScheme = (HTTPAuthSecurityScheme) + result.orElseThrow().securitySchemes().get("basicAuth"); + assertEquals("basic", projectedScheme.scheme()); + assertEquals("none", projectedScheme.bearerFormat()); + assertEquals("HTTP Basic authentication", projectedScheme.description()); + } + + @Test + void reportsUnsupportedSecuritySchemeConversion() throws Exception { + AgentCard_v0_3 card = primaryOnlyCard("jsonrpc", Map.of("oauth", new OAuth2SecurityScheme_v0_3( + new OAuthFlows_v0_3(null, null, + new ImplicitOAuthFlow_v0_3("https://example.test/authorize", null, Map.of()), null), + "OAuth", null))); + + A2AClientJSONError exception = assertThrows(A2AClientJSONError.class, + () -> new Compat03AgentCardCompatibilityParser().parse( + JsonUtil_v0_3.toJson(card), null, Set.of("1.0", "0.3"))); + + assertTrue(exception.getMessage().contains("Could not convert A2A 0.3 agent card")); + assertTrue(exception.getCause().getMessage().contains("implicit")); + } + + private static AgentCard_v0_3 primaryOnlyCard(String preferredTransport) { + return primaryOnlyCard(preferredTransport, null); + } + + private static AgentCard_v0_3 primaryOnlyCard(String preferredTransport, + Map securitySchemes) { + return new AgentCard_v0_3.Builder() + .name("legacy").description("legacy").url("http://localhost:8081") + .version("1.0.0").preferredTransport(preferredTransport).protocolVersion("0.3.0") + .capabilities(new AgentCapabilities_v0_3.Builder().build()) + .defaultInputModes(List.of("text")).defaultOutputModes(List.of("text")) + .skills(List.of()).securitySchemes(securitySchemes).additionalInterfaces(List.of()).build(); + } +} diff --git a/compat-0.3/client/adapter/src/test/java/org/a2aproject/sdk/compat03/client/adapter/Compat03ClientCallContextMapperTest.java b/compat-0.3/client/adapter/src/test/java/org/a2aproject/sdk/compat03/client/adapter/Compat03ClientCallContextMapperTest.java new file mode 100644 index 000000000..f942c5281 --- /dev/null +++ b/compat-0.3/client/adapter/src/test/java/org/a2aproject/sdk/compat03/client/adapter/Compat03ClientCallContextMapperTest.java @@ -0,0 +1,48 @@ +package org.a2aproject.sdk.compat03.client.adapter; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.util.List; +import java.util.Map; + +import org.a2aproject.sdk.client.transport.spi.interceptors.ClientCallContext; +import org.a2aproject.sdk.client.transport.spi.interceptors.ClientCallInterceptor; +import org.a2aproject.sdk.client.transport.spi.interceptors.PayloadAndHeaders; +import org.a2aproject.sdk.spec.AgentCard; +import org.junit.jupiter.api.Test; + +class Compat03ClientCallContextMapperTest { + @Test + void translatesExtensionHeadersWithoutChangingTheNativeContext() { + for (String name : List.of("A2A-Extensions", "a2a-extensions", "A2A-EXTENSIONS")) { + ClientCallContext context = new ClientCallContext(Map.of("request-id", "request-1"), + Map.of(name, "urn:example:extension", "Authorization", "Bearer token")); + + var legacy = Compat03ClientCallContextMapper.toV03(context); + + assertEquals(Map.of("X-A2A-Extensions", "urn:example:extension", + "Authorization", "Bearer token"), legacy.getHeaders()); + assertEquals(context.getState(), legacy.getState()); + assertEquals("urn:example:extension", context.getHeaders().get(name)); + } + } + + @Test + void translatesExtensionsAfterNativeInterceptorsAndPrefersTheNativeHeader() { + ClientCallInterceptor interceptor = new ClientCallInterceptor() { + @Override + public PayloadAndHeaders intercept(String method, Object payload, Map headers, + AgentCard card, ClientCallContext context) { + assertEquals("urn:original", headers.get("A2A-Extensions")); + return new PayloadAndHeaders(payload, + Map.of("a2a-extensions", "urn:replacement", "x-a2a-extensions", "urn:stale")); + } + }; + var result = Compat03InterceptorSupport.apply(List.of(interceptor), "message/send", "payload", null, + new ClientCallContext(Map.of(), Map.of("A2A-Extensions", "urn:original")), String.class); + + var legacy = Compat03ClientCallContextMapper.toV03(new ClientCallContext(Map.of(), result.getHeaders())); + + assertEquals(Map.of("X-A2A-Extensions", "urn:replacement"), legacy.getHeaders()); + } +} diff --git a/compat-0.3/client/adapter/src/test/java/org/a2aproject/sdk/compat03/client/adapter/Compat03ClientErrorMapperTest.java b/compat-0.3/client/adapter/src/test/java/org/a2aproject/sdk/compat03/client/adapter/Compat03ClientErrorMapperTest.java new file mode 100644 index 000000000..aec925158 --- /dev/null +++ b/compat-0.3/client/adapter/src/test/java/org/a2aproject/sdk/compat03/client/adapter/Compat03ClientErrorMapperTest.java @@ -0,0 +1,112 @@ +package org.a2aproject.sdk.compat03.client.adapter; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; + +import java.io.IOException; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicReference; + +import org.a2aproject.sdk.compat03.spec.A2AClientException_v0_3; +import org.a2aproject.sdk.compat03.spec.A2AClientHTTPError_v0_3; +import org.a2aproject.sdk.compat03.spec.JSONRPCError_v0_3; +import org.a2aproject.sdk.compat03.spec.UnsupportedOperationError_v0_3; +import org.a2aproject.sdk.spec.A2AClientException; +import org.a2aproject.sdk.spec.A2AClientHTTPError; +import org.a2aproject.sdk.spec.A2AError; +import org.a2aproject.sdk.spec.UnsupportedOperationError; +import org.junit.jupiter.api.Test; + +class Compat03ClientErrorMapperTest { + + @Test + void mapsUnsupportedOperationError() { + A2AClientException_v0_3 legacy = new A2AClientException_v0_3( + "unsupported", new UnsupportedOperationError_v0_3()); + + A2AClientException mapped = Compat03ClientErrorMapper.toV10(legacy); + + assertInstanceOf(UnsupportedOperationError.class, mapped.getCause()); + } + + @Test + void exposesHttpErrorsWrappedByTheJdkClient() { + var httpError = new A2AClientHTTPError(401, "authentication failed", "unauthorized", + Map.of("WWW-Authenticate", List.of("Bearer"))); + var legacy = new A2AClientException_v0_3("getTask failed", new IOException("authentication failed", httpError)); + var exception = Compat03ClientErrorMapper.toV10(legacy); + var cause = assertInstanceOf(A2AClientHTTPError.class, exception.getCause()); + assertEquals(401, cause.getCode()); + assertEquals("unauthorized", cause.getResponseBody()); + assertEquals(List.of("Bearer"), cause.getResponseHeaders().get("www-authenticate")); + } + + @Test + void preservesHttpFailureDetailsForBlockingAndStreamingErrors() { + var legacy = new A2AClientException_v0_3("HTTP 429", new A2AClientHTTPError_v0_3( + 429, "HTTP 429", "rate limited", Map.of("Retry-After", List.of("30")))); + AtomicReference streaming = new AtomicReference<>(); + Compat03ClientTransportSupport.mapAsyncError(streaming::set).accept(legacy); + for (A2AClientException exception : List.of(Compat03ClientErrorMapper.toV10(legacy), + assertInstanceOf(A2AClientException.class, streaming.get()))) { + var cause = assertInstanceOf(A2AClientHTTPError.class, exception.getCause()); + assertEquals(429, cause.getCode()); + assertEquals("rate limited", cause.getResponseBody()); + assertEquals(List.of("30"), cause.getResponseHeaders().get("retry-after")); + } + } + + @Test + void preservesScalarAndArrayDataForSynchronousAndStreamingErrors() { + for (Object data : List.of("missingScope", 42, true, List.of("missingScope", "write"))) { + JSONRPCError_v0_3 legacy = new JSONRPCError_v0_3(-32602, "invalid parameters", data); + + A2AClientException synchronous = Compat03ClientErrorMapper.toV10( + new A2AClientException_v0_3("request failed", legacy)); + assertEquals(Map.of("data", data), + assertInstanceOf(A2AError.class, synchronous.getCause()).getDetails()); + + AtomicReference received = new AtomicReference<>(); + Compat03ClientTransportSupport.mapAsyncError(received::set).accept(legacy); + A2AClientException streaming = assertInstanceOf(A2AClientException.class, received.get()); + assertEquals(Map.of("data", data), + assertInstanceOf(A2AError.class, streaming.getCause()).getDetails()); + } + } + + @Test + void absentErrorDataRemainsEmpty() { + assertEquals(Map.of(), Compat03ClientErrorMapper.toV10( + new JSONRPCError_v0_3(-32602, "invalid parameters", null)).getDetails()); + } + + @Test + void mapsGenericJsonRpcErrorsByTheirLegacyCodes() { + List cases = List.of( + new ErrorCase(-32700, org.a2aproject.sdk.spec.JSONParseError.class), + new ErrorCase(-32600, org.a2aproject.sdk.spec.InvalidRequestError.class), + new ErrorCase(-32601, org.a2aproject.sdk.spec.MethodNotFoundError.class), + new ErrorCase(-32602, org.a2aproject.sdk.spec.InvalidParamsError.class), + new ErrorCase(-32603, org.a2aproject.sdk.spec.InternalError.class), + new ErrorCase(-32001, org.a2aproject.sdk.spec.TaskNotFoundError.class), + new ErrorCase(-32002, org.a2aproject.sdk.spec.TaskNotCancelableError.class), + new ErrorCase(-32003, org.a2aproject.sdk.spec.PushNotificationNotSupportedError.class), + new ErrorCase(-32004, org.a2aproject.sdk.spec.UnsupportedOperationError.class), + new ErrorCase(-32005, org.a2aproject.sdk.spec.ContentTypeNotSupportedError.class), + new ErrorCase(-32006, org.a2aproject.sdk.spec.InvalidAgentResponseError.class), + new ErrorCase(-32007, org.a2aproject.sdk.spec.ExtendedAgentCardNotConfiguredError.class)); + + for (ErrorCase errorCase : cases) { + A2AClientException mapped = Compat03ClientErrorMapper.toV10(new A2AClientException_v0_3( + "legacy failure", new JSONRPCError_v0_3(errorCase.code(), "legacy error", Map.of("key", "value")))); + + A2AError error = assertInstanceOf(A2AError.class, mapped.getCause()); + assertInstanceOf(errorCase.expectedType(), error); + org.junit.jupiter.api.Assertions.assertEquals(Map.of("key", "value"), error.getDetails()); + } + } + + private record ErrorCase(int code, Class expectedType) { + } +} diff --git a/compat-0.3/client/adapter/src/test/java/org/a2aproject/sdk/compat03/client/adapter/Compat03ClientTransportSupportTest.java b/compat-0.3/client/adapter/src/test/java/org/a2aproject/sdk/compat03/client/adapter/Compat03ClientTransportSupportTest.java new file mode 100644 index 000000000..f25208ca9 --- /dev/null +++ b/compat-0.3/client/adapter/src/test/java/org/a2aproject/sdk/compat03/client/adapter/Compat03ClientTransportSupportTest.java @@ -0,0 +1,264 @@ +package org.a2aproject.sdk.compat03.client.adapter; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicReference; + +import org.a2aproject.sdk.client.transport.spi.interceptors.ClientCallContext; +import org.a2aproject.sdk.compat03.client.transport.spi.interceptors.ClientCallContext_v0_3; +import org.a2aproject.sdk.compat03.spec.A2AClientException_v0_3; +import org.a2aproject.sdk.compat03.spec.EventKind_v0_3; +import org.a2aproject.sdk.spec.A2AClientException; +import org.a2aproject.sdk.spec.CancelTaskParams; +import org.a2aproject.sdk.spec.DataPart; +import org.a2aproject.sdk.spec.ListTaskPushNotificationConfigsParams; +import org.a2aproject.sdk.spec.Message; +import org.a2aproject.sdk.spec.MessageSendConfiguration; +import org.a2aproject.sdk.spec.MessageSendParams; +import org.a2aproject.sdk.spec.TaskIdParams; +import org.a2aproject.sdk.spec.TaskPushNotificationConfig; +import org.a2aproject.sdk.spec.TaskQueryParams; +import org.a2aproject.sdk.spec.TextPart; +import org.a2aproject.sdk.spec.UnsupportedOperationError; +import org.junit.jupiter.api.Test; + +class Compat03ClientTransportSupportTest { + + @Test + void normalizesDefaultPushConfigurationIdAfterProtobufConversion() { + var original = new org.a2aproject.sdk.spec.GetTaskPushNotificationConfigParams("task"); + var proto = org.a2aproject.sdk.grpc.utils.ProtoUtils.ToProto.getTaskPushNotificationConfigRequest(original); + assertNull(Compat03ClientTransportSupport.toV03( + org.a2aproject.sdk.grpc.utils.ProtoUtils.FromProto.getTaskPushNotificationConfigParams(proto)) + .pushNotificationConfigId()); + assertEquals("specific", Compat03ClientTransportSupport.toV03( + org.a2aproject.sdk.grpc.utils.ProtoUtils.FromProto.getTaskPushNotificationConfigParams( + proto.toBuilder().setId("specific").build())).pushNotificationConfigId()); + assertNull(Compat03ClientTransportSupport.toV03( + org.a2aproject.sdk.grpc.utils.ProtoUtils.FromProto.getTaskPushNotificationConfigParams( + proto.toBuilder().setId("specific").clearId().build())).pushNotificationConfigId()); + } + + @Test + void rejectsUnsupportedOperationsBeforeDelegateUse() { + assertThrows(A2AClientException.class, + () -> Compat03ClientTransportSupport.validateListTasks(null)); + assertThrows(A2AClientException.class, + () -> Compat03ClientTransportSupport.validateExtendedAgentCard(null)); + assertThrows(A2AClientException.class, + () -> Compat03ClientTransportSupport.validateTenant("getTask", "tenant")); + assertThrows(A2AClientException.class, + () -> Compat03ClientTransportSupport.validatePushConfig( + TaskPushNotificationConfig.builder().taskId("task").url("https://example.test") + .tenant("tenant").build())); + assertThrows(A2AClientException.class, + () -> Compat03ClientTransportSupport.validatePushList( + new ListTaskPushNotificationConfigsParams("task", 10, "", null))); + } + + @Test + void rejectsNonObjectDataPartsThroughClientExceptionBoundary() { + for (Object data : List.of("value", 42, true, List.of("item"))) { + MessageSendParams request = new MessageSendParams( + new Message(Message.Role.ROLE_USER, List.of(new DataPart(data)), "message", null, null, + null, null, null), null, null); + + A2AClientException exception = assertThrows(A2AClientException.class, + () -> Compat03ClientTransportSupport.toV03(request)); + + assertInstanceOf(UnsupportedOperationError.class, exception.getCause()); + } + } + + @Test + void preservesObjectDataParts() { + MessageSendParams request = new MessageSendParams( + new Message(Message.Role.ROLE_USER, List.of(new DataPart(Map.of("key", "value"))), + "message", null, null, null, null, null), null, null); + + var legacy = Compat03ClientTransportSupport.toV03(request); + + var data = assertInstanceOf(org.a2aproject.sdk.compat03.spec.DataPart_v0_3.class, + legacy.message().parts().get(0)); + assertEquals(Map.of("key", "value"), data.data()); + } + + @Test + void preservesNullValuedDataAndMetadataAcrossVersionConversion() { + Map values = new java.util.LinkedHashMap<>(); + values.put("nil", null); + MessageSendParams request = new MessageSendParams( + new Message(Message.Role.ROLE_USER, List.of(new DataPart(values, values)), "message", + null, null, null, values, null), null, values); + var legacy = Compat03ClientTransportSupport.toV03(request); + var data = assertInstanceOf(org.a2aproject.sdk.compat03.spec.DataPart_v0_3.class, + legacy.message().parts().get(0)); + assertEquals(values, data.data()); + assertEquals(values, data.metadata()); + assertEquals(values, legacy.message().metadata()); + assertEquals(values, legacy.metadata()); + assertEquals(request.message(), Compat03ClientTransportSupport.toV10((EventKind_v0_3) legacy.message())); + } + + @Test + void rejectsFieldsMissingFromLegacyProtobufButPreservesThemForJsonRpc() { + MessageSendParams request = new MessageSendParams( + new Message(Message.Role.ROLE_USER, List.of(new TextPart("hello")), "message", + null, null, List.of("referenced-task"), null, null), null, null); + assertEquals(List.of("referenced-task"), Compat03ClientTransportSupport.toV03(request).message().referenceTaskIds()); + A2AClientException send = assertThrows(A2AClientException.class, + () -> Compat03ClientTransportSupport.toV03Protobuf(request)); + assertInstanceOf(UnsupportedOperationError.class, send.getCause()); + assertTrue(send.getMessage().contains("referenceTaskIds")); + + CancelTaskParams cancel = new CancelTaskParams("task", null, Map.of("reason", "stop")); + assertEquals(cancel.metadata(), Compat03ClientTransportSupport.toV03(cancel).metadata()); + A2AClientException cancellation = assertThrows(A2AClientException.class, + () -> Compat03ClientTransportSupport.toV03Protobuf(cancel)); + assertInstanceOf(UnsupportedOperationError.class, cancellation.getCause()); + assertTrue(cancellation.getMessage().contains("metadata")); + Compat03ClientTransportSupport.toV03Protobuf(new CancelTaskParams("task", null, Map.of())); + } + + @Test + void acceptsDefaultPushListAndReturnsNoPageToken() { + var result = Compat03ClientTransportSupport.toV10PushList(java.util.List.of()); + + assertEquals(java.util.List.of(), result.configs()); + assertNull(result.nextPageToken()); + Compat03ClientTransportSupport.validatePushList( + new ListTaskPushNotificationConfigsParams("task", 0, "", null)); + } + + @Test + void acceptsNegativeDefaultPushListPageSize() { + Compat03ClientTransportSupport.validatePushList( + new ListTaskPushNotificationConfigsParams("task", -1, "", null)); + } + + @Test + void mapsContextsAndRequestParametersInBothDirections() { + ClientCallContext context = new ClientCallContext( + Map.of("trace", "one"), Map.of("Authorization", "Bearer token")); + + ClientCallContext_v0_3 legacyContext = Compat03ClientTransportSupport.toV03Context(context); + + assertEquals(context.getState(), legacyContext.getState()); + assertEquals(context.getHeaders(), legacyContext.getHeaders()); + assertEquals("task", Compat03ClientTransportSupport.toV03( + new TaskQueryParams("task", 3, null)).id()); + assertEquals("task", Compat03ClientTransportSupport.toV03( + new TaskIdParams("task", null)).id()); + assertEquals("task", Compat03ClientTransportSupport.toV03( + new CancelTaskParams("task", null, Map.of())).id()); + MessageSendParams message = new MessageSendParams( + new org.a2aproject.sdk.spec.Message( + org.a2aproject.sdk.spec.Message.Role.ROLE_USER, + java.util.List.of(new TextPart("hello")), "message", null, null, null, null, null), + null, null, null); + assertEquals(null, Compat03ClientTransportSupport.toV03(message).metadata()); + } + + @Test + void mapsNullContextsToNull() { + assertNull(Compat03ClientTransportSupport.toV03Context(null)); + assertNull(Compat03ClientCallContextMapper.toV03(null)); + } + + @Test + void rejectsExplicitZeroHistoryLengthBecauseLegacyZeroMeansUnlimited() { + A2AClientException exception = assertThrows(A2AClientException.class, + () -> Compat03ClientTransportSupport.validateTaskQuery(new TaskQueryParams("task", 0, null))); + + assertTrue(exception.getMessage().contains("historyLength")); + } + + @Test + void rejectsMessageSendZeroHistoryLengthBecauseLegacyZeroMeansUnlimited() { + MessageSendParams request = new MessageSendParams( + new Message(Message.Role.ROLE_USER, java.util.List.of(new TextPart("hello")), "message", null, null, + null, null, null), + MessageSendConfiguration.builder().historyLength(0).build(), null); + + A2AClientException exception = assertThrows(A2AClientException.class, + () -> Compat03ClientTransportSupport.validateMessageSend(request)); + + assertTrue(exception.getMessage().contains("historyLength")); + } + + @Test + void rejectsMessageSendWithTenantInNestedPushConfiguration() { + MessageSendParams request = new MessageSendParams( + new Message(Message.Role.ROLE_USER, java.util.List.of(new TextPart("hello")), "message", null, null, + null, null, null), + MessageSendConfiguration.builder() + .taskPushNotificationConfig(TaskPushNotificationConfig.builder() + .url("https://callback.example.test") + .tenant("tenant") + .build()) + .build(), + null); + + A2AClientException exception = assertThrows(A2AClientException.class, + () -> Compat03ClientTransportSupport.validateMessageSend(request)); + + assertTrue(exception.getMessage().contains("tenant")); + } + + @Test + void rejectsGenericParametersAndMapsLegacyErrors() { + assertThrows(A2AClientException.class, + () -> Compat03ClientTransportSupport.validateParameters(Map.of("unsupported", true))); + Compat03ClientTransportSupport.validateParameters(Map.of()); + + A2AClientException mapped = Compat03ClientTransportSupport.mapLegacyException( + new org.a2aproject.sdk.compat03.spec.A2AClientException_v0_3( + "legacy failure", new org.a2aproject.sdk.compat03.spec.TaskNotFoundError_v0_3())); + assertTrue(mapped.getMessage().contains("legacy failure")); + assertTrue(mapped.getCause() instanceof org.a2aproject.sdk.spec.TaskNotFoundError); + } + + @Test + void retainsNonProtocolLegacyFailureCause() { + IOException cause = new IOException("connection reset"); + + A2AClientException mapped = Compat03ClientTransportSupport.mapLegacyException( + new A2AClientException_v0_3("request failed", cause)); + + assertSame(cause, mapped.getCause()); + } + + @Test + void mapsAsynchronousLegacyErrors() { + AtomicReference mapped = new AtomicReference<>(); + + Compat03ClientTransportSupport.mapAsyncError(mapped::set).accept( + new org.a2aproject.sdk.compat03.spec.A2AClientException_v0_3( + "legacy stream failure", new org.a2aproject.sdk.compat03.spec.InternalError_v0_3("legacy"))); + + assertTrue(mapped.get() instanceof A2AClientException); + assertTrue(mapped.get().getCause() instanceof org.a2aproject.sdk.spec.InternalError); + } + + @Test + void mapsAsynchronousGenericJsonRpcErrorsByTheirLegacyCodes() { + AtomicReference mapped = new AtomicReference<>(); + + Compat03ClientTransportSupport.mapAsyncError(mapped::set).accept( + new org.a2aproject.sdk.compat03.spec.JSONRPCError_v0_3( + -32004, "unsupported", Map.of("operation", "listTasks"))); + + A2AClientException exception = assertInstanceOf(A2AClientException.class, mapped.get()); + org.a2aproject.sdk.spec.UnsupportedOperationError error = assertInstanceOf( + org.a2aproject.sdk.spec.UnsupportedOperationError.class, exception.getCause()); + assertEquals(Map.of("operation", "listTasks"), error.getDetails()); + } +} diff --git a/compat-0.3/client/adapter/src/test/java/org/a2aproject/sdk/compat03/client/adapter/Compat03InterceptorSupportTest.java b/compat-0.3/client/adapter/src/test/java/org/a2aproject/sdk/compat03/client/adapter/Compat03InterceptorSupportTest.java new file mode 100644 index 000000000..0afc11968 --- /dev/null +++ b/compat-0.3/client/adapter/src/test/java/org/a2aproject/sdk/compat03/client/adapter/Compat03InterceptorSupportTest.java @@ -0,0 +1,45 @@ +package org.a2aproject.sdk.compat03.client.adapter; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertSame; + +import java.util.Map; +import java.util.concurrent.atomic.AtomicReference; + +import org.a2aproject.sdk.client.transport.spi.interceptors.ClientCallContext; +import org.a2aproject.sdk.client.transport.spi.interceptors.ClientCallInterceptor; +import org.a2aproject.sdk.client.transport.spi.interceptors.PayloadAndHeaders; +import org.junit.jupiter.api.Test; + +class Compat03InterceptorSupportTest { + + @Test + void stripsVersionHeaderFromInitialContextBeforeInterceptors() { + AtomicReference> interceptedHeaders = new AtomicReference<>(); + AtomicReference interceptedContext = new AtomicReference<>(); + Map state = Map.of("request-id", "request-1"); + ClientCallInterceptor interceptor = new ClientCallInterceptor() { + @Override + public PayloadAndHeaders intercept(String methodName, Object payload, Map headers, + org.a2aproject.sdk.spec.AgentCard agentCard, ClientCallContext clientCallContext) { + interceptedHeaders.set(headers); + interceptedContext.set(clientCallContext); + return new PayloadAndHeaders(payload, headers); + } + }; + + Compat03InterceptorSupport.apply( + java.util.List.of(interceptor), "message/send", "payload", null, + new ClientCallContext(state, Map.of("a2a-version", "1.0", "Authorization", "Bearer token")), + String.class); + + assertFalse(interceptedHeaders.get().keySet().stream() + .anyMatch(name -> name.equalsIgnoreCase("A2A-Version"))); + assertEquals("Bearer token", interceptedHeaders.get().get("Authorization")); + assertSame(state, interceptedContext.get().getState()); + assertFalse(interceptedContext.get().getHeaders().keySet().stream() + .anyMatch(name -> name.equalsIgnoreCase("A2A-Version"))); + assertEquals("Bearer token", interceptedContext.get().getHeaders().get("Authorization")); + } +} diff --git a/compat-0.3/client/transport/grpc/src/main/java/org/a2aproject/sdk/compat03/client/transport/grpc/GrpcTransport_v0_3.java b/compat-0.3/client/transport/grpc/src/main/java/org/a2aproject/sdk/compat03/client/transport/grpc/GrpcTransport_v0_3.java index 50f73e641..c5509d1f1 100644 --- a/compat-0.3/client/transport/grpc/src/main/java/org/a2aproject/sdk/compat03/client/transport/grpc/GrpcTransport_v0_3.java +++ b/compat-0.3/client/transport/grpc/src/main/java/org/a2aproject/sdk/compat03/client/transport/grpc/GrpcTransport_v0_3.java @@ -3,6 +3,7 @@ import static org.a2aproject.sdk.util.Assert.checkNotNullParam; import java.util.List; +import java.util.Locale; import java.util.Map; import java.util.function.Consumer; import java.util.stream.Collectors; @@ -319,7 +320,8 @@ private Metadata createGrpcMetadata(@Nullable ClientCallContext_v0_3 context, @N metadata.put(AUTHORIZATION_METADATA_KEY, headerValue); } else { // Create a metadata key dynamically for API keys and other custom headers - Metadata.Key metadataKey = Metadata.Key.of(headerName, Metadata.ASCII_STRING_MARSHALLER); + Metadata.Key metadataKey = Metadata.Key.of( + headerName.toLowerCase(Locale.ROOT), Metadata.ASCII_STRING_MARSHALLER); metadata.put(metadataKey, headerValue); } } @@ -360,17 +362,10 @@ private String getTaskPushNotificationConfigName(GetTaskPushNotificationConfigPa } private String getTaskPushNotificationConfigName(String taskId, @Nullable String pushNotificationConfigId) { - StringBuilder name = new StringBuilder(); - name.append("tasks/"); - name.append(taskId); - if (pushNotificationConfigId != null) { - name.append("/pushNotificationConfigs/"); - name.append(pushNotificationConfigId); + if (pushNotificationConfigId == null) { + return "tasks/" + taskId; } - //name.append("/pushNotificationConfigs/"); - // Use taskId as default config ID if none provided - //name.append(pushNotificationConfigId != null ? pushNotificationConfigId : taskId); - return name.toString(); + return "tasks/" + taskId + "/pushNotificationConfigs/" + pushNotificationConfigId; } private PayloadAndHeaders_v0_3 applyInterceptors(String methodName, Object payload, @@ -386,4 +381,4 @@ private PayloadAndHeaders_v0_3 applyInterceptors(String methodName, Object paylo return payloadAndHeaders; } -} \ No newline at end of file +} diff --git a/compat-0.3/client/transport/jsonrpc/src/main/java/org/a2aproject/sdk/compat03/client/transport/jsonrpc/sse/SSEEventListener_v0_3.java b/compat-0.3/client/transport/jsonrpc/src/main/java/org/a2aproject/sdk/compat03/client/transport/jsonrpc/sse/SSEEventListener_v0_3.java index 991769a2d..a9372a52b 100644 --- a/compat-0.3/client/transport/jsonrpc/src/main/java/org/a2aproject/sdk/compat03/client/transport/jsonrpc/sse/SSEEventListener_v0_3.java +++ b/compat-0.3/client/transport/jsonrpc/src/main/java/org/a2aproject/sdk/compat03/client/transport/jsonrpc/sse/SSEEventListener_v0_3.java @@ -7,7 +7,9 @@ import org.a2aproject.sdk.compat03.json.JsonUtil_v0_3; import org.a2aproject.sdk.compat03.spec.JSONRPCError_v0_3; import org.a2aproject.sdk.compat03.spec.StreamingEventKind_v0_3; +import org.a2aproject.sdk.compat03.spec.Task_v0_3; import org.a2aproject.sdk.compat03.spec.TaskStatusUpdateEvent_v0_3; +import org.jspecify.annotations.Nullable; import java.util.concurrent.Future; import java.util.concurrent.atomic.AtomicBoolean; @@ -26,23 +28,25 @@ public SSEEventListener_v0_3(Consumer eventHandler, this.errorHandler = errorHandler; } - public void onMessage(String message, Future completableFuture) { + public void onMessage(String message, @Nullable Future completableFuture) { try { handleMessage(JsonParser.parseString(message).getAsJsonObject(), completableFuture); } catch (JsonSyntaxException e) { - LOGGER.warning("Failed to parse JSON message: " + message); + fail(e, completableFuture); } catch (JsonProcessingException_v0_3 e) { - LOGGER.warning("Failed to process JSON message: " + message); + fail(e, completableFuture); } catch (IllegalArgumentException e) { - LOGGER.warning("Invalid message format: " + message); - signalTerminal(e); - completableFuture.cancel(true); // close SSE channel + fail(e, completableFuture); + } catch (IllegalStateException e) { + fail(e, completableFuture); } } - public void onError(Throwable throwable, Future future) { + public void onError(Throwable throwable, @Nullable Future future) { signalTerminal(throwable); - future.cancel(true); // close SSE channel + if (future != null) { + future.cancel(true); // close SSE channel + } } private void signalTerminal(Throwable error) { @@ -62,22 +66,35 @@ public void onComplete() { signalTerminal(null); } - private void handleMessage(JsonObject jsonObject, Future future) throws JsonProcessingException_v0_3 { + private void handleMessage(JsonObject jsonObject, @Nullable Future future) throws JsonProcessingException_v0_3 { if (jsonObject.has("error")) { JSONRPCError_v0_3 error = JsonUtil_v0_3.fromJson(jsonObject.get("error").toString(), JSONRPCError_v0_3.class); signalTerminal(error); + if (future != null) { + future.cancel(true); // close SSE channel + } } else if (jsonObject.has("result")) { // result can be a Task, Message, TaskStatusUpdateEvent, or TaskArtifactUpdateEvent String resultJson = jsonObject.get("result").toString(); StreamingEventKind_v0_3 event = JsonUtil_v0_3.fromJson(resultJson, StreamingEventKind_v0_3.class); eventHandler.accept(event); - if (event instanceof TaskStatusUpdateEvent_v0_3 tsue && tsue.isFinal()) { + if ((event instanceof TaskStatusUpdateEvent_v0_3 tsue && tsue.isFinal()) + || (event instanceof Task_v0_3 task && task.status().state().isFinal())) { signalTerminal(null); - future.cancel(true); // close SSE channel + if (future != null) { + future.cancel(true); // close SSE channel + } } } else { throw new IllegalArgumentException("Unknown message type"); } } + private void fail(Throwable throwable, @Nullable Future future) { + signalTerminal(throwable); + if (future != null) { + future.cancel(true); + } + } + } diff --git a/compat-0.3/client/transport/jsonrpc/src/test/java/org/a2aproject/sdk/compat03/client/transport/jsonrpc/sse/SSEEventListener_v0_3_Test.java b/compat-0.3/client/transport/jsonrpc/src/test/java/org/a2aproject/sdk/compat03/client/transport/jsonrpc/sse/SSEEventListener_v0_3_Test.java index 0f22828a5..5ea6b078a 100644 --- a/compat-0.3/client/transport/jsonrpc/src/test/java/org/a2aproject/sdk/compat03/client/transport/jsonrpc/sse/SSEEventListener_v0_3_Test.java +++ b/compat-0.3/client/transport/jsonrpc/src/test/java/org/a2aproject/sdk/compat03/client/transport/jsonrpc/sse/SSEEventListener_v0_3_Test.java @@ -60,6 +60,21 @@ public void testOnEventWithTaskResult() throws Exception { assertEquals(TaskState_v0_3.WORKING, task.status().state()); } + @Test + public void testFinalTaskResultCancelsStream() { + AtomicReference receivedEvent = new AtomicReference<>(); + SSEEventListener_v0_3 listener = new SSEEventListener_v0_3(receivedEvent::set, error -> {}); + CancelCapturingFuture future = new CancelCapturingFuture(); + + String eventData = JsonStreamingMessages_v0_3.STREAMING_TASK_EVENT + .replace("\"working\"", "\"completed\"") + .substring(JsonStreamingMessages_v0_3.STREAMING_TASK_EVENT.indexOf("{")); + listener.onMessage(eventData, future); + + assertInstanceOf(Task_v0_3.class, receivedEvent.get()); + assertTrue(future.cancelHandlerCalled); + } + @Test public void testOnEventWithMessageResult() throws Exception { // Set up event handler @@ -160,7 +175,8 @@ public void testOnEventWithError() throws Exception { JsonStreamingMessages_v0_3.STREAMING_ERROR_EVENT.indexOf("{")); // Call onEvent method - listener.onMessage(eventData, null); + CancelCapturingFuture future = new CancelCapturingFuture(); + listener.onMessage(eventData, future); // Verify the error was processed correctly assertNotNull(receivedError.get()); @@ -169,6 +185,33 @@ public void testOnEventWithError() throws Exception { assertEquals(-32602, jsonrpcError.getCode()); assertEquals("Invalid parameters", jsonrpcError.getMessage()); assertEquals("Missing required field", jsonrpcError.getData()); + assertTrue(future.cancelHandlerCalled); + } + + @Test + public void testMalformedEventReportsAndCancels() { + AtomicReference receivedError = new AtomicReference<>(); + SSEEventListener_v0_3 listener = new SSEEventListener_v0_3( + event -> {}, receivedError::set); + CancelCapturingFuture future = new CancelCapturingFuture(); + + listener.onMessage("{not-json", future); + + assertNotNull(receivedError.get()); + assertTrue(future.cancelHandlerCalled); + } + + @Test + public void testNonObjectJsonEventReportsAndCancels() { + AtomicReference receivedError = new AtomicReference<>(); + SSEEventListener_v0_3 listener = new SSEEventListener_v0_3( + event -> {}, receivedError::set); + CancelCapturingFuture future = new CancelCapturingFuture(); + + listener.onMessage("[]", future); + + assertNotNull(receivedError.get()); + assertTrue(future.cancelHandlerCalled); } @Test @@ -189,6 +232,14 @@ public void testOnFailure() { assertTrue(future.cancelHandlerCalled); } + @Test + public void testOnFailureWithNullFutureDoesNotThrow() { + SSEEventListener_v0_3 listener = new SSEEventListener_v0_3( + event -> {}, error -> {}); + + listener.onError(new RuntimeException("Test exception"), null); + } + @Test public void testFinalTaskStatusUpdateEventCancels() { TaskStatusUpdateEvent_v0_3 tsue = new TaskStatusUpdateEvent_v0_3.Builder() @@ -237,6 +288,17 @@ public void testOnEventWithFinalTaskStatusUpdateEventEventCancels() throws Excep assertTrue(future.cancelHandlerCalled); } + @Test + public void testFinalTaskStatusUpdateWithNullFutureDoesNotThrow() { + SSEEventListener_v0_3 listener = new SSEEventListener_v0_3( + event -> {}, error -> {}); + + String eventData = JsonStreamingMessages_v0_3.STREAMING_STATUS_UPDATE_EVENT_FINAL.substring( + JsonStreamingMessages_v0_3.STREAMING_STATUS_UPDATE_EVENT_FINAL.indexOf("{")); + + listener.onMessage(eventData, null); + } + @Test diff --git a/compat-0.3/client/transport/rest/src/main/java/org/a2aproject/sdk/compat03/client/transport/rest/RestErrorMapper_v0_3.java b/compat-0.3/client/transport/rest/src/main/java/org/a2aproject/sdk/compat03/client/transport/rest/RestErrorMapper_v0_3.java index d9b6d1368..b66387831 100644 --- a/compat-0.3/client/transport/rest/src/main/java/org/a2aproject/sdk/compat03/client/transport/rest/RestErrorMapper_v0_3.java +++ b/compat-0.3/client/transport/rest/src/main/java/org/a2aproject/sdk/compat03/client/transport/rest/RestErrorMapper_v0_3.java @@ -1,11 +1,17 @@ package org.a2aproject.sdk.compat03.client.transport.rest; +import java.util.List; +import java.util.Map; +import java.util.logging.Level; +import java.util.logging.Logger; + import com.google.gson.JsonElement; import com.google.gson.JsonObject; +import org.a2aproject.sdk.client.http.A2AHttpResponse; import org.a2aproject.sdk.compat03.json.JsonProcessingException_v0_3; import org.a2aproject.sdk.compat03.json.JsonUtil_v0_3; -import org.a2aproject.sdk.client.http.A2AHttpResponse; import org.a2aproject.sdk.compat03.spec.A2AClientException_v0_3; +import org.a2aproject.sdk.compat03.spec.A2AClientHTTPError_v0_3; import org.a2aproject.sdk.compat03.spec.AuthenticatedExtendedCardNotConfiguredError_v0_3; import org.a2aproject.sdk.compat03.spec.ContentTypeNotSupportedError_v0_3; import org.a2aproject.sdk.compat03.spec.InternalError_v0_3; @@ -18,31 +24,44 @@ import org.a2aproject.sdk.compat03.spec.TaskNotCancelableError_v0_3; import org.a2aproject.sdk.compat03.spec.TaskNotFoundError_v0_3; import org.a2aproject.sdk.compat03.spec.UnsupportedOperationError_v0_3; -import java.util.logging.Level; -import java.util.logging.Logger; /** * Utility class to A2AHttpResponse to appropriate A2A error types */ public class RestErrorMapper_v0_3 { + private static final Logger LOGGER = Logger.getLogger(RestErrorMapper_v0_3.class.getName()); public static A2AClientException_v0_3 mapRestError(A2AHttpResponse response) { - return RestErrorMapper_v0_3.mapRestError(response.body(), response.status()); + return mapRestError(response.body(), response.status(), response.headers().toMap()); } public static A2AClientException_v0_3 mapRestError(String body, int code) { + return mapRestError(body, code, Map.of()); + } + + private static A2AClientException_v0_3 mapRestError(String body, int code, Map> headers) { try { if (body != null && !body.isBlank()) { - JsonObject node = JsonUtil_v0_3.fromJson(body, JsonObject.class); - String className = safeGetString(node, "error"); - String errorMessage = safeGetString(node, "message"); - return mapRestError(className, errorMessage, code); + JsonElement node = JsonUtil_v0_3.fromJson(body, JsonElement.class); + if (node != null && node.isJsonObject()) { + String className = safeGetString(node.getAsJsonObject(), "error"); + String errorMessage = safeGetString(node.getAsJsonObject(), "message"); + A2AClientException_v0_3 mapped = mapRestError(className, errorMessage, code); + if (!(mapped.getCause() instanceof A2AClientHTTPError_v0_3)) { + return mapped; + } + } } - return mapRestError("", "", code); - } catch (JsonProcessingException_v0_3 ex) { - Logger.getLogger(RestErrorMapper_v0_3.class.getName()).log(Level.SEVERE, null, ex); - return new A2AClientException_v0_3("Failed to parse error response: " + ex.getMessage()); + } catch (JsonProcessingException_v0_3 e) { + // A non-JSON error body is still an HTTP failure with useful response details. + LOGGER.log(Level.SEVERE, "Failed to parse REST error response body as JSON", e); } + return httpError(body, code, headers); + } + + private static A2AClientException_v0_3 httpError(String body, int code, Map> headers) { + String message = "HTTP " + code + (body == null || body.isBlank() ? "" : ": " + body); + return new A2AClientException_v0_3(message, new A2AClientHTTPError_v0_3(code, message, body, headers)); } private static String safeGetString(JsonObject obj, String fieldName) { @@ -69,7 +88,7 @@ public static A2AClientException_v0_3 mapRestError(String className, String erro case "org.a2aproject.sdk.compat03.spec.PushNotificationNotSupportedError_v0_3" -> new A2AClientException_v0_3(errorMessage, new PushNotificationNotSupportedError_v0_3()); case "org.a2aproject.sdk.compat03.spec.TaskNotCancelableError_v0_3" -> new A2AClientException_v0_3(errorMessage, new TaskNotCancelableError_v0_3()); case "org.a2aproject.sdk.compat03.spec.UnsupportedOperationError_v0_3" -> new A2AClientException_v0_3(errorMessage, new UnsupportedOperationError_v0_3()); - default -> new A2AClientException_v0_3(errorMessage); + default -> httpError(errorMessage, code, Map.of()); }; } } diff --git a/compat-0.3/client/transport/rest/src/main/java/org/a2aproject/sdk/compat03/client/transport/rest/RestTransport_v0_3.java b/compat-0.3/client/transport/rest/src/main/java/org/a2aproject/sdk/compat03/client/transport/rest/RestTransport_v0_3.java index 7a8e6a1ec..a29e91c60 100644 --- a/compat-0.3/client/transport/rest/src/main/java/org/a2aproject/sdk/compat03/client/transport/rest/RestTransport_v0_3.java +++ b/compat-0.3/client/transport/rest/src/main/java/org/a2aproject/sdk/compat03/client/transport/rest/RestTransport_v0_3.java @@ -2,14 +2,24 @@ import static org.a2aproject.sdk.util.Assert.checkNotNullParam; -import org.a2aproject.sdk.compat03.json.JsonProcessingException_v0_3; +import java.io.IOException; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Consumer; +import java.util.logging.Level; +import java.util.logging.Logger; + import com.google.protobuf.InvalidProtocolBufferException; import com.google.protobuf.MessageOrBuilder; import com.google.protobuf.util.JsonFormat; import org.a2aproject.sdk.client.http.A2AHttpClient; import org.a2aproject.sdk.client.http.A2AHttpClientFactory; import org.a2aproject.sdk.client.http.A2AHttpResponse; -import org.a2aproject.sdk.client.http.ServerSentEvent; import org.a2aproject.sdk.compat03.client.http.A2ACardResolver_v0_3; import org.a2aproject.sdk.compat03.client.transport.rest.sse.RestSSEEventListener_v0_3; import org.a2aproject.sdk.compat03.client.transport.spi.ClientTransport_v0_3; @@ -18,44 +28,37 @@ import org.a2aproject.sdk.compat03.client.transport.spi.interceptors.PayloadAndHeaders_v0_3; import org.a2aproject.sdk.compat03.grpc.CancelTaskRequest; import org.a2aproject.sdk.compat03.grpc.CreateTaskPushNotificationConfigRequest; +import org.a2aproject.sdk.compat03.grpc.CreateTaskPushNotificationConfigRequestOrBuilder; import org.a2aproject.sdk.compat03.grpc.GetTaskPushNotificationConfigRequest; import org.a2aproject.sdk.compat03.grpc.GetTaskRequest; import org.a2aproject.sdk.compat03.grpc.ListTaskPushNotificationConfigRequest; -import org.a2aproject.sdk.compat03.spec.CancelTaskRequest_v0_3; -import org.a2aproject.sdk.compat03.spec.DeleteTaskPushNotificationConfigRequest_v0_3; -import org.a2aproject.sdk.compat03.spec.GetTaskPushNotificationConfigRequest_v0_3; -import org.a2aproject.sdk.compat03.spec.GetTaskRequest_v0_3; -import org.a2aproject.sdk.compat03.spec.ListTaskPushNotificationConfigRequest_v0_3; -import org.a2aproject.sdk.compat03.spec.SendMessageRequest_v0_3; -import org.a2aproject.sdk.compat03.spec.TaskPushNotificationConfig_v0_3; +import org.a2aproject.sdk.compat03.grpc.utils.ProtoJsonUtils_v0_3; +import org.a2aproject.sdk.compat03.grpc.utils.ProtoUtils_v0_3; +import org.a2aproject.sdk.compat03.json.JsonProcessingException_v0_3; +import org.a2aproject.sdk.compat03.json.JsonUtil_v0_3; +import org.a2aproject.sdk.compat03.spec.A2AClientError_v0_3; import org.a2aproject.sdk.compat03.spec.A2AClientException_v0_3; import org.a2aproject.sdk.compat03.spec.AgentCard_v0_3; +import org.a2aproject.sdk.compat03.spec.CancelTaskRequest_v0_3; import org.a2aproject.sdk.compat03.spec.DeleteTaskPushNotificationConfigParams_v0_3; +import org.a2aproject.sdk.compat03.spec.DeleteTaskPushNotificationConfigRequest_v0_3; import org.a2aproject.sdk.compat03.spec.EventKind_v0_3; import org.a2aproject.sdk.compat03.spec.GetAuthenticatedExtendedCardRequest_v0_3; import org.a2aproject.sdk.compat03.spec.GetTaskPushNotificationConfigParams_v0_3; +import org.a2aproject.sdk.compat03.spec.GetTaskPushNotificationConfigRequest_v0_3; +import org.a2aproject.sdk.compat03.spec.GetTaskRequest_v0_3; import org.a2aproject.sdk.compat03.spec.ListTaskPushNotificationConfigParams_v0_3; +import org.a2aproject.sdk.compat03.spec.ListTaskPushNotificationConfigRequest_v0_3; import org.a2aproject.sdk.compat03.spec.MessageSendParams_v0_3; +import org.a2aproject.sdk.compat03.spec.SendMessageRequest_v0_3; +import org.a2aproject.sdk.compat03.spec.SendStreamingMessageRequest_v0_3; +import org.a2aproject.sdk.compat03.spec.SetTaskPushNotificationConfigRequest_v0_3; import org.a2aproject.sdk.compat03.spec.StreamingEventKind_v0_3; -import org.a2aproject.sdk.compat03.spec.TaskResubscriptionRequest_v0_3; -import org.a2aproject.sdk.compat03.spec.Task_v0_3; import org.a2aproject.sdk.compat03.spec.TaskIdParams_v0_3; +import org.a2aproject.sdk.compat03.spec.TaskPushNotificationConfig_v0_3; import org.a2aproject.sdk.compat03.spec.TaskQueryParams_v0_3; -import org.a2aproject.sdk.compat03.grpc.utils.ProtoJsonUtils_v0_3; -import org.a2aproject.sdk.compat03.grpc.utils.ProtoUtils_v0_3; -import org.a2aproject.sdk.compat03.spec.A2AClientError_v0_3; -import org.a2aproject.sdk.compat03.spec.SendStreamingMessageRequest_v0_3; -import org.a2aproject.sdk.compat03.spec.SetTaskPushNotificationConfigRequest_v0_3; -import org.a2aproject.sdk.compat03.json.JsonUtil_v0_3; -import java.io.IOException; -import java.util.Collections; -import java.util.List; -import java.util.logging.Level; -import java.util.logging.Logger; -import java.util.Map; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.atomic.AtomicReference; -import java.util.function.Consumer; +import org.a2aproject.sdk.compat03.spec.TaskResubscriptionRequest_v0_3; +import org.a2aproject.sdk.compat03.spec.Task_v0_3; import org.a2aproject.sdk.grpc.utils.ProtoJsonUtils; import org.jspecify.annotations.Nullable; @@ -194,8 +197,14 @@ public TaskPushNotificationConfig_v0_3 setTaskPushNotificationConfiguration(Task builder.setConfigId(request.pushNotificationConfig().id()); } PayloadAndHeaders_v0_3 payloadAndHeaders = applyInterceptors(SetTaskPushNotificationConfigRequest_v0_3.METHOD, builder, agentCard, context); + CreateTaskPushNotificationConfigRequestOrBuilder payload = + checkNotNullParam("payload", (CreateTaskPushNotificationConfigRequestOrBuilder) payloadAndHeaders.getPayload()); + PayloadAndHeaders_v0_3 configBody = new PayloadAndHeaders_v0_3(payload.getConfig(), payloadAndHeaders.getHeaders()); + String configId = payload.getConfigId().isEmpty() ? request.taskId() : payload.getConfigId(); + String url = agentUrl + String.format("/v1/tasks/%1s/pushNotificationConfigs", request.taskId()) + + "?configId=" + URLEncoder.encode(configId, StandardCharsets.UTF_8); try { - String httpResponseBody = sendPostRequest(agentUrl + String.format("/v1/tasks/%1s/pushNotificationConfigs", request.taskId()), payloadAndHeaders); + String httpResponseBody = sendPostRequest(url, configBody); org.a2aproject.sdk.compat03.grpc.TaskPushNotificationConfig.Builder responseBuilder = org.a2aproject.sdk.compat03.grpc.TaskPushNotificationConfig.newBuilder(); JsonFormat.parser().merge(httpResponseBody, responseBuilder); return ProtoUtils_v0_3.FromProto.taskPushNotificationConfig(responseBuilder); @@ -313,11 +322,11 @@ public void resubscribe(TaskIdParams_v0_3 request, Consumer completableFuture) LOGGER.fine("Streaming message received: " + message); org.a2aproject.sdk.compat03.grpc.StreamResponse.Builder builder = org.a2aproject.sdk.compat03.grpc.StreamResponse.newBuilder(); JsonFormat.parser().merge(message, builder); - handleMessage(builder.build()); + handleMessage(builder.build(), completableFuture); } catch (InvalidProtocolBufferException e) { - errorHandler.accept(RestErrorMapper_v0_3.mapRestError(message, 500)); + if (errorHandler != null) { + errorHandler.accept(RestErrorMapper_v0_3.mapRestError(message, 500)); + } + cancel(completableFuture); } } @@ -44,7 +49,7 @@ public void onError(Throwable throwable, @Nullable Future future) { } } - private void handleMessage(StreamResponse response) { + private void handleMessage(StreamResponse response, @Nullable Future future) { StreamingEventKind_v0_3 event; switch (response.getPayloadCase()) { case MSG -> @@ -57,11 +62,24 @@ private void handleMessage(StreamResponse response) { event = ProtoUtils_v0_3.FromProto.taskArtifactUpdateEvent(response.getArtifactUpdate()); default -> { LOGGER.warning("Invalid stream response " + response.getPayloadCase()); - errorHandler.accept(new IllegalStateException("Invalid stream response from server: " + response.getPayloadCase())); + if (errorHandler != null) { + errorHandler.accept(new IllegalStateException("Invalid stream response from server: " + response.getPayloadCase())); + } + cancel(future); return; } } eventHandler.accept(event); + if ((event instanceof TaskStatusUpdateEvent_v0_3 statusUpdate && statusUpdate.isFinal()) + || (event instanceof Task_v0_3 task && task.status().state().isFinal())) { + cancel(future); + } + } + + private static void cancel(@Nullable Future future) { + if (future != null) { + future.cancel(true); + } } } diff --git a/compat-0.3/client/transport/rest/src/test/java/org/a2aproject/sdk/compat03/client/transport/rest/JsonRestMessages_v0_3.java b/compat-0.3/client/transport/rest/src/test/java/org/a2aproject/sdk/compat03/client/transport/rest/JsonRestMessages_v0_3.java index f061d1d58..635b686de 100644 --- a/compat-0.3/client/transport/rest/src/test/java/org/a2aproject/sdk/compat03/client/transport/rest/JsonRestMessages_v0_3.java +++ b/compat-0.3/client/transport/rest/src/test/java/org/a2aproject/sdk/compat03/client/transport/rest/JsonRestMessages_v0_3.java @@ -417,14 +417,11 @@ public class JsonRestMessages_v0_3 { static final String SET_TASK_PUSH_NOTIFICATION_CONFIG_TEST_REQUEST = """ { - "parent": "tasks/de38c76d-d54c-436c-8b9f-4c2703648d64", - "config": { - "name": "tasks/de38c76d-d54c-436c-8b9f-4c2703648d64/pushNotificationConfigs", - "pushNotificationConfig": { - "url": "https://example.com/callback", - "authentication": { - "schemes": [ "jwt" ] - } + "name": "tasks/de38c76d-d54c-436c-8b9f-4c2703648d64/pushNotificationConfigs", + "pushNotificationConfig": { + "url": "https://example.com/callback", + "authentication": { + "schemes": [ "jwt" ] } } }"""; diff --git a/compat-0.3/client/transport/rest/src/test/java/org/a2aproject/sdk/compat03/client/transport/rest/RestErrorMapper_v0_3_Test.java b/compat-0.3/client/transport/rest/src/test/java/org/a2aproject/sdk/compat03/client/transport/rest/RestErrorMapper_v0_3_Test.java new file mode 100644 index 000000000..b473f9358 --- /dev/null +++ b/compat-0.3/client/transport/rest/src/test/java/org/a2aproject/sdk/compat03/client/transport/rest/RestErrorMapper_v0_3_Test.java @@ -0,0 +1,60 @@ +package org.a2aproject.sdk.compat03.client.transport.rest; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import java.util.Map; + +import org.a2aproject.sdk.client.http.A2AHttpHeaders; +import org.a2aproject.sdk.client.http.A2AHttpResponse; +import org.a2aproject.sdk.compat03.spec.A2AClientHTTPError_v0_3; +import org.a2aproject.sdk.compat03.spec.TaskNotFoundError_v0_3; +import org.junit.jupiter.api.Test; + +class RestErrorMapper_v0_3_Test { + @Test + void preservesStatusRawBodyAndHeadersForUnrecognizedResponses() { + for (String body : List.of("", "rate limited", "null", "[]", "{\"message\":\"rate limited\"}", + "{\"error\":\"UNKNOWN\",\"message\":\"rate limited\"}")) { + var exception = RestErrorMapper_v0_3.mapRestError(response(429, body, + Map.of("Retry-After", List.of("30"), "X-Request-Id", List.of("request")))); + var cause = assertInstanceOf(A2AClientHTTPError_v0_3.class, exception.getCause()); + assertEquals(429, cause.getCode()); + assertEquals(body, cause.getResponseBody()); + assertEquals(List.of("30"), cause.getResponseHeaders().get("retry-after")); + assertTrue(exception.getMessage().contains("429")); + } + var unauthorized = RestErrorMapper_v0_3.mapRestError(response(401, "", + Map.of("WWW-Authenticate", List.of("Bearer")))); + var cause = assertInstanceOf(A2AClientHTTPError_v0_3.class, unauthorized.getCause()); + assertEquals(401, cause.getCode()); + assertEquals(List.of("Bearer"), cause.getResponseHeaders().get("www-authenticate")); + } + + @Test + void retainsRecognizedProtocolErrors() { + var exception = RestErrorMapper_v0_3.mapRestError(response(404, + "{\"error\":\"org.a2aproject.sdk.compat03.spec.TaskNotFoundError_v0_3\",\"message\":\"missing\"}", Map.of())); + assertInstanceOf(TaskNotFoundError_v0_3.class, exception.getCause()); + assertEquals("missing", exception.getMessage()); + } + + @Test + void bodyAndClassNameOverloadsRetainHttpStatus() { + assertEquals(503, assertInstanceOf(A2AClientHTTPError_v0_3.class, + RestErrorMapper_v0_3.mapRestError("unavailable", 503).getCause()).getCode()); + assertEquals(401, assertInstanceOf(A2AClientHTTPError_v0_3.class, + RestErrorMapper_v0_3.mapRestError("unknown", "unauthorized", 401).getCause()).getCode()); + } + + private static A2AHttpResponse response(int status, String body, Map> headers) { + return new A2AHttpResponse() { + @Override public int status() { return status; } + @Override public boolean success() { return false; } + @Override public String body() { return body; } + @Override public A2AHttpHeaders headers() { return A2AHttpHeaders.of(headers); } + }; + } +} diff --git a/compat-0.3/client/transport/rest/src/test/java/org/a2aproject/sdk/compat03/client/transport/rest/RestTransport_v0_3_Test.java b/compat-0.3/client/transport/rest/src/test/java/org/a2aproject/sdk/compat03/client/transport/rest/RestTransport_v0_3_Test.java index af841eac6..c39610b0f 100644 --- a/compat-0.3/client/transport/rest/src/test/java/org/a2aproject/sdk/compat03/client/transport/rest/RestTransport_v0_3_Test.java +++ b/compat-0.3/client/transport/rest/src/test/java/org/a2aproject/sdk/compat03/client/transport/rest/RestTransport_v0_3_Test.java @@ -6,10 +6,10 @@ import static org.a2aproject.sdk.compat03.client.transport.rest.JsonRestMessages_v0_3.GET_TASK_PUSH_NOTIFICATION_CONFIG_TEST_RESPONSE; import static org.a2aproject.sdk.compat03.client.transport.rest.JsonRestMessages_v0_3.GET_TASK_TEST_RESPONSE; import static org.a2aproject.sdk.compat03.client.transport.rest.JsonRestMessages_v0_3.LIST_TASK_PUSH_NOTIFICATION_CONFIG_TEST_RESPONSE; +import static org.a2aproject.sdk.compat03.client.transport.rest.JsonRestMessages_v0_3.SEND_MESSAGE_STREAMING_TEST_REQUEST; import static org.a2aproject.sdk.compat03.client.transport.rest.JsonRestMessages_v0_3.SEND_MESSAGE_STREAMING_TEST_RESPONSE; import static org.a2aproject.sdk.compat03.client.transport.rest.JsonRestMessages_v0_3.SEND_MESSAGE_TEST_REQUEST; import static org.a2aproject.sdk.compat03.client.transport.rest.JsonRestMessages_v0_3.SEND_MESSAGE_TEST_RESPONSE; -import static org.a2aproject.sdk.compat03.client.transport.rest.JsonRestMessages_v0_3.SEND_MESSAGE_STREAMING_TEST_REQUEST; import static org.a2aproject.sdk.compat03.client.transport.rest.JsonRestMessages_v0_3.SET_TASK_PUSH_NOTIFICATION_CONFIG_TEST_REQUEST; import static org.a2aproject.sdk.compat03.client.transport.rest.JsonRestMessages_v0_3.SET_TASK_PUSH_NOTIFICATION_CONFIG_TEST_RESPONSE; import static org.a2aproject.sdk.compat03.client.transport.rest.JsonRestMessages_v0_3.TASK_RESUBSCRIPTION_REQUEST_TEST_RESPONSE; @@ -17,11 +17,25 @@ import static org.junit.jupiter.api.Assertions.assertInstanceOf; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockserver.model.HttpRequest.request; import static org.mockserver.model.HttpResponse.response; +import java.io.IOException; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Consumer; +import java.util.logging.Logger; + import org.a2aproject.sdk.compat03.client.transport.spi.interceptors.ClientCallContext_v0_3; +import org.a2aproject.sdk.compat03.client.transport.spi.interceptors.ClientCallInterceptor_v0_3; +import org.a2aproject.sdk.compat03.client.transport.spi.interceptors.PayloadAndHeaders_v0_3; +import org.a2aproject.sdk.compat03.spec.A2AClientException_v0_3; import org.a2aproject.sdk.compat03.spec.AgentCapabilities_v0_3; import org.a2aproject.sdk.compat03.spec.AgentCard_v0_3; import org.a2aproject.sdk.compat03.spec.AgentSkill_v0_3; @@ -33,29 +47,21 @@ import org.a2aproject.sdk.compat03.spec.FileWithUri_v0_3; import org.a2aproject.sdk.compat03.spec.GetTaskPushNotificationConfigParams_v0_3; import org.a2aproject.sdk.compat03.spec.ListTaskPushNotificationConfigParams_v0_3; -import org.a2aproject.sdk.compat03.spec.Message_v0_3; import org.a2aproject.sdk.compat03.spec.MessageSendConfiguration_v0_3; import org.a2aproject.sdk.compat03.spec.MessageSendParams_v0_3; -import org.a2aproject.sdk.compat03.spec.Part_v0_3; +import org.a2aproject.sdk.compat03.spec.Message_v0_3; import org.a2aproject.sdk.compat03.spec.Part_v0_3.Kind; +import org.a2aproject.sdk.compat03.spec.Part_v0_3; import org.a2aproject.sdk.compat03.spec.PushNotificationAuthenticationInfo_v0_3; import org.a2aproject.sdk.compat03.spec.PushNotificationConfig_v0_3; import org.a2aproject.sdk.compat03.spec.StreamingEventKind_v0_3; -import org.a2aproject.sdk.compat03.spec.Task_v0_3; import org.a2aproject.sdk.compat03.spec.TaskIdParams_v0_3; import org.a2aproject.sdk.compat03.spec.TaskPushNotificationConfig_v0_3; import org.a2aproject.sdk.compat03.spec.TaskQueryParams_v0_3; import org.a2aproject.sdk.compat03.spec.TaskState_v0_3; +import org.a2aproject.sdk.compat03.spec.Task_v0_3; import org.a2aproject.sdk.compat03.spec.TextPart_v0_3; -import java.io.IOException; -import java.util.Collections; -import java.util.HashMap; -import java.util.List; -import java.util.concurrent.CountDownLatch; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicReference; -import java.util.function.Consumer; -import java.util.logging.Logger; +import org.a2aproject.sdk.spec.A2AClientHTTPError; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -227,7 +233,7 @@ public void testGetTask() throws Exception { assertEquals(Kind.FILE, part.kind()); assertEquals("text/plain", part.file().mimeType()); assertEquals("greeting.txt", part.file().name()); - assertEquals("hello", ((FileWithBytes_v0_3) part.file()).bytes()); + assertEquals("aGVsbG8=", ((FileWithBytes_v0_3) part.file()).bytes()); assertNull(history.metadata()); assertNull(history.referenceTaskIds()); } @@ -293,7 +299,8 @@ public void testSetTaskPushNotificationConfiguration() throws Exception { request() .withMethod("POST") .withPath("/v1/tasks/de38c76d-d54c-436c-8b9f-4c2703648d64/pushNotificationConfigs") - .withBody(JsonBody.json(SET_TASK_PUSH_NOTIFICATION_CONFIG_TEST_REQUEST, MatchType.ONLY_MATCHING_FIELDS)) + .withQueryStringParameter("configId", "de38c76d-d54c-436c-8b9f-4c2703648d64") + .withBody(JsonBody.json(SET_TASK_PUSH_NOTIFICATION_CONFIG_TEST_REQUEST, MatchType.STRICT)) ) .respond( response() @@ -401,6 +408,49 @@ public void testDeleteTaskPushNotificationConfigurations() throws Exception { instance.deleteTaskPushNotificationConfigurations(new DeleteTaskPushNotificationConfigParams_v0_3("de38c76d-d54c-436c-8b9f-4c2703648d64", "10"), context); } + @Test + public void testJdkAuthenticationFailuresRetainResponseDetails() { + for (int code : List.of(401, 403)) { + String taskId = "auth-" + code; + server.when(request().withMethod("GET").withPath("/v1/tasks/" + taskId)) + .respond(response().withStatusCode(code).withBody("unauthorized") + .withHeader("WWW-Authenticate", "Bearer")); + var client = new RestTransport_v0_3(CARD); + var exception = assertThrows(A2AClientException_v0_3.class, + () -> client.getTask(new TaskQueryParams_v0_3(taskId), null)); + var io = assertInstanceOf(IOException.class, exception.getCause()); + var cause = assertInstanceOf(A2AClientHTTPError.class, io.getCause()); + assertEquals(code, cause.getCode()); + assertEquals("unauthorized", cause.getResponseBody()); + assertEquals(List.of("Bearer"), cause.getResponseHeaders().get("www-authenticate")); + } + } + + @Test + public void testSetPushConfigPreservesIdAndImmutableInterceptorPayload() throws Exception { + String body = """ + {"name":"tasks/task/pushNotificationConfigs/config-1", + "pushNotificationConfig":{"id":"config-1","url":"https://example.com/callback"}} + """; + server.when(request().withMethod("POST").withPath("/v1/tasks/task/pushNotificationConfigs") + .withQueryStringParameter("configId", "config-1") + .withHeader("X-Trace", "trace") + .withBody(JsonBody.json(body, MatchType.STRICT))) + .respond(response().withStatusCode(201).withBody(body)); + ClientCallInterceptor_v0_3 interceptor = new ClientCallInterceptor_v0_3() { + @Override + public PayloadAndHeaders_v0_3 intercept(String method, Object payload, java.util.Map headers, + AgentCard_v0_3 card, ClientCallContext_v0_3 context) { + var builder = (org.a2aproject.sdk.compat03.grpc.CreateTaskPushNotificationConfigRequest.Builder) payload; + return new PayloadAndHeaders_v0_3(builder.build(), java.util.Map.of("X-Trace", "trace")); + } + }; + var client = new RestTransport_v0_3(null, CARD, CARD.url(), List.of(interceptor)); + var result = client.setTaskPushNotificationConfiguration(new TaskPushNotificationConfig_v0_3("task", + new PushNotificationConfig_v0_3.Builder().id("config-1").url("https://example.com/callback").build()), null); + assertEquals("config-1", result.pushNotificationConfig().id()); + } + /** * Test of resubscribe method, of class JSONRestTransport. */ @@ -408,11 +458,14 @@ public void testDeleteTaskPushNotificationConfigurations() throws Exception { public void testResubscribe() throws Exception { LOGGER.info("Testing resubscribe"); - this.server.when( - request() - .withMethod("POST") - .withPath("/v1/tasks/task-1234:subscribe") - ) + this.server.when(request() + .withMethod("GET") + .withPath("/v1/tasks/task-1234:subscribe")) + .respond(response().withStatusCode(405)); + this.server.when(request() + .withMethod("POST") + .withPath("/v1/tasks/task-1234:subscribe") + .withBody(JsonBody.json("{\"name\":\"tasks/task-1234\"}", MatchType.ONLY_MATCHING_FIELDS))) .respond( response() .withStatusCode(200) @@ -429,10 +482,15 @@ public void testResubscribe() throws Exception { receivedEvent.set(event); latch.countDown(); }; - Consumer errorHandler = error -> {}; + AtomicReference failure = new AtomicReference<>(); + Consumer errorHandler = error -> { + failure.set(error); + latch.countDown(); + }; client.resubscribe(taskIdParams, eventHandler, errorHandler, null); boolean eventReceived = latch.await(10, TimeUnit.SECONDS); + assertNull(failure.get()); assertTrue(eventReceived); StreamingEventKind_v0_3 eventKind = receivedEvent.get();; diff --git a/compat-0.3/client/transport/rest/src/test/java/org/a2aproject/sdk/compat03/client/transport/rest/sse/RestSSEEventListener_v0_3_Test.java b/compat-0.3/client/transport/rest/src/test/java/org/a2aproject/sdk/compat03/client/transport/rest/sse/RestSSEEventListener_v0_3_Test.java new file mode 100644 index 000000000..89e4853f9 --- /dev/null +++ b/compat-0.3/client/transport/rest/src/test/java/org/a2aproject/sdk/compat03/client/transport/rest/sse/RestSSEEventListener_v0_3_Test.java @@ -0,0 +1,124 @@ +package org.a2aproject.sdk.compat03.client.transport.rest.sse; + +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.concurrent.ExecutionException; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import java.util.concurrent.atomic.AtomicReference; + +import org.junit.jupiter.api.Test; + +class RestSSEEventListener_v0_3_Test { + @Test + void malformedEventReportsAndCancels() { + AtomicReference receivedError = new AtomicReference<>(); + RestSSEEventListener_v0_3 listener = new RestSSEEventListener_v0_3( + event -> {}, receivedError::set); + CancelCapturingFuture future = new CancelCapturingFuture(); + + listener.onMessage("{not-json", future); + + assertNotNull(receivedError.get()); + assertTrue(future.cancelled); + } + + @Test + void invalidPayloadReportsAndCancels() { + AtomicReference receivedError = new AtomicReference<>(); + RestSSEEventListener_v0_3 listener = new RestSSEEventListener_v0_3( + event -> {}, receivedError::set); + CancelCapturingFuture future = new CancelCapturingFuture(); + + listener.onMessage("{}", future); + + assertNotNull(receivedError.get()); + assertTrue(future.cancelled); + } + + @Test + void finalStatusUpdateCancels() { + RestSSEEventListener_v0_3 listener = new RestSSEEventListener_v0_3( + event -> {}, error -> {}); + CancelCapturingFuture future = new CancelCapturingFuture(); + + listener.onMessage(""" + { + "status_update": { + "task_id": "task-1", + "context_id": "context-1", + "status": {"state": "TASK_STATE_COMPLETED"}, + "final": true + } + }""", future); + + assertTrue(future.cancelled); + } + + @Test + void finalTaskCancels() { + RestSSEEventListener_v0_3 listener = new RestSSEEventListener_v0_3( + event -> {}, error -> {}); + CancelCapturingFuture future = new CancelCapturingFuture(); + + listener.onMessage(""" + { + "task": { + "id": "task-1", + "contextId": "context-1", + "status": {"state": "TASK_STATE_COMPLETED"} + } + }""", future); + + assertTrue(future.cancelled); + } + + @Test + void malformedEventWithoutErrorHandlerDoesNotThrow() { + RestSSEEventListener_v0_3 listener = new RestSSEEventListener_v0_3( + event -> {}, null); + + listener.onMessage("{not-json", null); + } + + @Test + void invalidPayloadWithoutErrorHandlerDoesNotThrow() { + RestSSEEventListener_v0_3 listener = new RestSSEEventListener_v0_3( + event -> {}, null); + + listener.onMessage("{}", null); + } + + private static final class CancelCapturingFuture implements Future { + private boolean cancelled; + + @Override + public boolean cancel(boolean mayInterruptIfRunning) { + cancelled = true; + return true; + } + + @Override + public boolean isCancelled() { + return cancelled; + } + + @Override + public boolean isDone() { + return cancelled; + } + + @Override + public Void get() throws InterruptedException, ExecutionException { + return null; + } + + @Override + public Void get(long timeout, TimeUnit unit) + throws InterruptedException, ExecutionException, TimeoutException { + return null; + } + } +} diff --git a/compat-0.3/conversion/pom.xml b/compat-0.3/conversion/pom.xml new file mode 100644 index 000000000..a438195fa --- /dev/null +++ b/compat-0.3/conversion/pom.xml @@ -0,0 +1,39 @@ + + + 4.0.0 + + org.a2aproject.sdk + a2a-java-sdk-compat-0.3-parent + 1.4.1.Final-SNAPSHOT + .. + + a2a-java-sdk-compat-0.3-conversion + Java SDK A2A Compat 0.3 Conversion + Neutral 0.3 to 1.0 type conversion mappings + + + ${project.groupId} + a2a-java-sdk-compat-0.3-spec + + + ${project.groupId} + a2a-java-sdk-spec + + + org.mapstruct + mapstruct + + + org.junit.jupiter + junit-jupiter-api + test + + + org.junit.jupiter + junit-jupiter-params + test + + + diff --git a/compat-0.3/server-conversion/src/main/java/org/a2aproject/sdk/compat03/conversion/mappers/config/A03ToV10MapperConfig.java b/compat-0.3/conversion/src/main/java/org/a2aproject/sdk/compat03/conversion/mappers/config/A03ToV10MapperConfig.java similarity index 100% rename from compat-0.3/server-conversion/src/main/java/org/a2aproject/sdk/compat03/conversion/mappers/config/A03ToV10MapperConfig.java rename to compat-0.3/conversion/src/main/java/org/a2aproject/sdk/compat03/conversion/mappers/config/A03ToV10MapperConfig.java diff --git a/compat-0.3/server-conversion/src/main/java/org/a2aproject/sdk/compat03/conversion/mappers/config/A2AMappers_v0_3.java b/compat-0.3/conversion/src/main/java/org/a2aproject/sdk/compat03/conversion/mappers/config/A2AMappers_v0_3.java similarity index 100% rename from compat-0.3/server-conversion/src/main/java/org/a2aproject/sdk/compat03/conversion/mappers/config/A2AMappers_v0_3.java rename to compat-0.3/conversion/src/main/java/org/a2aproject/sdk/compat03/conversion/mappers/config/A2AMappers_v0_3.java diff --git a/compat-0.3/conversion/src/main/java/org/a2aproject/sdk/compat03/conversion/mappers/domain/AgentCardMapper_v0_3.java b/compat-0.3/conversion/src/main/java/org/a2aproject/sdk/compat03/conversion/mappers/domain/AgentCardMapper_v0_3.java new file mode 100644 index 000000000..5b3d44032 --- /dev/null +++ b/compat-0.3/conversion/src/main/java/org/a2aproject/sdk/compat03/conversion/mappers/domain/AgentCardMapper_v0_3.java @@ -0,0 +1,240 @@ +package org.a2aproject.sdk.compat03.conversion.mappers.domain; + +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.stream.Collectors; + +import org.a2aproject.sdk.compat03.spec.APIKeySecurityScheme_v0_3; +import org.a2aproject.sdk.compat03.spec.AgentCapabilities_v0_3; +import org.a2aproject.sdk.compat03.spec.AgentCardSignature_v0_3; +import org.a2aproject.sdk.compat03.spec.AgentCard_v0_3; +import org.a2aproject.sdk.compat03.spec.AgentExtension_v0_3; +import org.a2aproject.sdk.compat03.spec.AgentInterface_v0_3; +import org.a2aproject.sdk.compat03.spec.AgentProvider_v0_3; +import org.a2aproject.sdk.compat03.spec.AgentSkill_v0_3; +import org.a2aproject.sdk.compat03.spec.HTTPAuthSecurityScheme_v0_3; +import org.a2aproject.sdk.compat03.spec.SecurityScheme_v0_3; +import org.a2aproject.sdk.spec.APIKeySecurityScheme; +import org.a2aproject.sdk.spec.AgentCapabilities; +import org.a2aproject.sdk.spec.AgentCard; +import org.a2aproject.sdk.spec.AgentCardSignature; +import org.a2aproject.sdk.spec.AgentExtension; +import org.a2aproject.sdk.spec.AgentInterface; +import org.a2aproject.sdk.spec.AgentProvider; +import org.a2aproject.sdk.spec.AgentSkill; +import org.a2aproject.sdk.spec.AuthorizationCodeOAuthFlow; +import org.a2aproject.sdk.spec.ClientCredentialsOAuthFlow; +import org.a2aproject.sdk.spec.HTTPAuthSecurityScheme; +import org.a2aproject.sdk.spec.Legacy_0_3_AgentInterface; +import org.a2aproject.sdk.spec.MutualTLSSecurityScheme; +import org.a2aproject.sdk.spec.OAuth2SecurityScheme; +import org.a2aproject.sdk.spec.OAuthFlows; +import org.a2aproject.sdk.spec.OpenIdConnectSecurityScheme; +import org.a2aproject.sdk.spec.SecurityRequirement; +import org.a2aproject.sdk.spec.SecurityScheme; + +/** Converts the protocol card model without depending on server components. */ +public final class AgentCardMapper_v0_3 { + + public static final AgentCardMapper_v0_3 INSTANCE = new AgentCardMapper_v0_3(); + + private AgentCardMapper_v0_3() { + } + + public AgentCard toV10(AgentCard_v0_3 source) { + List interfaces = new ArrayList<>(); + AgentInterface primaryInterface = new AgentInterface( + canonicalBinding(source.preferredTransport()), source.url(), null, "0.3"); + interfaces.add(primaryInterface); + if (source.additionalInterfaces() != null) { + source.additionalInterfaces().stream() + .map(i -> new AgentInterface(canonicalBinding(i.transport()), i.url(), null, "0.3")) + .filter(i -> !interfaces.contains(i)) + .forEach(interfaces::add); + } + String preferredTransport = canonicalBinding(source.preferredTransport()); + return AgentCard.builder() + .name(source.name()).description(source.description()).provider(toV10(source.provider())) + .version(source.version()).documentationUrl(source.documentationUrl()) + .capabilities(toV10Capabilities(source)) + .defaultInputModes(source.defaultInputModes()).defaultOutputModes(source.defaultOutputModes()) + .skills(source.skills().stream().map(this::toV10).toList()) + .securitySchemes(toV10Security(source.securitySchemes())) + .securityRequirements(toV10Requirements(source.security())) + .iconUrl(source.iconUrl()).supportedInterfaces(interfaces) + .signatures(source.signatures() == null ? null : source.signatures().stream().map(this::toV10).toList()) + .url(source.url()).preferredTransport(preferredTransport) + .additionalInterfaces(source.additionalInterfaces() == null ? List.of() : source.additionalInterfaces().stream() + .map(i -> new Legacy_0_3_AgentInterface(canonicalBinding(i.transport()), i.url())).toList()) + .build(); + } + + public AgentCard_v0_3 fromV10(AgentCard source) { + List legacyInterfaces = source.supportedInterfaces().stream() + .filter(AgentCardMapper_v0_3::isV03Interface) + .toList(); + AgentInterface primary = source.url() == null + ? legacyInterfaces.stream().findFirst().orElseThrow( + () -> new IllegalArgumentException("Agent card has no A2A 0.3 interface")) + : new AgentInterface(canonicalBinding(source.preferredTransport()), source.url(), null, "0.3"); + String primaryBinding = canonicalBinding(primary.protocolBinding()); + List interfaces = legacyInterfaces.stream() + .filter(i -> !primary.url().equals(i.url()) + || !Objects.equals(primaryBinding, canonicalBinding(i.protocolBinding()))) + .map(i -> new AgentInterface_v0_3(i.protocolBinding(), i.url())).toList(); + return new AgentCard_v0_3(source.name(), source.description(), primary.url(), fromV10(source.provider()), + source.version(), source.documentationUrl(), fromV10(source.capabilities()), source.defaultInputModes(), + source.defaultOutputModes(), source.skills().stream().map(this::fromV10).toList(), + source.capabilities().extendedAgentCard(), fromV03Security(source.securitySchemes()), + fromV10Requirements(source.securityRequirements()), source.iconUrl(), interfaces, + primary.protocolBinding(), "0.3", source.signatures() == null ? null : source.signatures().stream() + .map(this::fromV10).toList()); + } + + private static boolean isV03Interface(AgentInterface agentInterface) { + return switch (agentInterface.protocolVersion().trim()) { + case "0.3", "0.3.0" -> true; + default -> false; + }; + } + + private AgentProvider toV10(AgentProvider_v0_3 value) { + return value == null ? null : new AgentProvider(value.organization(), value.url()); + } + + private AgentProvider_v0_3 fromV10(AgentProvider value) { + return value == null ? null : new AgentProvider_v0_3(value.organization(), value.url()); + } + + private AgentCapabilities toV10Capabilities(AgentCard_v0_3 value) { + AgentCapabilities_v0_3 capabilities = value.capabilities(); + List extensions = capabilities.extensions() == null ? null : capabilities.extensions().stream() + .map(e -> new AgentExtension(e.description(), e.params(), e.required(), e.uri())).toList(); + return new AgentCapabilities(capabilities.streaming(), capabilities.pushNotifications(), + value.supportsAuthenticatedExtendedCard(), extensions); + } + + private AgentCapabilities_v0_3 fromV10(AgentCapabilities value) { + List extensions = value.extensions() == null ? null : value.extensions().stream() + .map(e -> new AgentExtension_v0_3(e.description(), e.params(), e.required(), e.uri())).toList(); + return new AgentCapabilities_v0_3(value.streaming(), value.pushNotifications(), false, extensions); + } + + private AgentSkill toV10(AgentSkill_v0_3 value) { + return AgentSkill.builder().id(value.id()).name(value.name()).description(value.description()).tags(value.tags()) + .examples(value.examples()).inputModes(value.inputModes()).outputModes(value.outputModes()) + .securityRequirements(toV10Requirements(value.security())).build(); + } + + private AgentSkill_v0_3 fromV10(AgentSkill value) { + return new AgentSkill_v0_3(value.id(), value.name(), value.description(), value.tags(), value.examples(), + value.inputModes(), value.outputModes(), fromV10Requirements(value.securityRequirements())); + } + + private AgentCardSignature toV10(AgentCardSignature_v0_3 value) { + return new AgentCardSignature(value.header(), value.protectedHeader(), value.signature()); + } + + private AgentCardSignature_v0_3 fromV10(AgentCardSignature value) { + return new AgentCardSignature_v0_3(value.header(), value.protectedHeader(), value.signature()); + } + + private Map toV10Security(Map source) { + if (source == null) return null; + return source.entrySet().stream().collect(Collectors.toUnmodifiableMap(Map.Entry::getKey, e -> { + if (e.getValue() instanceof APIKeySecurityScheme_v0_3 api) { + return new APIKeySecurityScheme(APIKeySecurityScheme.Location.fromString(api.in()), api.name(), api.description()); + } + if (e.getValue() instanceof HTTPAuthSecurityScheme_v0_3 http) { + return new HTTPAuthSecurityScheme(http.bearerFormat(), http.scheme(), http.description()); + } + if (e.getValue() instanceof org.a2aproject.sdk.compat03.spec.OpenIdConnectSecurityScheme_v0_3 oidc) { + return new OpenIdConnectSecurityScheme(oidc.openIdConnectUrl(), oidc.description()); + } + if (e.getValue() instanceof org.a2aproject.sdk.compat03.spec.MutualTLSSecurityScheme_v0_3 mtls) { + return new MutualTLSSecurityScheme(mtls.description()); + } + if (e.getValue() instanceof org.a2aproject.sdk.compat03.spec.OAuth2SecurityScheme_v0_3 oauth) { + return new OAuth2SecurityScheme(toV10OAuthFlows(oauth.flows()), oauth.description(), oauth.oauth2MetadataUrl()); + } + throw new IllegalArgumentException("Unsupported 0.3 security scheme: " + e.getValue().type()); + })); + } + + private Map fromV03Security(Map source) { + if (source == null) return null; + return source.entrySet().stream().collect(Collectors.toUnmodifiableMap(Map.Entry::getKey, e -> { + if (e.getValue() instanceof APIKeySecurityScheme api) { + return new APIKeySecurityScheme_v0_3(api.location().asString(), api.name(), api.description()); + } + if (e.getValue() instanceof HTTPAuthSecurityScheme http) { + return new HTTPAuthSecurityScheme_v0_3(http.bearerFormat(), http.scheme(), http.description()); + } + if (e.getValue() instanceof OpenIdConnectSecurityScheme oidc) { + return new org.a2aproject.sdk.compat03.spec.OpenIdConnectSecurityScheme_v0_3( + oidc.openIdConnectUrl(), oidc.description()); + } + if (e.getValue() instanceof MutualTLSSecurityScheme mtls) { + return new org.a2aproject.sdk.compat03.spec.MutualTLSSecurityScheme_v0_3(mtls.description()); + } + if (e.getValue() instanceof OAuth2SecurityScheme oauth) { + return new org.a2aproject.sdk.compat03.spec.OAuth2SecurityScheme_v0_3( + fromV10OAuthFlows(oauth.flows()), oauth.description(), oauth.oauth2MetadataUrl()); + } + throw new IllegalArgumentException("Unsupported 1.0 security scheme: " + e.getValue().type()); + })); + } + + private List toV10Requirements(List>> source) { + return source == null ? null : source.stream().map(SecurityRequirement::new).toList(); + } + + private List>> fromV10Requirements(List source) { + return source == null ? null : source.stream().map(SecurityRequirement::schemes).toList(); + } + + private OAuthFlows toV10OAuthFlows(org.a2aproject.sdk.compat03.spec.OAuthFlows_v0_3 source) { + if (source.implicit() != null || source.password() != null) { + throw new IllegalArgumentException("OAuth implicit and password flows are not supported by A2A protocol 1.0"); + } + AuthorizationCodeOAuthFlow authorizationCode = source.authorizationCode() == null ? null + : new AuthorizationCodeOAuthFlow(source.authorizationCode().authorizationUrl(), source.authorizationCode().refreshUrl(), + source.authorizationCode().scopes(), source.authorizationCode().tokenUrl(), false); + ClientCredentialsOAuthFlow clientCredentials = source.clientCredentials() == null ? null + : new ClientCredentialsOAuthFlow(source.clientCredentials().refreshUrl(), source.clientCredentials().scopes(), + source.clientCredentials().tokenUrl()); + return new OAuthFlows(authorizationCode, clientCredentials, null); + } + + private org.a2aproject.sdk.compat03.spec.OAuthFlows_v0_3 fromV10OAuthFlows(OAuthFlows source) { + if (source.deviceCode() != null) { + throw new IllegalArgumentException("OAuth device code flow is not supported by A2A protocol 0.3"); + } + if (source.authorizationCode() != null && source.authorizationCode().pkceRequired()) { + throw new IllegalArgumentException("PKCE-required OAuth authorization code flow is not supported by A2A protocol 0.3"); + } + org.a2aproject.sdk.compat03.spec.AuthorizationCodeOAuthFlow_v0_3 authorizationCode = + source.authorizationCode() == null ? null : new org.a2aproject.sdk.compat03.spec.AuthorizationCodeOAuthFlow_v0_3( + source.authorizationCode().authorizationUrl(), source.authorizationCode().refreshUrl(), + source.authorizationCode().scopes(), source.authorizationCode().tokenUrl()); + org.a2aproject.sdk.compat03.spec.ClientCredentialsOAuthFlow_v0_3 clientCredentials = + source.clientCredentials() == null ? null : new org.a2aproject.sdk.compat03.spec.ClientCredentialsOAuthFlow_v0_3( + source.clientCredentials().refreshUrl(), source.clientCredentials().scopes(), source.clientCredentials().tokenUrl()); + return new org.a2aproject.sdk.compat03.spec.OAuthFlows_v0_3(authorizationCode, clientCredentials, null, null); + } + + private static String canonicalBinding(String transport) { + if (transport == null) { + return null; + } + return switch (transport.toLowerCase(Locale.ROOT)) { + case "jsonrpc" -> "JSONRPC"; + case "http", "rest", "http+json" -> "HTTP+JSON"; + case "grpc" -> "GRPC"; + default -> transport; + }; + } +} diff --git a/compat-0.3/server-conversion/src/main/java/org/a2aproject/sdk/compat03/conversion/mappers/domain/ArtifactMapper_v0_3.java b/compat-0.3/conversion/src/main/java/org/a2aproject/sdk/compat03/conversion/mappers/domain/ArtifactMapper_v0_3.java similarity index 100% rename from compat-0.3/server-conversion/src/main/java/org/a2aproject/sdk/compat03/conversion/mappers/domain/ArtifactMapper_v0_3.java rename to compat-0.3/conversion/src/main/java/org/a2aproject/sdk/compat03/conversion/mappers/domain/ArtifactMapper_v0_3.java diff --git a/compat-0.3/server-conversion/src/main/java/org/a2aproject/sdk/compat03/conversion/mappers/domain/AuthenticationInfoMapper_v0_3.java b/compat-0.3/conversion/src/main/java/org/a2aproject/sdk/compat03/conversion/mappers/domain/AuthenticationInfoMapper_v0_3.java similarity index 100% rename from compat-0.3/server-conversion/src/main/java/org/a2aproject/sdk/compat03/conversion/mappers/domain/AuthenticationInfoMapper_v0_3.java rename to compat-0.3/conversion/src/main/java/org/a2aproject/sdk/compat03/conversion/mappers/domain/AuthenticationInfoMapper_v0_3.java diff --git a/compat-0.3/server-conversion/src/main/java/org/a2aproject/sdk/compat03/conversion/mappers/domain/EventKindMapper_v0_3.java b/compat-0.3/conversion/src/main/java/org/a2aproject/sdk/compat03/conversion/mappers/domain/EventKindMapper_v0_3.java similarity index 100% rename from compat-0.3/server-conversion/src/main/java/org/a2aproject/sdk/compat03/conversion/mappers/domain/EventKindMapper_v0_3.java rename to compat-0.3/conversion/src/main/java/org/a2aproject/sdk/compat03/conversion/mappers/domain/EventKindMapper_v0_3.java diff --git a/compat-0.3/server-conversion/src/main/java/org/a2aproject/sdk/compat03/conversion/mappers/domain/FileContentMapper_v0_3.java b/compat-0.3/conversion/src/main/java/org/a2aproject/sdk/compat03/conversion/mappers/domain/FileContentMapper_v0_3.java similarity index 91% rename from compat-0.3/server-conversion/src/main/java/org/a2aproject/sdk/compat03/conversion/mappers/domain/FileContentMapper_v0_3.java rename to compat-0.3/conversion/src/main/java/org/a2aproject/sdk/compat03/conversion/mappers/domain/FileContentMapper_v0_3.java index a39f840cf..37fa0bf3f 100644 --- a/compat-0.3/server-conversion/src/main/java/org/a2aproject/sdk/compat03/conversion/mappers/domain/FileContentMapper_v0_3.java +++ b/compat-0.3/conversion/src/main/java/org/a2aproject/sdk/compat03/conversion/mappers/domain/FileContentMapper_v0_3.java @@ -1,7 +1,7 @@ package org.a2aproject.sdk.compat03.conversion.mappers.domain; -import org.a2aproject.sdk.compat03.conversion.mappers.config.A2AMappers_v0_3; import org.a2aproject.sdk.compat03.conversion.mappers.config.A03ToV10MapperConfig; +import org.a2aproject.sdk.compat03.conversion.mappers.config.A2AMappers_v0_3; import org.a2aproject.sdk.compat03.spec.FileContent_v0_3; import org.a2aproject.sdk.compat03.spec.FileWithBytes_v0_3; import org.a2aproject.sdk.compat03.spec.FileWithUri_v0_3; @@ -51,11 +51,13 @@ default FileContent toV10(FileContent_v0_3 v03) { } if (v03 instanceof FileWithBytes_v0_3 v03Bytes) { + String mimeType = v03Bytes.mimeType() != null ? v03Bytes.mimeType() : ""; String name = v03Bytes.name() != null ? v03Bytes.name() : ""; - return new FileWithBytes(v03Bytes.mimeType(), name, v03Bytes.bytes()); + return new FileWithBytes(mimeType, name, v03Bytes.bytes()); } else if (v03 instanceof FileWithUri_v0_3 v03Uri) { + String mimeType = v03Uri.mimeType() != null ? v03Uri.mimeType() : ""; String name = v03Uri.name() != null ? v03Uri.name() : ""; - return new FileWithUri(v03Uri.mimeType(), name, v03Uri.uri()); + return new FileWithUri(mimeType, name, v03Uri.uri()); } throw new InvalidRequestError(null, "Unrecognized FileContent type: " + v03.getClass().getName(), null); diff --git a/compat-0.3/server-conversion/src/main/java/org/a2aproject/sdk/compat03/conversion/mappers/domain/MessageMapper_v0_3.java b/compat-0.3/conversion/src/main/java/org/a2aproject/sdk/compat03/conversion/mappers/domain/MessageMapper_v0_3.java similarity index 100% rename from compat-0.3/server-conversion/src/main/java/org/a2aproject/sdk/compat03/conversion/mappers/domain/MessageMapper_v0_3.java rename to compat-0.3/conversion/src/main/java/org/a2aproject/sdk/compat03/conversion/mappers/domain/MessageMapper_v0_3.java diff --git a/compat-0.3/server-conversion/src/main/java/org/a2aproject/sdk/compat03/conversion/mappers/domain/PartMapper_v0_3.java b/compat-0.3/conversion/src/main/java/org/a2aproject/sdk/compat03/conversion/mappers/domain/PartMapper_v0_3.java similarity index 100% rename from compat-0.3/server-conversion/src/main/java/org/a2aproject/sdk/compat03/conversion/mappers/domain/PartMapper_v0_3.java rename to compat-0.3/conversion/src/main/java/org/a2aproject/sdk/compat03/conversion/mappers/domain/PartMapper_v0_3.java diff --git a/compat-0.3/server-conversion/src/main/java/org/a2aproject/sdk/compat03/conversion/mappers/domain/RoleMapper_v0_3.java b/compat-0.3/conversion/src/main/java/org/a2aproject/sdk/compat03/conversion/mappers/domain/RoleMapper_v0_3.java similarity index 100% rename from compat-0.3/server-conversion/src/main/java/org/a2aproject/sdk/compat03/conversion/mappers/domain/RoleMapper_v0_3.java rename to compat-0.3/conversion/src/main/java/org/a2aproject/sdk/compat03/conversion/mappers/domain/RoleMapper_v0_3.java diff --git a/compat-0.3/server-conversion/src/main/java/org/a2aproject/sdk/compat03/conversion/mappers/domain/StreamingEventKindMapper_v0_3.java b/compat-0.3/conversion/src/main/java/org/a2aproject/sdk/compat03/conversion/mappers/domain/StreamingEventKindMapper_v0_3.java similarity index 100% rename from compat-0.3/server-conversion/src/main/java/org/a2aproject/sdk/compat03/conversion/mappers/domain/StreamingEventKindMapper_v0_3.java rename to compat-0.3/conversion/src/main/java/org/a2aproject/sdk/compat03/conversion/mappers/domain/StreamingEventKindMapper_v0_3.java diff --git a/compat-0.3/server-conversion/src/main/java/org/a2aproject/sdk/compat03/conversion/mappers/domain/TaskArtifactUpdateEventMapper_v0_3.java b/compat-0.3/conversion/src/main/java/org/a2aproject/sdk/compat03/conversion/mappers/domain/TaskArtifactUpdateEventMapper_v0_3.java similarity index 100% rename from compat-0.3/server-conversion/src/main/java/org/a2aproject/sdk/compat03/conversion/mappers/domain/TaskArtifactUpdateEventMapper_v0_3.java rename to compat-0.3/conversion/src/main/java/org/a2aproject/sdk/compat03/conversion/mappers/domain/TaskArtifactUpdateEventMapper_v0_3.java diff --git a/compat-0.3/server-conversion/src/main/java/org/a2aproject/sdk/compat03/conversion/mappers/domain/TaskMapper_v0_3.java b/compat-0.3/conversion/src/main/java/org/a2aproject/sdk/compat03/conversion/mappers/domain/TaskMapper_v0_3.java similarity index 100% rename from compat-0.3/server-conversion/src/main/java/org/a2aproject/sdk/compat03/conversion/mappers/domain/TaskMapper_v0_3.java rename to compat-0.3/conversion/src/main/java/org/a2aproject/sdk/compat03/conversion/mappers/domain/TaskMapper_v0_3.java diff --git a/compat-0.3/server-conversion/src/main/java/org/a2aproject/sdk/compat03/conversion/mappers/domain/TaskPushNotificationConfigMapper_v0_3.java b/compat-0.3/conversion/src/main/java/org/a2aproject/sdk/compat03/conversion/mappers/domain/TaskPushNotificationConfigMapper_v0_3.java similarity index 86% rename from compat-0.3/server-conversion/src/main/java/org/a2aproject/sdk/compat03/conversion/mappers/domain/TaskPushNotificationConfigMapper_v0_3.java rename to compat-0.3/conversion/src/main/java/org/a2aproject/sdk/compat03/conversion/mappers/domain/TaskPushNotificationConfigMapper_v0_3.java index 0b9534a30..e91ff1108 100644 --- a/compat-0.3/server-conversion/src/main/java/org/a2aproject/sdk/compat03/conversion/mappers/domain/TaskPushNotificationConfigMapper_v0_3.java +++ b/compat-0.3/conversion/src/main/java/org/a2aproject/sdk/compat03/conversion/mappers/domain/TaskPushNotificationConfigMapper_v0_3.java @@ -1,7 +1,7 @@ package org.a2aproject.sdk.compat03.conversion.mappers.domain; -import org.a2aproject.sdk.compat03.conversion.mappers.config.A2AMappers_v0_3; import org.a2aproject.sdk.compat03.conversion.mappers.config.A03ToV10MapperConfig; +import org.a2aproject.sdk.compat03.conversion.mappers.config.A2AMappers_v0_3; import org.a2aproject.sdk.compat03.spec.PushNotificationConfig_v0_3; import org.a2aproject.sdk.compat03.spec.TaskPushNotificationConfig_v0_3; import org.a2aproject.sdk.spec.TaskPushNotificationConfig; @@ -73,17 +73,19 @@ default TaskPushNotificationConfig_v0_3 fromV10( return null; } - PushNotificationConfig_v0_3 pushConfig = - new PushNotificationConfig_v0_3( - v10.url(), - v10.token(), - AuthenticationInfoMapper_v0_3.INSTANCE.fromV10ToPushNotification(v10.authentication()), - v10.id() - ); - return new TaskPushNotificationConfig_v0_3( v10.taskId(), - pushConfig + fromV10PushNotificationConfig(v10) + ); + } + + /** Converts callback fields without requiring a task ID for inline message configuration. */ + default PushNotificationConfig_v0_3 fromV10PushNotificationConfig(TaskPushNotificationConfig v10) { + return new PushNotificationConfig_v0_3( + v10.url(), + v10.token(), + AuthenticationInfoMapper_v0_3.INSTANCE.fromV10ToPushNotification(v10.authentication()), + v10.id() ); } } diff --git a/compat-0.3/server-conversion/src/main/java/org/a2aproject/sdk/compat03/conversion/mappers/domain/TaskStateMapper_v0_3.java b/compat-0.3/conversion/src/main/java/org/a2aproject/sdk/compat03/conversion/mappers/domain/TaskStateMapper_v0_3.java similarity index 100% rename from compat-0.3/server-conversion/src/main/java/org/a2aproject/sdk/compat03/conversion/mappers/domain/TaskStateMapper_v0_3.java rename to compat-0.3/conversion/src/main/java/org/a2aproject/sdk/compat03/conversion/mappers/domain/TaskStateMapper_v0_3.java diff --git a/compat-0.3/server-conversion/src/main/java/org/a2aproject/sdk/compat03/conversion/mappers/domain/TaskStatusMapper_v0_3.java b/compat-0.3/conversion/src/main/java/org/a2aproject/sdk/compat03/conversion/mappers/domain/TaskStatusMapper_v0_3.java similarity index 100% rename from compat-0.3/server-conversion/src/main/java/org/a2aproject/sdk/compat03/conversion/mappers/domain/TaskStatusMapper_v0_3.java rename to compat-0.3/conversion/src/main/java/org/a2aproject/sdk/compat03/conversion/mappers/domain/TaskStatusMapper_v0_3.java diff --git a/compat-0.3/server-conversion/src/main/java/org/a2aproject/sdk/compat03/conversion/mappers/domain/TaskStatusUpdateEventMapper_v0_3.java b/compat-0.3/conversion/src/main/java/org/a2aproject/sdk/compat03/conversion/mappers/domain/TaskStatusUpdateEventMapper_v0_3.java similarity index 100% rename from compat-0.3/server-conversion/src/main/java/org/a2aproject/sdk/compat03/conversion/mappers/domain/TaskStatusUpdateEventMapper_v0_3.java rename to compat-0.3/conversion/src/main/java/org/a2aproject/sdk/compat03/conversion/mappers/domain/TaskStatusUpdateEventMapper_v0_3.java diff --git a/compat-0.3/server-conversion/src/main/java/org/a2aproject/sdk/compat03/conversion/mappers/params/CancelTaskParamsMapper_v0_3.java b/compat-0.3/conversion/src/main/java/org/a2aproject/sdk/compat03/conversion/mappers/params/CancelTaskParamsMapper_v0_3.java similarity index 100% rename from compat-0.3/server-conversion/src/main/java/org/a2aproject/sdk/compat03/conversion/mappers/params/CancelTaskParamsMapper_v0_3.java rename to compat-0.3/conversion/src/main/java/org/a2aproject/sdk/compat03/conversion/mappers/params/CancelTaskParamsMapper_v0_3.java diff --git a/compat-0.3/server-conversion/src/main/java/org/a2aproject/sdk/compat03/conversion/mappers/params/MessageSendConfigurationMapper_v0_3.java b/compat-0.3/conversion/src/main/java/org/a2aproject/sdk/compat03/conversion/mappers/params/MessageSendConfigurationMapper_v0_3.java similarity index 94% rename from compat-0.3/server-conversion/src/main/java/org/a2aproject/sdk/compat03/conversion/mappers/params/MessageSendConfigurationMapper_v0_3.java rename to compat-0.3/conversion/src/main/java/org/a2aproject/sdk/compat03/conversion/mappers/params/MessageSendConfigurationMapper_v0_3.java index c1228087e..f3aaa28ec 100644 --- a/compat-0.3/server-conversion/src/main/java/org/a2aproject/sdk/compat03/conversion/mappers/params/MessageSendConfigurationMapper_v0_3.java +++ b/compat-0.3/conversion/src/main/java/org/a2aproject/sdk/compat03/conversion/mappers/params/MessageSendConfigurationMapper_v0_3.java @@ -1,7 +1,7 @@ package org.a2aproject.sdk.compat03.conversion.mappers.params; -import org.a2aproject.sdk.compat03.conversion.mappers.config.A2AMappers_v0_3; import org.a2aproject.sdk.compat03.conversion.mappers.config.A03ToV10MapperConfig; +import org.a2aproject.sdk.compat03.conversion.mappers.config.A2AMappers_v0_3; import org.a2aproject.sdk.compat03.conversion.mappers.domain.TaskPushNotificationConfigMapper_v0_3; import org.a2aproject.sdk.compat03.spec.MessageSendConfiguration_v0_3; import org.a2aproject.sdk.compat03.spec.PushNotificationConfig_v0_3; @@ -89,9 +89,8 @@ default MessageSendConfiguration_v0_3 fromV10( // Extract PushNotificationConfig from TaskPushNotificationConfig if present PushNotificationConfig_v0_3 pushConfig = null; if (v10.taskPushNotificationConfig() != null) { - TaskPushNotificationConfig_v0_3 v03TaskConfig = - TaskPushNotificationConfigMapper_v0_3.INSTANCE.fromV10(v10.taskPushNotificationConfig()); - pushConfig = v03TaskConfig.pushNotificationConfig(); + pushConfig = TaskPushNotificationConfigMapper_v0_3.INSTANCE + .fromV10PushNotificationConfig(v10.taskPushNotificationConfig()); } // Convert returnImmediately to blocking (inverse semantics) diff --git a/compat-0.3/server-conversion/src/main/java/org/a2aproject/sdk/compat03/conversion/mappers/params/MessageSendParamsMapper_v0_3.java b/compat-0.3/conversion/src/main/java/org/a2aproject/sdk/compat03/conversion/mappers/params/MessageSendParamsMapper_v0_3.java similarity index 100% rename from compat-0.3/server-conversion/src/main/java/org/a2aproject/sdk/compat03/conversion/mappers/params/MessageSendParamsMapper_v0_3.java rename to compat-0.3/conversion/src/main/java/org/a2aproject/sdk/compat03/conversion/mappers/params/MessageSendParamsMapper_v0_3.java diff --git a/compat-0.3/server-conversion/src/main/java/org/a2aproject/sdk/compat03/conversion/mappers/params/TaskIdParamsMapper_v0_3.java b/compat-0.3/conversion/src/main/java/org/a2aproject/sdk/compat03/conversion/mappers/params/TaskIdParamsMapper_v0_3.java similarity index 100% rename from compat-0.3/server-conversion/src/main/java/org/a2aproject/sdk/compat03/conversion/mappers/params/TaskIdParamsMapper_v0_3.java rename to compat-0.3/conversion/src/main/java/org/a2aproject/sdk/compat03/conversion/mappers/params/TaskIdParamsMapper_v0_3.java diff --git a/compat-0.3/server-conversion/src/main/java/org/a2aproject/sdk/compat03/conversion/mappers/params/TaskQueryParamsMapper_v0_3.java b/compat-0.3/conversion/src/main/java/org/a2aproject/sdk/compat03/conversion/mappers/params/TaskQueryParamsMapper_v0_3.java similarity index 100% rename from compat-0.3/server-conversion/src/main/java/org/a2aproject/sdk/compat03/conversion/mappers/params/TaskQueryParamsMapper_v0_3.java rename to compat-0.3/conversion/src/main/java/org/a2aproject/sdk/compat03/conversion/mappers/params/TaskQueryParamsMapper_v0_3.java diff --git a/compat-0.3/server-conversion/src/main/java/org/a2aproject/sdk/compat03/conversion/mappers/result/ListTaskPushNotificationConfigsResultMapper_v0_3.java b/compat-0.3/conversion/src/main/java/org/a2aproject/sdk/compat03/conversion/mappers/result/ListTaskPushNotificationConfigsResultMapper_v0_3.java similarity index 100% rename from compat-0.3/server-conversion/src/main/java/org/a2aproject/sdk/compat03/conversion/mappers/result/ListTaskPushNotificationConfigsResultMapper_v0_3.java rename to compat-0.3/conversion/src/main/java/org/a2aproject/sdk/compat03/conversion/mappers/result/ListTaskPushNotificationConfigsResultMapper_v0_3.java diff --git a/compat-0.3/conversion/src/test/java/org/a2aproject/sdk/compat03/conversion/mappers/domain/AgentCardMapper_v0_3_Test.java b/compat-0.3/conversion/src/test/java/org/a2aproject/sdk/compat03/conversion/mappers/domain/AgentCardMapper_v0_3_Test.java new file mode 100644 index 000000000..49ea7c227 --- /dev/null +++ b/compat-0.3/conversion/src/test/java/org/a2aproject/sdk/compat03/conversion/mappers/domain/AgentCardMapper_v0_3_Test.java @@ -0,0 +1,194 @@ +package org.a2aproject.sdk.compat03.conversion.mappers.domain; + +import java.util.List; +import java.util.Map; + +import org.a2aproject.sdk.compat03.spec.APIKeySecurityScheme_v0_3; +import org.a2aproject.sdk.compat03.spec.AgentCapabilities_v0_3; +import org.a2aproject.sdk.compat03.spec.AgentCardSignature_v0_3; +import org.a2aproject.sdk.compat03.spec.AgentCard_v0_3; +import org.a2aproject.sdk.compat03.spec.AgentInterface_v0_3; +import org.a2aproject.sdk.compat03.spec.AuthorizationCodeOAuthFlow_v0_3; +import org.a2aproject.sdk.compat03.spec.ClientCredentialsOAuthFlow_v0_3; +import org.a2aproject.sdk.compat03.spec.HTTPAuthSecurityScheme_v0_3; +import org.a2aproject.sdk.compat03.spec.AgentSkill_v0_3; +import org.a2aproject.sdk.compat03.spec.MutualTLSSecurityScheme_v0_3; +import org.a2aproject.sdk.compat03.spec.OAuth2SecurityScheme_v0_3; +import org.a2aproject.sdk.compat03.spec.OAuthFlows_v0_3; +import org.a2aproject.sdk.compat03.spec.OpenIdConnectSecurityScheme_v0_3; +import org.a2aproject.sdk.spec.AgentCard; +import org.a2aproject.sdk.spec.AgentInterface; +import org.a2aproject.sdk.spec.APIKeySecurityScheme; +import org.a2aproject.sdk.spec.HTTPAuthSecurityScheme; +import org.a2aproject.sdk.spec.MutualTLSSecurityScheme; +import org.a2aproject.sdk.spec.OAuth2SecurityScheme; +import org.a2aproject.sdk.spec.OpenIdConnectSecurityScheme; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +class AgentCardMapper_v0_3_Test { + + @Test + void projectsCompleteLegacyCardAndConvertsItBack() { + AgentCard_v0_3 legacy = new AgentCard_v0_3( + "agent", "description", "https://agent.example", null, "1", "https://docs.example", + new AgentCapabilities_v0_3(true, true, false, null), List.of("text"), List.of("text"), + List.of(new AgentSkill_v0_3("skill", "Skill", "Does things", List.of("tag"), + List.of("example"), List.of("text"), List.of("text"), List.of(Map.of("auth", List.of("read"))))), + true, Map.of("auth", new APIKeySecurityScheme_v0_3("header", "Authorization", "token")), + List.of(Map.of("auth", List.of("read"))), "https://icon.example", + List.of(new AgentInterface_v0_3("JSONRPC", "https://agent.example/rpc")), "JSONRPC", "0.3.0", + List.of(new AgentCardSignature_v0_3(Map.of("alg", "none"), "protected", "signature"))); + + AgentCard current = AgentCardMapper_v0_3.INSTANCE.toV10(legacy); + + assertEquals("https://agent.example", current.url()); + assertEquals("JSONRPC", current.preferredTransport()); + assertEquals("0.3", current.supportedInterfaces().get(0).protocolVersion()); + assertEquals(true, current.capabilities().extendedAgentCard()); + assertEquals("Authorization", ((APIKeySecurityScheme) current.securitySchemes().get("auth")).name()); + assertNotNull(current.signatures()); + + AgentCard_v0_3 roundTrip = AgentCardMapper_v0_3.INSTANCE.fromV10(current); + assertEquals("https://agent.example", roundTrip.url()); + assertEquals("0.3", roundTrip.protocolVersion()); + assertEquals(true, roundTrip.supportsAuthenticatedExtendedCard()); + assertEquals(false, roundTrip.capabilities().stateTransitionHistory()); + assertEquals("Authorization", ((APIKeySecurityScheme_v0_3) roundTrip.securitySchemes().get("auth")).name()); + assertEquals("signature", roundTrip.signatures().get(0).signature()); + } + + @ParameterizedTest + @CsvSource({ + "jsonrpc,JSONRPC", + "http,HTTP+JSON", + "rest,HTTP+JSON", + "grpc,GRPC", + "HTTP+JSON,HTTP+JSON" + }) + void primaryEndpointBecomesSupportedInterface(String preferred, String expectedBinding) { + AgentCard_v0_3 card = primaryOnlyCard(preferred); + + AgentCard projected = AgentCardMapper_v0_3.INSTANCE.toV10(card); + + assertEquals(1, projected.supportedInterfaces().size()); + assertEquals(expectedBinding, projected.supportedInterfaces().get(0).protocolBinding()); + assertEquals("0.3", projected.supportedInterfaces().get(0).protocolVersion()); + assertEquals(expectedBinding, projected.preferredTransport()); + } + + @Test + void projectsPrimaryInterfaceBeforeAdditionalInterfaces() { + AgentCard_v0_3 card = new AgentCard_v0_3( + "legacy", "legacy", "https://agent.example/jsonrpc", null, "1", null, + new AgentCapabilities_v0_3.Builder().build(), List.of("text"), List.of("text"), List.of(), + false, null, null, null, + List.of(new AgentInterface_v0_3("grpc", "https://agent.example/grpc")), "jsonrpc", "0.3", null); + + AgentCard projected = AgentCardMapper_v0_3.INSTANCE.toV10(card); + + assertEquals(List.of("JSONRPC", "GRPC"), projected.supportedInterfaces().stream() + .map(AgentInterface::protocolBinding).toList()); + assertEquals("https://agent.example/jsonrpc", projected.supportedInterfaces().get(0).url()); + } + + @Test + void projectsCardWhenAdditionalInterfacesAreOmitted() { + AgentCard_v0_3 card = new AgentCard_v0_3( + "legacy", "legacy", "https://agent.example/jsonrpc", null, "1", null, + new AgentCapabilities_v0_3.Builder().build(), List.of("text"), List.of("text"), List.of(), + false, null, null, null, null, "jsonrpc", "0.3", null); + + AgentCard projected = AgentCardMapper_v0_3.INSTANCE.toV10(card); + + assertEquals(1, projected.supportedInterfaces().size()); + assertEquals("JSONRPC", projected.supportedInterfaces().get(0).protocolBinding()); + } + + @Test + void doesNotCopyPrimaryInterfaceIntoAdditionalInterfacesOnRoundTrip() { + AgentCard_v0_3 card = primaryOnlyCard("jsonrpc"); + + AgentCard_v0_3 roundTrip = AgentCardMapper_v0_3.INSTANCE.fromV10( + AgentCardMapper_v0_3.INSTANCE.toV10(card)); + + assertEquals(List.of(), roundTrip.additionalInterfaces()); + } + + @Test + void convertsPatchVersionLegacyInterfaceWhenV1CardHasNoLegacyUrl() { + AgentCard card = AgentCard.builder() + .name("agent").description("description").version("1") + .capabilities(new org.a2aproject.sdk.spec.AgentCapabilities(false, false, false, null)) + .defaultInputModes(List.of("text")).defaultOutputModes(List.of("text")) + .skills(List.of()) + .supportedInterfaces(List.of(new AgentInterface("JSONRPC", "https://agent.example/rpc", null, "0.3.0"))) + .build(); + + AgentCard_v0_3 legacy = AgentCardMapper_v0_3.INSTANCE.fromV10(card); + + assertEquals("https://agent.example/rpc", legacy.url()); + assertEquals("JSONRPC", legacy.preferredTransport()); + assertEquals(List.of(), legacy.additionalInterfaces()); + } + + @Test + void convertsHttpAuthSecuritySchemeBothWays() { + HTTPAuthSecurityScheme_v0_3 legacyScheme = new HTTPAuthSecurityScheme_v0_3.Builder() + .scheme("basic").bearerFormat("none").description("HTTP Basic authentication").build(); + AgentCard_v0_3 card = primaryOnlyCard("http", Map.of("basicAuth", legacyScheme)); + + AgentCard projected = AgentCardMapper_v0_3.INSTANCE.toV10(card); + HTTPAuthSecurityScheme projectedScheme = + (HTTPAuthSecurityScheme) projected.securitySchemes().get("basicAuth"); + assertEquals("basic", projectedScheme.scheme()); + assertEquals("none", projectedScheme.bearerFormat()); + assertEquals("HTTP Basic authentication", projectedScheme.description()); + + HTTPAuthSecurityScheme_v0_3 roundTripScheme = (HTTPAuthSecurityScheme_v0_3) + AgentCardMapper_v0_3.INSTANCE.fromV10(projected).securitySchemes().get("basicAuth"); + assertEquals("basic", roundTripScheme.scheme()); + assertEquals("none", roundTripScheme.bearerFormat()); + assertEquals("HTTP Basic authentication", roundTripScheme.description()); + } + + @Test + void convertsOpenIdConnectMutualTlsAndCompatibleOAuthSecuritySchemes() { + OAuthFlows_v0_3 flows = new OAuthFlows_v0_3( + new AuthorizationCodeOAuthFlow_v0_3("https://auth.example", "https://refresh.example", + Map.of("read", "Read"), "https://token.example"), + new ClientCredentialsOAuthFlow_v0_3("https://refresh.example", Map.of("write", "Write"), + "https://token.example"), null, null); + AgentCard_v0_3 card = primaryOnlyCard("jsonrpc", Map.of( + "oidc", new OpenIdConnectSecurityScheme_v0_3("https://oidc.example", "OIDC"), + "mtls", new MutualTLSSecurityScheme_v0_3("mTLS"), + "oauth", new OAuth2SecurityScheme_v0_3(flows, "OAuth", "https://metadata.example"))); + + AgentCard projected = AgentCardMapper_v0_3.INSTANCE.toV10(card); + + assertEquals("https://oidc.example", ((OpenIdConnectSecurityScheme) projected.securitySchemes().get("oidc")) + .openIdConnectUrl()); + assertEquals("mTLS", ((MutualTLSSecurityScheme) projected.securitySchemes().get("mtls")).description()); + OAuth2SecurityScheme oauth = (OAuth2SecurityScheme) projected.securitySchemes().get("oauth"); + assertEquals("https://auth.example", oauth.flows().authorizationCode().authorizationUrl()); + assertEquals("https://token.example", oauth.flows().clientCredentials().tokenUrl()); + } + + private static AgentCard_v0_3 primaryOnlyCard(String preferredTransport) { + return primaryOnlyCard(preferredTransport, null); + } + + private static AgentCard_v0_3 primaryOnlyCard(String preferredTransport, + Map securitySchemes) { + return new AgentCard_v0_3.Builder() + .name("legacy").description("legacy").url("http://localhost:8081") + .version("1.0.0").preferredTransport(preferredTransport).protocolVersion("0.3.0") + .capabilities(new AgentCapabilities_v0_3.Builder().build()) + .defaultInputModes(List.of("text")).defaultOutputModes(List.of("text")) + .skills(List.of()).securitySchemes(securitySchemes).additionalInterfaces(List.of()).build(); + } +} diff --git a/compat-0.3/server-conversion/src/test/java/org/a2aproject/sdk/compat03/conversion/mappers/domain/FileContentMapper_v0_3_Test.java b/compat-0.3/conversion/src/test/java/org/a2aproject/sdk/compat03/conversion/mappers/domain/FileContentMapper_v0_3_Test.java similarity index 70% rename from compat-0.3/server-conversion/src/test/java/org/a2aproject/sdk/compat03/conversion/mappers/domain/FileContentMapper_v0_3_Test.java rename to compat-0.3/conversion/src/test/java/org/a2aproject/sdk/compat03/conversion/mappers/domain/FileContentMapper_v0_3_Test.java index 2b562ccc0..4070c6b05 100644 --- a/compat-0.3/server-conversion/src/test/java/org/a2aproject/sdk/compat03/conversion/mappers/domain/FileContentMapper_v0_3_Test.java +++ b/compat-0.3/conversion/src/test/java/org/a2aproject/sdk/compat03/conversion/mappers/domain/FileContentMapper_v0_3_Test.java @@ -1,5 +1,8 @@ package org.a2aproject.sdk.compat03.conversion.mappers.domain; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; + import org.a2aproject.sdk.compat03.spec.FileWithBytes_v0_3; import org.a2aproject.sdk.compat03.spec.FileWithUri_v0_3; import org.a2aproject.sdk.spec.FileContent; @@ -7,9 +10,6 @@ import org.a2aproject.sdk.spec.FileWithUri; import org.junit.jupiter.api.Test; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertInstanceOf; - class FileContentMapper_v0_3_Test { @Test @@ -50,6 +50,32 @@ void fileWithUriFromV10PreservesName() { assertEquals("file:///path/to/readme.txt", result.uri()); } + @Test + void inlineFileWithoutMimeTypeConvertsToV10() { + FileWithBytes_v0_3 legacy = new FileWithBytes_v0_3(null, null, "aGVsbG8="); + + FileWithBytes current = assertInstanceOf(FileWithBytes.class, + FileContentMapper_v0_3.INSTANCE.toV10(legacy)); + + assertEquals("", current.mimeType()); + assertEquals("", current.name()); + assertEquals("aGVsbG8=", current.bytes()); + assertEquals(new FileWithBytes_v0_3("", "", "aGVsbG8="), + FileContentMapper_v0_3.INSTANCE.fromV10(current)); + } + + @Test + void uriFileWithoutMimeTypeConvertsToV10() { + FileWithUri_v0_3 legacy = new FileWithUri_v0_3(null, null, "https://example.test/file"); + + FileWithUri current = assertInstanceOf(FileWithUri.class, + FileContentMapper_v0_3.INSTANCE.toV10(legacy)); + + assertEquals(new FileWithUri("", "", "https://example.test/file"), current); + assertEquals(new FileWithUri_v0_3("", "", "https://example.test/file"), + FileContentMapper_v0_3.INSTANCE.fromV10(current)); + } + @Test void nullNameInV03BecomesEmptyStringInV10() { FileWithBytes_v0_3 v03 = new FileWithBytes_v0_3("image/png", null, "aGVsbG8="); diff --git a/compat-0.3/server-conversion/src/test/java/org/a2aproject/sdk/compat03/conversion/mappers/domain/TaskMapper_v0_3_Test.java b/compat-0.3/conversion/src/test/java/org/a2aproject/sdk/compat03/conversion/mappers/domain/TaskMapper_v0_3_Test.java similarity index 100% rename from compat-0.3/server-conversion/src/test/java/org/a2aproject/sdk/compat03/conversion/mappers/domain/TaskMapper_v0_3_Test.java rename to compat-0.3/conversion/src/test/java/org/a2aproject/sdk/compat03/conversion/mappers/domain/TaskMapper_v0_3_Test.java diff --git a/compat-0.3/server-conversion/src/test/java/org/a2aproject/sdk/compat03/conversion/mappers/domain/TaskStatusUpdateEventMapper_v0_3_Test.java b/compat-0.3/conversion/src/test/java/org/a2aproject/sdk/compat03/conversion/mappers/domain/TaskStatusUpdateEventMapper_v0_3_Test.java similarity index 100% rename from compat-0.3/server-conversion/src/test/java/org/a2aproject/sdk/compat03/conversion/mappers/domain/TaskStatusUpdateEventMapper_v0_3_Test.java rename to compat-0.3/conversion/src/test/java/org/a2aproject/sdk/compat03/conversion/mappers/domain/TaskStatusUpdateEventMapper_v0_3_Test.java diff --git a/compat-0.3/conversion/src/test/java/org/a2aproject/sdk/compat03/conversion/mappers/params/MessageSendConfigurationMapper_v0_3_Test.java b/compat-0.3/conversion/src/test/java/org/a2aproject/sdk/compat03/conversion/mappers/params/MessageSendConfigurationMapper_v0_3_Test.java new file mode 100644 index 000000000..2f894c5f7 --- /dev/null +++ b/compat-0.3/conversion/src/test/java/org/a2aproject/sdk/compat03/conversion/mappers/params/MessageSendConfigurationMapper_v0_3_Test.java @@ -0,0 +1,67 @@ +package org.a2aproject.sdk.compat03.conversion.mappers.params; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.util.List; + +import org.a2aproject.sdk.compat03.conversion.mappers.domain.TaskPushNotificationConfigMapper_v0_3; +import org.a2aproject.sdk.compat03.spec.MessageSendConfiguration_v0_3; +import org.a2aproject.sdk.compat03.spec.PushNotificationAuthenticationInfo_v0_3; +import org.a2aproject.sdk.compat03.spec.PushNotificationConfig_v0_3; +import org.a2aproject.sdk.compat03.spec.TaskPushNotificationConfig_v0_3; +import org.a2aproject.sdk.spec.AuthenticationInfo; +import org.a2aproject.sdk.spec.MessageSendConfiguration; +import org.a2aproject.sdk.spec.TaskPushNotificationConfig; +import org.jspecify.annotations.Nullable; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.NullAndEmptySource; + +class MessageSendConfigurationMapper_v0_3_Test { + + @ParameterizedTest + @NullAndEmptySource + void inlinePushConfigurationDoesNotRequireTaskId(@Nullable String taskId) { + TaskPushNotificationConfig push = new TaskPushNotificationConfig( + "config", taskId, "https://callback.example.test", "token", + new AuthenticationInfo("Bearer", "credentials"), null); + MessageSendConfiguration current = new MessageSendConfiguration(List.of("text/plain"), 3, push, false); + + MessageSendConfiguration_v0_3 legacy = MessageSendConfigurationMapper_v0_3.INSTANCE.fromV10(current); + + assertEquals(new MessageSendConfiguration_v0_3(List.of("text/plain"), 3, + new PushNotificationConfig_v0_3("https://callback.example.test", "token", + new PushNotificationAuthenticationInfo_v0_3(List.of("Bearer"), "credentials"), "config"), + true), legacy); + MessageSendConfiguration roundTrip = MessageSendConfigurationMapper_v0_3.INSTANCE.toV10(legacy); + assertEquals(new TaskPushNotificationConfig("config", "", "https://callback.example.test", "token", + new AuthenticationInfo("Bearer", "credentials"), ""), roundTrip.taskPushNotificationConfig()); + assertEquals(List.of("text/plain"), roundTrip.acceptedOutputModes()); + assertEquals(3, roundTrip.historyLength()); + assertEquals(false, roundTrip.returnImmediately()); + } + + @Test + void standalonePushConfigurationStillRequiresTaskId() { + TaskPushNotificationConfig push = TaskPushNotificationConfig.builder() + .url("https://callback.example.test").build(); + + assertThrows(IllegalArgumentException.class, + () -> TaskPushNotificationConfigMapper_v0_3.INSTANCE.fromV10(push)); + } + + @Test + void standalonePushConfigurationRetainsTaskIdAndCallbackFields() { + TaskPushNotificationConfig current = new TaskPushNotificationConfig( + "config", "task", "https://callback.example.test", "token", + new AuthenticationInfo("Bearer", "credentials"), ""); + + TaskPushNotificationConfig_v0_3 legacy = TaskPushNotificationConfigMapper_v0_3.INSTANCE.fromV10(current); + + assertEquals(new TaskPushNotificationConfig_v0_3("task", + new PushNotificationConfig_v0_3("https://callback.example.test", "token", + new PushNotificationAuthenticationInfo_v0_3(List.of("Bearer"), "credentials"), "config")), legacy); + assertEquals(current, TaskPushNotificationConfigMapper_v0_3.INSTANCE.toV10(legacy)); + } +} diff --git a/compat-0.3/conversion/src/test/java/org/a2aproject/sdk/compat03/conversion/mappers/params/MessageSendParamsMapper_v0_3_Test.java b/compat-0.3/conversion/src/test/java/org/a2aproject/sdk/compat03/conversion/mappers/params/MessageSendParamsMapper_v0_3_Test.java new file mode 100644 index 000000000..05371e643 --- /dev/null +++ b/compat-0.3/conversion/src/test/java/org/a2aproject/sdk/compat03/conversion/mappers/params/MessageSendParamsMapper_v0_3_Test.java @@ -0,0 +1,27 @@ +package org.a2aproject.sdk.compat03.conversion.mappers.params; + +import java.util.List; + +import org.a2aproject.sdk.compat03.spec.MessageSendParams_v0_3; +import org.a2aproject.sdk.compat03.spec.Message_v0_3; +import org.a2aproject.sdk.compat03.spec.TextPart_v0_3; +import org.a2aproject.sdk.spec.MessageSendParams; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class MessageSendParamsMapper_v0_3_Test { + + @Test + void roundTripPreservesMessageAndAddsOnlyDefaultTenant() { + Message_v0_3 message = new Message_v0_3( + Message_v0_3.Role.USER, List.of(new TextPart_v0_3("hello")), "message", "context", + null, null, null, null); + MessageSendParams_v0_3 legacy = new MessageSendParams_v0_3(message, null, null); + + MessageSendParams current = MessageSendParamsMapper_v0_3.INSTANCE.toV10(legacy); + + assertEquals("", current.tenant()); + assertEquals(legacy, MessageSendParamsMapper_v0_3.INSTANCE.fromV10(current)); + } +} diff --git a/compat-0.3/pom.xml b/compat-0.3/pom.xml index 87b7c1ca4..d7a7f9e4f 100644 --- a/compat-0.3/pom.xml +++ b/compat-0.3/pom.xml @@ -24,6 +24,31 @@ a2a-java-sdk-compat-0.3-spec ${project.version} + + ${project.groupId} + a2a-java-sdk-compat-0.3-conversion + ${project.version} + + + ${project.groupId} + a2a-java-sdk-compat-0.3-client-adapter + ${project.version} + + + ${project.groupId} + a2a-java-sdk-compat-0.3-client-adapter-jsonrpc + ${project.version} + + + ${project.groupId} + a2a-java-sdk-compat-0.3-client-adapter-rest + ${project.version} + + + ${project.groupId} + a2a-java-sdk-compat-0.3-client-adapter-grpc + ${project.version} + ${project.groupId} a2a-java-sdk-compat-0.3-spec-grpc @@ -118,8 +143,13 @@ spec spec-grpc - + + conversion server-conversion + client/adapter + client/adapter-jsonrpc + client/adapter-rest + client/adapter-grpc tests/server-common diff --git a/compat-0.3/reference/grpc/pom.xml b/compat-0.3/reference/grpc/pom.xml index 95ac53dec..62c9ac46f 100644 --- a/compat-0.3/reference/grpc/pom.xml +++ b/compat-0.3/reference/grpc/pom.xml @@ -15,6 +15,32 @@ Java SDK for the Agent2Agent Protocol (A2A) - A2A gRPC Reference Server (based on Quarkus) + + ${project.groupId} + a2a-java-sdk-client + test + + + ${project.groupId} + a2a-java-sdk-client-transport-grpc + test + + + ${project.groupId} + a2a-java-sdk-tests-server-common + test-jar + test + + + ${project.groupId} + a2a-java-sdk-compat-0.3-client-adapter + test + + + ${project.groupId} + a2a-java-sdk-compat-0.3-client-adapter-grpc + test + ${project.groupId} a2a-java-sdk-compat-0.3-spec @@ -136,4 +162,4 @@ - \ No newline at end of file + diff --git a/compat-0.3/reference/grpc/src/test/java/org/a2aproject/sdk/compat03/server/grpc/quarkus/A2ATestResource_v0_3.java b/compat-0.3/reference/grpc/src/test/java/org/a2aproject/sdk/compat03/server/grpc/quarkus/A2ATestResource_v0_3.java index 971d09afe..ebdf0a9ff 100644 --- a/compat-0.3/reference/grpc/src/test/java/org/a2aproject/sdk/compat03/server/grpc/quarkus/A2ATestResource_v0_3.java +++ b/compat-0.3/reference/grpc/src/test/java/org/a2aproject/sdk/compat03/server/grpc/quarkus/A2ATestResource_v0_3.java @@ -14,6 +14,7 @@ import jakarta.ws.rs.Path; import jakarta.ws.rs.PathParam; import jakarta.ws.rs.Produces; +import jakarta.ws.rs.QueryParam; import jakarta.ws.rs.core.HttpHeaders; import jakarta.ws.rs.core.MediaType; import jakarta.ws.rs.core.Response; @@ -81,8 +82,13 @@ public Response deleteTask(@PathParam("taskId") String taskId) { @POST @Path("/queue/ensure/{taskId}") - public Response ensureQueue(@PathParam("taskId") String taskId) { - testUtilsBean.ensureQueue(taskId); + public Response ensureQueue(@PathParam("taskId") String taskId, + @QueryParam("withoutConsumer") boolean withoutConsumer) { + if (withoutConsumer) { + testUtilsBean.ensureQueueWithoutConsumer(taskId); + } else { + testUtilsBean.ensureQueue(taskId); + } return Response.ok().build(); } diff --git a/compat-0.3/reference/grpc/src/test/java/org/a2aproject/sdk/compat03/server/grpc/quarkus/CompatibilityAuthTestProfile_v0_3.java b/compat-0.3/reference/grpc/src/test/java/org/a2aproject/sdk/compat03/server/grpc/quarkus/CompatibilityAuthTestProfile_v0_3.java new file mode 100644 index 000000000..6438bbf88 --- /dev/null +++ b/compat-0.3/reference/grpc/src/test/java/org/a2aproject/sdk/compat03/server/grpc/quarkus/CompatibilityAuthTestProfile_v0_3.java @@ -0,0 +1,30 @@ +package org.a2aproject.sdk.compat03.server.grpc.quarkus; + +import java.util.Map; + +import io.quarkus.test.junit.QuarkusTestProfile; + +public final class CompatibilityAuthTestProfile_v0_3 implements QuarkusTestProfile { + @Override + public Map getConfigOverrides() { + return Map.ofEntries( + Map.entry("test.identity.auto-auth", "false"), + Map.entry("quarkus.test.security.auth.enabled", "false"), + Map.entry("test.agent.security.enabled", "true"), + Map.entry("test.authorization.enabled", "true"), + Map.entry("quarkus.security.users.embedded.enabled", "true"), + Map.entry("quarkus.security.users.embedded.plain-text", "true"), + Map.entry("quarkus.security.users.embedded.users.testuser", "testpass"), + Map.entry("quarkus.security.users.embedded.roles.testuser", "user"), + Map.entry("quarkus.http.auth.basic", "true"), + Map.entry("quarkus.http.auth.proactive", "true"), + Map.entry( + "quarkus.arc.exclude-types", + "org.a2aproject.sdk.compat03.conversion.test.AgentExecutorProducer_v0_3")); + } + + @Override + public String getConfigProfile() { + return "test"; + } +} diff --git a/compat-0.3/reference/grpc/src/test/java/org/a2aproject/sdk/compat03/server/grpc/quarkus/CompatibilityTestProfile_v0_3.java b/compat-0.3/reference/grpc/src/test/java/org/a2aproject/sdk/compat03/server/grpc/quarkus/CompatibilityTestProfile_v0_3.java new file mode 100644 index 000000000..e65de9800 --- /dev/null +++ b/compat-0.3/reference/grpc/src/test/java/org/a2aproject/sdk/compat03/server/grpc/quarkus/CompatibilityTestProfile_v0_3.java @@ -0,0 +1,19 @@ +package org.a2aproject.sdk.compat03.server.grpc.quarkus; + +import java.util.Map; + +import io.quarkus.test.junit.QuarkusTestProfile; + +public final class CompatibilityTestProfile_v0_3 implements QuarkusTestProfile { + @Override + public Map getConfigOverrides() { + return Map.of( + "quarkus.arc.exclude-types", + "org.a2aproject.sdk.compat03.conversion.test.AgentExecutorProducer_v0_3"); + } + + @Override + public String getConfigProfile() { + return "test"; + } +} diff --git a/compat-0.3/reference/grpc/src/test/java/org/a2aproject/sdk/compat03/server/grpc/quarkus/QuarkusA2AGrpc_v0_3_CompatibilityTest.java b/compat-0.3/reference/grpc/src/test/java/org/a2aproject/sdk/compat03/server/grpc/quarkus/QuarkusA2AGrpc_v0_3_CompatibilityTest.java new file mode 100644 index 000000000..1f1469170 --- /dev/null +++ b/compat-0.3/reference/grpc/src/test/java/org/a2aproject/sdk/compat03/server/grpc/quarkus/QuarkusA2AGrpc_v0_3_CompatibilityTest.java @@ -0,0 +1,68 @@ +package org.a2aproject.sdk.compat03.server.grpc.quarkus; + +import java.util.List; +import java.util.Map; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.TimeUnit; + +import io.grpc.ManagedChannel; +import io.grpc.ManagedChannelBuilder; +import io.quarkus.test.junit.QuarkusTest; +import io.quarkus.test.junit.TestProfile; +import org.a2aproject.sdk.client.ClientBuilder; +import org.a2aproject.sdk.client.transport.grpc.GrpcTransport; +import org.a2aproject.sdk.client.transport.grpc.GrpcTransportConfigBuilder; +import org.a2aproject.sdk.server.apps.common.AbstractA2AServerCompatibilityTest_v0_3; +import org.a2aproject.sdk.spec.AgentCapabilities; +import org.a2aproject.sdk.spec.AgentCard; +import org.a2aproject.sdk.spec.AgentInterface; +import org.a2aproject.sdk.spec.SecurityRequirement; +import org.a2aproject.sdk.spec.TransportProtocol; +import org.junit.jupiter.api.AfterAll; + +@QuarkusTest +@TestProfile(CompatibilityTestProfile_v0_3.class) +public class QuarkusA2AGrpc_v0_3_CompatibilityTest extends AbstractA2AServerCompatibilityTest_v0_3 { + private static final List channels = new CopyOnWriteArrayList<>(); + + public QuarkusA2AGrpc_v0_3_CompatibilityTest() { super(8081); } + @Override protected String getTransportProtocol() { return TransportProtocol.GRPC.asString(); } + @Override protected String getTransportUrl() { return "localhost:8081"; } + @Override protected void configureTransport(ClientBuilder builder) { + builder.withTransport(GrpcTransport.class, new GrpcTransportConfigBuilder().channelFactory(target -> { + ManagedChannel created = ManagedChannelBuilder.forTarget(target).usePlaintext().build(); + channels.add(created); + return created; + })); + } + @Override protected AgentCard getAgentCard() { return card(false); } + + static AgentCard card(boolean auth) { + AgentCard.Builder builder = AgentCard.builder().name("legacy").description("legacy") + .url("localhost:8081").version("1.0.0").capabilities(AgentCapabilities.builder().streaming(true) + .pushNotifications(true).build()).defaultInputModes(List.of("text")) + .defaultOutputModes(List.of("text")).skills(List.of()) + .supportedInterfaces(List.of(new AgentInterface("GRPC", "localhost:8081", null, "0.3"))); + if (auth) { + builder.securitySchemes(Map.of("basicAuth", + new org.a2aproject.sdk.spec.HTTPAuthSecurityScheme("none", "basic", "HTTP Basic authentication"))) + .securityRequirements(List.of(new SecurityRequirement(Map.of("basicAuth", List.of())))); + } + return builder.build(); + } + + @AfterAll + public static void closeChannels() { + // Shut down every caller-owned channel before waiting for any one of them. + channels.forEach(ManagedChannel::shutdownNow); + try { + for (ManagedChannel channel : channels) { + channel.awaitTermination(10, TimeUnit.SECONDS); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } finally { + channels.clear(); + } + } +} diff --git a/compat-0.3/reference/grpc/src/test/java/org/a2aproject/sdk/compat03/server/grpc/quarkus/QuarkusA2AGrpc_v0_3_WithAuthCompatibilityTest.java b/compat-0.3/reference/grpc/src/test/java/org/a2aproject/sdk/compat03/server/grpc/quarkus/QuarkusA2AGrpc_v0_3_WithAuthCompatibilityTest.java new file mode 100644 index 000000000..2f424187b --- /dev/null +++ b/compat-0.3/reference/grpc/src/test/java/org/a2aproject/sdk/compat03/server/grpc/quarkus/QuarkusA2AGrpc_v0_3_WithAuthCompatibilityTest.java @@ -0,0 +1,63 @@ +package org.a2aproject.sdk.compat03.server.grpc.quarkus; + +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.TimeUnit; + +import io.grpc.ManagedChannel; +import io.grpc.ManagedChannelBuilder; +import io.quarkus.test.junit.QuarkusTest; +import io.quarkus.test.junit.TestProfile; +import org.a2aproject.sdk.client.ClientBuilder; +import org.a2aproject.sdk.client.transport.grpc.GrpcTransport; +import org.a2aproject.sdk.client.transport.grpc.GrpcTransportConfigBuilder; +import org.a2aproject.sdk.client.transport.spi.interceptors.auth.AuthInterceptor; +import org.a2aproject.sdk.server.apps.common.AbstractA2AServerCompatibilityWithAuthTest_v0_3; +import org.a2aproject.sdk.spec.AgentCard; +import org.a2aproject.sdk.spec.TransportProtocol; +import org.junit.jupiter.api.AfterAll; + +@QuarkusTest +@TestProfile(CompatibilityAuthTestProfile_v0_3.class) +public class QuarkusA2AGrpc_v0_3_WithAuthCompatibilityTest + extends AbstractA2AServerCompatibilityWithAuthTest_v0_3 { + private static final List channels = new CopyOnWriteArrayList<>(); + + public QuarkusA2AGrpc_v0_3_WithAuthCompatibilityTest() { super(8081); } + @Override protected String getTransportProtocol() { return TransportProtocol.GRPC.asString(); } + @Override protected String getTransportUrl() { return "localhost:8081"; } + @Override protected AgentCard getAgentCard() { + return QuarkusA2AGrpc_v0_3_CompatibilityTest.card(true); + } + @Override protected void configureTransport(ClientBuilder builder) { + builder.withTransport(GrpcTransport.class, new GrpcTransportConfigBuilder().channelFactory(target -> { + ManagedChannel created = ManagedChannelBuilder.forTarget(target).usePlaintext().build(); + channels.add(created); + return created; + })); + } + @Override protected void configureTransportWithAuth(ClientBuilder builder) { + builder.withTransport(GrpcTransport.class, new GrpcTransportConfigBuilder() + .channelFactory(target -> { + ManagedChannel created = ManagedChannelBuilder.forTarget(target).usePlaintext().build(); + channels.add(created); + return created; + }).addInterceptor(new AuthInterceptor( + (scheme, context) -> BASIC_AUTH_SCHEME_NAME.equals(scheme) ? getEncodedCredentials() : null))); + } + + @AfterAll + public static void closeChannels() { + // Shut down every caller-owned channel before waiting for any one of them. + channels.forEach(ManagedChannel::shutdownNow); + try { + for (ManagedChannel channel : channels) { + channel.awaitTermination(10, TimeUnit.SECONDS); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } finally { + channels.clear(); + } + } +} diff --git a/compat-0.3/reference/grpc/src/test/resources/a2a-requesthandler-test.properties b/compat-0.3/reference/grpc/src/test/resources/a2a-requesthandler-test.properties new file mode 100644 index 000000000..a8f637a17 --- /dev/null +++ b/compat-0.3/reference/grpc/src/test/resources/a2a-requesthandler-test.properties @@ -0,0 +1 @@ +preferred-transport=GRPC diff --git a/compat-0.3/reference/grpc/src/test/resources/application.properties b/compat-0.3/reference/grpc/src/test/resources/application.properties index 420f83b52..6eb751990 100644 --- a/compat-0.3/reference/grpc/src/test/resources/application.properties +++ b/compat-0.3/reference/grpc/src/test/resources/application.properties @@ -6,6 +6,7 @@ quarkus.http.port=8081 quarkus.http.test-port=8081 # Index dependencies for CDI bean discovery +quarkus.arc.exclude-types=org.a2aproject.sdk.server.apps.common.AgentExecutorProducer quarkus.index-dependency.server-conversion.group-id=org.a2aproject.sdk quarkus.index-dependency.server-conversion.artifact-id=a2a-java-sdk-compat-0.3-server-conversion quarkus.index-dependency.server-conversion.classifier=tests diff --git a/compat-0.3/reference/jsonrpc/pom.xml b/compat-0.3/reference/jsonrpc/pom.xml index a178e3e0f..7fe7b5568 100644 --- a/compat-0.3/reference/jsonrpc/pom.xml +++ b/compat-0.3/reference/jsonrpc/pom.xml @@ -18,6 +18,32 @@ Java SDK for the Agent2Agent Protocol (A2A) - A2A JSONRPC Reference Server (based on Quarkus) + + ${project.groupId} + a2a-java-sdk-client + test + + + ${project.groupId} + a2a-java-sdk-client-transport-jsonrpc + test + + + ${project.groupId} + a2a-java-sdk-tests-server-common + test-jar + test + + + ${project.groupId} + a2a-java-sdk-compat-0.3-client-adapter + test + + + ${project.groupId} + a2a-java-sdk-compat-0.3-client-adapter-jsonrpc + test + ${project.groupId} a2a-java-sdk-compat-0.3-spec diff --git a/compat-0.3/reference/jsonrpc/src/test/java/org/a2aproject/sdk/compat03/server/apps/quarkus/A2ATestRoutes_v0_3.java b/compat-0.3/reference/jsonrpc/src/test/java/org/a2aproject/sdk/compat03/server/apps/quarkus/A2ATestRoutes_v0_3.java index ab460d8b1..94f8d430d 100644 --- a/compat-0.3/reference/jsonrpc/src/test/java/org/a2aproject/sdk/compat03/server/apps/quarkus/A2ATestRoutes_v0_3.java +++ b/compat-0.3/reference/jsonrpc/src/test/java/org/a2aproject/sdk/compat03/server/apps/quarkus/A2ATestRoutes_v0_3.java @@ -173,7 +173,11 @@ public void deleteTask(String taskId, RoutingContext rc) { public void ensureTaskQueue(String taskId, RoutingContext rc) { try { - testUtilsBean.ensureQueue(taskId); + if (Boolean.parseBoolean(rc.request().getParam("withoutConsumer"))) { + testUtilsBean.ensureQueueWithoutConsumer(taskId); + } else { + testUtilsBean.ensureQueue(taskId); + } rc.response() .setStatusCode(200) .end(); diff --git a/compat-0.3/reference/jsonrpc/src/test/java/org/a2aproject/sdk/compat03/server/apps/quarkus/CompatibilityAuthTestProfile_v0_3.java b/compat-0.3/reference/jsonrpc/src/test/java/org/a2aproject/sdk/compat03/server/apps/quarkus/CompatibilityAuthTestProfile_v0_3.java new file mode 100644 index 000000000..45600fa6e --- /dev/null +++ b/compat-0.3/reference/jsonrpc/src/test/java/org/a2aproject/sdk/compat03/server/apps/quarkus/CompatibilityAuthTestProfile_v0_3.java @@ -0,0 +1,30 @@ +package org.a2aproject.sdk.compat03.server.apps.quarkus; + +import java.util.Map; + +import io.quarkus.test.junit.QuarkusTestProfile; + +public final class CompatibilityAuthTestProfile_v0_3 implements QuarkusTestProfile { + @Override + public Map getConfigOverrides() { + return Map.ofEntries( + Map.entry("test.identity.auto-auth", "false"), + Map.entry("quarkus.test.security.auth.enabled", "false"), + Map.entry("test.agent.security.enabled", "true"), + Map.entry("test.authorization.enabled", "true"), + Map.entry("quarkus.security.users.embedded.enabled", "true"), + Map.entry("quarkus.security.users.embedded.plain-text", "true"), + Map.entry("quarkus.security.users.embedded.users.testuser", "testpass"), + Map.entry("quarkus.security.users.embedded.roles.testuser", "user"), + Map.entry("quarkus.http.auth.basic", "true"), + Map.entry("quarkus.http.auth.proactive", "true"), + Map.entry( + "quarkus.arc.exclude-types", + "org.a2aproject.sdk.compat03.conversion.test.AgentExecutorProducer_v0_3")); + } + + @Override + public String getConfigProfile() { + return "test"; + } +} diff --git a/compat-0.3/reference/jsonrpc/src/test/java/org/a2aproject/sdk/compat03/server/apps/quarkus/CompatibilityTestProfile_v0_3.java b/compat-0.3/reference/jsonrpc/src/test/java/org/a2aproject/sdk/compat03/server/apps/quarkus/CompatibilityTestProfile_v0_3.java new file mode 100644 index 000000000..450fb665e --- /dev/null +++ b/compat-0.3/reference/jsonrpc/src/test/java/org/a2aproject/sdk/compat03/server/apps/quarkus/CompatibilityTestProfile_v0_3.java @@ -0,0 +1,19 @@ +package org.a2aproject.sdk.compat03.server.apps.quarkus; + +import java.util.Map; + +import io.quarkus.test.junit.QuarkusTestProfile; + +public final class CompatibilityTestProfile_v0_3 implements QuarkusTestProfile { + @Override + public Map getConfigOverrides() { + return Map.of( + "quarkus.arc.exclude-types", + "org.a2aproject.sdk.compat03.conversion.test.AgentExecutorProducer_v0_3"); + } + + @Override + public String getConfigProfile() { + return "test"; + } +} diff --git a/compat-0.3/reference/jsonrpc/src/test/java/org/a2aproject/sdk/compat03/server/apps/quarkus/QuarkusA2AJSONRPC_v0_3_CompatibilityAndroidTest.java b/compat-0.3/reference/jsonrpc/src/test/java/org/a2aproject/sdk/compat03/server/apps/quarkus/QuarkusA2AJSONRPC_v0_3_CompatibilityAndroidTest.java new file mode 100644 index 000000000..263ea463b --- /dev/null +++ b/compat-0.3/reference/jsonrpc/src/test/java/org/a2aproject/sdk/compat03/server/apps/quarkus/QuarkusA2AJSONRPC_v0_3_CompatibilityAndroidTest.java @@ -0,0 +1,21 @@ +package org.a2aproject.sdk.compat03.server.apps.quarkus; + +import io.quarkus.test.junit.QuarkusTest; +import io.quarkus.test.junit.TestProfile; +import org.a2aproject.sdk.client.ClientBuilder; +import org.a2aproject.sdk.client.http.android.AndroidA2AHttpClient; +import org.a2aproject.sdk.client.transport.jsonrpc.JSONRPCTransport; +import org.a2aproject.sdk.client.transport.jsonrpc.JSONRPCTransportConfigBuilder; +import org.a2aproject.sdk.server.apps.common.AbstractA2AServerCompatibilityTest_v0_3; + +@QuarkusTest +@TestProfile(CompatibilityTestProfile_v0_3.class) +public class QuarkusA2AJSONRPC_v0_3_CompatibilityAndroidTest extends AbstractA2AServerCompatibilityTest_v0_3 { + public QuarkusA2AJSONRPC_v0_3_CompatibilityAndroidTest() { super(8081); } + @Override protected String getTransportProtocol() { return "JSONRPC"; } + @Override protected String getTransportUrl() { return "http://localhost:8081"; } + @Override protected void configureTransport(ClientBuilder builder) { + builder.withTransport(JSONRPCTransport.class, new JSONRPCTransportConfigBuilder() + .httpClient(new AndroidA2AHttpClient())); + } +} diff --git a/compat-0.3/reference/jsonrpc/src/test/java/org/a2aproject/sdk/compat03/server/apps/quarkus/QuarkusA2AJSONRPC_v0_3_CompatibilityJdkTest.java b/compat-0.3/reference/jsonrpc/src/test/java/org/a2aproject/sdk/compat03/server/apps/quarkus/QuarkusA2AJSONRPC_v0_3_CompatibilityJdkTest.java new file mode 100644 index 000000000..d2289b5e5 --- /dev/null +++ b/compat-0.3/reference/jsonrpc/src/test/java/org/a2aproject/sdk/compat03/server/apps/quarkus/QuarkusA2AJSONRPC_v0_3_CompatibilityJdkTest.java @@ -0,0 +1,21 @@ +package org.a2aproject.sdk.compat03.server.apps.quarkus; + +import io.quarkus.test.junit.QuarkusTest; +import io.quarkus.test.junit.TestProfile; +import org.a2aproject.sdk.client.ClientBuilder; +import org.a2aproject.sdk.client.http.JdkA2AHttpClient; +import org.a2aproject.sdk.client.transport.jsonrpc.JSONRPCTransport; +import org.a2aproject.sdk.client.transport.jsonrpc.JSONRPCTransportConfigBuilder; +import org.a2aproject.sdk.server.apps.common.AbstractA2AServerCompatibilityTest_v0_3; + +@QuarkusTest +@TestProfile(CompatibilityTestProfile_v0_3.class) +public class QuarkusA2AJSONRPC_v0_3_CompatibilityJdkTest extends AbstractA2AServerCompatibilityTest_v0_3 { + public QuarkusA2AJSONRPC_v0_3_CompatibilityJdkTest() { super(8081); } + @Override protected String getTransportProtocol() { return "JSONRPC"; } + @Override protected String getTransportUrl() { return "http://localhost:8081"; } + @Override protected void configureTransport(ClientBuilder builder) { + builder.withTransport(JSONRPCTransport.class, new JSONRPCTransportConfigBuilder() + .httpClient(new JdkA2AHttpClient())); + } +} diff --git a/compat-0.3/reference/jsonrpc/src/test/java/org/a2aproject/sdk/compat03/server/apps/quarkus/QuarkusA2AJSONRPC_v0_3_CompatibilityVertxTest.java b/compat-0.3/reference/jsonrpc/src/test/java/org/a2aproject/sdk/compat03/server/apps/quarkus/QuarkusA2AJSONRPC_v0_3_CompatibilityVertxTest.java new file mode 100644 index 000000000..2f2009139 --- /dev/null +++ b/compat-0.3/reference/jsonrpc/src/test/java/org/a2aproject/sdk/compat03/server/apps/quarkus/QuarkusA2AJSONRPC_v0_3_CompatibilityVertxTest.java @@ -0,0 +1,24 @@ +package org.a2aproject.sdk.compat03.server.apps.quarkus; + +import io.quarkus.test.junit.QuarkusTest; +import io.quarkus.test.junit.TestProfile; +import io.vertx.core.Vertx; +import jakarta.inject.Inject; +import org.a2aproject.sdk.client.ClientBuilder; +import org.a2aproject.sdk.client.http.vertx.VertxA2AHttpClient; +import org.a2aproject.sdk.client.transport.jsonrpc.JSONRPCTransport; +import org.a2aproject.sdk.client.transport.jsonrpc.JSONRPCTransportConfigBuilder; +import org.a2aproject.sdk.server.apps.common.AbstractA2AServerCompatibilityTest_v0_3; + +@QuarkusTest +@TestProfile(CompatibilityTestProfile_v0_3.class) +public class QuarkusA2AJSONRPC_v0_3_CompatibilityVertxTest extends AbstractA2AServerCompatibilityTest_v0_3 { + @Inject Vertx vertx; + public QuarkusA2AJSONRPC_v0_3_CompatibilityVertxTest() { super(8081); } + @Override protected String getTransportProtocol() { return "JSONRPC"; } + @Override protected String getTransportUrl() { return "http://localhost:8081"; } + @Override protected void configureTransport(ClientBuilder builder) { + builder.withTransport(JSONRPCTransport.class, new JSONRPCTransportConfigBuilder() + .httpClient(new VertxA2AHttpClient(vertx))); + } +} diff --git a/compat-0.3/reference/jsonrpc/src/test/java/org/a2aproject/sdk/compat03/server/apps/quarkus/QuarkusA2AJSONRPC_v0_3_WithAuthCompatibilityAndroidTest.java b/compat-0.3/reference/jsonrpc/src/test/java/org/a2aproject/sdk/compat03/server/apps/quarkus/QuarkusA2AJSONRPC_v0_3_WithAuthCompatibilityAndroidTest.java new file mode 100644 index 000000000..6b2f33fb1 --- /dev/null +++ b/compat-0.3/reference/jsonrpc/src/test/java/org/a2aproject/sdk/compat03/server/apps/quarkus/QuarkusA2AJSONRPC_v0_3_WithAuthCompatibilityAndroidTest.java @@ -0,0 +1,28 @@ +package org.a2aproject.sdk.compat03.server.apps.quarkus; + +import io.quarkus.test.junit.QuarkusTest; +import io.quarkus.test.junit.TestProfile; +import org.a2aproject.sdk.client.ClientBuilder; +import org.a2aproject.sdk.client.http.android.AndroidA2AHttpClient; +import org.a2aproject.sdk.client.transport.jsonrpc.JSONRPCTransport; +import org.a2aproject.sdk.client.transport.jsonrpc.JSONRPCTransportConfigBuilder; +import org.a2aproject.sdk.client.transport.spi.interceptors.auth.AuthInterceptor; +import org.a2aproject.sdk.server.apps.common.AbstractA2AServerCompatibilityWithAuthTest_v0_3; + +@QuarkusTest +@TestProfile(CompatibilityAuthTestProfile_v0_3.class) +public class QuarkusA2AJSONRPC_v0_3_WithAuthCompatibilityAndroidTest + extends AbstractA2AServerCompatibilityWithAuthTest_v0_3 { + public QuarkusA2AJSONRPC_v0_3_WithAuthCompatibilityAndroidTest() { super(8081); } + @Override protected String getTransportProtocol() { return "JSONRPC"; } + @Override protected String getTransportUrl() { return "http://localhost:8081"; } + @Override protected void configureTransport(ClientBuilder builder) { + builder.withTransport(JSONRPCTransport.class, new JSONRPCTransportConfigBuilder() + .httpClient(new AndroidA2AHttpClient())); + } + @Override protected void configureTransportWithAuth(ClientBuilder builder) { + builder.withTransport(JSONRPCTransport.class, new JSONRPCTransportConfigBuilder() + .httpClient(new AndroidA2AHttpClient()).addInterceptor(new AuthInterceptor( + (scheme, context) -> BASIC_AUTH_SCHEME_NAME.equals(scheme) ? getEncodedCredentials() : null))); + } +} diff --git a/compat-0.3/reference/jsonrpc/src/test/java/org/a2aproject/sdk/compat03/server/apps/quarkus/QuarkusA2AJSONRPC_v0_3_WithAuthCompatibilityTest.java b/compat-0.3/reference/jsonrpc/src/test/java/org/a2aproject/sdk/compat03/server/apps/quarkus/QuarkusA2AJSONRPC_v0_3_WithAuthCompatibilityTest.java new file mode 100644 index 000000000..d13517e49 --- /dev/null +++ b/compat-0.3/reference/jsonrpc/src/test/java/org/a2aproject/sdk/compat03/server/apps/quarkus/QuarkusA2AJSONRPC_v0_3_WithAuthCompatibilityTest.java @@ -0,0 +1,28 @@ +package org.a2aproject.sdk.compat03.server.apps.quarkus; + +import io.quarkus.test.junit.QuarkusTest; +import io.quarkus.test.junit.TestProfile; +import org.a2aproject.sdk.client.ClientBuilder; +import org.a2aproject.sdk.client.http.JdkA2AHttpClient; +import org.a2aproject.sdk.client.transport.jsonrpc.JSONRPCTransport; +import org.a2aproject.sdk.client.transport.jsonrpc.JSONRPCTransportConfigBuilder; +import org.a2aproject.sdk.client.transport.spi.interceptors.auth.AuthInterceptor; +import org.a2aproject.sdk.server.apps.common.AbstractA2AServerCompatibilityWithAuthTest_v0_3; + +@QuarkusTest +@TestProfile(CompatibilityAuthTestProfile_v0_3.class) +public class QuarkusA2AJSONRPC_v0_3_WithAuthCompatibilityTest + extends AbstractA2AServerCompatibilityWithAuthTest_v0_3 { + public QuarkusA2AJSONRPC_v0_3_WithAuthCompatibilityTest() { super(8081); } + @Override protected String getTransportProtocol() { return "JSONRPC"; } + @Override protected String getTransportUrl() { return "http://localhost:8081"; } + @Override protected void configureTransport(ClientBuilder builder) { + builder.withTransport(JSONRPCTransport.class, new JSONRPCTransportConfigBuilder() + .httpClient(new JdkA2AHttpClient())); + } + @Override protected void configureTransportWithAuth(ClientBuilder builder) { + builder.withTransport(JSONRPCTransport.class, new JSONRPCTransportConfigBuilder() + .httpClient(new JdkA2AHttpClient()).addInterceptor(new AuthInterceptor( + (scheme, context) -> BASIC_AUTH_SCHEME_NAME.equals(scheme) ? getEncodedCredentials() : null))); + } +} diff --git a/compat-0.3/reference/jsonrpc/src/test/java/org/a2aproject/sdk/compat03/server/apps/quarkus/QuarkusA2AJSONRPC_v0_3_WithAuthCompatibilityVertxTest.java b/compat-0.3/reference/jsonrpc/src/test/java/org/a2aproject/sdk/compat03/server/apps/quarkus/QuarkusA2AJSONRPC_v0_3_WithAuthCompatibilityVertxTest.java new file mode 100644 index 000000000..d9cf3dbac --- /dev/null +++ b/compat-0.3/reference/jsonrpc/src/test/java/org/a2aproject/sdk/compat03/server/apps/quarkus/QuarkusA2AJSONRPC_v0_3_WithAuthCompatibilityVertxTest.java @@ -0,0 +1,31 @@ +package org.a2aproject.sdk.compat03.server.apps.quarkus; + +import io.quarkus.test.junit.QuarkusTest; +import io.quarkus.test.junit.TestProfile; +import io.vertx.core.Vertx; +import jakarta.inject.Inject; +import org.a2aproject.sdk.client.ClientBuilder; +import org.a2aproject.sdk.client.http.vertx.VertxA2AHttpClient; +import org.a2aproject.sdk.client.transport.jsonrpc.JSONRPCTransport; +import org.a2aproject.sdk.client.transport.jsonrpc.JSONRPCTransportConfigBuilder; +import org.a2aproject.sdk.client.transport.spi.interceptors.auth.AuthInterceptor; +import org.a2aproject.sdk.server.apps.common.AbstractA2AServerCompatibilityWithAuthTest_v0_3; + +@QuarkusTest +@TestProfile(CompatibilityAuthTestProfile_v0_3.class) +public class QuarkusA2AJSONRPC_v0_3_WithAuthCompatibilityVertxTest + extends AbstractA2AServerCompatibilityWithAuthTest_v0_3 { + @Inject Vertx vertx; + public QuarkusA2AJSONRPC_v0_3_WithAuthCompatibilityVertxTest() { super(8081); } + @Override protected String getTransportProtocol() { return "JSONRPC"; } + @Override protected String getTransportUrl() { return "http://localhost:8081"; } + @Override protected void configureTransport(ClientBuilder builder) { + builder.withTransport(JSONRPCTransport.class, new JSONRPCTransportConfigBuilder() + .httpClient(new VertxA2AHttpClient(vertx))); + } + @Override protected void configureTransportWithAuth(ClientBuilder builder) { + builder.withTransport(JSONRPCTransport.class, new JSONRPCTransportConfigBuilder() + .httpClient(new VertxA2AHttpClient(vertx)).addInterceptor(new AuthInterceptor( + (scheme, context) -> BASIC_AUTH_SCHEME_NAME.equals(scheme) ? getEncodedCredentials() : null))); + } +} diff --git a/compat-0.3/reference/jsonrpc/src/test/resources/a2a-requesthandler-test.properties b/compat-0.3/reference/jsonrpc/src/test/resources/a2a-requesthandler-test.properties new file mode 100644 index 000000000..2d2582df3 --- /dev/null +++ b/compat-0.3/reference/jsonrpc/src/test/resources/a2a-requesthandler-test.properties @@ -0,0 +1 @@ +preferred-transport=JSONRPC diff --git a/compat-0.3/reference/jsonrpc/src/test/resources/application.properties b/compat-0.3/reference/jsonrpc/src/test/resources/application.properties index fa8005c40..cdeb51244 100644 --- a/compat-0.3/reference/jsonrpc/src/test/resources/application.properties +++ b/compat-0.3/reference/jsonrpc/src/test/resources/application.properties @@ -1,4 +1,5 @@ # Index dependencies for CDI bean discovery +quarkus.arc.exclude-types=org.a2aproject.sdk.server.apps.common.AgentExecutorProducer quarkus.index-dependency.server-conversion.group-id=org.a2aproject.sdk quarkus.index-dependency.server-conversion.artifact-id=a2a-java-sdk-compat-0.3-server-conversion quarkus.index-dependency.server-conversion.classifier=tests diff --git a/compat-0.3/reference/rest/pom.xml b/compat-0.3/reference/rest/pom.xml index abf91a16e..e9de477da 100644 --- a/compat-0.3/reference/rest/pom.xml +++ b/compat-0.3/reference/rest/pom.xml @@ -18,6 +18,32 @@ Java SDK for the Agent2Agent Protocol (A2A) - A2A JSON+HTTP/REST Reference Server (based on Quarkus) + + ${project.groupId} + a2a-java-sdk-client + test + + + ${project.groupId} + a2a-java-sdk-client-transport-rest + test + + + ${project.groupId} + a2a-java-sdk-tests-server-common + test-jar + test + + + ${project.groupId} + a2a-java-sdk-compat-0.3-client-adapter + test + + + ${project.groupId} + a2a-java-sdk-compat-0.3-client-adapter-rest + test + ${project.groupId} a2a-java-sdk-compat-0.3-spec diff --git a/compat-0.3/reference/rest/src/main/java/org/a2aproject/sdk/compat03/server/rest/quarkus/A2AServerRoutes_v0_3.java b/compat-0.3/reference/rest/src/main/java/org/a2aproject/sdk/compat03/server/rest/quarkus/A2AServerRoutes_v0_3.java index a22b0de93..9c3e98109 100644 --- a/compat-0.3/reference/rest/src/main/java/org/a2aproject/sdk/compat03/server/rest/quarkus/A2AServerRoutes_v0_3.java +++ b/compat-0.3/reference/rest/src/main/java/org/a2aproject/sdk/compat03/server/rest/quarkus/A2AServerRoutes_v0_3.java @@ -12,12 +12,6 @@ import java.util.concurrent.atomic.AtomicLong; import java.util.function.Consumer; -import jakarta.annotation.Priority; -import jakarta.enterprise.event.Observes; -import jakarta.enterprise.inject.Instance; -import jakarta.inject.Inject; -import jakarta.inject.Singleton; - import io.quarkus.security.Authenticated; import io.quarkus.security.ForbiddenException; import io.quarkus.security.UnauthorizedException; @@ -26,6 +20,11 @@ import io.vertx.ext.web.Router; import io.vertx.ext.web.RoutingContext; import io.vertx.ext.web.handler.BodyHandler; +import jakarta.annotation.Priority; +import jakarta.enterprise.event.Observes; +import jakarta.enterprise.inject.Instance; +import jakarta.inject.Inject; +import jakarta.inject.Singleton; import org.a2aproject.sdk.compat03.common.A2AHeaders_v0_3; import org.a2aproject.sdk.compat03.conversion.A2AProtocol_v0_3; import org.a2aproject.sdk.compat03.spec.CancelTaskRequest_v0_3; @@ -40,9 +39,9 @@ import org.a2aproject.sdk.compat03.spec.SendStreamingMessageRequest_v0_3; import org.a2aproject.sdk.compat03.spec.SetTaskPushNotificationConfigRequest_v0_3; import org.a2aproject.sdk.compat03.spec.TaskResubscriptionRequest_v0_3; -import org.a2aproject.sdk.compat03.transport.rest.handler.RestHandler_v0_3; import org.a2aproject.sdk.compat03.transport.rest.handler.RestHandler_v0_3.HTTPRestResponse; import org.a2aproject.sdk.compat03.transport.rest.handler.RestHandler_v0_3.HTTPRestStreamingResponse; +import org.a2aproject.sdk.compat03.transport.rest.handler.RestHandler_v0_3; import org.a2aproject.sdk.server.PublicAgentCard; import org.a2aproject.sdk.server.ServerCallContext; import org.a2aproject.sdk.server.auth.AuthenticatedUser; @@ -95,6 +94,11 @@ void setupRouter(@Observes @Priority(10) Router router) { .order(1) .blockingHandler(authenticated(this::cancelTask), false); + // GET /v1/tasks/{id}:subscribe (0.3 HTTP binding) + router.getWithRegex("^\\/v1\\/tasks\\/([^/]+):subscribe$") + .order(0) + .blockingHandler(authenticatedStreaming(this::resubscribeTask), false); + // POST /v1/tasks/{id}:subscribe router.postWithRegex("^\\/v1\\/tasks\\/([^/]+):subscribe$") .order(1) @@ -308,7 +312,7 @@ public void setTaskPushNotificationConfiguration(String body, RoutingContext rc) if (taskId == null || taskId.isEmpty()) { response = jsonRestHandler.createErrorResponse(new InvalidParamsError_v0_3("bad task id")); } else { - response = jsonRestHandler.setTaskPushNotificationConfiguration(taskId, body, context); + response = jsonRestHandler.setTaskPushNotificationConfiguration(taskId, body, rc.request().getParam("configId"), context); } } catch (Throwable t) { response = jsonRestHandler.createErrorResponse(new InternalError_v0_3(t.getMessage())); diff --git a/compat-0.3/reference/rest/src/test/java/org/a2aproject/sdk/compat03/server/rest/quarkus/A2ATestRoutes_v0_3.java b/compat-0.3/reference/rest/src/test/java/org/a2aproject/sdk/compat03/server/rest/quarkus/A2ATestRoutes_v0_3.java index 753cca76f..823fa4a11 100644 --- a/compat-0.3/reference/rest/src/test/java/org/a2aproject/sdk/compat03/server/rest/quarkus/A2ATestRoutes_v0_3.java +++ b/compat-0.3/reference/rest/src/test/java/org/a2aproject/sdk/compat03/server/rest/quarkus/A2ATestRoutes_v0_3.java @@ -173,7 +173,11 @@ public void deleteTask(String taskId, RoutingContext rc) { public void ensureTaskQueue(String taskId, RoutingContext rc) { try { - testUtilsBean.ensureQueue(taskId); + if (Boolean.parseBoolean(rc.request().getParam("withoutConsumer"))) { + testUtilsBean.ensureQueueWithoutConsumer(taskId); + } else { + testUtilsBean.ensureQueue(taskId); + } rc.response() .setStatusCode(200) .end(); diff --git a/compat-0.3/reference/rest/src/test/java/org/a2aproject/sdk/compat03/server/rest/quarkus/CompatibilityAuthTestProfile_v0_3.java b/compat-0.3/reference/rest/src/test/java/org/a2aproject/sdk/compat03/server/rest/quarkus/CompatibilityAuthTestProfile_v0_3.java new file mode 100644 index 000000000..df7e78749 --- /dev/null +++ b/compat-0.3/reference/rest/src/test/java/org/a2aproject/sdk/compat03/server/rest/quarkus/CompatibilityAuthTestProfile_v0_3.java @@ -0,0 +1,30 @@ +package org.a2aproject.sdk.compat03.server.rest.quarkus; + +import java.util.Map; + +import io.quarkus.test.junit.QuarkusTestProfile; + +public final class CompatibilityAuthTestProfile_v0_3 implements QuarkusTestProfile { + @Override + public Map getConfigOverrides() { + return Map.ofEntries( + Map.entry("test.identity.auto-auth", "false"), + Map.entry("quarkus.test.security.auth.enabled", "false"), + Map.entry("test.agent.security.enabled", "true"), + Map.entry("test.authorization.enabled", "true"), + Map.entry("quarkus.security.users.embedded.enabled", "true"), + Map.entry("quarkus.security.users.embedded.plain-text", "true"), + Map.entry("quarkus.security.users.embedded.users.testuser", "testpass"), + Map.entry("quarkus.security.users.embedded.roles.testuser", "user"), + Map.entry("quarkus.http.auth.basic", "true"), + Map.entry("quarkus.http.auth.proactive", "true"), + Map.entry( + "quarkus.arc.exclude-types", + "org.a2aproject.sdk.compat03.conversion.test.AgentExecutorProducer_v0_3")); + } + + @Override + public String getConfigProfile() { + return "test"; + } +} diff --git a/compat-0.3/reference/rest/src/test/java/org/a2aproject/sdk/compat03/server/rest/quarkus/CompatibilityTestProfile_v0_3.java b/compat-0.3/reference/rest/src/test/java/org/a2aproject/sdk/compat03/server/rest/quarkus/CompatibilityTestProfile_v0_3.java new file mode 100644 index 000000000..820935fb1 --- /dev/null +++ b/compat-0.3/reference/rest/src/test/java/org/a2aproject/sdk/compat03/server/rest/quarkus/CompatibilityTestProfile_v0_3.java @@ -0,0 +1,19 @@ +package org.a2aproject.sdk.compat03.server.rest.quarkus; + +import java.util.Map; + +import io.quarkus.test.junit.QuarkusTestProfile; + +public final class CompatibilityTestProfile_v0_3 implements QuarkusTestProfile { + @Override + public Map getConfigOverrides() { + return Map.of( + "quarkus.arc.exclude-types", + "org.a2aproject.sdk.compat03.conversion.test.AgentExecutorProducer_v0_3"); + } + + @Override + public String getConfigProfile() { + return "test"; + } +} diff --git a/compat-0.3/reference/rest/src/test/java/org/a2aproject/sdk/compat03/server/rest/quarkus/QuarkusA2ARest_v0_3_CompatibilityAndroidTest.java b/compat-0.3/reference/rest/src/test/java/org/a2aproject/sdk/compat03/server/rest/quarkus/QuarkusA2ARest_v0_3_CompatibilityAndroidTest.java new file mode 100644 index 000000000..b4f2be082 --- /dev/null +++ b/compat-0.3/reference/rest/src/test/java/org/a2aproject/sdk/compat03/server/rest/quarkus/QuarkusA2ARest_v0_3_CompatibilityAndroidTest.java @@ -0,0 +1,21 @@ +package org.a2aproject.sdk.compat03.server.rest.quarkus; + +import io.quarkus.test.junit.QuarkusTest; +import io.quarkus.test.junit.TestProfile; +import org.a2aproject.sdk.client.ClientBuilder; +import org.a2aproject.sdk.client.http.android.AndroidA2AHttpClient; +import org.a2aproject.sdk.client.transport.rest.RestTransport; +import org.a2aproject.sdk.client.transport.rest.RestTransportConfigBuilder; +import org.a2aproject.sdk.server.apps.common.AbstractA2AServerCompatibilityTest_v0_3; + +@QuarkusTest +@TestProfile(CompatibilityTestProfile_v0_3.class) +public class QuarkusA2ARest_v0_3_CompatibilityAndroidTest extends AbstractA2AServerCompatibilityTest_v0_3 { + public QuarkusA2ARest_v0_3_CompatibilityAndroidTest() { super(8081); } + @Override protected String getTransportProtocol() { return "HTTP+JSON"; } + @Override protected String getTransportUrl() { return "http://localhost:8081"; } + @Override protected void configureTransport(ClientBuilder builder) { + builder.withTransport(RestTransport.class, new RestTransportConfigBuilder() + .httpClient(new AndroidA2AHttpClient())); + } +} diff --git a/compat-0.3/reference/rest/src/test/java/org/a2aproject/sdk/compat03/server/rest/quarkus/QuarkusA2ARest_v0_3_CompatibilityJdkTest.java b/compat-0.3/reference/rest/src/test/java/org/a2aproject/sdk/compat03/server/rest/quarkus/QuarkusA2ARest_v0_3_CompatibilityJdkTest.java new file mode 100644 index 000000000..6f6ffc65c --- /dev/null +++ b/compat-0.3/reference/rest/src/test/java/org/a2aproject/sdk/compat03/server/rest/quarkus/QuarkusA2ARest_v0_3_CompatibilityJdkTest.java @@ -0,0 +1,21 @@ +package org.a2aproject.sdk.compat03.server.rest.quarkus; + +import io.quarkus.test.junit.QuarkusTest; +import io.quarkus.test.junit.TestProfile; +import org.a2aproject.sdk.client.ClientBuilder; +import org.a2aproject.sdk.client.http.JdkA2AHttpClient; +import org.a2aproject.sdk.client.transport.rest.RestTransport; +import org.a2aproject.sdk.client.transport.rest.RestTransportConfigBuilder; +import org.a2aproject.sdk.server.apps.common.AbstractA2AServerCompatibilityTest_v0_3; + +@QuarkusTest +@TestProfile(CompatibilityTestProfile_v0_3.class) +public class QuarkusA2ARest_v0_3_CompatibilityJdkTest extends AbstractA2AServerCompatibilityTest_v0_3 { + public QuarkusA2ARest_v0_3_CompatibilityJdkTest() { super(8081); } + @Override protected String getTransportProtocol() { return "HTTP+JSON"; } + @Override protected String getTransportUrl() { return "http://localhost:8081"; } + @Override protected void configureTransport(ClientBuilder builder) { + builder.withTransport(RestTransport.class, new RestTransportConfigBuilder() + .httpClient(new JdkA2AHttpClient())); + } +} diff --git a/compat-0.3/reference/rest/src/test/java/org/a2aproject/sdk/compat03/server/rest/quarkus/QuarkusA2ARest_v0_3_CompatibilityVertxTest.java b/compat-0.3/reference/rest/src/test/java/org/a2aproject/sdk/compat03/server/rest/quarkus/QuarkusA2ARest_v0_3_CompatibilityVertxTest.java new file mode 100644 index 000000000..154b8f52b --- /dev/null +++ b/compat-0.3/reference/rest/src/test/java/org/a2aproject/sdk/compat03/server/rest/quarkus/QuarkusA2ARest_v0_3_CompatibilityVertxTest.java @@ -0,0 +1,24 @@ +package org.a2aproject.sdk.compat03.server.rest.quarkus; + +import io.quarkus.test.junit.QuarkusTest; +import io.quarkus.test.junit.TestProfile; +import io.vertx.core.Vertx; +import jakarta.inject.Inject; +import org.a2aproject.sdk.client.ClientBuilder; +import org.a2aproject.sdk.client.http.vertx.VertxA2AHttpClient; +import org.a2aproject.sdk.client.transport.rest.RestTransport; +import org.a2aproject.sdk.client.transport.rest.RestTransportConfigBuilder; +import org.a2aproject.sdk.server.apps.common.AbstractA2AServerCompatibilityTest_v0_3; + +@QuarkusTest +@TestProfile(CompatibilityTestProfile_v0_3.class) +public class QuarkusA2ARest_v0_3_CompatibilityVertxTest extends AbstractA2AServerCompatibilityTest_v0_3 { + @Inject Vertx vertx; + public QuarkusA2ARest_v0_3_CompatibilityVertxTest() { super(8081); } + @Override protected String getTransportProtocol() { return "HTTP+JSON"; } + @Override protected String getTransportUrl() { return "http://localhost:8081"; } + @Override protected void configureTransport(ClientBuilder builder) { + builder.withTransport(RestTransport.class, new RestTransportConfigBuilder() + .httpClient(new VertxA2AHttpClient(vertx))); + } +} diff --git a/compat-0.3/reference/rest/src/test/java/org/a2aproject/sdk/compat03/server/rest/quarkus/QuarkusA2ARest_v0_3_WithAuthCompatibilityAndroidTest.java b/compat-0.3/reference/rest/src/test/java/org/a2aproject/sdk/compat03/server/rest/quarkus/QuarkusA2ARest_v0_3_WithAuthCompatibilityAndroidTest.java new file mode 100644 index 000000000..8969fd439 --- /dev/null +++ b/compat-0.3/reference/rest/src/test/java/org/a2aproject/sdk/compat03/server/rest/quarkus/QuarkusA2ARest_v0_3_WithAuthCompatibilityAndroidTest.java @@ -0,0 +1,28 @@ +package org.a2aproject.sdk.compat03.server.rest.quarkus; + +import io.quarkus.test.junit.QuarkusTest; +import io.quarkus.test.junit.TestProfile; +import org.a2aproject.sdk.client.ClientBuilder; +import org.a2aproject.sdk.client.http.android.AndroidA2AHttpClient; +import org.a2aproject.sdk.client.transport.rest.RestTransport; +import org.a2aproject.sdk.client.transport.rest.RestTransportConfigBuilder; +import org.a2aproject.sdk.client.transport.spi.interceptors.auth.AuthInterceptor; +import org.a2aproject.sdk.server.apps.common.AbstractA2AServerCompatibilityWithAuthTest_v0_3; + +@QuarkusTest +@TestProfile(CompatibilityAuthTestProfile_v0_3.class) +public class QuarkusA2ARest_v0_3_WithAuthCompatibilityAndroidTest + extends AbstractA2AServerCompatibilityWithAuthTest_v0_3 { + public QuarkusA2ARest_v0_3_WithAuthCompatibilityAndroidTest() { super(8081); } + @Override protected String getTransportProtocol() { return "HTTP+JSON"; } + @Override protected String getTransportUrl() { return "http://localhost:8081"; } + @Override protected void configureTransport(ClientBuilder builder) { + builder.withTransport(RestTransport.class, new RestTransportConfigBuilder() + .httpClient(new AndroidA2AHttpClient())); + } + @Override protected void configureTransportWithAuth(ClientBuilder builder) { + builder.withTransport(RestTransport.class, new RestTransportConfigBuilder() + .httpClient(new AndroidA2AHttpClient()).addInterceptor(new AuthInterceptor( + (scheme, context) -> BASIC_AUTH_SCHEME_NAME.equals(scheme) ? getEncodedCredentials() : null))); + } +} diff --git a/compat-0.3/reference/rest/src/test/java/org/a2aproject/sdk/compat03/server/rest/quarkus/QuarkusA2ARest_v0_3_WithAuthCompatibilityTest.java b/compat-0.3/reference/rest/src/test/java/org/a2aproject/sdk/compat03/server/rest/quarkus/QuarkusA2ARest_v0_3_WithAuthCompatibilityTest.java new file mode 100644 index 000000000..771d369b9 --- /dev/null +++ b/compat-0.3/reference/rest/src/test/java/org/a2aproject/sdk/compat03/server/rest/quarkus/QuarkusA2ARest_v0_3_WithAuthCompatibilityTest.java @@ -0,0 +1,28 @@ +package org.a2aproject.sdk.compat03.server.rest.quarkus; + +import io.quarkus.test.junit.QuarkusTest; +import io.quarkus.test.junit.TestProfile; +import org.a2aproject.sdk.client.ClientBuilder; +import org.a2aproject.sdk.client.http.JdkA2AHttpClient; +import org.a2aproject.sdk.client.transport.rest.RestTransport; +import org.a2aproject.sdk.client.transport.rest.RestTransportConfigBuilder; +import org.a2aproject.sdk.client.transport.spi.interceptors.auth.AuthInterceptor; +import org.a2aproject.sdk.server.apps.common.AbstractA2AServerCompatibilityWithAuthTest_v0_3; + +@QuarkusTest +@TestProfile(CompatibilityAuthTestProfile_v0_3.class) +public class QuarkusA2ARest_v0_3_WithAuthCompatibilityTest + extends AbstractA2AServerCompatibilityWithAuthTest_v0_3 { + public QuarkusA2ARest_v0_3_WithAuthCompatibilityTest() { super(8081); } + @Override protected String getTransportProtocol() { return "HTTP+JSON"; } + @Override protected String getTransportUrl() { return "http://localhost:8081"; } + @Override protected void configureTransport(ClientBuilder builder) { + builder.withTransport(RestTransport.class, new RestTransportConfigBuilder() + .httpClient(new JdkA2AHttpClient())); + } + @Override protected void configureTransportWithAuth(ClientBuilder builder) { + builder.withTransport(RestTransport.class, new RestTransportConfigBuilder() + .httpClient(new JdkA2AHttpClient()).addInterceptor(new AuthInterceptor( + (scheme, context) -> BASIC_AUTH_SCHEME_NAME.equals(scheme) ? getEncodedCredentials() : null))); + } +} diff --git a/compat-0.3/reference/rest/src/test/java/org/a2aproject/sdk/compat03/server/rest/quarkus/QuarkusA2ARest_v0_3_WithAuthCompatibilityVertxTest.java b/compat-0.3/reference/rest/src/test/java/org/a2aproject/sdk/compat03/server/rest/quarkus/QuarkusA2ARest_v0_3_WithAuthCompatibilityVertxTest.java new file mode 100644 index 000000000..09fb123cf --- /dev/null +++ b/compat-0.3/reference/rest/src/test/java/org/a2aproject/sdk/compat03/server/rest/quarkus/QuarkusA2ARest_v0_3_WithAuthCompatibilityVertxTest.java @@ -0,0 +1,31 @@ +package org.a2aproject.sdk.compat03.server.rest.quarkus; + +import io.quarkus.test.junit.QuarkusTest; +import io.quarkus.test.junit.TestProfile; +import io.vertx.core.Vertx; +import jakarta.inject.Inject; +import org.a2aproject.sdk.client.ClientBuilder; +import org.a2aproject.sdk.client.http.vertx.VertxA2AHttpClient; +import org.a2aproject.sdk.client.transport.rest.RestTransport; +import org.a2aproject.sdk.client.transport.rest.RestTransportConfigBuilder; +import org.a2aproject.sdk.client.transport.spi.interceptors.auth.AuthInterceptor; +import org.a2aproject.sdk.server.apps.common.AbstractA2AServerCompatibilityWithAuthTest_v0_3; + +@QuarkusTest +@TestProfile(CompatibilityAuthTestProfile_v0_3.class) +public class QuarkusA2ARest_v0_3_WithAuthCompatibilityVertxTest + extends AbstractA2AServerCompatibilityWithAuthTest_v0_3 { + @Inject Vertx vertx; + public QuarkusA2ARest_v0_3_WithAuthCompatibilityVertxTest() { super(8081); } + @Override protected String getTransportProtocol() { return "HTTP+JSON"; } + @Override protected String getTransportUrl() { return "http://localhost:8081"; } + @Override protected void configureTransport(ClientBuilder builder) { + builder.withTransport(RestTransport.class, new RestTransportConfigBuilder() + .httpClient(new VertxA2AHttpClient(vertx))); + } + @Override protected void configureTransportWithAuth(ClientBuilder builder) { + builder.withTransport(RestTransport.class, new RestTransportConfigBuilder() + .httpClient(new VertxA2AHttpClient(vertx)).addInterceptor(new AuthInterceptor( + (scheme, context) -> BASIC_AUTH_SCHEME_NAME.equals(scheme) ? getEncodedCredentials() : null))); + } +} diff --git a/compat-0.3/reference/rest/src/test/resources/a2a-requesthandler-test.properties b/compat-0.3/reference/rest/src/test/resources/a2a-requesthandler-test.properties new file mode 100644 index 000000000..61696e179 --- /dev/null +++ b/compat-0.3/reference/rest/src/test/resources/a2a-requesthandler-test.properties @@ -0,0 +1 @@ +preferred-transport=HTTP+JSON diff --git a/compat-0.3/reference/rest/src/test/resources/application.properties b/compat-0.3/reference/rest/src/test/resources/application.properties index d5aaac10c..f221180de 100644 --- a/compat-0.3/reference/rest/src/test/resources/application.properties +++ b/compat-0.3/reference/rest/src/test/resources/application.properties @@ -3,6 +3,7 @@ quarkus.http.port=8081 quarkus.http.test-port=8081 # Index dependencies for CDI bean discovery +quarkus.arc.exclude-types=org.a2aproject.sdk.server.apps.common.AgentExecutorProducer quarkus.index-dependency.server-conversion.group-id=org.a2aproject.sdk quarkus.index-dependency.server-conversion.artifact-id=a2a-java-sdk-compat-0.3-server-conversion quarkus.index-dependency.server-conversion.classifier=tests diff --git a/compat-0.3/server-conversion/pom.xml b/compat-0.3/server-conversion/pom.xml index 80acaa031..72f3099fe 100644 --- a/compat-0.3/server-conversion/pom.xml +++ b/compat-0.3/server-conversion/pom.xml @@ -18,6 +18,12 @@ Java SDK for the Agent2Agent Protocol (A2A) - 0.3 to 1.0 Type Conversion Layer + + + ${project.groupId} + a2a-java-sdk-compat-0.3-conversion + + ${project.groupId} @@ -36,12 +42,6 @@ a2a-java-sdk-server-common - - - org.mapstruct - mapstruct - - jakarta.enterprise diff --git a/compat-0.3/server-conversion/src/test/java/org/a2aproject/sdk/compat03/conversion/TaskAuthorizationTestProfile_v0_3.java b/compat-0.3/server-conversion/src/test/java/org/a2aproject/sdk/compat03/conversion/TaskAuthorizationTestProfile_v0_3.java index 79f8784fc..909c667fb 100644 --- a/compat-0.3/server-conversion/src/test/java/org/a2aproject/sdk/compat03/conversion/TaskAuthorizationTestProfile_v0_3.java +++ b/compat-0.3/server-conversion/src/test/java/org/a2aproject/sdk/compat03/conversion/TaskAuthorizationTestProfile_v0_3.java @@ -13,6 +13,11 @@ public Map getConfigOverrides() { config.put("quarkus.security.users.embedded.users.userB", "passB"); config.put("quarkus.security.users.embedded.roles.userB", "user"); config.put("test.task-authorization.enabled", "true"); + // The compatibility test JAR contributes a v0.3 provider as well as the shared provider. + // Keep exactly one provider resolvable so DefaultRequestHandler can enforce ownership. + config.put("quarkus.arc.exclude-types", + "org.a2aproject.sdk.server.apps.common.AgentExecutorProducer," + + "org.a2aproject.sdk.compat03.conversion.test.TestTaskAuthorizationProvider_v0_3"); return config; } } diff --git a/compat-0.3/server-conversion/src/test/java/org/a2aproject/sdk/compat03/conversion/TestUtilsBean_v0_3.java b/compat-0.3/server-conversion/src/test/java/org/a2aproject/sdk/compat03/conversion/TestUtilsBean_v0_3.java index 3800880d4..7e8c61756 100644 --- a/compat-0.3/server-conversion/src/test/java/org/a2aproject/sdk/compat03/conversion/TestUtilsBean_v0_3.java +++ b/compat-0.3/server-conversion/src/test/java/org/a2aproject/sdk/compat03/conversion/TestUtilsBean_v0_3.java @@ -3,6 +3,8 @@ import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; +import org.a2aproject.sdk.server.events.EventQueue; +import org.a2aproject.sdk.server.events.NoTaskQueueException; import org.a2aproject.sdk.server.events.QueueManager; import org.a2aproject.sdk.server.tasks.PushNotificationConfigStore; import org.a2aproject.sdk.server.tasks.TaskStore; @@ -44,13 +46,31 @@ public Task getTask(String taskId) { } public void deleteTask(String taskId) { - taskStore.delete(taskId); + EventQueue queue = queueManager.get(taskId); + try { + if (queue != null) { + // Stop every child consumer, including subscriptions to non-final tasks. + queue.close(true); + } + try { + queueManager.close(taskId); + } catch (NoTaskQueueException ignored) { + // Finalization may already have removed the queue, or none was created. + } + } finally { + taskStore.delete(taskId); + } } public void ensureQueue(String taskId) { queueManager.createOrTap(taskId); } + public void ensureQueueWithoutConsumer(String taskId) { + // createOrTap returns a child; release only that setup child and retain the non-final main queue. + queueManager.createOrTap(taskId).close(); + } + public void enqueueEvent(String taskId, Event event) { queueManager.get(taskId).enqueueEvent(event); } diff --git a/compat-0.3/spec-grpc/pom.xml b/compat-0.3/spec-grpc/pom.xml index ccfcf1ca0..bd467aafa 100644 --- a/compat-0.3/spec-grpc/pom.xml +++ b/compat-0.3/spec-grpc/pom.xml @@ -36,14 +36,6 @@ grpc-stub provided - - jakarta.enterprise - jakarta.enterprise.cdi-api - - - jakarta.inject - jakarta.inject-api - com.google.api.grpc proto-google-common-protos diff --git a/compat-0.3/spec-grpc/src/main/java/org/a2aproject/sdk/compat03/grpc/utils/ProtoUtils_v0_3.java b/compat-0.3/spec-grpc/src/main/java/org/a2aproject/sdk/compat03/grpc/utils/ProtoUtils_v0_3.java index 7a4e6f284..42c24c0a9 100644 --- a/compat-0.3/spec-grpc/src/main/java/org/a2aproject/sdk/compat03/grpc/utils/ProtoUtils_v0_3.java +++ b/compat-0.3/spec-grpc/src/main/java/org/a2aproject/sdk/compat03/grpc/utils/ProtoUtils_v0_3.java @@ -1,23 +1,25 @@ package org.a2aproject.sdk.compat03.grpc.utils; -import java.nio.charset.StandardCharsets; import java.time.Instant; import java.time.OffsetDateTime; import java.time.ZoneOffset; import java.util.ArrayList; +import java.util.Base64; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.stream.Collectors; import com.google.protobuf.ByteString; +import com.google.protobuf.NullValue; import com.google.protobuf.Struct; import com.google.protobuf.Value; import org.a2aproject.sdk.compat03.grpc.StreamResponse; import org.a2aproject.sdk.compat03.spec.APIKeySecurityScheme_v0_3; import org.a2aproject.sdk.compat03.spec.AgentCapabilities_v0_3; -import org.a2aproject.sdk.compat03.spec.AgentCard_v0_3; import org.a2aproject.sdk.compat03.spec.AgentCardSignature_v0_3; +import org.a2aproject.sdk.compat03.spec.AgentCard_v0_3; import org.a2aproject.sdk.compat03.spec.AgentExtension_v0_3; import org.a2aproject.sdk.compat03.spec.AgentInterface_v0_3; import org.a2aproject.sdk.compat03.spec.AgentProvider_v0_3; @@ -37,9 +39,9 @@ import org.a2aproject.sdk.compat03.spec.ImplicitOAuthFlow_v0_3; import org.a2aproject.sdk.compat03.spec.InvalidRequestError_v0_3; import org.a2aproject.sdk.compat03.spec.ListTaskPushNotificationConfigParams_v0_3; -import org.a2aproject.sdk.compat03.spec.Message_v0_3; import org.a2aproject.sdk.compat03.spec.MessageSendConfiguration_v0_3; import org.a2aproject.sdk.compat03.spec.MessageSendParams_v0_3; +import org.a2aproject.sdk.compat03.spec.Message_v0_3; import org.a2aproject.sdk.compat03.spec.MutualTLSSecurityScheme_v0_3; import org.a2aproject.sdk.compat03.spec.OAuth2SecurityScheme_v0_3; import org.a2aproject.sdk.compat03.spec.OAuthFlows_v0_3; @@ -50,14 +52,14 @@ import org.a2aproject.sdk.compat03.spec.PushNotificationConfig_v0_3; import org.a2aproject.sdk.compat03.spec.SecurityScheme_v0_3; import org.a2aproject.sdk.compat03.spec.StreamingEventKind_v0_3; -import org.a2aproject.sdk.compat03.spec.Task_v0_3; import org.a2aproject.sdk.compat03.spec.TaskArtifactUpdateEvent_v0_3; import org.a2aproject.sdk.compat03.spec.TaskIdParams_v0_3; import org.a2aproject.sdk.compat03.spec.TaskPushNotificationConfig_v0_3; import org.a2aproject.sdk.compat03.spec.TaskQueryParams_v0_3; import org.a2aproject.sdk.compat03.spec.TaskState_v0_3; -import org.a2aproject.sdk.compat03.spec.TaskStatus_v0_3; import org.a2aproject.sdk.compat03.spec.TaskStatusUpdateEvent_v0_3; +import org.a2aproject.sdk.compat03.spec.TaskStatus_v0_3; +import org.a2aproject.sdk.compat03.spec.Task_v0_3; import org.a2aproject.sdk.compat03.spec.TextPart_v0_3; import org.jspecify.annotations.Nullable; @@ -163,6 +165,9 @@ public static org.a2aproject.sdk.compat03.grpc.Message message(Message_v0_3 mess if (message.parts() != null) { builder.addAllContent(message.parts().stream().map(ToProto::part).collect(Collectors.toList())); } + if (message.extensions() != null) { + builder.addAllExtensions(message.extensions()); + } builder.setMetadata(struct(message.metadata())); return builder.build(); } @@ -266,7 +271,7 @@ private static org.a2aproject.sdk.compat03.grpc.FilePart filePart(FilePart_v0_3 org.a2aproject.sdk.compat03.grpc.FilePart.Builder builder = org.a2aproject.sdk.compat03.grpc.FilePart.newBuilder(); FileContent_v0_3 fileContent = filePart.file(); if (fileContent instanceof FileWithBytes_v0_3) { - builder.setFileWithBytes(ByteString.copyFrom(((FileWithBytes_v0_3) fileContent).bytes(), StandardCharsets.UTF_8)); + builder.setFileWithBytes(ByteString.copyFrom(Base64.getDecoder().decode(((FileWithBytes_v0_3) fileContent).bytes()))); } else if (fileContent instanceof FileWithUri_v0_3) { builder.setFileWithUri(((FileWithUri_v0_3) fileContent).uri()); } @@ -636,9 +641,11 @@ public static Struct struct(@Nullable Map map) { return structBuilder.build(); } - private static Value value(Object value) { + private static Value value(@Nullable Object value) { Value.Builder valueBuilder = Value.newBuilder(); - if (value instanceof String) { + if (value == null) { + valueBuilder.setNullValue(NullValue.NULL_VALUE); + } else if (value instanceof String) { valueBuilder.setStringValue((String) value); } else if (value instanceof Number) { valueBuilder.setNumberValue(((Number) value).doubleValue()); @@ -939,7 +946,7 @@ private static FilePart_v0_3 filePart(org.a2aproject.sdk.compat03.grpc.FilePartO @Nullable Map metadata) { String name = filePart.getName().isEmpty() ? null : filePart.getName(); if (filePart.hasFileWithBytes()) { - return new FilePart_v0_3(new FileWithBytes_v0_3(filePart.getMimeType(), name, filePart.getFileWithBytes().toStringUtf8()), metadata); + return new FilePart_v0_3(new FileWithBytes_v0_3(filePart.getMimeType(), name, Base64.getEncoder().encodeToString(filePart.getFileWithBytes().toByteArray())), metadata); } else if (filePart.hasFileWithUri()) { return new FilePart_v0_3(new FileWithUri_v0_3(filePart.getMimeType(), name, filePart.getFileWithUri()), metadata); } @@ -948,7 +955,7 @@ private static FilePart_v0_3 filePart(org.a2aproject.sdk.compat03.grpc.FilePartO private static DataPart_v0_3 dataPart(org.a2aproject.sdk.compat03.grpc.DataPartOrBuilder dataPart, @Nullable Map metadata) { - return new DataPart_v0_3(struct(dataPart.getData()), metadata); + return new DataPart_v0_3(dataStruct(dataPart.getData()), metadata); } private static @Nullable TaskStatus_v0_3 taskStatus(org.a2aproject.sdk.compat03.grpc.TaskStatusOrBuilder taskStatus) { @@ -1009,14 +1016,19 @@ private static DataPart_v0_3 dataPart(org.a2aproject.sdk.compat03.grpc.DataPartO if (struct == null || struct.getFieldsCount() == 0) { return null; } - return struct.getFieldsMap().entrySet().stream() - .collect(Collectors.toMap(Map.Entry::getKey, e -> value(e.getValue()))); + return dataStruct(struct); + } + + private static Map dataStruct(Struct struct) { + Map result = new LinkedHashMap<>(); + struct.getFieldsMap().forEach((key, field) -> result.put(key, value(field))); + return result; } private static @Nullable Object value(Value value) { switch (value.getKindCase()) { case STRUCT_VALUE: - return struct(value.getStructValue()); + return dataStruct(value.getStructValue()); case LIST_VALUE: return value.getListValue().getValuesList().stream() .map(FromProto::value) @@ -1028,6 +1040,7 @@ private static DataPart_v0_3 dataPart(org.a2aproject.sdk.compat03.grpc.DataPartO case STRING_VALUE: return value.getStringValue(); case NULL_VALUE: + return null; default: throw new InvalidRequestError_v0_3(); } diff --git a/compat-0.3/spec-grpc/src/test/java/org/a2aproject/sdk/compat03/grpc/utils/ProtoJsonData_v0_3_Test.java b/compat-0.3/spec-grpc/src/test/java/org/a2aproject/sdk/compat03/grpc/utils/ProtoJsonData_v0_3_Test.java new file mode 100644 index 000000000..69a9a91ce --- /dev/null +++ b/compat-0.3/spec-grpc/src/test/java/org/a2aproject/sdk/compat03/grpc/utils/ProtoJsonData_v0_3_Test.java @@ -0,0 +1,80 @@ +package org.a2aproject.sdk.compat03.grpc.utils; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNull; + +import java.util.Arrays; +import java.util.Base64; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import com.google.protobuf.ByteString; +import com.google.protobuf.ListValue; +import com.google.protobuf.NullValue; +import com.google.protobuf.Struct; +import com.google.protobuf.Value; +import org.a2aproject.sdk.compat03.grpc.DataPart; +import org.a2aproject.sdk.compat03.grpc.FilePart; +import org.a2aproject.sdk.compat03.grpc.Part; +import org.a2aproject.sdk.compat03.spec.DataPart_v0_3; +import org.a2aproject.sdk.compat03.spec.FilePart_v0_3; +import org.a2aproject.sdk.compat03.spec.FileWithBytes_v0_3; +import org.a2aproject.sdk.compat03.spec.Message_v0_3; +import org.a2aproject.sdk.compat03.spec.TextPart_v0_3; +import org.junit.jupiter.api.Test; + +class ProtoJsonData_v0_3_Test { + @Test + void convertsBinaryFilesUsingRawProtobufBytes() { + byte[] bytes = {0, (byte) 0xff, (byte) 0x80, 42}; + String encoded = Base64.getEncoder().encodeToString(bytes); + var outgoing = ProtoUtils_v0_3.ToProto.part(new FilePart_v0_3( + new FileWithBytes_v0_3("application/octet-stream", "binary", encoded))); + assertArrayEquals(bytes, outgoing.getFile().getFileWithBytes().toByteArray()); + + Part incoming = Part.newBuilder().setFile(FilePart.newBuilder() + .setMimeType("application/octet-stream").setFileWithBytes(ByteString.copyFrom(bytes))).build(); + var file = assertInstanceOf(FilePart_v0_3.class, ProtoUtils_v0_3.FromProto.part(incoming)); + assertEquals(encoded, assertInstanceOf(FileWithBytes_v0_3.class, file.file()).bytes()); + } + + @Test + void readsEmptyDataObjectWithoutTreatingItAsAbsent() { + Part incoming = Part.newBuilder().setData(DataPart.newBuilder().setData(Struct.getDefaultInstance())).build(); + var data = assertInstanceOf(DataPart_v0_3.class, ProtoUtils_v0_3.FromProto.part(incoming)); + assertEquals(Map.of(), data.data()); + } + + @Test + void preservesNestedEmptyObjectsAndJsonNullsInBothDirections() { + Value empty = Value.newBuilder().setStructValue(Struct.getDefaultInstance()).build(); + Value nil = Value.newBuilder().setNullValue(NullValue.NULL_VALUE).build(); + Struct fixture = Struct.newBuilder().putFields("empty", empty).putFields("nil", nil) + .putFields("items", Value.newBuilder().setListValue(ListValue.newBuilder() + .addValues(empty).addValues(nil)).build()).build(); + Map expected = new LinkedHashMap<>(); + expected.put("empty", Map.of()); + expected.put("nil", null); + expected.put("items", Arrays.asList(Map.of(), null)); + + var incoming = Part.newBuilder().setData(DataPart.newBuilder().setData(fixture)).setMetadata(fixture).build(); + var data = assertInstanceOf(DataPart_v0_3.class, ProtoUtils_v0_3.FromProto.part(incoming)); + assertEquals(expected, data.data()); + assertEquals(expected, data.metadata()); + var outgoing = ProtoUtils_v0_3.ToProto.part(new DataPart_v0_3(expected, expected)); + assertEquals(fixture, outgoing.getData().getData()); + assertEquals(fixture, outgoing.getMetadata()); + } + + @Test + void retainsAbsentMessageMetadataAndPreservesExtensions() { + var message = new Message_v0_3.Builder().role(Message_v0_3.Role.USER).messageId("message") + .parts(List.of(new TextPart_v0_3("hello"))).extensions(List.of("https://example.test/ext")).build(); + var proto = ProtoUtils_v0_3.ToProto.message(message); + assertEquals(List.of("https://example.test/ext"), proto.getExtensionsList()); + assertNull(ProtoUtils_v0_3.FromProto.message(proto).metadata()); + } +} diff --git a/compat-0.3/spec-grpc/src/test/java/org/a2aproject/sdk/compat03/grpc/utils/ToProto_v0_3_Test.java b/compat-0.3/spec-grpc/src/test/java/org/a2aproject/sdk/compat03/grpc/utils/ToProto_v0_3_Test.java index 7a27827e4..fbefc36c1 100644 --- a/compat-0.3/spec-grpc/src/test/java/org/a2aproject/sdk/compat03/grpc/utils/ToProto_v0_3_Test.java +++ b/compat-0.3/spec-grpc/src/test/java/org/a2aproject/sdk/compat03/grpc/utils/ToProto_v0_3_Test.java @@ -20,16 +20,16 @@ import org.a2aproject.sdk.compat03.spec.FileWithBytes_v0_3; import org.a2aproject.sdk.compat03.spec.FileWithUri_v0_3; import org.a2aproject.sdk.compat03.spec.HTTPAuthSecurityScheme_v0_3; -import org.a2aproject.sdk.compat03.spec.Message_v0_3; import org.a2aproject.sdk.compat03.spec.MessageSendConfiguration_v0_3; +import org.a2aproject.sdk.compat03.spec.Message_v0_3; import org.a2aproject.sdk.compat03.spec.PushNotificationAuthenticationInfo_v0_3; import org.a2aproject.sdk.compat03.spec.PushNotificationConfig_v0_3; -import org.a2aproject.sdk.compat03.spec.Task_v0_3; import org.a2aproject.sdk.compat03.spec.TaskArtifactUpdateEvent_v0_3; import org.a2aproject.sdk.compat03.spec.TaskPushNotificationConfig_v0_3; import org.a2aproject.sdk.compat03.spec.TaskState_v0_3; -import org.a2aproject.sdk.compat03.spec.TaskStatus_v0_3; import org.a2aproject.sdk.compat03.spec.TaskStatusUpdateEvent_v0_3; +import org.a2aproject.sdk.compat03.spec.TaskStatus_v0_3; +import org.a2aproject.sdk.compat03.spec.Task_v0_3; import org.a2aproject.sdk.compat03.spec.TextPart_v0_3; import org.junit.jupiter.api.Test; @@ -296,7 +296,7 @@ public void convertFilePartWithBytesRoundTrip() { assertEquals(true, proto.hasFile()); assertEquals("image/png", proto.getFile().getMimeType()); assertEquals("photo.png", proto.getFile().getName()); - assertEquals("aGVsbG8=", proto.getFile().getFileWithBytes().toStringUtf8()); + assertEquals("hello", proto.getFile().getFileWithBytes().toStringUtf8()); FilePart_v0_3 roundTripped = (FilePart_v0_3) ProtoUtils_v0_3.FromProto.part(proto); FileWithBytes_v0_3 fileContent = (FileWithBytes_v0_3) roundTripped.file(); assertEquals("image/png", fileContent.mimeType()); diff --git a/compat-0.3/spec/src/main/java/org/a2aproject/sdk/compat03/json/JsonUtil_v0_3.java b/compat-0.3/spec/src/main/java/org/a2aproject/sdk/compat03/json/JsonUtil_v0_3.java index 4239cd2e5..9c3dba8ab 100644 --- a/compat-0.3/spec/src/main/java/org/a2aproject/sdk/compat03/json/JsonUtil_v0_3.java +++ b/compat-0.3/spec/src/main/java/org/a2aproject/sdk/compat03/json/JsonUtil_v0_3.java @@ -13,6 +13,14 @@ import static org.a2aproject.sdk.compat03.spec.A2AErrorCodes_v0_3.TASK_NOT_FOUND_ERROR_CODE; import static org.a2aproject.sdk.compat03.spec.A2AErrorCodes_v0_3.UNSUPPORTED_OPERATION_ERROR_CODE; +import java.lang.reflect.ParameterizedType; +import java.lang.reflect.Type; +import java.time.OffsetDateTime; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; +import java.util.List; +import java.util.Map; + import com.google.gson.Gson; import com.google.gson.GsonBuilder; import com.google.gson.JsonSyntaxException; @@ -24,43 +32,38 @@ import com.google.gson.stream.JsonWriter; import org.a2aproject.sdk.compat03.spec.APIKeySecurityScheme_v0_3; import org.a2aproject.sdk.compat03.spec.AgentCapabilities_v0_3; -import org.a2aproject.sdk.compat03.spec.EventKind_v0_3; -import org.a2aproject.sdk.compat03.spec.JSONRPCResponse_v0_3; +import org.a2aproject.sdk.compat03.spec.AuthenticatedExtendedCardNotConfiguredError_v0_3; +import org.a2aproject.sdk.compat03.spec.ContentTypeNotSupportedError_v0_3; import org.a2aproject.sdk.compat03.spec.DataPart_v0_3; +import org.a2aproject.sdk.compat03.spec.EventKind_v0_3; import org.a2aproject.sdk.compat03.spec.FileContent_v0_3; import org.a2aproject.sdk.compat03.spec.FilePart_v0_3; import org.a2aproject.sdk.compat03.spec.FileWithBytes_v0_3; import org.a2aproject.sdk.compat03.spec.FileWithUri_v0_3; import org.a2aproject.sdk.compat03.spec.HTTPAuthSecurityScheme_v0_3; +import org.a2aproject.sdk.compat03.spec.InternalError_v0_3; +import org.a2aproject.sdk.compat03.spec.InvalidAgentResponseError_v0_3; +import org.a2aproject.sdk.compat03.spec.InvalidParamsError_v0_3; +import org.a2aproject.sdk.compat03.spec.InvalidRequestError_v0_3; +import org.a2aproject.sdk.compat03.spec.JSONParseError_v0_3; import org.a2aproject.sdk.compat03.spec.JSONRPCError_v0_3; +import org.a2aproject.sdk.compat03.spec.JSONRPCResponse_v0_3; import org.a2aproject.sdk.compat03.spec.Message_v0_3; +import org.a2aproject.sdk.compat03.spec.MethodNotFoundError_v0_3; import org.a2aproject.sdk.compat03.spec.MutualTLSSecurityScheme_v0_3; import org.a2aproject.sdk.compat03.spec.OAuth2SecurityScheme_v0_3; import org.a2aproject.sdk.compat03.spec.OpenIdConnectSecurityScheme_v0_3; import org.a2aproject.sdk.compat03.spec.Part_v0_3; +import org.a2aproject.sdk.compat03.spec.PushNotificationNotSupportedError_v0_3; import org.a2aproject.sdk.compat03.spec.SecurityScheme_v0_3; import org.a2aproject.sdk.compat03.spec.StreamingEventKind_v0_3; -import org.a2aproject.sdk.compat03.spec.Task_v0_3; import org.a2aproject.sdk.compat03.spec.TaskArtifactUpdateEvent_v0_3; +import org.a2aproject.sdk.compat03.spec.TaskNotCancelableError_v0_3; +import org.a2aproject.sdk.compat03.spec.TaskNotFoundError_v0_3; import org.a2aproject.sdk.compat03.spec.TaskState_v0_3; import org.a2aproject.sdk.compat03.spec.TaskStatusUpdateEvent_v0_3; +import org.a2aproject.sdk.compat03.spec.Task_v0_3; import org.a2aproject.sdk.compat03.spec.TextPart_v0_3; -import java.lang.reflect.Type; -import java.time.OffsetDateTime; -import java.time.format.DateTimeFormatter; -import java.time.format.DateTimeParseException; -import java.util.List; -import org.a2aproject.sdk.compat03.spec.AuthenticatedExtendedCardNotConfiguredError_v0_3; -import org.a2aproject.sdk.compat03.spec.ContentTypeNotSupportedError_v0_3; -import org.a2aproject.sdk.compat03.spec.InternalError_v0_3; -import org.a2aproject.sdk.compat03.spec.InvalidAgentResponseError_v0_3; -import org.a2aproject.sdk.compat03.spec.InvalidParamsError_v0_3; -import org.a2aproject.sdk.compat03.spec.InvalidRequestError_v0_3; -import org.a2aproject.sdk.compat03.spec.JSONParseError_v0_3; -import org.a2aproject.sdk.compat03.spec.MethodNotFoundError_v0_3; -import org.a2aproject.sdk.compat03.spec.PushNotificationNotSupportedError_v0_3; -import org.a2aproject.sdk.compat03.spec.TaskNotCancelableError_v0_3; -import org.a2aproject.sdk.compat03.spec.TaskNotFoundError_v0_3; import org.a2aproject.sdk.compat03.spec.UnsupportedOperationError_v0_3; import org.jspecify.annotations.Nullable; @@ -71,6 +74,7 @@ public class JsonUtil_v0_3 { private static GsonBuilder createBaseGsonBuilder() { return new GsonBuilder() .setObjectToNumberStrategy(ToNumberPolicy.LONG_OR_DOUBLE) + .registerTypeAdapterFactory(new JsonMapTypeAdapterFactory()) .registerTypeAdapter(OffsetDateTime.class, new OffsetDateTimeTypeAdapter()) // Register JSONRPCError hierarchy adapter for all error subclasses .registerTypeAdapterFactory(new JSONRPCErrorTypeAdapterFactory()) @@ -583,6 +587,51 @@ public void write(JsonWriter out, Part_v0_3.Kind value) throws java.io.IOExcepti } } + /** Preserve null entries inside JSON objects without emitting absent protocol fields. */ + static class JsonMapTypeAdapterFactory implements TypeAdapterFactory { + @Override + public @Nullable TypeAdapter create(Gson gson, TypeToken type) { + if (!Map.class.isAssignableFrom(type.getRawType())) { + return null; + } + TypeAdapter delegate = gson.getDelegateAdapter(this, type); + Type valueType = type.getRawType() == Map.class + && type.getType() instanceof ParameterizedType parameterized + ? parameterized.getActualTypeArguments()[1] : Object.class; + return new TypeAdapter() { + @Override + public void write(JsonWriter out, T value) throws java.io.IOException { + if (value == null) { + out.nullValue(); + return; + } + out.beginObject(); + for (Map.Entry entry : ((Map) value).entrySet()) { + out.name(String.valueOf(entry.getKey())); + Object entryValue = entry.getValue(); + if (entryValue == null) { + boolean previous = out.getSerializeNulls(); + out.setSerializeNulls(true); + try { + out.nullValue(); + } finally { + out.setSerializeNulls(previous); + } + } else { + gson.toJson(entryValue, valueType, out); + } + } + out.endObject(); + } + + @Override + public T read(JsonReader in) throws java.io.IOException { + return delegate.read(in); + } + }; + } + } + /** * Gson TypeAdapter for serializing and deserializing {@link Part_v0_3} and its subclasses. *

diff --git a/compat-0.3/spec/src/main/java/org/a2aproject/sdk/compat03/spec/A2AClientHTTPError_v0_3.java b/compat-0.3/spec/src/main/java/org/a2aproject/sdk/compat03/spec/A2AClientHTTPError_v0_3.java index 40168cc2c..63dc20b05 100644 --- a/compat-0.3/spec/src/main/java/org/a2aproject/sdk/compat03/spec/A2AClientHTTPError_v0_3.java +++ b/compat-0.3/spec/src/main/java/org/a2aproject/sdk/compat03/spec/A2AClientHTTPError_v0_3.java @@ -1,16 +1,30 @@ package org.a2aproject.sdk.compat03.spec; +import java.util.List; +import java.util.Map; + import org.a2aproject.sdk.util.Assert; +import org.a2aproject.sdk.util.HttpHeaderUtils; +import org.jspecify.annotations.Nullable; public class A2AClientHTTPError_v0_3 extends A2AClientError_v0_3 { private final int code; private final String message; + private final @Nullable String responseBody; + private final Map> responseHeaders; public A2AClientHTTPError_v0_3(int code, String message, Object data) { - Assert.checkNotNullParam("code", code); + this(code, message, null, Map.of()); + } + + public A2AClientHTTPError_v0_3(int code, String message, @Nullable String responseBody, + Map> responseHeaders) { Assert.checkNotNullParam("message", message); + Assert.checkNotNullParam("responseHeaders", responseHeaders); this.code = code; this.message = message; + this.responseBody = responseBody; + this.responseHeaders = HttpHeaderUtils.copyOfCaseInsensitive(responseHeaders); } /** @@ -31,4 +45,12 @@ public int getCode() { public String getMessage() { return message; } + public @Nullable String getResponseBody() { + return responseBody; + } + + public Map> getResponseHeaders() { + return responseHeaders; + } + } diff --git a/compat-0.3/spec/src/main/java/org/a2aproject/sdk/compat03/spec/Artifact_v0_3.java b/compat-0.3/spec/src/main/java/org/a2aproject/sdk/compat03/spec/Artifact_v0_3.java index e10c267c1..cd8087034 100644 --- a/compat-0.3/spec/src/main/java/org/a2aproject/sdk/compat03/spec/Artifact_v0_3.java +++ b/compat-0.3/spec/src/main/java/org/a2aproject/sdk/compat03/spec/Artifact_v0_3.java @@ -3,6 +3,7 @@ import java.util.List; import java.util.Map; +import org.a2aproject.sdk.compat03.util.Utils_v0_3; import org.a2aproject.sdk.util.Assert; /** @@ -66,7 +67,7 @@ public Builder parts(Part_v0_3... parts) { } public Builder metadata(Map metadata) { - this.metadata = Map.copyOf(metadata); + this.metadata = Utils_v0_3.copyJsonMap(metadata); return this; } diff --git a/compat-0.3/spec/src/main/java/org/a2aproject/sdk/compat03/spec/DataPart_v0_3.java b/compat-0.3/spec/src/main/java/org/a2aproject/sdk/compat03/spec/DataPart_v0_3.java index f18d784a3..9f198b43e 100644 --- a/compat-0.3/spec/src/main/java/org/a2aproject/sdk/compat03/spec/DataPart_v0_3.java +++ b/compat-0.3/spec/src/main/java/org/a2aproject/sdk/compat03/spec/DataPart_v0_3.java @@ -2,6 +2,7 @@ import java.util.Map; +import org.a2aproject.sdk.compat03.util.Utils_v0_3; import org.a2aproject.sdk.util.Assert; import org.jspecify.annotations.Nullable; @@ -17,8 +18,8 @@ public DataPart_v0_3(Map data, @Nullable Map met if (kind != Kind.DATA) { throw new IllegalArgumentException("Invalid DataPart kind: " + kind); } - this.data = Map.copyOf(data); - this.metadata = metadata == null ? Map.of() : Map.copyOf(metadata); + this.data = Utils_v0_3.copyJsonMap(data); + this.metadata = metadata == null ? Map.of() : Utils_v0_3.copyJsonMap(metadata); this.kind = kind; } diff --git a/compat-0.3/spec/src/main/java/org/a2aproject/sdk/compat03/spec/FilePart_v0_3.java b/compat-0.3/spec/src/main/java/org/a2aproject/sdk/compat03/spec/FilePart_v0_3.java index d3c89abdf..1db7dc657 100644 --- a/compat-0.3/spec/src/main/java/org/a2aproject/sdk/compat03/spec/FilePart_v0_3.java +++ b/compat-0.3/spec/src/main/java/org/a2aproject/sdk/compat03/spec/FilePart_v0_3.java @@ -2,6 +2,7 @@ import java.util.Map; +import org.a2aproject.sdk.compat03.util.Utils_v0_3; import org.a2aproject.sdk.util.Assert; import org.jspecify.annotations.Nullable; @@ -19,7 +20,7 @@ public FilePart_v0_3 (FileContent_v0_3 file, @Nullable Map metad throw new IllegalArgumentException("Invalid FilePart kind: " + kind); } this.file = file; - this.metadata = metadata == null ? Map.of() : Map.copyOf(metadata); + this.metadata = metadata == null ? Map.of() : Utils_v0_3.copyJsonMap(metadata); this.kind = kind; } diff --git a/compat-0.3/spec/src/main/java/org/a2aproject/sdk/compat03/spec/Message_v0_3.java b/compat-0.3/spec/src/main/java/org/a2aproject/sdk/compat03/spec/Message_v0_3.java index 474d47aff..f15199480 100644 --- a/compat-0.3/spec/src/main/java/org/a2aproject/sdk/compat03/spec/Message_v0_3.java +++ b/compat-0.3/spec/src/main/java/org/a2aproject/sdk/compat03/spec/Message_v0_3.java @@ -4,6 +4,7 @@ import java.util.Map; import java.util.UUID; +import org.a2aproject.sdk.compat03.util.Utils_v0_3; import org.a2aproject.sdk.util.Assert; import org.jspecify.annotations.Nullable; @@ -42,7 +43,7 @@ public Message_v0_3(Role role, List> parts, @Nullable String messag this.contextId = contextId; this.taskId = taskId; this.referenceTaskIds = referenceTaskIds != null ? List.copyOf(referenceTaskIds) : null; - this.metadata = metadata != null ? Map.copyOf(metadata) : null; + this.metadata = metadata != null ? Utils_v0_3.copyJsonMap(metadata) : null; this.extensions = extensions != null ? List.copyOf(extensions) : null; } diff --git a/compat-0.3/spec/src/main/java/org/a2aproject/sdk/compat03/spec/TaskArtifactUpdateEvent_v0_3.java b/compat-0.3/spec/src/main/java/org/a2aproject/sdk/compat03/spec/TaskArtifactUpdateEvent_v0_3.java index 64c8dc0c5..c981cb097 100644 --- a/compat-0.3/spec/src/main/java/org/a2aproject/sdk/compat03/spec/TaskArtifactUpdateEvent_v0_3.java +++ b/compat-0.3/spec/src/main/java/org/a2aproject/sdk/compat03/spec/TaskArtifactUpdateEvent_v0_3.java @@ -2,6 +2,7 @@ import java.util.Map; +import org.a2aproject.sdk.compat03.util.Utils_v0_3; import org.a2aproject.sdk.util.Assert; import org.jspecify.annotations.Nullable; @@ -35,7 +36,7 @@ public TaskArtifactUpdateEvent_v0_3 (String taskId, @Nullable Boolean append, @N this.lastChunk = lastChunk; this.artifact = artifact; this.contextId = contextId; - this.metadata = metadata != null ? Map.copyOf(metadata) : null; + this.metadata = metadata != null ? Utils_v0_3.copyJsonMap(metadata) : null; } public TaskArtifactUpdateEvent_v0_3(String taskId, Artifact_v0_3 artifact, String contextId, diff --git a/compat-0.3/spec/src/main/java/org/a2aproject/sdk/compat03/spec/TaskStatusUpdateEvent_v0_3.java b/compat-0.3/spec/src/main/java/org/a2aproject/sdk/compat03/spec/TaskStatusUpdateEvent_v0_3.java index d527815b3..69ea07e04 100644 --- a/compat-0.3/spec/src/main/java/org/a2aproject/sdk/compat03/spec/TaskStatusUpdateEvent_v0_3.java +++ b/compat-0.3/spec/src/main/java/org/a2aproject/sdk/compat03/spec/TaskStatusUpdateEvent_v0_3.java @@ -1,8 +1,9 @@ package org.a2aproject.sdk.compat03.spec; -import com.google.gson.annotations.SerializedName; import java.util.Map; +import com.google.gson.annotations.SerializedName; +import org.a2aproject.sdk.compat03.util.Utils_v0_3; import org.a2aproject.sdk.util.Assert; import org.jspecify.annotations.Nullable; @@ -34,7 +35,7 @@ public TaskStatusUpdateEvent_v0_3 (String taskId, TaskStatus_v0_3 status, String this.status = status; this.contextId = contextId; this.isFinal = isFinal; - this.metadata = metadata != null ? Map.copyOf(metadata) : null; + this.metadata = metadata != null ? Utils_v0_3.copyJsonMap(metadata) : null; } public TaskStatusUpdateEvent_v0_3(String taskId, TaskStatus_v0_3 status, String contextId, boolean isFinal, diff --git a/compat-0.3/spec/src/main/java/org/a2aproject/sdk/compat03/spec/Task_v0_3.java b/compat-0.3/spec/src/main/java/org/a2aproject/sdk/compat03/spec/Task_v0_3.java index 3b0a28cdc..4892caefb 100644 --- a/compat-0.3/spec/src/main/java/org/a2aproject/sdk/compat03/spec/Task_v0_3.java +++ b/compat-0.3/spec/src/main/java/org/a2aproject/sdk/compat03/spec/Task_v0_3.java @@ -3,6 +3,7 @@ import java.util.List; import java.util.Map; +import org.a2aproject.sdk.compat03.util.Utils_v0_3; import org.a2aproject.sdk.util.Assert; import org.jspecify.annotations.Nullable; @@ -35,7 +36,7 @@ public Task_v0_3 (String id, String contextId, TaskStatus_v0_3 status, List artifacts, diff --git a/compat-0.3/spec/src/main/java/org/a2aproject/sdk/compat03/spec/TextPart_v0_3.java b/compat-0.3/spec/src/main/java/org/a2aproject/sdk/compat03/spec/TextPart_v0_3.java index 70f8563c7..5f156bdde 100644 --- a/compat-0.3/spec/src/main/java/org/a2aproject/sdk/compat03/spec/TextPart_v0_3.java +++ b/compat-0.3/spec/src/main/java/org/a2aproject/sdk/compat03/spec/TextPart_v0_3.java @@ -2,6 +2,7 @@ import java.util.Map; +import org.a2aproject.sdk.compat03.util.Utils_v0_3; import org.a2aproject.sdk.util.Assert; import org.jspecify.annotations.Nullable; @@ -18,7 +19,7 @@ public TextPart_v0_3 (String text, @Nullable Map metadata, Kind throw new IllegalArgumentException("Invalid TextPart kind: " + kind); } this.text = text; - this.metadata = metadata == null ? Map.of() : Map.copyOf(metadata); + this.metadata = metadata == null ? Map.of() : Utils_v0_3.copyJsonMap(metadata); this.kind = kind; } diff --git a/compat-0.3/spec/src/main/java/org/a2aproject/sdk/compat03/util/Utils_v0_3.java b/compat-0.3/spec/src/main/java/org/a2aproject/sdk/compat03/util/Utils_v0_3.java index f12830974..da85a6e5b 100644 --- a/compat-0.3/spec/src/main/java/org/a2aproject/sdk/compat03/util/Utils_v0_3.java +++ b/compat-0.3/spec/src/main/java/org/a2aproject/sdk/compat03/util/Utils_v0_3.java @@ -1,16 +1,18 @@ package org.a2aproject.sdk.compat03.util; import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; +import java.util.logging.Logger; import org.a2aproject.sdk.compat03.json.JsonProcessingException_v0_3; import org.a2aproject.sdk.compat03.json.JsonUtil_v0_3; - import org.a2aproject.sdk.compat03.spec.Artifact_v0_3; -import org.a2aproject.sdk.compat03.spec.Task_v0_3; -import org.a2aproject.sdk.compat03.spec.TaskArtifactUpdateEvent_v0_3; import org.a2aproject.sdk.compat03.spec.Part_v0_3; -import java.util.logging.Logger; +import org.a2aproject.sdk.compat03.spec.TaskArtifactUpdateEvent_v0_3; +import org.a2aproject.sdk.compat03.spec.Task_v0_3; @@ -35,6 +37,11 @@ public class Utils_v0_3 { private static final Logger LOGGER = Logger.getLogger(Utils_v0_3.class.getName()); + /** Returns an unmodifiable shallow copy that preserves JSON null values. */ + public static Map copyJsonMap(Map map) { + return Collections.unmodifiableMap(new LinkedHashMap<>(map)); + } + public static String toJsonString(Object data) { try { return JsonUtil_v0_3.toJson(data); diff --git a/compat-0.3/spec/src/test/java/org/a2aproject/sdk/compat03/json/JsonNullValues_v0_3_Test.java b/compat-0.3/spec/src/test/java/org/a2aproject/sdk/compat03/json/JsonNullValues_v0_3_Test.java new file mode 100644 index 000000000..303df2840 --- /dev/null +++ b/compat-0.3/spec/src/test/java/org/a2aproject/sdk/compat03/json/JsonNullValues_v0_3_Test.java @@ -0,0 +1,89 @@ +package org.a2aproject.sdk.compat03.json; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import com.google.gson.JsonParser; +import org.a2aproject.sdk.compat03.spec.Artifact_v0_3; +import org.a2aproject.sdk.compat03.spec.DataPart_v0_3; +import org.a2aproject.sdk.compat03.spec.FilePart_v0_3; +import org.a2aproject.sdk.compat03.spec.FileWithBytes_v0_3; +import org.a2aproject.sdk.compat03.spec.Message_v0_3; +import org.a2aproject.sdk.compat03.spec.TaskArtifactUpdateEvent_v0_3; +import org.a2aproject.sdk.compat03.spec.TaskState_v0_3; +import org.a2aproject.sdk.compat03.spec.TaskStatusUpdateEvent_v0_3; +import org.a2aproject.sdk.compat03.spec.TaskStatus_v0_3; +import org.a2aproject.sdk.compat03.spec.Task_v0_3; +import org.a2aproject.sdk.compat03.spec.TextPart_v0_3; +import org.junit.jupiter.api.Test; + +class JsonNullValues_v0_3_Test { + @Test + void preservesNullObjectEntriesAndOmitsAbsentProtocolFields() throws Exception { + String fixture = """ + {"kind":"message","role":"user","messageId":"message", + "parts":[{"kind":"data","data":{"nil":null,"nested":{"nil":null},"empty":{}}, + "metadata":{"nil":null}}],"metadata":{"nil":null}} + """; + Message_v0_3 message = JsonUtil_v0_3.fromJson(fixture, Message_v0_3.class); + var json = JsonParser.parseString(JsonUtil_v0_3.toJson(message)).getAsJsonObject(); + var part = json.getAsJsonArray("parts").get(0).getAsJsonObject(); + assertEquals(JsonParser.parseString("{\"nil\":null,\"nested\":{\"nil\":null},\"empty\":{}}"), part.get("data")); + assertTrue(part.getAsJsonObject("metadata").get("nil").isJsonNull()); + assertTrue(json.getAsJsonObject("metadata").get("nil").isJsonNull()); + assertFalse(json.has("taskId")); + assertFalse(json.has("contextId")); + } + + @Test + void preservesNullMetadataAcrossPartsTasksArtifactsAndEvents() { + Map metadata = new LinkedHashMap<>(); + metadata.put("nil", null); + var text = new TextPart_v0_3("hello", metadata); + var file = new FilePart_v0_3(new FileWithBytes_v0_3("text/plain", "file", "aGVsbG8="), metadata); + var status = new TaskStatus_v0_3(TaskState_v0_3.WORKING); + var artifact = new Artifact_v0_3.Builder().artifactId("artifact").parts(text).metadata(metadata).build(); + var task = new Task_v0_3("task", "context", status, List.of(artifact), List.of(), metadata); + var statusEvent = new TaskStatusUpdateEvent_v0_3("task", status, "context", false, metadata); + var artifactEvent = new TaskArtifactUpdateEvent_v0_3("task", artifact, "context", false, false, metadata); + for (Map copy : List.of(text.metadata(), file.metadata(), artifact.metadata(), task.metadata(), + statusEvent.metadata(), artifactEvent.metadata())) { + assertEquals(metadata, copy); + assertThrows(UnsupportedOperationException.class, () -> copy.put("change", true)); + } + metadata.put("later", "change"); + assertEquals(1, artifact.metadata().size()); + assertEquals(1, task.metadata().size()); + } + + @Test + void doesNotEmitAbsentFieldsInObjectsStoredInMaps() throws Exception { + var json = JsonParser.parseString(JsonUtil_v0_3.toJson(Map.of("value", new OptionalFields("present", null)))) + .getAsJsonObject(); + assertEquals("present", json.getAsJsonObject("value").get("required").getAsString()); + assertFalse(json.getAsJsonObject("value").has("optional")); + } + + private record OptionalFields(String required, String optional) { + } + + @Test + void defensivelyCopiesMapsWithoutRejectingNullValues() { + Map values = new LinkedHashMap<>(); + values.put("nil", null); + var data = new DataPart_v0_3(values, values); + var message = new Message_v0_3.Builder().role(Message_v0_3.Role.USER).messageId("message") + .parts(List.of(data)).metadata(values).build(); + values.put("later", "change"); + assertEquals(1, data.data().size()); + assertEquals(1, data.metadata().size()); + assertEquals(1, message.metadata().size()); + assertThrows(UnsupportedOperationException.class, () -> data.data().put("change", true)); + } +} diff --git a/compat-0.3/transport/rest/src/main/java/org/a2aproject/sdk/compat03/transport/rest/handler/RestHandler_v0_3.java b/compat-0.3/transport/rest/src/main/java/org/a2aproject/sdk/compat03/transport/rest/handler/RestHandler_v0_3.java index 8a8443d2d..1d3981e05 100644 --- a/compat-0.3/transport/rest/src/main/java/org/a2aproject/sdk/compat03/transport/rest/handler/RestHandler_v0_3.java +++ b/compat-0.3/transport/rest/src/main/java/org/a2aproject/sdk/compat03/transport/rest/handler/RestHandler_v0_3.java @@ -2,22 +2,27 @@ import static org.a2aproject.sdk.server.util.async.AsyncUtils.createTubeConfig; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.Executor; +import java.util.concurrent.Flow; +import java.util.logging.Level; +import java.util.logging.Logger; + import com.google.gson.JsonParser; import com.google.gson.JsonSyntaxException; import com.google.protobuf.InvalidProtocolBufferException; import com.google.protobuf.util.JsonFormat; -import org.a2aproject.sdk.compat03.grpc.utils.ProtoJsonUtils_v0_3; -import org.a2aproject.sdk.compat03.grpc.utils.ProtoUtils_v0_3; import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.inject.Instance; import jakarta.inject.Inject; - -import java.util.List; -import java.util.Map; -import java.util.concurrent.Flow; - -import org.a2aproject.sdk.server.ExtendedAgentCard; -import org.a2aproject.sdk.server.PublicAgentCard; -import org.a2aproject.sdk.server.ServerCallContext; +import mutiny.zero.ZeroPublisher; +import org.a2aproject.sdk.compat03.conversion.Convert_v0_3_To10RequestHandler; +import org.a2aproject.sdk.compat03.conversion.ErrorConverter_v0_3; +import org.a2aproject.sdk.compat03.grpc.utils.ProtoJsonUtils_v0_3; +import org.a2aproject.sdk.compat03.grpc.utils.ProtoUtils_v0_3; +import org.a2aproject.sdk.compat03.json.JsonUtil_v0_3; import org.a2aproject.sdk.compat03.spec.AgentCard_v0_3; import org.a2aproject.sdk.compat03.spec.AuthenticatedExtendedCardNotConfiguredError_v0_3; import org.a2aproject.sdk.compat03.spec.ContentTypeNotSupportedError_v0_3; @@ -34,24 +39,18 @@ import org.a2aproject.sdk.compat03.spec.MethodNotFoundError_v0_3; import org.a2aproject.sdk.compat03.spec.PushNotificationNotSupportedError_v0_3; import org.a2aproject.sdk.compat03.spec.StreamingEventKind_v0_3; -import org.a2aproject.sdk.compat03.spec.Task_v0_3; import org.a2aproject.sdk.compat03.spec.TaskIdParams_v0_3; import org.a2aproject.sdk.compat03.spec.TaskNotCancelableError_v0_3; import org.a2aproject.sdk.compat03.spec.TaskNotFoundError_v0_3; import org.a2aproject.sdk.compat03.spec.TaskPushNotificationConfig_v0_3; import org.a2aproject.sdk.compat03.spec.TaskQueryParams_v0_3; +import org.a2aproject.sdk.compat03.spec.Task_v0_3; import org.a2aproject.sdk.compat03.spec.UnsupportedOperationError_v0_3; +import org.a2aproject.sdk.server.ExtendedAgentCard; +import org.a2aproject.sdk.server.PublicAgentCard; +import org.a2aproject.sdk.server.ServerCallContext; import org.a2aproject.sdk.server.util.async.Internal; -import org.a2aproject.sdk.compat03.json.JsonUtil_v0_3; -import org.a2aproject.sdk.compat03.conversion.Convert_v0_3_To10RequestHandler; -import org.a2aproject.sdk.compat03.conversion.ErrorConverter_v0_3; import org.a2aproject.sdk.spec.A2AError; -import jakarta.enterprise.inject.Instance; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.Executor; -import java.util.logging.Level; -import java.util.logging.Logger; -import mutiny.zero.ZeroPublisher; import org.jspecify.annotations.Nullable; @ApplicationScoped @@ -142,12 +141,34 @@ public HTTPRestResponse cancelTask(String taskId, ServerCallContext context) { } public HTTPRestResponse setTaskPushNotificationConfiguration(String taskId, String body, ServerCallContext context) { + return setTaskPushNotificationConfiguration(taskId, body, null, context); + } + + public HTTPRestResponse setTaskPushNotificationConfiguration(String taskId, String body, + @Nullable String configId, ServerCallContext context) { try { if (!agentCard.capabilities().pushNotifications()) { throw new PushNotificationNotSupportedError_v0_3(); } org.a2aproject.sdk.compat03.grpc.CreateTaskPushNotificationConfigRequest.Builder builder = org.a2aproject.sdk.compat03.grpc.CreateTaskPushNotificationConfigRequest.newBuilder(); - parseRequestBody(body, builder); + validate(body); + var json = JsonParser.parseString(body); + if (json.isJsonObject() && json.getAsJsonObject().has("config")) { + // Accept the envelope sent by earlier SDK clients. + parseRequestBody(body, builder); + } else { + var config = org.a2aproject.sdk.compat03.grpc.TaskPushNotificationConfig.newBuilder(); + parseRequestBody(body, config); + if (config.getName().isEmpty()) { + config.setName("tasks/" + taskId + "/pushNotificationConfigs"); + } + builder.setParent("tasks/" + taskId).setConfig(config); + } + if (configId != null && !configId.isEmpty()) { + builder.setConfigId(configId); + builder.getConfigBuilder().setName("tasks/" + taskId + "/pushNotificationConfigs/" + configId); + builder.getConfigBuilder().getPushNotificationConfigBuilder().setId(configId); + } TaskPushNotificationConfig_v0_3 result = requestHandler.onSetTaskPushNotificationConfig(ProtoUtils_v0_3.FromProto.taskPushNotificationConfig(builder), context); return createSuccessResponse(201, org.a2aproject.sdk.compat03.grpc.TaskPushNotificationConfig.newBuilder(ProtoUtils_v0_3.ToProto.taskPushNotificationConfig(result))); } catch (A2AError e) { diff --git a/compat-0.3/transport/rest/src/test/java/org/a2aproject/sdk/compat03/transport/rest/handler/RestHandler_v0_3_Test.java b/compat-0.3/transport/rest/src/test/java/org/a2aproject/sdk/compat03/transport/rest/handler/RestHandler_v0_3_Test.java index 032d50503..12992cca3 100644 --- a/compat-0.3/transport/rest/src/test/java/org/a2aproject/sdk/compat03/transport/rest/handler/RestHandler_v0_3_Test.java +++ b/compat-0.3/transport/rest/src/test/java/org/a2aproject/sdk/compat03/transport/rest/handler/RestHandler_v0_3_Test.java @@ -1,25 +1,25 @@ package org.a2aproject.sdk.compat03.transport.rest.handler; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + import java.util.HashSet; import java.util.Map; import org.a2aproject.sdk.compat03.conversion.A2AProtocol_v0_3; import org.a2aproject.sdk.compat03.conversion.AbstractA2ARequestHandlerTest_v0_3; -import org.a2aproject.sdk.server.requesthandlers.LogCaptureAssertions; import org.a2aproject.sdk.compat03.conversion.Convert_v0_3_To10RequestHandler; import org.a2aproject.sdk.compat03.conversion.mappers.domain.TaskMapper_v0_3; import org.a2aproject.sdk.compat03.spec.AgentCapabilities_v0_3; import org.a2aproject.sdk.compat03.spec.AgentCard_v0_3; import org.a2aproject.sdk.server.ServerCallContext; import org.a2aproject.sdk.server.auth.UnauthenticatedUser; +import org.a2aproject.sdk.server.requesthandlers.LogCaptureAssertions; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertInstanceOf; -import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.junit.jupiter.api.Assertions.assertTrue; - /** * Test suite for v0.3 RestHandler with v1.0 backend. *

@@ -40,6 +40,23 @@ public class RestHandler_v0_3_Test extends AbstractA2ARequestHandlerTest_v0_3 { // GetTask Tests // ======================================== + @Test + public void testSetPushNotificationConfigWithSchemaBody() throws Exception { + RestHandler_v0_3 handler = new RestHandler_v0_3(CARD, internalExecutor, convert03To10Handler); + taskStore.save(TaskMapper_v0_3.INSTANCE.toV10(MINIMAL_TASK), false); + String body = """ + {"pushNotificationConfig":{"id":"body-id","url":"https://example.com/callback"}} + """; + RestHandler_v0_3.HTTPRestResponse response = handler.setTaskPushNotificationConfiguration( + MINIMAL_TASK.id(), body, "requested-id", callContext); + assertEquals(201, response.getStatusCode(), response.toString()); + assertNotNull(response.getBody()); + var json = com.google.gson.JsonParser.parseString(response.getBody()).getAsJsonObject(); + assertEquals("tasks/" + MINIMAL_TASK.id() + "/pushNotificationConfigs/requested-id", json.get("name").getAsString()); + assertEquals("requested-id", json.getAsJsonObject("pushNotificationConfig").get("id").getAsString()); + assertEquals("application/json", response.getContentType()); + } + @Test public void testGetTaskSuccess() { RestHandler_v0_3 handler = new RestHandler_v0_3(CARD, internalExecutor, convert03To10Handler); diff --git a/docs/content/dev/client.md b/docs/content/dev/client.md index 5dcd64ce6..5bef2f9ce 100644 --- a/docs/content/dev/client.md +++ b/docs/content/dev/client.md @@ -248,7 +248,12 @@ Add distributed tracing and W3C Trace Context propagation to client calls with t ## Communicating with v0.3 Agents -See [Backward Compatibility](compatibility#client-communicating-with-v03-agents) for using `Client_v0_3` with older protocol agents. +The SDK supports two client APIs for communicating with v0.3 agents: + +- The recommended unified `Client` API uses the normal v1.0 types and an optional v0.3 compatibility adapter. See [Backward Compatibility](compatibility#client-communicating-with-v03-agents) for setup and supported operations. +- The legacy `Client_v0_3` API remains available for applications that already use v0.3 domain types and transport APIs directly. + +New applications should prefer the unified client; existing applications can continue using `Client_v0_3` without migrating immediately. ## Examples diff --git a/docs/content/dev/compatibility.md b/docs/content/dev/compatibility.md index 09ec47190..171a042a3 100644 --- a/docs/content/dev/compatibility.md +++ b/docs/content/dev/compatibility.md @@ -89,32 +89,69 @@ Push notification payloads are automatically formatted to match the protocol ver ## Client: Communicating with v0.3 Agents -Use `Client_v0_3` to communicate with agents that only support protocol v0.3: +The normal concrete 1.0 `Client` can communicate with a 0.3-only agent when +legacy support is explicitly requested during agent-card discovery. The +compatibility parser and one binding adapter are optional dependencies: ```xml org.a2aproject.sdk - a2a-java-sdk-compat-0.3-client + a2a-java-sdk-compat-0.3-client-adapter $\{org.a2aproject.sdk.version} org.a2aproject.sdk - a2a-java-sdk-compat-0.3-client-transport-jsonrpc + a2a-java-sdk-compat-0.3-client-adapter-jsonrpc $\{org.a2aproject.sdk.version} ``` -gRPC and REST transports are also available: -- `a2a-java-sdk-compat-0.3-client-transport-grpc` -- `a2a-java-sdk-compat-0.3-client-transport-rest` +Use `a2a-java-sdk-compat-0.3-client-adapter-rest` for REST or +`a2a-java-sdk-compat-0.3-client-adapter-grpc` for gRPC instead. ```java -// getAgentCard() handles agent card discovery internally -AgentCard_v0_3 agentCard = A2A_v0_3.getAgentCard("http://localhost:1234"); +AgentCard agentCard = A2A.getAgentCard( + "http://localhost:1234", Set.of("1.0", "0.3")); -Client_v0_3 client = Client_v0_3.builder(agentCard) - .withTransport(JSONRPCTransport_v0_3.class, new JSONRPCTransportConfigBuilder_v0_3()) +Client client = Client.builder(agentCard) + .withTransport(JSONRPCTransport.class, new JSONRPCTransportConfigBuilder() + .httpClient(A2AHttpClientFactory.create()) + .build()) .build(); ``` -**Note:** `Client_v0_3` exposes only operations available in protocol v0.3. For example, `listTasks()` is not available (it was added in v1.0). Return types use v0.3 domain objects from the `org.a2aproject.sdk.compat03.spec` package. +The returned card contains a 1.0 `AgentInterface` whose protocol version is +`"0.3"`, so the ordinary builder selects the matching optional adapter through +the versioned transport-provider SPI. With the default server preference, +the builder selects the first usable interface in the card's order. Requesting +both versions does not give 1.0 priority over an earlier 0.3 interface. With +client transport preference enabled, the builder first restricts selection to +usable 1.0 interfaces if any configured binding provides one, then applies +configured binding order. It considers 0.3 only when no such 1.0 interface is +available. + +The adapter rejects 1.0 operations that have no 0.3 equivalent (such as +`listTasks`), non-empty tenant values, extended-agent-card retrieval, and +non-default push-configuration pagination before any network request. Generic +1.0 transport parameters are also unsupported for 0.3 adapters. REST and gRPC +also reject non-empty `Message.referenceTaskIds` and cancellation metadata, +which the 0.3 protobuf schema cannot represent. JSON-RPC preserves these fields. +Validation also applies to requests modified by interceptors. + +JSON-RPC retains the original JSON numbers in fields whose protobuf values +interceptors leave unchanged. Interceptors see protobuf doubles. Fields with +identical protobuf values are treated as unchanged, including replacements or +reordering of numbers with the same double representation. Such mutations are +unsupported; use a string for +an identifier that must be edited with exact precision. If an interceptor edits +an array containing numbers that protobuf cannot represent exactly, the adapter +rejects the request unless both the original and modified array contain a single +element. This also applies to message parts and arrays in data or metadata. +Unchanged arrays retain their original values. REST and gRPC use protobuf +numeric precision throughout. + +If 0.3 is not requested, the optional parser is not used. If it is requested +but the parser or binding adapter is absent, discovery or client construction +fails with an actionable error identifying the missing optional artifact. +Client-only applications do not need to depend on 0.3 domain types, server +libraries, CDI, Quarkus, or reference-server modules. diff --git a/extras/http-client-vertx/pom.xml b/extras/http-client-vertx/pom.xml index 301d19eea..968964b99 100644 --- a/extras/http-client-vertx/pom.xml +++ b/extras/http-client-vertx/pom.xml @@ -33,6 +33,14 @@ vertx-web-client provided + + jakarta.enterprise + jakarta.enterprise.cdi-api + + + jakarta.inject + jakarta.inject-api + org.junit.jupiter diff --git a/http-client/src/main/java/org/a2aproject/sdk/client/http/A2ACardResolver.java b/http-client/src/main/java/org/a2aproject/sdk/client/http/A2ACardResolver.java index 90b87c992..00060f0bd 100644 --- a/http-client/src/main/java/org/a2aproject/sdk/client/http/A2ACardResolver.java +++ b/http-client/src/main/java/org/a2aproject/sdk/client/http/A2ACardResolver.java @@ -4,6 +4,9 @@ import java.net.URISyntaxException; import java.util.HashMap; import java.util.Map; +import java.util.Optional; +import java.util.ServiceLoader; +import java.util.Set; import static org.a2aproject.sdk.util.Assert.checkNotNullParam; @@ -85,8 +88,12 @@ public class A2ACardResolver { private final String cardUrl; private final @Nullable String fallbackUrl; private final @Nullable Map authHeaders; + private final Set supportedProtocolVersions; + private final boolean protocolVersionsExplicitlySet; - private A2ACardResolver(A2AHttpClient httpClient, String baseUrl, @Nullable String tenant, @Nullable String agentCardPath, @Nullable Map authHeaders) throws A2AClientError { + private A2ACardResolver(A2AHttpClient httpClient, String baseUrl, @Nullable String tenant, + @Nullable String agentCardPath, @Nullable Map authHeaders, + Set supportedProtocolVersions, boolean protocolVersionsExplicitlySet) throws A2AClientError { checkNotNullParam("httpClient", httpClient); checkNotNullParam("baseUrl", baseUrl); this.httpClient = httpClient; @@ -101,6 +108,8 @@ private A2ACardResolver(A2AHttpClient httpClient, String baseUrl, @Nullable Stri throw new A2AClientError("Invalid agent URL", e); } this.authHeaders = authHeaders != null ? Map.copyOf(authHeaders) : null; + this.supportedProtocolVersions = Set.copyOf(supportedProtocolVersions); + this.protocolVersionsExplicitlySet = protocolVersionsExplicitlySet; LOGGER.debug("Initialized A2ACardResolver with cardUrl={}", cardUrl); } @@ -123,6 +132,8 @@ public static class Builder { private @Nullable String tenant; private @Nullable String agentCardPath; private @Nullable Map authHeaders; + private Set supportedProtocolVersions = Set.of("1.0"); + private boolean protocolVersionsExplicitlySet; private Builder() { } @@ -201,6 +212,30 @@ public Builder authHeader(String name, String value) { return this; } + /** + * Sets the protocol versions this resolver is allowed to discover. + * Calling this method makes protocol selection explicit. When omitted, discovery defaults + * to v1.0 but returns a parsed card with no supported interfaces so legacy callers can + * select their v0.3 client. + * + * @param supportedProtocolVersions non-empty set of supported versions, such as {@code 1.0} + * or {@code 0.3}; patch forms are normalized + * @return this builder + * @throws IllegalArgumentException if the set is empty or contains an unsupported version + */ + public Builder supportedProtocolVersions(Set supportedProtocolVersions) { + checkNotNullParam("supportedProtocolVersions", supportedProtocolVersions); + Set normalizedVersions = supportedProtocolVersions.stream() + .map(A2ACardResolver::normalizeSupportedProtocolVersion) + .collect(java.util.stream.Collectors.toUnmodifiableSet()); + if (normalizedVersions.isEmpty()) { + throw new IllegalArgumentException("supportedProtocolVersions must not be empty"); + } + this.supportedProtocolVersions = normalizedVersions; + this.protocolVersionsExplicitlySet = true; + return this; + } + /** * Builds the A2ACardResolver instance. * @@ -213,8 +248,20 @@ public A2ACardResolver build() throws A2AClientError { if (baseUrl == null) { throw new IllegalArgumentException("baseUrl must not be null"); } - return new A2ACardResolver(client, baseUrl, tenant, agentCardPath, authHeaders); + return new A2ACardResolver(client, baseUrl, tenant, agentCardPath, authHeaders, + supportedProtocolVersions, protocolVersionsExplicitlySet); + } + } + + public static String normalizeSupportedProtocolVersion(String version) { + if (version == null) { + throw new IllegalArgumentException("Protocol version must not be null"); } + return switch (version.trim()) { + case "1.0", "1.0.0" -> "1.0"; + case "0.3", "0.3.0" -> "0.3"; + default -> throw new IllegalArgumentException("Unsupported protocol version: " + version); + }; } /** @@ -309,12 +356,66 @@ private AgentCard fetchAgentCard(String url) throws A2AClientError, A2AClientJSO throw new A2AClientError("Failed to obtain agent card", e); } + AgentCard parsedV10Card = null; try { org.a2aproject.sdk.grpc.AgentCard.Builder agentCardBuilder = org.a2aproject.sdk.grpc.AgentCard.newBuilder(); JSONRPCUtils.parseJsonString(body, agentCardBuilder, "", true); - return ProtoUtils.FromProto.agentCard(agentCardBuilder); - } catch (JsonProcessingException e) { - throw new A2AClientJSONError("Could not unmarshal agent card response", e); + parsedV10Card = ProtoUtils.FromProto.agentCard(agentCardBuilder); + } catch (JsonProcessingException | RuntimeException e) { + if (!supportedProtocolVersions.contains("0.3")) { + throw new A2AClientJSONError("Could not unmarshal agent card response", e); + } + } + + if (parsedV10Card != null) { + if (parsedV10Card.supportedInterfaces().stream() + .anyMatch(i -> supportedProtocolVersions.contains(normalizeCardVersion(i.protocolVersion())))) { + return filterInterfaces(parsedV10Card); + } + // A successfully parsed v1 card is authoritative. Do not reinterpret a valid v1 card + // as a legacy card merely because it does not advertise a requested version. + if (!parsedV10Card.supportedInterfaces().isEmpty()) { + throw new A2AClientJSONError("Agent card does not expose a requested protocol version"); + } + // The default discovery API predates protocol-version selection. Preserve its + // legacy signal: callers can detect an empty interface list and use their v0.3 client. + if (!protocolVersionsExplicitlySet) { + return parsedV10Card; + } + } + + if (supportedProtocolVersions.contains("0.3")) { + boolean parserAvailable = false; + for (AgentCardCompatibilityParser parser : ServiceLoader.load(AgentCardCompatibilityParser.class)) { + if ("0.3".equals(normalizeSupportedProtocolVersion(parser.supportedProtocolVersion()))) { + parserAvailable = true; + Optional parsed = parser.parse(body, parsedV10Card, supportedProtocolVersions); + if (parsed.isPresent()) { + return filterInterfaces(parsed.get()); + } + } + } + if (parserAvailable) { + throw new A2AClientJSONError("Agent card does not expose a requested protocol version"); + } + throw new A2AClientJSONError( + "Agent card requires the optional a2a-java-sdk-compat-0.3-client-adapter artifact"); + } + throw new A2AClientJSONError("Agent card does not expose a requested protocol version"); + } + + private AgentCard filterInterfaces(AgentCard card) { + return AgentCard.builder(card).supportedInterfaces(card.supportedInterfaces().stream() + .filter(i -> supportedProtocolVersions.contains(normalizeCardVersion(i.protocolVersion()))) + .toList()).build(); + } + + private static String normalizeCardVersion(@Nullable String version) { + if (version == null) return ""; + try { + return normalizeSupportedProtocolVersion(version); + } catch (IllegalArgumentException e) { + return version; } } } diff --git a/http-client/src/main/java/org/a2aproject/sdk/client/http/AgentCardCompatibilityParser.java b/http-client/src/main/java/org/a2aproject/sdk/client/http/AgentCardCompatibilityParser.java new file mode 100644 index 000000000..1933f49a5 --- /dev/null +++ b/http-client/src/main/java/org/a2aproject/sdk/client/http/AgentCardCompatibilityParser.java @@ -0,0 +1,15 @@ +package org.a2aproject.sdk.client.http; + +import java.util.Optional; +import java.util.Set; + +import org.a2aproject.sdk.spec.AgentCard; +import org.jspecify.annotations.Nullable; + +/** Optional parser for agent-card formats supported by compatibility artifacts. */ +public interface AgentCardCompatibilityParser { + String supportedProtocolVersion(); + + Optional parse(String rawCardJson, @Nullable AgentCard parsedV10Card, + Set requestedProtocolVersions); +} diff --git a/http-client/src/main/java/org/a2aproject/sdk/client/http/JdkA2AHttpClient.java b/http-client/src/main/java/org/a2aproject/sdk/client/http/JdkA2AHttpClient.java index a5307ee24..0ce0dfd95 100644 --- a/http-client/src/main/java/org/a2aproject/sdk/client/http/JdkA2AHttpClient.java +++ b/http-client/src/main/java/org/a2aproject/sdk/client/http/JdkA2AHttpClient.java @@ -1,19 +1,19 @@ package org.a2aproject.sdk.client.http; -import static org.a2aproject.sdk.util.Assert.checkNotNullParam; import static java.net.HttpURLConnection.HTTP_FORBIDDEN; import static java.net.HttpURLConnection.HTTP_MULT_CHOICE; import static java.net.HttpURLConnection.HTTP_OK; import static java.net.HttpURLConnection.HTTP_UNAUTHORIZED; +import static org.a2aproject.sdk.util.Assert.checkNotNullParam; 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.net.http.HttpResponse.BodyHandler; import java.net.http.HttpResponse.BodyHandlers; import java.net.http.HttpResponse.BodySubscribers; +import java.net.http.HttpResponse; import java.nio.ByteBuffer; import java.nio.charset.StandardCharsets; import java.util.HashMap; @@ -23,10 +23,10 @@ import java.util.concurrent.Flow; import java.util.concurrent.atomic.AtomicBoolean; import java.util.function.Consumer; -import org.jspecify.annotations.Nullable; import org.a2aproject.sdk.common.A2AErrorMessages; import org.a2aproject.sdk.spec.A2AClientHTTPError; +import org.jspecify.annotations.Nullable; /** * Default HTTP client implementation using JDK 11+ {@link HttpClient}. @@ -367,11 +367,11 @@ public A2AHttpResponse get() throws IOException, InterruptedException { if (response.statusCode() == HTTP_UNAUTHORIZED) { throw new IOException(A2AErrorMessages.AUTHENTICATION_FAILED, new A2AClientHTTPError(HTTP_UNAUTHORIZED, A2AErrorMessages.AUTHENTICATION_FAILED, - null, response.headers().map())); + response.body(), response.headers().map())); } else if (response.statusCode() == HTTP_FORBIDDEN) { throw new IOException(A2AErrorMessages.AUTHORIZATION_FAILED, new A2AClientHTTPError(HTTP_FORBIDDEN, A2AErrorMessages.AUTHORIZATION_FAILED, - null, response.headers().map())); + response.body(), response.headers().map())); } return new JdkHttpResponse(response); @@ -400,11 +400,11 @@ public A2AHttpResponse delete() throws IOException, InterruptedException { if (response.statusCode() == HTTP_UNAUTHORIZED) { throw new IOException(A2AErrorMessages.AUTHENTICATION_FAILED, new A2AClientHTTPError(HTTP_UNAUTHORIZED, A2AErrorMessages.AUTHENTICATION_FAILED, - null, response.headers().map())); + response.body(), response.headers().map())); } else if (response.statusCode() == HTTP_FORBIDDEN) { throw new IOException(A2AErrorMessages.AUTHORIZATION_FAILED, new A2AClientHTTPError(HTTP_FORBIDDEN, A2AErrorMessages.AUTHORIZATION_FAILED, - null, response.headers().map())); + response.body(), response.headers().map())); } return new JdkHttpResponse(response); @@ -468,11 +468,11 @@ public A2AHttpResponse post() throws IOException, InterruptedException { if (response.statusCode() == HTTP_UNAUTHORIZED) { throw new IOException(A2AErrorMessages.AUTHENTICATION_FAILED, new A2AClientHTTPError(HTTP_UNAUTHORIZED, A2AErrorMessages.AUTHENTICATION_FAILED, - null, response.headers().map())); + response.body(), response.headers().map())); } else if (response.statusCode() == HTTP_FORBIDDEN) { throw new IOException(A2AErrorMessages.AUTHORIZATION_FAILED, new A2AClientHTTPError(HTTP_FORBIDDEN, A2AErrorMessages.AUTHORIZATION_FAILED, - null, response.headers().map())); + response.body(), response.headers().map())); } return new JdkHttpResponse(response); diff --git a/http-client/src/test/java/org/a2aproject/sdk/client/http/A2ACardResolverTest.java b/http-client/src/test/java/org/a2aproject/sdk/client/http/A2ACardResolverTest.java index a4d4a3da4..cfd302add 100644 --- a/http-client/src/test/java/org/a2aproject/sdk/client/http/A2ACardResolverTest.java +++ b/http-client/src/test/java/org/a2aproject/sdk/client/http/A2ACardResolverTest.java @@ -11,6 +11,8 @@ import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.Optional; +import java.util.Set; import java.util.concurrent.CompletableFuture; import java.util.function.Consumer; import org.a2aproject.sdk.grpc.utils.JSONRPCUtils; @@ -231,6 +233,84 @@ public void testBuilder_malformedBaseUrl_throws() { assertThrows(A2AClientError.class, () -> A2ACardResolver.builder().baseUrl("not-a-url").build()); } + @Test + public void testSupportedProtocolVersions_normalizesPatchVersion() throws Exception { + TestHttpClient client = createTestClient(); + A2ACardResolver.builder().httpClient(client).baseUrl("http://example.com") + .supportedProtocolVersions(Set.of("1.0.0")) + .build().getAgentCard(); + assertEquals("http://example.com" + AGENT_CARD_PATH, client.url); + } + + @Test + public void testDefaultProtocolDiscoveryReturnsLegacyCardWithoutInterfaces() throws Exception { + TestHttpClient client = createTestClient(); + client.body = JsonMessages.AGENT_CARD.replaceFirst("(?s)\"supportedInterfaces\"\\s*:\\s*\\[.*?\\]", + "\"supportedInterfaces\": []"); + + AgentCard card = A2ACardResolver.builder().httpClient(client).baseUrl("http://example.com") + .build().getAgentCard(); + + assertEquals("GeoSpatial Route Planner Agent", card.name()); + assertTrue(card.supportedInterfaces().isEmpty()); + } + + @Test + public void testExplicitProtocolDiscoveryRejectsCardWithoutRequestedInterface() { + TestHttpClient client = createTestClient(); + client.body = JsonMessages.AGENT_CARD.replaceFirst("(?s)\"supportedInterfaces\"\\s*:\\s*\\[.*?\\]", + "\"supportedInterfaces\": []"); + + A2AClientJSONError error = assertThrows(A2AClientJSONError.class, () -> A2ACardResolver.builder() + .httpClient(client).baseUrl("http://example.com") + .supportedProtocolVersions(Set.of("1.0")) + .build().getAgentCard()); + + assertTrue(error.getMessage().contains("does not expose a requested protocol version")); + } + + @Test + public void testSupportedProtocolVersions_rejectsEmptySet() { + assertThrows(IllegalArgumentException.class, () -> A2ACardResolver.builder() + .supportedProtocolVersions(Set.of())); + } + + @Test + public void testLegacyCardUsesRegisteredParserWithoutSecondFetch() throws Exception { + TestHttpClient client = createTestClient(); + client.body = "{\"legacy\":true}"; + AgentCard card = A2ACardResolver.builder().httpClient(client).baseUrl("http://example.com") + .supportedProtocolVersions(Set.of("1.0", "0.3")) + .build().getAgentCard(); + assertEquals("0.3", card.supportedInterfaces().get(0).protocolVersion()); + assertEquals(1, client.urlsCalled.size()); + } + + @Test + public void testModernCardWithOnlyRequestedLegacyInterfaceDoesNotUseLegacyParser() throws Exception { + TestHttpClient client = createTestClient(); + client.body = JsonMessages.AGENT_CARD.replace("\"tenant\": \"\"}", + "\"tenant\": \"\", \"protocolVersion\": \"0.3\"}"); + + AgentCard card = A2ACardResolver.builder().httpClient(client).baseUrl("http://example.com") + .supportedProtocolVersions(Set.of("0.3")) + .build().getAgentCard(); + + assertEquals("GeoSpatial Route Planner Agent", card.name()); + assertEquals("0.3", card.supportedInterfaces().get(0).protocolVersion()); + } + + @Test + public void testModernCardWithoutRequestedLegacyInterfaceDoesNotUseLegacyParser() { + TestHttpClient client = createTestClient(); + A2AClientJSONError error = assertThrows(A2AClientJSONError.class, () -> A2ACardResolver.builder() + .httpClient(client).baseUrl("http://example.com") + .supportedProtocolVersions(Set.of("0.3")) + .build().getAgentCard()); + + assertTrue(error.getMessage().contains("does not expose a requested protocol version")); + } + @Test public void testFullWellKnownUrlWithTenant() throws Exception { // Full well-known URL + tenant must strip the suffix before embedding tenant inside the path, diff --git a/http-client/src/test/java/org/a2aproject/sdk/client/http/TestAgentCardCompatibilityParser.java b/http-client/src/test/java/org/a2aproject/sdk/client/http/TestAgentCardCompatibilityParser.java new file mode 100644 index 000000000..19ca27b8d --- /dev/null +++ b/http-client/src/test/java/org/a2aproject/sdk/client/http/TestAgentCardCompatibilityParser.java @@ -0,0 +1,33 @@ +package org.a2aproject.sdk.client.http; + +import java.util.Optional; +import java.util.Set; + +import org.a2aproject.sdk.spec.AgentCard; +import org.a2aproject.sdk.spec.AgentCapabilities; +import org.a2aproject.sdk.spec.AgentInterface; +import org.a2aproject.sdk.spec.AgentSkill; + +public final class TestAgentCardCompatibilityParser implements AgentCardCompatibilityParser { + @Override + public String supportedProtocolVersion() { + return "0.3"; + } + + @Override + public Optional parse(String rawCardJson, AgentCard parsedV10Card, + Set requestedProtocolVersions) { + return Optional.of(AgentCard.builder() + .name("legacy") + .description("legacy") + .version("1") + .url("http://example.com") + .capabilities(new AgentCapabilities(false, false, false, null)) + .defaultInputModes(java.util.List.of("text")) + .defaultOutputModes(java.util.List.of("text")) + .skills(java.util.List.of(AgentSkill.builder().id("legacy").name("legacy").description("legacy") + .tags(java.util.List.of("legacy")).build())) + .supportedInterfaces(java.util.List.of(new AgentInterface("JSONRPC", "http://example.com", null, "0.3"))) + .build()); + } +} diff --git a/http-client/src/test/resources/META-INF/services/org.a2aproject.sdk.client.http.AgentCardCompatibilityParser b/http-client/src/test/resources/META-INF/services/org.a2aproject.sdk.client.http.AgentCardCompatibilityParser new file mode 100644 index 000000000..4775fb589 --- /dev/null +++ b/http-client/src/test/resources/META-INF/services/org.a2aproject.sdk.client.http.AgentCardCompatibilityParser @@ -0,0 +1 @@ +org.a2aproject.sdk.client.http.TestAgentCardCompatibilityParser diff --git a/pom.xml b/pom.xml index 34af82a92..21eb6a476 100644 --- a/pom.xml +++ b/pom.xml @@ -644,6 +644,9 @@ compat-0.3 + + tests/client-builder-classpath + reference/multiversion-jsonrpc reference/multiversion-rest diff --git a/reference/multiversion-rest/src/main/java/org/a2aproject/sdk/server/multiversion/rest/MultiVersionRestRoutes.java b/reference/multiversion-rest/src/main/java/org/a2aproject/sdk/server/multiversion/rest/MultiVersionRestRoutes.java index 816f2639a..6ec25e79c 100644 --- a/reference/multiversion-rest/src/main/java/org/a2aproject/sdk/server/multiversion/rest/MultiVersionRestRoutes.java +++ b/reference/multiversion-rest/src/main/java/org/a2aproject/sdk/server/multiversion/rest/MultiVersionRestRoutes.java @@ -5,26 +5,24 @@ import java.util.function.BiConsumer; import java.util.function.Consumer; -import jakarta.annotation.Priority; -import jakarta.enterprise.event.Observes; -import jakarta.inject.Inject; -import jakarta.inject.Singleton; - import com.google.gson.JsonObject; +import io.quarkus.security.ForbiddenException; +import io.quarkus.security.UnauthorizedException; import io.vertx.ext.web.Router; import io.vertx.ext.web.RoutingContext; import io.vertx.ext.web.handler.BodyHandler; +import jakarta.annotation.Priority; +import jakarta.enterprise.event.Observes; +import jakarta.inject.Inject; +import jakarta.inject.Singleton; +import org.a2aproject.sdk.compat03.server.rest.quarkus.A2AServerRoutes_v0_3; import org.a2aproject.sdk.server.common.quarkus.VersionRouter; import org.a2aproject.sdk.server.common.quarkus.VertxSecurityHelper; import org.a2aproject.sdk.server.rest.quarkus.A2AServerRoutes; -import org.a2aproject.sdk.compat03.server.rest.quarkus.A2AServerRoutes_v0_3; import org.a2aproject.sdk.spec.A2AError; import org.a2aproject.sdk.spec.A2AErrorCodes; import org.a2aproject.sdk.spec.VersionNotSupportedError; -import io.quarkus.security.ForbiddenException; -import io.quarkus.security.UnauthorizedException; - @Singleton public class MultiVersionRestRoutes { @@ -73,6 +71,16 @@ void setupRoutes(@Observes @Priority(5) Router router) { (body, ctx) -> v10Routes.cancelTask(body, ctx), (body, ctx) -> v03Routes.cancelTask(ctx)), false); + // GET /v1/tasks/{taskId}:subscribe for the 0.3 HTTP binding. + router.getWithRegex("^\\/v1\\/tasks\\/(?[^/]+):subscribe$") + .order(-1) + .blockingHandler(versionDispatchNoBody(true, + ctx -> { bridgeTenant(ctx); bridgeTaskId(ctx); }, + ctx -> { + throw new VersionNotSupportedError(null, "GET task subscription is only supported by A2A 0.3", null); + }, + ctx -> v03Routes.resubscribeTask(ctx)), false); + // POST /v1/tasks/{taskId}:subscribe (deferred CDI context destruction) router.postWithRegex("^\\/v1\\/tasks\\/(?[^/]+):subscribe$") .order(-1) diff --git a/spec-grpc/pom.xml b/spec-grpc/pom.xml index 63bac2c4f..4af4f5249 100644 --- a/spec-grpc/pom.xml +++ b/spec-grpc/pom.xml @@ -42,14 +42,6 @@ grpc-stub provided - - jakarta.enterprise - jakarta.enterprise.cdi-api - - - jakarta.inject - jakarta.inject-api - com.google.api.grpc proto-google-common-protos diff --git a/tests/client-builder-classpath/pom.xml b/tests/client-builder-classpath/pom.xml new file mode 100644 index 000000000..0dab97879 --- /dev/null +++ b/tests/client-builder-classpath/pom.xml @@ -0,0 +1,40 @@ + + + 4.0.0 + + org.a2aproject.sdk + a2a-java-sdk-parent + 1.4.1.Final-SNAPSHOT + ../../pom.xml + + a2a-java-sdk-client-builder-classpath-fixture + Java SDK Client Builder Classpath Fixture + Classpath-isolated compatibility adapter diagnostics + + + + ${project.groupId} + a2a-java-sdk-client + + + ${project.groupId} + a2a-java-sdk-client-transport-jsonrpc + + + ${project.groupId} + a2a-java-sdk-http-client + + + ${project.groupId} + a2a-java-sdk-compat-0.3-client-adapter + ${project.version} + + + org.junit.jupiter + junit-jupiter + test + + + diff --git a/tests/client-builder-classpath/src/test/java/org/a2aproject/sdk/client/fixture/MissingJsonRpcAdapterClasspathTest.java b/tests/client-builder-classpath/src/test/java/org/a2aproject/sdk/client/fixture/MissingJsonRpcAdapterClasspathTest.java new file mode 100644 index 000000000..ed9bb847c --- /dev/null +++ b/tests/client-builder-classpath/src/test/java/org/a2aproject/sdk/client/fixture/MissingJsonRpcAdapterClasspathTest.java @@ -0,0 +1,59 @@ +package org.a2aproject.sdk.client.fixture; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.File; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Arrays; +import java.util.Comparator; +import java.util.stream.Collectors; + +import org.junit.jupiter.api.Test; + +class MissingJsonRpcAdapterClasspathTest { + + @Test + void reportsMissingJsonRpcAdapterWithCoreAdapterOnClasspath() throws Exception { + String[] entries = System.getProperty("java.class.path").split(java.util.regex.Pattern.quote(File.pathSeparator)); + assertTrue(Arrays.stream(entries).noneMatch(entry -> entry.contains("compat-0.3-client-adapter-jsonrpc")), + () -> "The fixture must not depend on the versioned JSON-RPC adapter: " + Arrays.toString(entries)); + + Path probeDirectory = Files.createTempDirectory("a2a-client-builder-probe"); + Path probeClass = probeDirectory.resolve("org/a2aproject/sdk/client/fixture/MissingJsonRpcAdapterProbe.class"); + Files.createDirectories(probeClass.getParent()); + Files.copy(Path.of("target/test-classes/org/a2aproject/sdk/client/fixture/MissingJsonRpcAdapterProbe.class"), + probeClass); + + String productionClassPath = Arrays.stream(entries) + .filter(entry -> !entry.endsWith("target/test-classes")) + .collect(Collectors.joining(File.pathSeparator)); + Process process = null; + try { + process = new ProcessBuilder( + Path.of(System.getProperty("java.home"), "bin", "java").toString(), + "-cp", probeDirectory + File.pathSeparator + productionClassPath, + MissingJsonRpcAdapterProbe.class.getName()) + .redirectErrorStream(true) + .start(); + String output; + try (var input = process.getInputStream()) { + output = new String(input.readAllBytes(), StandardCharsets.UTF_8); + } + + assertEquals(0, process.waitFor(), output); + assertTrue(output.contains("a2a-java-sdk-compat-0.3-client-adapter-jsonrpc"), output); + } finally { + if (process != null && process.isAlive()) { + process.destroyForcibly(); + } + try (var paths = Files.walk(probeDirectory)) { + for (Path path : paths.sorted(Comparator.reverseOrder()).toList()) { + Files.deleteIfExists(path); + } + } + } + } +} diff --git a/tests/client-builder-classpath/src/test/java/org/a2aproject/sdk/client/fixture/MissingJsonRpcAdapterProbe.java b/tests/client-builder-classpath/src/test/java/org/a2aproject/sdk/client/fixture/MissingJsonRpcAdapterProbe.java new file mode 100644 index 000000000..cdc349d20 --- /dev/null +++ b/tests/client-builder-classpath/src/test/java/org/a2aproject/sdk/client/fixture/MissingJsonRpcAdapterProbe.java @@ -0,0 +1,74 @@ +package org.a2aproject.sdk.client.fixture; + +import java.io.IOException; +import java.io.OutputStream; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.util.Set; + +import com.sun.net.httpserver.HttpServer; +import org.a2aproject.sdk.client.Client; +import org.a2aproject.sdk.client.http.A2ACardResolver; +import org.a2aproject.sdk.client.transport.jsonrpc.JSONRPCTransport; +import org.a2aproject.sdk.client.transport.jsonrpc.JSONRPCTransportConfigBuilder; +import org.a2aproject.sdk.spec.A2AClientException; +import org.a2aproject.sdk.spec.AgentCard; + +public final class MissingJsonRpcAdapterProbe { + + private static final String LEGACY_AGENT_CARD = """ + { + "protocolVersion": "0.3", + "name": "Legacy agent", + "description": "Legacy agent for classpath probing", + "url": "http://127.0.0.1/a2a", + "preferredTransport": "JSONRPC", + "version": "1.0.0", + "capabilities": {"streaming": false, "pushNotifications": false}, + "defaultInputModes": ["text/plain"], + "defaultOutputModes": ["text/plain"], + "skills": [] + }"""; + + private MissingJsonRpcAdapterProbe() { + } + + public static void main(String[] args) throws Exception { + HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext("/.well-known/agent-card.json", exchange -> { + byte[] response = LEGACY_AGENT_CARD.getBytes(StandardCharsets.UTF_8); + exchange.sendResponseHeaders(200, response.length); + try (OutputStream output = exchange.getResponseBody()) { + output.write(response); + } + }); + server.start(); + try { + String baseUrl = "http://127.0.0.1:" + server.getAddress().getPort(); + AgentCard card = A2ACardResolver.builder() + .baseUrl(baseUrl) + .supportedProtocolVersions(Set.of("0.3")) + .build() + .getAgentCard(); + if (card.supportedInterfaces().stream().noneMatch(agentInterface -> + "JSONRPC".equals(agentInterface.protocolBinding()) + && "0.3".equals(agentInterface.protocolVersion()))) { + throw new AssertionError("The 0.3 card was not resolved through the compatibility parser"); + } + + try { + Client.builder(card) + .withTransport(JSONRPCTransport.class, new JSONRPCTransportConfigBuilder()) + .build(); + throw new AssertionError("The missing JSON-RPC compatibility adapter was not reported"); + } catch (A2AClientException expected) { + if (!expected.getMessage().contains("a2a-java-sdk-compat-0.3-client-adapter-jsonrpc")) { + throw new AssertionError("Unexpected diagnostic: " + expected.getMessage(), expected); + } + System.out.println(expected.getMessage()); + } + } finally { + server.stop(0); + } + } +} diff --git a/tests/multiversion/grpc/src/test/java/org/a2aproject/sdk/tests/multiversion/grpc/A2ATestResource.java b/tests/multiversion/grpc/src/test/java/org/a2aproject/sdk/tests/multiversion/grpc/A2ATestResource.java index d30fa6e9f..d9de9ebe0 100644 --- a/tests/multiversion/grpc/src/test/java/org/a2aproject/sdk/tests/multiversion/grpc/A2ATestResource.java +++ b/tests/multiversion/grpc/src/test/java/org/a2aproject/sdk/tests/multiversion/grpc/A2ATestResource.java @@ -15,10 +15,10 @@ import jakarta.ws.rs.Path; import jakarta.ws.rs.PathParam; import jakarta.ws.rs.Produces; +import jakarta.ws.rs.QueryParam; import jakarta.ws.rs.core.HttpHeaders; import jakarta.ws.rs.core.MediaType; import jakarta.ws.rs.core.Response; - import org.a2aproject.sdk.compat03.transport.grpc.handler.GrpcHandler_v0_3; import org.a2aproject.sdk.jsonrpc.common.json.JsonUtil; import org.a2aproject.sdk.server.apps.common.TestUtilsBean; @@ -80,8 +80,13 @@ public Response deleteTask(@PathParam("taskId") String taskId) { @POST @Path("/queue/ensure/{taskId}") - public Response ensureQueue(@PathParam("taskId") String taskId) { - testUtilsBean.ensureQueue(taskId); + public Response ensureQueue(@PathParam("taskId") String taskId, + @QueryParam("withoutConsumer") boolean withoutConsumer) { + if (withoutConsumer) { + testUtilsBean.ensureQueueWithoutConsumer(taskId); + } else { + testUtilsBean.ensureQueue(taskId); + } return Response.ok().build(); } diff --git a/tests/multiversion/jsonrpc/src/test/java/org/a2aproject/sdk/tests/multiversion/jsonrpc/A2ATestRoutes.java b/tests/multiversion/jsonrpc/src/test/java/org/a2aproject/sdk/tests/multiversion/jsonrpc/A2ATestRoutes.java index 1c06793c6..347207406 100644 --- a/tests/multiversion/jsonrpc/src/test/java/org/a2aproject/sdk/tests/multiversion/jsonrpc/A2ATestRoutes.java +++ b/tests/multiversion/jsonrpc/src/test/java/org/a2aproject/sdk/tests/multiversion/jsonrpc/A2ATestRoutes.java @@ -6,18 +6,17 @@ import java.util.concurrent.atomic.AtomicInteger; +import io.vertx.ext.web.Router; +import io.vertx.ext.web.RoutingContext; +import io.vertx.ext.web.handler.BodyHandler; import jakarta.annotation.PostConstruct; import jakarta.enterprise.event.Observes; import jakarta.inject.Inject; import jakarta.inject.Singleton; - -import io.vertx.ext.web.Router; -import io.vertx.ext.web.RoutingContext; -import io.vertx.ext.web.handler.BodyHandler; +import org.a2aproject.sdk.compat03.server.apps.quarkus.A2AServerRoutes_v0_3; import org.a2aproject.sdk.jsonrpc.common.json.JsonUtil; import org.a2aproject.sdk.server.apps.common.TestUtilsBean; import org.a2aproject.sdk.server.apps.quarkus.A2AServerRoutes; -import org.a2aproject.sdk.compat03.server.apps.quarkus.A2AServerRoutes_v0_3; import org.a2aproject.sdk.spec.Task; import org.a2aproject.sdk.spec.TaskArtifactUpdateEvent; import org.a2aproject.sdk.spec.TaskPushNotificationConfig; @@ -164,7 +163,11 @@ public void deleteTask(String taskId, RoutingContext rc) { public void ensureTaskQueue(String taskId, RoutingContext rc) { try { - testUtilsBean.ensureQueue(taskId); + if (Boolean.parseBoolean(rc.request().getParam("withoutConsumer"))) { + testUtilsBean.ensureQueueWithoutConsumer(taskId); + } else { + testUtilsBean.ensureQueue(taskId); + } rc.response().setStatusCode(200).end(); } catch (Throwable t) { errorResponse(t, rc); diff --git a/tests/multiversion/rest/src/test/java/org/a2aproject/sdk/tests/multiversion/rest/A2ATestRoutes.java b/tests/multiversion/rest/src/test/java/org/a2aproject/sdk/tests/multiversion/rest/A2ATestRoutes.java index 038ac6e96..c1b1d5f87 100644 --- a/tests/multiversion/rest/src/test/java/org/a2aproject/sdk/tests/multiversion/rest/A2ATestRoutes.java +++ b/tests/multiversion/rest/src/test/java/org/a2aproject/sdk/tests/multiversion/rest/A2ATestRoutes.java @@ -6,15 +6,14 @@ import java.util.concurrent.atomic.AtomicInteger; +import io.vertx.ext.web.Router; +import io.vertx.ext.web.RoutingContext; +import io.vertx.ext.web.handler.BodyHandler; import jakarta.annotation.PostConstruct; import jakarta.annotation.Priority; import jakarta.enterprise.event.Observes; import jakarta.inject.Inject; import jakarta.inject.Singleton; - -import io.vertx.ext.web.Router; -import io.vertx.ext.web.RoutingContext; -import io.vertx.ext.web.handler.BodyHandler; import org.a2aproject.sdk.compat03.server.rest.quarkus.A2AServerRoutes_v0_3; import org.a2aproject.sdk.jsonrpc.common.json.JsonUtil; import org.a2aproject.sdk.server.apps.common.TestUtilsBean; @@ -216,7 +215,11 @@ public void deleteTask(String taskId, RoutingContext rc) { public void ensureTaskQueue(String taskId, RoutingContext rc) { try { - testUtilsBean.ensureQueue(taskId); + if (Boolean.parseBoolean(rc.request().getParam("withoutConsumer"))) { + testUtilsBean.ensureQueueWithoutConsumer(taskId); + } else { + testUtilsBean.ensureQueue(taskId); + } rc.response() .setStatusCode(200) .end(); diff --git a/tests/server-common/src/test/java/org/a2aproject/sdk/server/apps/common/AbstractA2AServerCompatibilityTest_v0_3.java b/tests/server-common/src/test/java/org/a2aproject/sdk/server/apps/common/AbstractA2AServerCompatibilityTest_v0_3.java new file mode 100644 index 000000000..ca4df9af0 --- /dev/null +++ b/tests/server-common/src/test/java/org/a2aproject/sdk/server/apps/common/AbstractA2AServerCompatibilityTest_v0_3.java @@ -0,0 +1,973 @@ +package org.a2aproject.sdk.server.apps.common; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.fail; + +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.ArrayList; +import java.util.List; +import java.util.Set; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.BiConsumer; + +import org.a2aproject.sdk.A2A; +import org.a2aproject.sdk.client.Client; +import org.a2aproject.sdk.client.ClientBuilder; +import org.a2aproject.sdk.client.MessageEvent; +import org.a2aproject.sdk.client.TaskEvent; +import org.a2aproject.sdk.client.TaskUpdateEvent; +import org.a2aproject.sdk.client.config.ClientConfig; +import org.a2aproject.sdk.jsonrpc.common.json.JsonUtil; +import org.a2aproject.sdk.spec.A2AClientException; +import org.a2aproject.sdk.spec.AgentCard; +import org.a2aproject.sdk.spec.Artifact; +import org.a2aproject.sdk.spec.CancelTaskParams; +import org.a2aproject.sdk.spec.DataPart; +import org.a2aproject.sdk.spec.DeleteTaskPushNotificationConfigParams; +import org.a2aproject.sdk.spec.Event; +import org.a2aproject.sdk.spec.GetTaskPushNotificationConfigParams; +import org.a2aproject.sdk.spec.ListTaskPushNotificationConfigsParams; +import org.a2aproject.sdk.spec.ListTaskPushNotificationConfigsResult; +import org.a2aproject.sdk.spec.ListTasksParams; +import org.a2aproject.sdk.spec.Message; +import org.a2aproject.sdk.spec.MessageSendConfiguration; +import org.a2aproject.sdk.spec.MessageSendParams; +import org.a2aproject.sdk.spec.Part; +import org.a2aproject.sdk.spec.Task; +import org.a2aproject.sdk.spec.TaskArtifactUpdateEvent; +import org.a2aproject.sdk.spec.TaskIdParams; +import org.a2aproject.sdk.spec.TaskNotFoundError; +import org.a2aproject.sdk.spec.TaskPushNotificationConfig; +import org.a2aproject.sdk.spec.TaskQueryParams; +import org.a2aproject.sdk.spec.TaskState; +import org.a2aproject.sdk.spec.TaskStatusUpdateEvent; +import org.a2aproject.sdk.spec.TextPart; +import org.a2aproject.sdk.spec.UnsupportedOperationError; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +/** v1 client compatibility scenarios for standalone A2A v0.3 servers. */ +public abstract class AbstractA2AServerCompatibilityTest_v0_3 { + + protected static final Task MINIMAL_TASK = Task.builder() + .id("task-123") + .contextId("session-xyz") + .status(new org.a2aproject.sdk.spec.TaskStatus( + org.a2aproject.sdk.spec.TaskState.TASK_STATE_SUBMITTED)) + .build(); + + protected static final Message MESSAGE = Message.builder() + .messageId("111") + .role(Message.Role.ROLE_AGENT) + .parts(new TextPart("test message")) + .build(); + + protected static final String APPLICATION_JSON = "application/json"; + + protected final int serverPort; + private final List createdClients = new ArrayList<>(); + private Client client; + private Client nonStreamingClient; + private Client pollingClient; + + protected AbstractA2AServerCompatibilityTest_v0_3(int serverPort) { + this.serverPort = serverPort; + } + + protected abstract String getTransportProtocol(); + + protected abstract String getTransportUrl(); + + protected abstract void configureTransport(ClientBuilder builder); + + protected AgentCard getAgentCard() { + return A2A.getAgentCard(getTransportUrl(), Set.of("0.3")); + } + + protected Client getClient() throws A2AClientException { + if (client == null) { + client = createClient(true); + } + return client; + } + + protected Client getNonStreamingClient() throws A2AClientException { + if (nonStreamingClient == null) { + nonStreamingClient = createClient(false); + } + return nonStreamingClient; + } + + protected Client getPollingClient() throws A2AClientException { + if (pollingClient == null) { + pollingClient = createPollingClient(); + } + return pollingClient; + } + + protected Client createClient(boolean streaming) throws A2AClientException { + ClientBuilder builder = Client.builder(getAgentCard()) + .clientConfig(new ClientConfig.Builder().setStreaming(streaming).build()); + configureTransport(builder); + Client created = builder.build(); + createdClients.add(created); + return created; + } + + protected Client createPollingClient() throws A2AClientException { + ClientBuilder builder = Client.builder(getAgentCard()) + .clientConfig(new ClientConfig.Builder().setStreaming(false).setPolling(true).build()); + configureTransport(builder); + Client created = builder.build(); + createdClients.add(created); + return created; + } + + private static final Task CANCEL_TASK = Task.builder(MINIMAL_TASK).id("cancel-task-123").build(); + private static final Task CANCEL_TASK_NOT_SUPPORTED = + Task.builder(MINIMAL_TASK).id("cancel-task-not-supported-123").build(); + private static final Task SEND_MESSAGE_NOT_SUPPORTED = + Task.builder(MINIMAL_TASK).id("task-not-supported-123").build(); + + @Test + public void testGetTaskSuccess() throws Exception { + saveTaskInTaskStore(MINIMAL_TASK); + try { + Task response = getClient().getTask(new TaskQueryParams(MINIMAL_TASK.id())); + assertEquals(MINIMAL_TASK.id(), response.id()); + assertEquals(MINIMAL_TASK.contextId(), response.contextId()); + assertEquals(TaskState.TASK_STATE_SUBMITTED, response.status().state()); + } finally { + deleteTaskInTaskStore(MINIMAL_TASK.id()); + } + } + + @Test + public void testGetTaskNotFound() throws Exception { + assertNull(getTaskFromTaskStore("non-existent-task")); + A2AClientException error = org.junit.jupiter.api.Assertions.assertThrows(A2AClientException.class, + () -> getClient().getTask(new TaskQueryParams("non-existent-task"))); + assertInstanceOf(TaskNotFoundError.class, error.getCause()); + } + + @Test + public void testCancelTaskSuccess() throws Exception { + saveTaskInTaskStore(CANCEL_TASK); + try { + Task task = getClient().cancelTask(new CancelTaskParams(CANCEL_TASK.id())); + assertEquals(CANCEL_TASK.id(), task.id()); + assertEquals(TaskState.TASK_STATE_CANCELED, task.status().state()); + } finally { + deleteTaskInTaskStore(CANCEL_TASK.id()); + } + } + + @Test + public void testCancelTaskNotSupported() throws Exception { + saveTaskInTaskStore(CANCEL_TASK_NOT_SUPPORTED); + try { + A2AClientException error = org.junit.jupiter.api.Assertions.assertThrows(A2AClientException.class, + () -> getClient().cancelTask(new CancelTaskParams(CANCEL_TASK_NOT_SUPPORTED.id()))); + assertInstanceOf(UnsupportedOperationError.class, error.getCause()); + } finally { + deleteTaskInTaskStore(CANCEL_TASK_NOT_SUPPORTED.id()); + } + } + + @Test + public void testCancelTaskNotFound() throws Exception { + A2AClientException error = org.junit.jupiter.api.Assertions.assertThrows(A2AClientException.class, + () -> getClient().cancelTask(new CancelTaskParams("non-existent-task"))); + assertInstanceOf(TaskNotFoundError.class, error.getCause()); + } + + @Test + public void testSendMessageNewMessageSuccess() throws Exception { + CountDownLatch latch = new CountDownLatch(1); + AtomicReference received = new AtomicReference<>(); + AtomicBoolean unexpected = new AtomicBoolean(); + getNonStreamingClient().sendMessage(MESSAGE, List.of((event, card) -> { + if (event instanceof MessageEvent messageEvent && latch.getCount() > 0) { + received.set(messageEvent.getMessage()); + latch.countDown(); + } else { + unexpected.set(true); + } + }), null); + assertTrue(latch.await(10, TimeUnit.SECONDS)); + assertFalse(unexpected.get()); + assertEquals(MESSAGE.messageId(), received.get().messageId()); + assertEquals(MESSAGE.role(), received.get().role()); + Part part = received.get().parts().get(0); + assertInstanceOf(TextPart.class, part); + assertEquals("test message", ((TextPart) part).text()); + } + + @Test + public void testSendMessageWithInlinePushConfiguration() throws Exception { + sendMessageWithInlinePushConfiguration(false); + } + + @Test + public void testSendStreamingMessageWithInlinePushConfiguration() throws Exception { + sendMessageWithInlinePushConfiguration(true); + } + + private void sendMessageWithInlinePushConfiguration(boolean streaming) throws Exception { + Client sendingClient = streaming ? getClient() : getNonStreamingClient(); + for (String taskId : new String[] {null, ""}) { + TaskPushNotificationConfig push = new TaskPushNotificationConfig( + "inline-config", taskId, "http://localhost:" + serverPort + "/callback", null, null, null); + Message taskMessage = Message.builder(MESSAGE).parts(new TextPart("#a2a-delegated#inline push")).build(); + MessageSendParams request = new MessageSendParams(taskMessage, + MessageSendConfiguration.builder().taskPushNotificationConfig(push).build(), null); + CountDownLatch latch = new CountDownLatch(1); + AtomicReference received = new AtomicReference<>(); + AtomicReference error = new AtomicReference<>(); + try { + sendingClient.sendMessage(request, List.of((event, card) -> { + Task task = event instanceof TaskEvent initial ? initial.getTask() + : event instanceof TaskUpdateEvent update ? update.getTask() : null; + if (task != null) { + received.set(task); + if (task.status().state() == TaskState.TASK_STATE_COMPLETED) { + latch.countDown(); + } + } + }), throwable -> { + if (throwable != null && !isStreamClosedError(throwable)) { + error.set(throwable); + latch.countDown(); + } + }, null); + + assertTrue(latch.await(10, TimeUnit.SECONDS)); + assertNull(error.get()); + assertNotNull(received.get()); + assertEquals(TaskState.TASK_STATE_COMPLETED, received.get().status().state()); + TaskPushNotificationConfig stored = getClient().getTaskPushNotificationConfiguration( + new GetTaskPushNotificationConfigParams(received.get().id(), "inline-config")); + assertEquals("inline-config", stored.id()); + assertEquals(received.get().id(), stored.taskId()); + assertEquals(push.url(), stored.url()); + } finally { + if (received.get() != null) { + deletePushNotificationConfigInStore(received.get().id(), "inline-config"); + deleteTaskInTaskStore(received.get().id()); + } + } + } + } + + @Test + public void testRequestScopedBeanAvailableOnAgentExecutorThread() throws Exception { + Message message = Message.builder().messageId("request-scoped-test").role(Message.Role.ROLE_USER) + .parts(new TextPart("request-scoped:test")).build(); + CountDownLatch latch = new CountDownLatch(1); + AtomicReference received = new AtomicReference<>(); + AtomicReference error = new AtomicReference<>(); + getNonStreamingClient().sendMessage(message, List.of((event, card) -> { + if (event instanceof TaskEvent taskEvent) { + received.set(taskEvent.getTask()); + latch.countDown(); + } else if (event instanceof TaskUpdateEvent updateEvent) { + received.set(updateEvent.getTask()); + if (updateEvent.getTask().status().state() == TaskState.TASK_STATE_COMPLETED) { + latch.countDown(); + } + } + }), error::set); + assertTrue(latch.await(10, TimeUnit.SECONDS)); + assertNull(error.get()); + assertEquals(TaskState.TASK_STATE_COMPLETED, received.get().status().state()); + assertInstanceOf(TextPart.class, received.get().artifacts().get(0).parts().get(0)); + assertEquals("request-scoped:request-scoped-value", + ((TextPart) received.get().artifacts().get(0).parts().get(0)).text()); + } + + @Test + public void testSendMessageExistingTaskSuccess() throws Exception { + saveTaskInTaskStore(MINIMAL_TASK); + try { + Message message = Message.builder(MESSAGE).taskId(MINIMAL_TASK.id()) + .contextId(MINIMAL_TASK.contextId()).build(); + CountDownLatch latch = new CountDownLatch(1); + AtomicReference received = new AtomicReference<>(); + getNonStreamingClient().sendMessage(message, List.of((event, card) -> { + if (event instanceof MessageEvent messageEvent) { + received.set(messageEvent.getMessage()); + latch.countDown(); + } + }), null); + assertTrue(latch.await(10, TimeUnit.SECONDS)); + assertEquals(MESSAGE.messageId(), received.get().messageId()); + } finally { + deleteTaskInTaskStore(MINIMAL_TASK.id()); + } + } + + @Test + public void testSetPushNotificationSuccess() throws Exception { + saveTaskInTaskStore(MINIMAL_TASK); + try { + TaskPushNotificationConfig config = TaskPushNotificationConfig.builder() + .id("c295ea44-7543-4f78-b524-7a38915ad6e4").taskId(MINIMAL_TASK.id()) + .url("http://example.com").tenant("").build(); + TaskPushNotificationConfig result = getClient().createTaskPushNotificationConfiguration(config); + assertEquals(config.id(), result.id()); + assertEquals(config.url(), result.url()); + } finally { + deletePushNotificationConfigInStore(MINIMAL_TASK.id(), "c295ea44-7543-4f78-b524-7a38915ad6e4"); + deleteTaskInTaskStore(MINIMAL_TASK.id()); + } + } + + @Test + public void testGetPushNotificationSuccess() throws Exception { + saveTaskInTaskStore(MINIMAL_TASK); + try { + TaskPushNotificationConfig config = TaskPushNotificationConfig.builder() + .id("c295ea44-7543-4f78-b524-7a38915ad6e4").taskId(MINIMAL_TASK.id()) + .url("http://example.com").tenant("").build(); + getClient().createTaskPushNotificationConfiguration(config); + TaskPushNotificationConfig result = getClient().getTaskPushNotificationConfiguration( + new GetTaskPushNotificationConfigParams(MINIMAL_TASK.id(), config.id())); + assertEquals(config.url(), result.url()); + } finally { + deletePushNotificationConfigInStore(MINIMAL_TASK.id(), "c295ea44-7543-4f78-b524-7a38915ad6e4"); + deleteTaskInTaskStore(MINIMAL_TASK.id()); + } + } + + @Test + public void testDefaultPushConfigurationLookupWithSeveralConfigurations() throws Exception { + saveTaskInTaskStore(MINIMAL_TASK); + try { + savePushNotificationConfigInStore(MINIMAL_TASK.id(), pushConfig("other-1", "http://example.com/one")); + savePushNotificationConfigInStore(MINIMAL_TASK.id(), pushConfig("other-2", "http://example.com/two")); + // REST/gRPC turn the default into an explicit ID on the server, permitting legacy fallback. + // JSON-RPC preserves omission and must not take that explicit-ID fallback. + if ("JSONRPC".equals(getTransportProtocol())) { + A2AClientException missing = org.junit.jupiter.api.Assertions.assertThrows(A2AClientException.class, + () -> getClient().getTaskPushNotificationConfiguration( + new GetTaskPushNotificationConfigParams(MINIMAL_TASK.id()))); + assertInstanceOf(TaskNotFoundError.class, missing.getCause()); + } + savePushNotificationConfigInStore(MINIMAL_TASK.id(), + pushConfig(MINIMAL_TASK.id(), "http://example.com/default")); + TaskPushNotificationConfig result = getClient().getTaskPushNotificationConfiguration( + new GetTaskPushNotificationConfigParams(MINIMAL_TASK.id())); + assertEquals(MINIMAL_TASK.id(), result.id()); + assertEquals("http://example.com/default", result.url()); + } finally { + deletePushNotificationConfigInStore(MINIMAL_TASK.id(), "other-1"); + deletePushNotificationConfigInStore(MINIMAL_TASK.id(), "other-2"); + deletePushNotificationConfigInStore(MINIMAL_TASK.id(), MINIMAL_TASK.id()); + deleteTaskInTaskStore(MINIMAL_TASK.id()); + } + } + + @Test + public void testError() throws Exception { + saveTaskInTaskStore(SEND_MESSAGE_NOT_SUPPORTED); + try { + Message message = Message.builder(MESSAGE).taskId(SEND_MESSAGE_NOT_SUPPORTED.id()) + .contextId(SEND_MESSAGE_NOT_SUPPORTED.contextId()).build(); + A2AClientException error = org.junit.jupiter.api.Assertions.assertThrows(A2AClientException.class, + () -> getNonStreamingClient().sendMessage(message)); + assertInstanceOf(UnsupportedOperationError.class, error.getCause()); + } finally { + deleteTaskInTaskStore(SEND_MESSAGE_NOT_SUPPORTED.id()); + } + } + + @Test + public void testSendMessageStreamNewMessageSuccess() throws Exception { + sendStreamingMessage(false); + } + + @Test + public void testSendMessageStreamExistingTaskSuccess() throws Exception { + sendStreamingMessage(true); + } + + private void sendStreamingMessage(boolean existingTask) throws Exception { + if (existingTask) { + saveTaskInTaskStore(MINIMAL_TASK); + } + try { + Message.Builder messageBuilder = Message.builder(MESSAGE); + if (existingTask) { + messageBuilder.taskId(MINIMAL_TASK.id()).contextId(MINIMAL_TASK.contextId()); + } + CountDownLatch latch = new CountDownLatch(1); + AtomicReference received = new AtomicReference<>(); + AtomicBoolean unexpected = new AtomicBoolean(); + AtomicReference error = new AtomicReference<>(); + getClient().sendMessage(messageBuilder.build(), List.of((event, card) -> { + if (event instanceof MessageEvent messageEvent && latch.getCount() > 0) { + received.set(messageEvent.getMessage()); + latch.countDown(); + } else { + unexpected.set(true); + } + }), throwable -> { + if (!isStreamClosedError(throwable)) { + error.set(throwable); + } + latch.countDown(); + }); + assertTrue(latch.await(10, TimeUnit.SECONDS)); + assertFalse(unexpected.get()); + assertNull(error.get()); + assertEquals(MESSAGE.messageId(), received.get().messageId()); + assertEquals(MESSAGE.role(), received.get().role()); + assertEquals("test message", ((TextPart) received.get().parts().get(0)).text()); + } finally { + if (existingTask) { + deleteTaskInTaskStore(MINIMAL_TASK.id()); + } + } + } + + protected boolean isStreamClosedError(Throwable throwable) { + Throwable current = throwable; + while (current != null) { + if (current instanceof java.io.EOFException + || current instanceof java.util.concurrent.CancellationException + || (current instanceof IOException && current.getMessage() != null + && current.getMessage().contains("cancelled"))) { + return true; + } + current = current.getCause(); + } + return false; + } + + @Test + public void testResubscribeExistingTaskSuccess() throws Exception { + saveTaskInTaskStore(MINIMAL_TASK); + try { + ensureQueueForTask(MINIMAL_TASK.id()); + CountDownLatch events = new CountDownLatch(2); + AtomicReference artifact = new AtomicReference<>(); + AtomicReference status = new AtomicReference<>(); + AtomicBoolean initialTask = new AtomicBoolean(); + AtomicReference error = new AtomicReference<>(); + CompletableFuture subscription = awaitStreamingSubscription(); + getClient().subscribeToTask(new TaskIdParams(MINIMAL_TASK.id()), List.of((event, card) -> { + if (!initialTask.getAndSet(true)) { + assertInstanceOf(TaskEvent.class, event); + return; + } + if (event instanceof TaskUpdateEvent update) { + if (update.getUpdateEvent() instanceof TaskArtifactUpdateEvent value) { + artifact.set(value); + events.countDown(); + } else if (update.getUpdateEvent() instanceof TaskStatusUpdateEvent value) { + status.set(value); + events.countDown(); + } + } + }), failure -> { if (!isStreamClosedError(failure)) error.set(failure); }); + subscription.get(15, TimeUnit.SECONDS); + enqueueEventOnServer(TaskArtifactUpdateEvent.builder() + .taskId(MINIMAL_TASK.id()) + .contextId(MINIMAL_TASK.contextId()) + .artifact(Artifact.builder() + .artifactId("11") + .parts(new TextPart("text")) + .build()) + .build()); + enqueueEventOnServer(TaskStatusUpdateEvent.builder() + .taskId(MINIMAL_TASK.id()) + .contextId(MINIMAL_TASK.contextId()) + .status(new org.a2aproject.sdk.spec.TaskStatus(TaskState.TASK_STATE_COMPLETED)) + .build()); + assertTrue(events.await(30, TimeUnit.SECONDS)); + assertNull(error.get()); + assertEquals(MINIMAL_TASK.id(), artifact.get().taskId()); + assertEquals(TaskState.TASK_STATE_COMPLETED, status.get().status().state()); + } finally { + deleteTaskInTaskStore(MINIMAL_TASK.id()); + } + } + + @Test + public void testResubscribeNoExistingTaskError() throws Exception { + AtomicReference error = new AtomicReference<>(); + CountDownLatch latch = new CountDownLatch(1); + getClient().subscribeToTask(new TaskIdParams("non-existent-task"), List.of(), failure -> { + error.set(failure); + latch.countDown(); + }); + assertTrue(latch.await(10, TimeUnit.SECONDS)); + assertNotNull(error.get()); + } + + @Test + public void testMainQueueReferenceCountingWithMultipleConsumers() throws Exception { + saveTaskInTaskStore(MINIMAL_TASK); + try { + ensureQueueForTask(MINIMAL_TASK.id()); + CountDownLatch firstEvent = new CountDownLatch(1); + CountDownLatch secondEvent = new CountDownLatch(1); + BiConsumer firstConsumer = (event, card) -> { + if (event instanceof TaskUpdateEvent update + && update.getUpdateEvent() instanceof TaskArtifactUpdateEvent) { + firstEvent.countDown(); + } + }; + BiConsumer secondConsumer = (event, card) -> { + if (event instanceof TaskUpdateEvent update + && update.getUpdateEvent() instanceof TaskArtifactUpdateEvent) { + secondEvent.countDown(); + } + }; + CompletableFuture firstSubscription = awaitStreamingSubscription(); + getClient().subscribeToTask(new TaskIdParams(MINIMAL_TASK.id()), List.of(firstConsumer), null); + firstSubscription.get(15, TimeUnit.SECONDS); + enqueueEventOnServer(TaskArtifactUpdateEvent.builder().taskId(MINIMAL_TASK.id()) + .contextId(MINIMAL_TASK.contextId()).artifact(Artifact.builder().artifactId("artifact-1") + .parts(new TextPart("First artifact")).build()).build()); + assertTrue(firstEvent.await(15, TimeUnit.SECONDS)); + assertTrue(getChildQueueCount(MINIMAL_TASK.id()) >= 2); + + CompletableFuture secondSubscription = awaitStreamingSubscription(); + getClient().subscribeToTask(new TaskIdParams(MINIMAL_TASK.id()), List.of(secondConsumer), null); + secondSubscription.get(15, TimeUnit.SECONDS); + enqueueEventOnServer(TaskArtifactUpdateEvent.builder().taskId(MINIMAL_TASK.id()) + .contextId(MINIMAL_TASK.contextId()).artifact(Artifact.builder().artifactId("artifact-2") + .parts(new TextPart("Second artifact")).build()).build()); + assertTrue(secondEvent.await(15, TimeUnit.SECONDS)); + } finally { + deleteTaskInTaskStore(MINIMAL_TASK.id()); + } + } + + @Test + public void testNonBlockingWithMultipleMessages() throws Exception { + Client polling = getPollingClient(); + List> calls = new ArrayList<>(); + List tasks = new ArrayList<>(); + try { + for (int i = 0; i < 2; i++) { + Task task = Task.builder(MINIMAL_TASK).id("compat-held-task-" + i).build(); + tasks.add(task); + saveTaskInTaskStore(task); + Message message = Message.builder(MESSAGE).messageId("non-blocking-" + i) + .taskId(task.id()).contextId(task.contextId()).parts(new TextPart("compat-hold:")).build(); + calls.add(CompletableFuture.runAsync(() -> polling.sendMessage(message, + List.of((event, card) -> assertInstanceOf(TaskEvent.class, event)), null))); + } + for (int i = 0; i < calls.size(); i++) { + // The executor is still held; a blocking request cannot complete within this deadline. + calls.get(i).get(10, TimeUnit.SECONDS); + Task working = awaitTaskState(tasks.get(i).id(), TaskState.TASK_STATE_WORKING); + assertEquals(TaskState.TASK_STATE_WORKING, working.status().state()); + } + } finally { + cleanupHeldTasks(tasks, calls); + } + } + + private void cleanupHeldTasks(List tasks, List> calls) throws Exception { + List failures = new ArrayList<>(); + try { + // Independent release markers cannot be overwritten by an executor's task update. + // Release all executors before waiting for any individual request to finish. + for (Task task : tasks) { + try { + saveTaskInTaskStore(Task.builder(task).id(task.id() + "-release").build()); + } catch (Exception e) { + failures.add(e); + } + } + for (int i = 0; i < calls.size(); i++) { + try { + calls.get(i).get(10, TimeUnit.SECONDS); + awaitTaskState(tasks.get(i).id(), TaskState.TASK_STATE_COMPLETED); + } catch (Exception | AssertionError e) { + failures.add(e); + } + } + } finally { + for (Task task : tasks) { + for (String id : List.of(task.id(), task.id() + "-release")) { + try { + deleteTaskInTaskStore(id); + } catch (Exception e) { + failures.add(e); + } + } + } + } + if (!failures.isEmpty()) { + Exception failure = new Exception("Could not clean up held compatibility tasks"); + failures.forEach(failure::addSuppressed); + throw failure; + } + } + + private Task awaitTaskState(String taskId, TaskState expected) throws Exception { + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10); + Task task; + do { + task = getTaskFromTaskStore(taskId); + if (task != null && task.status().state() == expected) { + return task; + } + Thread.sleep(25); + } while (System.nanoTime() < deadline); + fail("Task " + taskId + " did not reach " + expected); + throw new AssertionError("unreachable"); + } + + @Test + public void testMainQueueStaysOpenForNonFinalTasks() throws Exception { + String taskId = "fire-and-forget-task-integration"; + Task task = Task.builder(MINIMAL_TASK).id(taskId).status(new org.a2aproject.sdk.spec.TaskStatus( + TaskState.TASK_STATE_WORKING)).build(); + saveTaskInTaskStore(task); + try { + ensureQueueWithoutConsumer(taskId); + assertEquals(0, getChildQueueCount(taskId), "Non-final task queue must stay open with no consumers"); + CountDownLatch updateReceived = new CountDownLatch(1); + AtomicReference error = new AtomicReference<>(); + CompletableFuture subscription = awaitStreamingSubscription(); + getClient().subscribeToTask(new TaskIdParams(taskId), List.of((event, card) -> { + if (event instanceof TaskUpdateEvent update + && update.getUpdateEvent() instanceof TaskStatusUpdateEvent status + && taskId.equals(status.taskId()) + && status.status().state() == TaskState.TASK_STATE_WORKING) { + updateReceived.countDown(); + } + }), failure -> { + if (failure != null && !isStreamClosedError(failure)) { + error.set(failure); + updateReceived.countDown(); + } + }); + subscription.get(15, TimeUnit.SECONDS); + assertEquals(1, getChildQueueCount(taskId), "Late subscription must create an active consumer"); + enqueueEventOnServer(TaskStatusUpdateEvent.builder().taskId(taskId).contextId(task.contextId()) + .status(new org.a2aproject.sdk.spec.TaskStatus(TaskState.TASK_STATE_WORKING)).build()); + assertTrue(updateReceived.await(30, TimeUnit.SECONDS), "Must receive the enqueued status update"); + assertNull(error.get(), "Subscription must not fail"); + assertEquals(1, getChildQueueCount(taskId), "Non-final update must leave the consumer and main queue open"); + } finally { + deleteTaskInTaskStore(taskId); + } + } + + @Test + public void testMainQueueClosesForFinalizedTasks() throws Exception { + String taskId = "finalized-task-integration"; + saveTaskInTaskStore(Task.builder(MINIMAL_TASK).id(taskId).build()); + try { + ensureQueueWithoutConsumer(taskId); + assertEquals(0, getChildQueueCount(taskId)); + CountDownLatch finalUpdateReceived = new CountDownLatch(1); + AtomicReference error = new AtomicReference<>(); + CompletableFuture subscription = awaitStreamingSubscription(); + getClient().subscribeToTask(new TaskIdParams(taskId), List.of((event, card) -> { + if (event instanceof TaskUpdateEvent update + && update.getUpdateEvent() instanceof TaskStatusUpdateEvent status + && taskId.equals(status.taskId()) + && status.status().state() == TaskState.TASK_STATE_COMPLETED + && status.isFinal()) { + finalUpdateReceived.countDown(); + } + }), failure -> { + if (failure != null && !isStreamClosedError(failure)) { + error.set(failure); + finalUpdateReceived.countDown(); + } + }); + subscription.get(15, TimeUnit.SECONDS); + assertEquals(1, getChildQueueCount(taskId), "Subscription must be active before finalization"); + enqueueEventOnServer(TaskStatusUpdateEvent.builder().taskId(taskId).contextId(MINIMAL_TASK.contextId()) + .status(new org.a2aproject.sdk.spec.TaskStatus(TaskState.TASK_STATE_COMPLETED)).build()); + assertTrue(finalUpdateReceived.await(30, TimeUnit.SECONDS), "Must receive the final status update"); + assertNull(error.get(), "Subscription must not fail before delivering the final update"); + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(15); + while (getChildQueueCount(taskId) != -1 && System.nanoTime() < deadline) { + Thread.sleep(50); + } + assertEquals(-1, getChildQueueCount(taskId), "Finalized task must close or remove its main queue"); + assertNull(error.get(), "Subscription must close without an unexpected error"); + } finally { + deleteTaskInTaskStore(taskId); + } + } + + private void ensureQueueWithoutConsumer(String taskId) throws Exception { + sendTestRequest("/test/queue/ensure/" + taskId + "?withoutConsumer=true", "POST", "", 200); + } + + private CompletableFuture awaitStreamingSubscription() { + int initial = getStreamingSubscribedCount(); + return CompletableFuture.runAsync(() -> { + long deadline = System.currentTimeMillis() + 15_000; + while (System.currentTimeMillis() < deadline) { + if (getStreamingSubscribedCount() > initial) { + return; + } + try { + Thread.sleep(250); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new RuntimeException(e); + } + } + throw new IllegalStateException("Timed out waiting for streaming subscription"); + }); + } + + @Test + public void testListPushNotificationConfigsWithConfigId() throws Exception { + saveTaskInTaskStore(MINIMAL_TASK); + try { + savePushNotificationConfigInStore(MINIMAL_TASK.id(), pushConfig("config1", "http://example.com")); + savePushNotificationConfigInStore(MINIMAL_TASK.id(), pushConfig("config2", "http://example.com")); + ListTaskPushNotificationConfigsResult result = getClient() + .listTaskPushNotificationConfigurations(new ListTaskPushNotificationConfigsParams(MINIMAL_TASK.id())); + assertEquals(2, result.size()); + } finally { + deletePushNotificationConfigInStore(MINIMAL_TASK.id(), "config1"); + deletePushNotificationConfigInStore(MINIMAL_TASK.id(), "config2"); + deleteTaskInTaskStore(MINIMAL_TASK.id()); + } + } + + @Test + public void testListPushNotificationConfigsWithoutConfigId() throws Exception { + saveTaskInTaskStore(MINIMAL_TASK); + try { + savePushNotificationConfigInStore(MINIMAL_TASK.id(), pushConfig(MINIMAL_TASK.id(), "http://example.com")); + assertEquals(1, getClient().listTaskPushNotificationConfigurations( + new ListTaskPushNotificationConfigsParams(MINIMAL_TASK.id())).size()); + } finally { + deletePushNotificationConfigInStore(MINIMAL_TASK.id(), MINIMAL_TASK.id()); + deleteTaskInTaskStore(MINIMAL_TASK.id()); + } + } + + @Test + public void testListPushNotificationConfigsTaskNotFound() throws Exception { + A2AClientException error = org.junit.jupiter.api.Assertions.assertThrows(A2AClientException.class, + () -> getClient().listTaskPushNotificationConfigurations( + new ListTaskPushNotificationConfigsParams("non-existent-task"))); + assertInstanceOf(TaskNotFoundError.class, error.getCause()); + } + + @Test + public void testListPushNotificationConfigsEmptyList() throws Exception { + saveTaskInTaskStore(MINIMAL_TASK); + try { + assertEquals(0, getClient().listTaskPushNotificationConfigurations( + new ListTaskPushNotificationConfigsParams(MINIMAL_TASK.id())).size()); + } finally { + deleteTaskInTaskStore(MINIMAL_TASK.id()); + } + } + + @Test + public void testDeletePushNotificationConfigWithValidConfigId() throws Exception { + saveTaskInTaskStore(MINIMAL_TASK); + try { + savePushNotificationConfigInStore(MINIMAL_TASK.id(), pushConfig("config1", "http://example.com")); + savePushNotificationConfigInStore(MINIMAL_TASK.id(), pushConfig("config2", "http://example.com")); + getClient().deleteTaskPushNotificationConfigurations( + new DeleteTaskPushNotificationConfigParams(MINIMAL_TASK.id(), "config1")); + assertEquals(1, getClient().listTaskPushNotificationConfigurations( + new ListTaskPushNotificationConfigsParams(MINIMAL_TASK.id())).size()); + } finally { + deletePushNotificationConfigInStore(MINIMAL_TASK.id(), "config1"); + deletePushNotificationConfigInStore(MINIMAL_TASK.id(), "config2"); + deleteTaskInTaskStore(MINIMAL_TASK.id()); + } + } + + @Test + public void testDeletePushNotificationConfigWithNonExistingConfigId() throws Exception { + saveTaskInTaskStore(MINIMAL_TASK); + try { + savePushNotificationConfigInStore(MINIMAL_TASK.id(), pushConfig("config1", "http://example.com")); + getClient().deleteTaskPushNotificationConfigurations(new DeleteTaskPushNotificationConfigParams( + MINIMAL_TASK.id(), "non-existent-config-id")); + assertEquals(1, getClient().listTaskPushNotificationConfigurations( + new ListTaskPushNotificationConfigsParams(MINIMAL_TASK.id())).size()); + } finally { + deletePushNotificationConfigInStore(MINIMAL_TASK.id(), "config1"); + deleteTaskInTaskStore(MINIMAL_TASK.id()); + } + } + + @Test + public void testDeletePushNotificationConfigTaskNotFound() throws Exception { + A2AClientException error = org.junit.jupiter.api.Assertions.assertThrows(A2AClientException.class, + () -> getClient().deleteTaskPushNotificationConfigurations( + new DeleteTaskPushNotificationConfigParams("non-existent-task", "config"))); + assertInstanceOf(TaskNotFoundError.class, error.getCause()); + } + + @Test + public void testDeletePushNotificationConfigSetWithoutConfigId() throws Exception { + saveTaskInTaskStore(MINIMAL_TASK); + try { + savePushNotificationConfigInStore(MINIMAL_TASK.id(), pushConfig(MINIMAL_TASK.id(), "http://example.com")); + getClient().deleteTaskPushNotificationConfigurations(new DeleteTaskPushNotificationConfigParams( + MINIMAL_TASK.id(), MINIMAL_TASK.id())); + assertEquals(0, getClient().listTaskPushNotificationConfigurations( + new ListTaskPushNotificationConfigsParams(MINIMAL_TASK.id())).size()); + } finally { + deletePushNotificationConfigInStore(MINIMAL_TASK.id(), MINIMAL_TASK.id()); + deleteTaskInTaskStore(MINIMAL_TASK.id()); + } + } + + private static TaskPushNotificationConfig pushConfig(String id, String url) { + return TaskPushNotificationConfig.builder().id(id).url(url).build(); + } + + @Test + public void testNonObjectDataPartsAreRejectedForBlockingAndStreamingSends() throws Exception { + for (Client sendingClient : List.of(getNonStreamingClient(), getClient())) { + for (Object data : List.of("value", 42, true, List.of("item"))) { + Message message = Message.builder().messageId("unsupported-data").role(Message.Role.ROLE_USER) + .parts(new DataPart(data)).build(); + + A2AClientException exception = org.junit.jupiter.api.Assertions.assertThrows(A2AClientException.class, + () -> sendingClient.sendMessage(message)); + + assertInstanceOf(UnsupportedOperationError.class, exception.getCause()); + } + } + } + + @Test + public void testUnsupportedOperationsAreRejectedLocally() throws Exception { + A2AClientException listError = org.junit.jupiter.api.Assertions.assertThrows(A2AClientException.class, + () -> getClient().listTasks(new ListTasksParams())); + assertInstanceOf(UnsupportedOperationError.class, listError.getCause()); + + A2AClientException cardError = org.junit.jupiter.api.Assertions.assertThrows(A2AClientException.class, + () -> getClient().getExtendedAgentCard()); + assertInstanceOf(UnsupportedOperationError.class, cardError.getCause()); + + A2AClientException tenantError = org.junit.jupiter.api.Assertions.assertThrows(A2AClientException.class, + () -> getClient().getTask(new TaskQueryParams("task-123", null, "tenant-a"))); + assertInstanceOf(UnsupportedOperationError.class, tenantError.getCause()); + + A2AClientException paginationError = org.junit.jupiter.api.Assertions.assertThrows(A2AClientException.class, + () -> getClient().listTaskPushNotificationConfigurations( + new ListTaskPushNotificationConfigsParams("task-123", 10, "next", null))); + assertInstanceOf(UnsupportedOperationError.class, paginationError.getCause()); + } + + protected final void registerCreatedClient(Client client) { + createdClients.add(client); + } + + @AfterEach + void closeCreatedClients() { + createdClients.forEach(Client::close); + createdClients.clear(); + client = null; + nonStreamingClient = null; + pollingClient = null; + } + + protected void saveTaskInTaskStore(Task task) throws Exception { + sendTestRequest("/test/task", "POST", JsonUtil.toJson(task), 200); + } + + protected Task getTaskFromTaskStore(String taskId) throws Exception { + HttpResponse response = testRequest("/test/task/" + taskId, "GET", null); + if (response.statusCode() == 404) { + return null; + } + assertEquals(200, response.statusCode(), response.body()); + return JsonUtil.fromJson(response.body(), Task.class); + } + + protected void deleteTaskInTaskStore(String taskId) throws Exception { + sendTestRequest("/test/task/" + taskId, "DELETE", null, 200); + } + + protected void ensureQueueForTask(String taskId) throws Exception { + sendTestRequest("/test/queue/ensure/" + taskId, "POST", "", 200); + } + + protected void enqueueEventOnServer(Event event) throws Exception { + String path; + if (event instanceof TaskArtifactUpdateEvent artifact) { + path = "/test/queue/enqueueTaskArtifactUpdateEvent/" + artifact.taskId(); + } else if (event instanceof TaskStatusUpdateEvent status) { + path = "/test/queue/enqueueTaskStatusUpdateEvent/" + status.taskId(); + } else { + throw new IllegalArgumentException("Unsupported event type: " + event.getClass()); + } + sendTestRequest(path, "POST", JsonUtil.toJson(event), 200); + } + + protected int getChildQueueCount(String taskId) { + try { + return Integer.parseInt(testRequest("/test/queue/childCount/" + taskId, "GET", null) + .body().trim()); + } catch (IOException | InterruptedException e) { + throw new RuntimeException(e); + } + } + + protected int getStreamingSubscribedCount() { + try { + return Integer.parseInt(testRequest("/test/streamingSubscribedCount", "GET", null).body().trim()); + } catch (IOException | InterruptedException e) { + throw new RuntimeException(e); + } + } + + protected void deletePushNotificationConfigInStore(String taskId, String configId) throws Exception { + sendTestRequest("/test/task/" + taskId + "/config/" + configId, "DELETE", null, 200); + } + + protected void savePushNotificationConfigInStore(String taskId, + TaskPushNotificationConfig notificationConfig) throws Exception { + sendTestRequest("/test/task/" + taskId, "POST", JsonUtil.toJson(notificationConfig), 200); + } + + private void sendTestRequest(String path, String method, String body, int expectedStatus) throws Exception { + HttpResponse response = testRequest(path, method, body); + assertEquals(expectedStatus, response.statusCode(), response.body()); + } + + private HttpResponse testRequest(String path, String method, String body) + throws IOException, InterruptedException { + HttpRequest.Builder request = HttpRequest.newBuilder() + .uri(URI.create("http://localhost:" + serverPort + path)); + if (body == null) { + request.method(method, HttpRequest.BodyPublishers.noBody()); + } else { + request.method(method, HttpRequest.BodyPublishers.ofString(body, StandardCharsets.UTF_8)) + .header("Content-Type", APPLICATION_JSON); + } + return HttpClient.newBuilder().version(HttpClient.Version.HTTP_2).build() + .send(request.build(), HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); + } +} diff --git a/tests/server-common/src/test/java/org/a2aproject/sdk/server/apps/common/AbstractA2AServerCompatibilityWithAuthTest_v0_3.java b/tests/server-common/src/test/java/org/a2aproject/sdk/server/apps/common/AbstractA2AServerCompatibilityWithAuthTest_v0_3.java new file mode 100644 index 000000000..b645a1cf8 --- /dev/null +++ b/tests/server-common/src/test/java/org/a2aproject/sdk/server/apps/common/AbstractA2AServerCompatibilityWithAuthTest_v0_3.java @@ -0,0 +1,123 @@ +package org.a2aproject.sdk.server.apps.common; + +import java.nio.charset.StandardCharsets; +import java.util.Base64; + +import org.a2aproject.sdk.client.Client; +import org.a2aproject.sdk.client.ClientBuilder; +import org.a2aproject.sdk.client.config.ClientConfig; +import org.a2aproject.sdk.spec.A2AClientException; +import org.a2aproject.sdk.spec.AgentCard; +import org.a2aproject.sdk.spec.TaskQueryParams; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.fail; + +/** Authentication support for v1 compatibility clients talking to v0.3 servers. */ +public abstract class AbstractA2AServerCompatibilityWithAuthTest_v0_3 + extends AbstractA2AServerCompatibilityTest_v0_3 { + + protected static final String TEST_USERNAME = "testuser"; + protected static final String TEST_PASSWORD = "testpass"; + protected static final String BASIC_AUTH_SCHEME_NAME = "basicAuth"; + + protected static String getEncodedCredentials() { + return Base64.getEncoder().encodeToString( + (TEST_USERNAME + ":" + TEST_PASSWORD).getBytes(StandardCharsets.UTF_8)); + } + + private Client authenticatedClient; + private Client unauthenticatedClient; + + protected AbstractA2AServerCompatibilityWithAuthTest_v0_3(int serverPort) { + super(serverPort); + } + + protected abstract void configureTransportWithAuth(ClientBuilder builder); + + @Override + protected Client createClient(boolean streaming) throws A2AClientException { + return createAuthenticatedClient(streaming, false); + } + + @Override + protected Client createPollingClient() throws A2AClientException { + return createAuthenticatedClient(false, true); + } + + protected Client createAuthenticatedClient() throws A2AClientException { + return createAuthenticatedClient(false, false); + } + + private Client createAuthenticatedClient(boolean streaming, boolean polling) throws A2AClientException { + ClientBuilder builder = Client.builder(getAgentCard()) + .clientConfig(new ClientConfig.Builder().setStreaming(streaming).setPolling(polling).build()); + configureTransportWithAuth(builder); + Client created = builder.build(); + registerCreatedClient(created); + return created; + } + + protected Client createUnauthenticatedClient() throws A2AClientException { + ClientBuilder builder = Client.builder(getAgentCard()) + .clientConfig(new ClientConfig.Builder().setStreaming(false).build()); + configureTransport(builder); + Client created = builder.build(); + registerCreatedClient(created); + return created; + } + + protected Client getAuthenticatedClient() throws A2AClientException { + if (authenticatedClient == null) { + authenticatedClient = createAuthenticatedClient(); + } + return authenticatedClient; + } + + protected Client getUnauthenticatedClient() throws A2AClientException { + if (unauthenticatedClient == null) { + unauthenticatedClient = createUnauthenticatedClient(); + } + return unauthenticatedClient; + } + + @Test + public void testGetTaskRequiresAuthenticationUnauthenticated() throws Exception { + saveTaskInTaskStore(MINIMAL_TASK); + try { + A2AClientException error = org.junit.jupiter.api.Assertions.assertThrows(A2AClientException.class, + () -> getUnauthenticatedClient().getTask(new TaskQueryParams(MINIMAL_TASK.id()))); + assertTrue(error.getMessage().contains("Authentication failed") + || error.getMessage().contains("401") + || error.getMessage().contains("Unauthorized"), error.getMessage()); + } finally { + deleteTaskInTaskStore(MINIMAL_TASK.id()); + } + } + + @Test + public void testGetTaskWithAuthentication() throws Exception { + saveTaskInTaskStore(MINIMAL_TASK); + try { + assertNotNull(getAuthenticatedClient().getTask(new TaskQueryParams(MINIMAL_TASK.id()))); + } finally { + deleteTaskInTaskStore(MINIMAL_TASK.id()); + } + } + + @Test + public void testGetAgentCardIsPublic() { + assertNotNull(getAgentCard()); + assertNotNull(getAgentCard().supportedInterfaces()); + } + + @Override + void closeCreatedClients() { + super.closeCreatedClients(); + authenticatedClient = null; + unauthenticatedClient = null; + } + +} diff --git a/tests/server-common/src/test/java/org/a2aproject/sdk/server/apps/common/AgentCardProducer.java b/tests/server-common/src/test/java/org/a2aproject/sdk/server/apps/common/AgentCardProducer.java index 710f8bae3..4262480f8 100644 --- a/tests/server-common/src/test/java/org/a2aproject/sdk/server/apps/common/AgentCardProducer.java +++ b/tests/server-common/src/test/java/org/a2aproject/sdk/server/apps/common/AgentCardProducer.java @@ -24,6 +24,7 @@ import org.a2aproject.sdk.spec.SecurityRequirement; import org.eclipse.microprofile.config.inject.ConfigProperty; +import io.quarkus.arc.DefaultBean; import io.quarkus.arc.profile.IfBuildProfile; import org.junit.jupiter.api.Assertions; @@ -42,6 +43,7 @@ public class AgentCardProducer { @Produces @PublicAgentCard @ExtendedAgentCard + @DefaultBean public AgentCard agentCard() { String port = System.getProperty("test.agent.card.port", "8081"); String preferredTransport = loadPreferredTransportFromProperties(); @@ -99,4 +101,3 @@ private static String loadPreferredTransportFromProperties() { return preferredTransport; } } - diff --git a/tests/server-common/src/test/java/org/a2aproject/sdk/server/apps/common/AgentExecutorProducer.java b/tests/server-common/src/test/java/org/a2aproject/sdk/server/apps/common/AgentExecutorProducer.java index b46519e30..b9d9866b3 100644 --- a/tests/server-common/src/test/java/org/a2aproject/sdk/server/apps/common/AgentExecutorProducer.java +++ b/tests/server-common/src/test/java/org/a2aproject/sdk/server/apps/common/AgentExecutorProducer.java @@ -3,6 +3,7 @@ import static org.a2aproject.sdk.server.ServerCallContext.TRANSPORT_KEY; import java.util.List; +import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicReference; import jakarta.enterprise.context.ApplicationScoped; @@ -19,6 +20,7 @@ import org.a2aproject.sdk.server.agentexecution.AgentExecutor; import org.a2aproject.sdk.server.agentexecution.RequestContext; import org.a2aproject.sdk.server.tasks.AgentEmitter; +import org.a2aproject.sdk.server.tasks.TaskStore; import org.a2aproject.sdk.spec.A2AClientException; import org.a2aproject.sdk.spec.A2AError; import org.a2aproject.sdk.spec.AgentCard; @@ -45,6 +47,9 @@ public class AgentExecutorProducer { @Inject RequestScopedBean requestScopedBean; + @Inject + TaskStore taskStore; + @Produces public AgentExecutor agentExecutor() { return new AgentExecutor() { @@ -53,6 +58,28 @@ public void execute(RequestContext context, AgentEmitter agentEmitter) throws A2 String taskId = context.getTaskId(); String input = context.getMessage() != null ? extractTextFromMessage(context.getMessage()) : ""; + if (input.startsWith("compat-hold:")) { + agentEmitter.startWork(); + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(60); + while (System.nanoTime() < deadline) { + Task task = taskStore.get(taskId); + if (task == null) { + throw new InternalError("Held compatibility task was removed"); + } + if (taskStore.get(taskId + "-release") != null) { + agentEmitter.complete(); + return; + } + try { + Thread.sleep(25); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new InternalError("Held compatibility task interrupted"); + } + } + throw new InternalError("Held compatibility task was not released"); + } + // Agent-to-agent communication test (routed by message content prefix) if (input.startsWith("delegate:") || input.startsWith("a2a-local:")) { handleAgentToAgentTest(context, agentEmitter); diff --git a/tests/server-common/src/test/java/org/a2aproject/sdk/server/apps/common/TestUtilsBean.java b/tests/server-common/src/test/java/org/a2aproject/sdk/server/apps/common/TestUtilsBean.java index a7360bbd3..722ca11fa 100644 --- a/tests/server-common/src/test/java/org/a2aproject/sdk/server/apps/common/TestUtilsBean.java +++ b/tests/server-common/src/test/java/org/a2aproject/sdk/server/apps/common/TestUtilsBean.java @@ -2,13 +2,12 @@ import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; - import org.a2aproject.sdk.server.events.QueueManager; import org.a2aproject.sdk.server.tasks.PushNotificationConfigStore; import org.a2aproject.sdk.server.tasks.TaskStore; import org.a2aproject.sdk.spec.Event; -import org.a2aproject.sdk.spec.TaskPushNotificationConfig; import org.a2aproject.sdk.spec.Task; +import org.a2aproject.sdk.spec.TaskPushNotificationConfig; /** * Contains utilities to interact with the server side for the tests. @@ -46,6 +45,11 @@ public void ensureQueue(String taskId) { queueManager.createOrTap(taskId); } + public void ensureQueueWithoutConsumer(String taskId) { + // Release the setup child so queue lifecycle assertions observe only real subscribers. + queueManager.createOrTap(taskId).close(); + } + public void enqueueEvent(String taskId, Event event) { queueManager.get(taskId).enqueueEvent(event); }