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 f1b18d454..32f0841b6 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..35f3890de 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. *

@@ -393,11 +422,21 @@ public static AgentCard getAgentCard(String agentUrl, String relativeCardPath, M * @throws org.a2aproject.sdk.spec.A2AClientJSONError if the response body cannot be decoded as JSON or validated against the AgentCard schema */ public static AgentCard getAgentCard(A2AHttpClient httpClient, String agentUrl, String relativeCardPath, Map authHeaders) throws A2AClientError, A2AClientJSONError { + return getAgentCard(httpClient, agentUrl, relativeCardPath, authHeaders, Set.of("1.0")); + } + + /** + * 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..7526cd730 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 @@ -17,6 +17,7 @@ import org.a2aproject.sdk.client.transport.spi.ClientTransportConfigBuilder; import org.a2aproject.sdk.client.transport.spi.ClientTransportProvider; import org.a2aproject.sdk.client.transport.spi.ClientTransportWrapper; +import org.a2aproject.sdk.client.http.A2ACardResolver; import org.a2aproject.sdk.spec.A2AClientException; import org.a2aproject.sdk.spec.AgentCard; import org.a2aproject.sdk.spec.AgentInterface; @@ -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; @@ -286,8 +296,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 +323,65 @@ 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); + } } - return serverInterfacesMap; + return ordered; } private List getClientPreferredTransports() { @@ -355,22 +398,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 +435,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..23f64d1d1 --- /dev/null +++ b/client/base/src/main/java/org/a2aproject/sdk/client/VersionedClientTransportProvider.java @@ -0,0 +1,19 @@ +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 { + String protocolBinding(); + + String protocolVersion(); + + Class configuredTransportClass(); + + ClientTransport create(ClientTransportConfig config, AgentCard card, AgentInterface agentInterface) + throws A2AClientException; +} 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..92638bf54 --- /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.3.3.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..74a2d0529 --- /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.validateMessageSend(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.toV03(ProtoUtils.FromProto.messageSendParams(payloadPayload(payload))), + legacyContext(context, payload)))); + } + + @Override + public void sendMessageStreaming(MessageSendParams request, Consumer events, + Consumer errors, @Nullable ClientCallContext context) { + Compat03ClientTransportSupport.validateMessageSend(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.toV03(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.validateCancel(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.toV03(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..ca3aa4917 --- /dev/null +++ b/compat-0.3/client/adapter-grpc/src/test/java/org/a2aproject/sdk/compat03/client/adapter/grpc/GrpcCompat03ClientTransportProviderTest.java @@ -0,0 +1,119 @@ +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.concurrent.atomic.AtomicBoolean; + +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.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.MessageSendParams; +import org.a2aproject.sdk.spec.TextPart; +import org.a2aproject.sdk.spec.TransportProtocol; +import io.grpc.ManagedChannel; +import io.grpc.Server; +import io.grpc.inprocess.InProcessChannelBuilder; +import io.grpc.inprocess.InProcessServerBuilder; +import io.grpc.stub.StreamObserver; + +import org.junit.jupiter.api.Test; + +class GrpcCompat03ClientTransportProviderTest { + @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-jsonrpc/pom.xml b/compat-0.3/client/adapter-jsonrpc/pom.xml new file mode 100644 index 000000000..b9e3b7fd6 --- /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.3.3.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..c6d3a1d50 --- /dev/null +++ b/compat-0.3/client/adapter-jsonrpc/src/main/java/org/a2aproject/sdk/compat03/client/adapter/jsonrpc/JSONRPCCompat03ClientTransport.java @@ -0,0 +1,157 @@ +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.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) { + Compat03ClientTransportSupport.validateMessageSend(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.toV03(ProtoUtils.FromProto.messageSendParams( + (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) { + Compat03ClientTransportSupport.validateMessageSend(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.toV03(ProtoUtils.FromProto.messageSendParams( + (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); + 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.toV03(ProtoUtils.FromProto.cancelTaskParams( + (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 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..fa7bff6ab --- /dev/null +++ b/compat-0.3/client/adapter-jsonrpc/src/main/java/org/a2aproject/sdk/compat03/client/adapter/jsonrpc/JSONRPCCompat03ClientTransportProvider.java @@ -0,0 +1,35 @@ +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()); + JSONRPCTransportConfig nativeConfig = config == null ? new JSONRPCTransportConfig() : + (JSONRPCTransportConfig) config; + 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/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..6a3abf392 --- /dev/null +++ b/compat-0.3/client/adapter-jsonrpc/src/test/java/org/a2aproject/sdk/compat03/client/adapter/jsonrpc/JSONRPCCompat03ClientTransportTest.java @@ -0,0 +1,119 @@ +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.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; + +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.client.transport.jsonrpc.JSONRPCTransport; +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.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.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.TaskQueryParams; +import org.junit.jupiter.api.Test; + +class JSONRPCCompat03ClientTransportTest { + @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 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; + + @Override public EventKind_v0_3 sendMessage(MessageSendParams_v0_3 request, ClientCallContext_v0_3 context) { return null; } + @Override public void sendMessageStreaming(MessageSendParams_v0_3 request, java.util.function.Consumer events, + java.util.function.Consumer errors, ClientCallContext_v0_3 context) { } + @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) { return 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..b3c5d4262 --- /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.3.3.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..e26dd55b2 --- /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.validateMessageSend(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.toV03(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.validateMessageSend(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.toV03(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.validateCancel(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.toV03(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..78c29aabe --- /dev/null +++ b/compat-0.3/client/adapter-rest/src/main/java/org/a2aproject/sdk/compat03/client/adapter/rest/RestCompat03ClientTransportProvider.java @@ -0,0 +1,34 @@ +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()); + RestTransportConfig nativeConfig = config == null ? new RestTransportConfig() : + (RestTransportConfig) config; + 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..a986f0feb --- /dev/null +++ b/compat-0.3/client/adapter-rest/src/test/java/org/a2aproject/sdk/compat03/client/adapter/rest/RestCompat03ClientTransportTest.java @@ -0,0 +1,42 @@ +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.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")); + } + + 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/pom.xml b/compat-0.3/client/adapter/pom.xml new file mode 100644 index 000000000..a1d8ed941 --- /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.3.3.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..1ca2dde11 --- /dev/null +++ b/compat-0.3/client/adapter/src/main/java/org/a2aproject/sdk/compat03/client/adapter/Compat03AgentCardCompatibilityParser.java @@ -0,0 +1,41 @@ +package org.a2aproject.sdk.compat03.client.adapter; + +import java.util.Optional; +import java.util.Set; + +import org.a2aproject.sdk.client.http.AgentCardCompatibilityParser; +import org.a2aproject.sdk.compat03.conversion.mappers.domain.AgentCardMapper_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) { + try { + AgentCard_v0_3 legacyCard = JsonUtil_v0_3.fromJson(rawCardJson, AgentCard_v0_3.class); + if (!"0.3".equals(normalize(legacyCard.protocolVersion()))) { + return Optional.empty(); + } + return Optional.of(AgentCardMapper_v0_3.INSTANCE.toV10(legacyCard)); + } catch (Exception e) { + throw new A2AClientJSONError("Could not convert A2A 0.3 agent card to the unified client model", e); + } + } + + private static String normalize(String version) { + return switch (version) { + case "0.3", "0.3.0" -> "0.3"; + default -> version; + }; + } +} 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..bd50d042f --- /dev/null +++ b/compat-0.3/client/adapter/src/main/java/org/a2aproject/sdk/compat03/client/adapter/Compat03ClientCallContextMapper.java @@ -0,0 +1,21 @@ +package org.a2aproject.sdk.compat03.client.adapter; + +import java.util.Map; + +import org.a2aproject.sdk.client.transport.spi.interceptors.ClientCallContext; +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 ClientCallContext_v0_3 toV03(@Nullable ClientCallContext context) { + if (context == null) { + return null; + } + return new ClientCallContext_v0_3(Map.copyOf(context.getState()), Map.copyOf(context.getHeaders())); + } +} 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..ba7fe6c94 --- /dev/null +++ b/compat-0.3/client/adapter/src/main/java/org/a2aproject/sdk/compat03/client/adapter/Compat03ClientErrorMapper.java @@ -0,0 +1,76 @@ +package org.a2aproject.sdk.compat03.client.adapter; + +import java.util.LinkedHashMap; +import java.util.Map; + +import org.a2aproject.sdk.compat03.spec.A2AClientException_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.A2AError; +import org.a2aproject.sdk.spec.ContentTypeNotSupportedError; +import org.a2aproject.sdk.spec.ExtendedAgentCardNotConfiguredError; +import org.a2aproject.sdk.spec.InvalidParamsError; +import org.a2aproject.sdk.spec.InvalidRequestError; +import org.a2aproject.sdk.spec.InternalError; +import org.a2aproject.sdk.spec.InvalidAgentResponseError; +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 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 instanceof Map rawDetails)) { + return Map.of(); + } + 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..714c9c79c --- /dev/null +++ b/compat-0.3/client/adapter/src/main/java/org/a2aproject/sdk/compat03/client/adapter/Compat03ClientTransportBase.java @@ -0,0 +1,151 @@ +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.A2AClientException; +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) { + Compat03ClientTransportSupport.validateListTasks(request); + throw new A2AClientException("listTasks is not supported by A2A protocol 0.3"); + } + + @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) { + Compat03ClientTransportSupport.validateExtendedAgentCard(params); + throw new A2AClientException("getExtendedAgentCard is not supported by A2A protocol 0.3"); + } + + @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..39346fc71 --- /dev/null +++ b/compat-0.3/client/adapter/src/main/java/org/a2aproject/sdk/compat03/client/adapter/Compat03ClientTransportSupport.java @@ -0,0 +1,235 @@ +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.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.JSONRPCError_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.InvalidParamsError; +import org.a2aproject.sdk.spec.InvalidRequestError; +import org.a2aproject.sdk.spec.InternalError; +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.TaskIdParams; +import org.a2aproject.sdk.spec.TaskPushNotificationConfig; +import org.a2aproject.sdk.spec.TaskQueryParams; +import org.a2aproject.sdk.spec.StreamingEventKind; +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 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()); + 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"); + } + } + + 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 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 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); + return new GetTaskPushNotificationConfigParams_v0_3(request.taskId(), request.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) { + ListTaskPushNotificationConfigsResult result = + ListTaskPushNotificationConfigsResultMapper_v0_3.INSTANCE.toV10(configs); + return result; + } + + 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..a516f2985 --- /dev/null +++ b/compat-0.3/client/adapter/src/test/java/org/a2aproject/sdk/compat03/client/adapter/Compat03AgentCardCompatibilityParserTest.java @@ -0,0 +1,107 @@ +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.util.List; +import java.util.Map; +import java.util.Set; + +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.AgentSkill_v0_3; +import org.a2aproject.sdk.compat03.spec.AgentInterface_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 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/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..5de6e4f19 --- /dev/null +++ b/compat-0.3/client/adapter/src/test/java/org/a2aproject/sdk/compat03/client/adapter/Compat03ClientErrorMapperTest.java @@ -0,0 +1,56 @@ +package org.a2aproject.sdk.compat03.client.adapter; + +import static org.junit.jupiter.api.Assertions.assertInstanceOf; + +import java.util.List; +import java.util.Map; + +import org.a2aproject.sdk.compat03.spec.A2AClientException_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.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 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..9b2c3c6ec --- /dev/null +++ b/compat-0.3/client/adapter/src/test/java/org/a2aproject/sdk/compat03/client/adapter/Compat03ClientTransportSupportTest.java @@ -0,0 +1,175 @@ +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.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.spec.A2AClientException; +import org.a2aproject.sdk.spec.CancelTaskParams; +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.junit.jupiter.api.Test; + +class Compat03ClientTransportSupportTest { + + @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 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 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..22ce73660 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); } } @@ -386,4 +388,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 75ec20c46..d8177f52d 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.function.Consumer; @@ -25,27 +27,28 @@ 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); - if (errorHandler != null) { - errorHandler.accept(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) { + completed = true; if (errorHandler != null) { errorHandler.accept(throwable); } - future.cancel(true); // close SSE channel + if (future != null) { + future.cancel(true); // close SSE channel + } } public void onComplete() { @@ -66,23 +69,40 @@ public void onComplete() { } } - 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")) { + completed = true; JSONRPCError_v0_3 error = JsonUtil_v0_3.fromJson(jsonObject.get("error").toString(), JSONRPCError_v0_3.class); if (errorHandler != null) { errorHandler.accept(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()) { - future.cancel(true); // close SSE channel + if ((event instanceof TaskStatusUpdateEvent_v0_3 tsue && tsue.isFinal()) + || (event instanceof Task_v0_3 task && task.status().state().isFinal())) { + if (future != null) { + future.cancel(true); // close SSE channel + } } } else { throw new IllegalArgumentException("Unknown message type"); } } + private void fail(Throwable throwable, @Nullable Future future) { + completed = true; + if (errorHandler != null) { + errorHandler.accept(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 8db887db5..4400ea4a2 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 @@ -54,6 +54,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 @@ -154,7 +169,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()); @@ -163,6 +179,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 @@ -183,6 +226,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() @@ -231,6 +282,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); + } + private static class CancelCapturingFuture implements Future { private boolean cancelHandlerCalled; @@ -264,4 +326,4 @@ public Void get(long timeout, TimeUnit unit) throws InterruptedException, Execut return null; } } -} \ No newline at end of file +} diff --git a/compat-0.3/client/transport/rest/src/main/java/org/a2aproject/sdk/compat03/client/transport/rest/sse/RestSSEEventListener_v0_3.java b/compat-0.3/client/transport/rest/src/main/java/org/a2aproject/sdk/compat03/client/transport/rest/sse/RestSSEEventListener_v0_3.java index 50dd3690d..7c8ab1b88 100644 --- a/compat-0.3/client/transport/rest/src/main/java/org/a2aproject/sdk/compat03/client/transport/rest/sse/RestSSEEventListener_v0_3.java +++ b/compat-0.3/client/transport/rest/src/main/java/org/a2aproject/sdk/compat03/client/transport/rest/sse/RestSSEEventListener_v0_3.java @@ -10,6 +10,8 @@ import org.a2aproject.sdk.compat03.grpc.StreamResponse; import org.a2aproject.sdk.compat03.grpc.utils.ProtoUtils_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; public class RestSSEEventListener_v0_3 { @@ -29,9 +31,12 @@ public void onMessage(String message, @Nullable Future 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/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..047ed9837 --- /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.3.3.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 100% 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 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 100% 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 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 100% 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 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 100% 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 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/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 3c036352b..077845086 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 338fadad5..5c52d05f3 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/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..bc2dbbb79 --- /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,60 @@ +package org.a2aproject.sdk.compat03.server.grpc.quarkus; + +import java.util.List; +import java.util.Map; +import java.util.concurrent.TimeUnit; + +import io.grpc.ManagedChannel; +import io.grpc.ManagedChannelBuilder; +import io.quarkus.test.junit.QuarkusTest; +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 io.quarkus.test.junit.TestProfile; +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 ManagedChannel channel; + + 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 -> { + channel = ManagedChannelBuilder.forTarget(target).usePlaintext().build(); + return channel; + })); + } + @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 closeChannel() { + if (channel != null) { + channel.shutdownNow(); + try { channel.awaitTermination(10, TimeUnit.SECONDS); } + catch (InterruptedException e) { Thread.currentThread().interrupt(); } + } + } +} 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..560da8dd0 --- /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,60 @@ +package org.a2aproject.sdk.compat03.server.grpc.quarkus; + +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.compat03.server.grpc.quarkus.CompatibilityAuthTestProfile_v0_3; +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 ManagedChannel authenticatedChannel; + private static ManagedChannel unauthenticatedChannel; + + 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 -> { + unauthenticatedChannel = ManagedChannelBuilder.forTarget(target).usePlaintext().build(); + return unauthenticatedChannel; + })); + } + @Override protected void configureTransportWithAuth(ClientBuilder builder) { + builder.withTransport(GrpcTransport.class, new GrpcTransportConfigBuilder() + .channelFactory(target -> { + authenticatedChannel = ManagedChannelBuilder.forTarget(target).usePlaintext().build(); + return authenticatedChannel; + }).addInterceptor(new AuthInterceptor( + (scheme, context) -> BASIC_AUTH_SCHEME_NAME.equals(scheme) ? getEncodedCredentials() : null))); + } + + @AfterAll + public static void closeChannels() { + close(authenticatedChannel); + close(unauthenticatedChannel); + } + + private static void close(ManagedChannel channel) { + if (channel != null) { + channel.shutdownNow(); + try { channel.awaitTermination(10, TimeUnit.SECONDS); } + catch (InterruptedException e) { Thread.currentThread().interrupt(); } + } + } +} 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 44f85c4c2..c9940e881 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/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 52d8d3acd..2b9808865 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/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 019bae663..c4fa28b14 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/spec-grpc/pom.xml b/compat-0.3/spec-grpc/pom.xml index c5aac13bb..7a2622bba 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/docs/content/dev/client.md b/docs/content/dev/client.md index 68e962c81..4df28e6fe 100644 --- a/docs/content/dev/client.md +++ b/docs/content/dev/client.md @@ -220,7 +220,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..cef3a9455 100644 --- a/docs/content/dev/compatibility.md +++ b/docs/content/dev/compatibility.md @@ -89,32 +89,49 @@ 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. A 1.0 card remains native when both +versions are requested. + +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. + +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/docs/superpowers/plans/2026-09-14-client-v03-compatibility-tests.md b/docs/superpowers/plans/2026-09-14-client-v03-compatibility-tests.md new file mode 100644 index 000000000..ecf4d18e3 --- /dev/null +++ b/docs/superpowers/plans/2026-09-14-client-v03-compatibility-tests.md @@ -0,0 +1,494 @@ +# v1 Client Compatibility Tests Against v0.3 Servers Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add parallel end-to-end tests proving that the public v1 `Client` compatibility layer communicates with standalone A2A v0.3 JSON-RPC, REST, and gRPC servers while preserving the existing v0.3 client tests unchanged. + +**Architecture:** Keep the legacy v0.3 abstract tests and subclasses as a frozen regression suite. Add v1-only compatibility abstract bases to the root server test-commons test-jar, then add sibling subclasses to the standalone `compat-0.3/reference` test modules. HTTP/JSON-RPC and REST tests discover the genuine legacy card through the opt-in `A2A.getAgentCard(..., Set.of("0.3"))` API; gRPC supplies an equivalent v1 card fixture because it has no card endpoint. + +**Tech Stack:** Java 17, Maven multi-module build, JUnit 5, Quarkus tests, REST Assured, Gson, v1 client transports, v0.3 client adapters, ServiceLoader. + +**Spec:** `docs/superpowers/specs/2026-09-14-client-v03-compatibility-tests-design.md` + +## Global Constraints + +- Keep `AbstractA2AServerServerTest_v0_3`, `AbstractA2AServerWithAuthTest_v0_3`, and all existing v0.3 subclasses unchanged. +- New compatibility tests use only v1 `Client`, `ClientBuilder`, `ClientConfig`, v1 spec types, v1 transport configs, and v1 interceptors. +- Name new bases `AbstractA2AServerCompatibilityTest_v0_3` and `AbstractA2AServerCompatibilityWithAuthTest_v0_3`. +- Place the new bases in `tests/server-common/src/test/java/org/a2aproject/sdk/server/apps/common/` so they are exported by the existing `a2a-java-sdk-tests-server-common` test-jar. +- Target standalone `compat-0.3/reference/jsonrpc`, `compat-0.3/reference/rest`, and `compat-0.3/reference/grpc` for compatibility end-to-end tests; do not use the co-hosted `tests/multiversion/*` v1 card for legacy-only discovery assertions. +- Keep the existing legacy v0.3 dependencies in each reference test module and add explicit v1 client, native transport, shared adapter, binding adapter, and root server-common test-jar dependencies. +- Do not create a client facade shared by the v0.3 and v1 suites. Add only small helpers with one obvious responsibility when duplication is mechanical. +- Close every v1 `Client` created by a compatibility base after each test. Concrete subclasses retain ownership of caller-supplied gRPC channels and close them explicitly. +- Preserve expected local v0.3 rejections for `listTasks`, non-empty tenants, extended-agent-card retrieval, and non-default push-config pagination. +- Before end-to-end tests are enabled, make card conversion support primary-only cards, canonicalize `jsonrpc`/`http` or `rest`/`grpc` to `JSONRPC`/`HTTP+JSON`/`GRPC`, and map v0.3 HTTP Basic schemes to v1 `HTTPAuthSecurityScheme`. +- The one-fetch guarantee belongs in resolver/parser unit tests with a counting HTTP client; end-to-end tests prove that the projected card builds and operates a client. +- The scenario groups in the plan are independently reviewable slices: each group gets focused red/green verification before the next group begins. Do not translate the entire legacy base in one unverified edit. +- The implementation is not related to a GitHub issue; commits must not include a `This fixes #...` footer. + +--- + +### Task 1: Make legacy agent-card conversion usable for compatibility discovery + +**Files:** + +- Modify: `compat-0.3/conversion/src/main/java/org/a2aproject/sdk/compat03/conversion/mappers/domain/AgentCardMapper_v0_3.java` +- Modify: `compat-0.3/conversion/src/test/java/org/a2aproject/sdk/compat03/conversion/mappers/domain/AgentCardMapper_v0_3_Test.java` +- Modify: `compat-0.3/client/adapter/src/test/java/org/a2aproject/sdk/compat03/client/adapter/Compat03AgentCardCompatibilityParserTest.java` + +**Interfaces:** + +- `AgentCardMapper_v0_3.toV10(AgentCard_v0_3)` must return at least one usable v1 `AgentInterface` when the legacy card has a primary `url` and `preferredTransport` but no `additionalInterfaces`. +- `AgentCardMapper_v0_3` must map the legacy binding spellings `jsonrpc`, `http`, `rest`, and `grpc` case-insensitively to `JSONRPC`, `HTTP+JSON`, and `GRPC`; canonical values remain unchanged. +- `AgentCardMapper_v0_3` must map `HTTPAuthSecurityScheme_v0_3` to v1 `HTTPAuthSecurityScheme`, preserving `bearerFormat`, `scheme`, and `description`, and map that v1 type back to the legacy type. + +- [ ] **Step 1: Add failing primary-interface mapper tests** + +Extend `AgentCardMapper_v0_3_Test` with a parameterized test using a card whose `additionalInterfaces` is empty. Supply `(preferredTransport, expectedBinding)` rows for `jsonrpc`/`JSONRPC`, `http`/`HTTP+JSON`, `rest`/`HTTP+JSON`, `grpc`/`GRPC`, and canonical `HTTP+JSON`/`HTTP+JSON`. Assert that `supportedInterfaces()` contains exactly one interface with `url`, expected binding, and protocol version `"0.3"`. + +Add a test using `new HTTPAuthSecurityScheme_v0_3.Builder().scheme("basic").bearerFormat("none").description("HTTP Basic authentication").build()`. Assert that the projected v1 card contains `HTTPAuthSecurityScheme` with the same scheme, bearer format, and description, and that `fromV10(toV10(card))` restores those fields on the legacy HTTP scheme. + +In `Compat03AgentCardCompatibilityParserTest`, add the same primary-only and Basic-auth fixtures serialized with `JsonUtil_v0_3`, using `protocolVersion("0.3.0")`. Assert that parsing returns one canonical `0.3` interface and the projected v1 HTTP auth scheme. These parser tests must be part of the initial red test run. + +```java +private static AgentCard_v0_3 primaryOnlyCard(String preferredTransport) { + 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()) + .additionalInterfaces(List.of()) + .build(); +} + +@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()); +} +``` + +- [ ] **Step 2: Run the focused tests and confirm failure** + +Run: + +```bash +mvn -pl compat-0.3/conversion,compat-0.3/client/adapter -am -Dtest=AgentCardMapper_v0_3_Test,Compat03AgentCardCompatibilityParserTest -Dsurefire.failIfNoSpecifiedTests=false test +``` + +Expected: the new tests fail because the mapper drops the primary endpoint and rejects HTTP auth schemes. + +- [ ] **Step 3: Implement the minimal mapper changes** + +In `toV10`, build interfaces from `additionalInterfaces`; when that list is empty and both `url` and `preferredTransport` are non-null, create one `AgentInterface(canonicalBinding(preferredTransport), url, null, "0.3")`. Use the same canonicalization helper for additional interfaces and set the projected card’s top-level `preferredTransport` to the canonical binding as well. Add explicit `HTTPAuthSecurityScheme_v0_3` and v1 `HTTPAuthSecurityScheme` branches to the bidirectional security conversion methods, preserving bearer format. Continue throwing for security scheme types with no representable v1 equivalent. + +- [ ] **Step 4: Run conversion and parser verification** + +Run: + +```bash +mvn -pl compat-0.3/conversion,compat-0.3/client/adapter -am test +``` + +Expected: all mapper, parser, and existing conversion tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add compat-0.3/conversion/src/main/java/org/a2aproject/sdk/compat03/conversion/mappers/domain/AgentCardMapper_v0_3.java compat-0.3/conversion/src/test/java/org/a2aproject/sdk/compat03/conversion/mappers/domain/AgentCardMapper_v0_3_Test.java compat-0.3/client/adapter/src/test/java/org/a2aproject/sdk/compat03/client/adapter/Compat03AgentCardCompatibilityParserTest.java +git commit -m "fix: support legacy agent card compatibility fields" +``` + +### Task 2: Add the v1-only compatibility test bases and client lifecycle + +**Files:** + +- Create: `tests/server-common/src/test/java/org/a2aproject/sdk/server/apps/common/AbstractA2AServerCompatibilityTest_v0_3.java` +- Create: `tests/server-common/src/test/java/org/a2aproject/sdk/server/apps/common/AbstractA2AServerCompatibilityWithAuthTest_v0_3.java` + +**Interfaces:** + +```java +protected abstract String getTransportProtocol(); +protected abstract String getTransportUrl(); +protected abstract void configureTransport(ClientBuilder builder); +protected AgentCard getAgentCard(); +protected Client getClient() throws A2AClientException; +protected Client getNonStreamingClient() throws A2AClientException; +protected Client getPollingClient() throws A2AClientException; +protected Client createClient(boolean streaming) throws A2AClientException; +protected Client createPollingClient() throws A2AClientException; +``` + +The auth base additionally exposes the existing v1-shaped hooks: + +```java +protected abstract void configureTransportWithAuth(ClientBuilder builder); +protected Client createAuthenticatedClient() throws A2AClientException; +protected Client createUnauthenticatedClient() throws A2AClientException; +protected Client getAuthenticatedClient() throws A2AClientException; +protected Client getUnauthenticatedClient() throws A2AClientException; +``` + +- [ ] **Step 1: Create the general compatibility base skeleton** + +Copy only the test-support structure from `compat-0.3/server-conversion/src/test/java/org/a2aproject/sdk/compat03/conversion/AbstractA2AServerServerTest_v0_3.java`. Replace every legacy client/spec import with its v1 equivalent. Do not copy direct wire tests, task-store sanity tests, or the legacy `getAgentCard()` test. + +Use v1 constants and builders: + +```java +protected static final Task MINIMAL_TASK = Task.builder() + .id("task-123") + .contextId("session-xyz") + .status(new TaskStatus(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(); +``` + +The default `getAgentCard()` must call `A2A.getAgentCard(getTransportUrl(), Set.of("0.3"))`. A gRPC subclass will override it with a v1 fixture. `createClient(boolean)` must build `ClientConfig` with the requested streaming setting, call `Client.builder(getAgentCard())`, invoke `configureTransport`, and retain the returned client for cleanup. `createPollingClient()` uses `ClientConfig.Builder().setStreaming(false).setPolling(true).build()`. + +- [ ] **Step 2: Add deterministic client cleanup** + +Track every client created by `createClient`, `createPollingClient`, and the auth base’s authenticated/unauthenticated factories in a `List`. Add `@AfterEach` that closes each client exactly once, clears the list, and sets `client`, `nonStreamingClient`, `pollingClient`, `authenticatedClient`, and `unauthenticatedClient` to `null`; this prevents a closed cached client from being reused by a later test. Do not close subclass-owned channels from the base. + +- [ ] **Step 3: Add v1 server utility helpers** + +Copy the v1 versions of `saveTaskInTaskStore`, `getTaskFromTaskStore`, `deleteTaskInTaskStore`, `ensureQueueForTask`, `enqueueEventOnServer`, and push-config store helpers from `AbstractA2AServerTest`. Preserve the existing `/test/*` endpoints and v1 Gson mapper. Do not use `TaskMapper_v0_3` or any v0.3 type in these new files. + +- [ ] **Step 4: Compile the test-jar sources** + +Run: + +```bash +mvn -pl tests/server-common -am test-compile +``` + +Expected: the two new bases compile while no compatibility adapter dependency is added to the root test-common POM. + +- [ ] **Step 5: Commit** + +```bash +git add tests/server-common/src/test/java/org/a2aproject/sdk/server/apps/common/AbstractA2AServerCompatibilityTest_v0_3.java tests/server-common/src/test/java/org/a2aproject/sdk/server/apps/common/AbstractA2AServerCompatibilityWithAuthTest_v0_3.java +git commit -m "test: add v1 compatibility test bases" +``` + +### Task 3: Translate supported general, streaming, resubscription, and push scenarios + +**Files:** + +- Modify: `tests/server-common/src/test/java/org/a2aproject/sdk/server/apps/common/AbstractA2AServerCompatibilityTest_v0_3.java` +- Create: `docs/superpowers/specs/2026-09-14-client-v03-compatibility-tests-matrix.md` + +**Interfaces:** + +- The base’s client methods use v1 signatures such as `getTask(new TaskQueryParams(id))`, `cancelTask(new CancelTaskParams(id))`, `sendMessage(message, consumers, errorHandler)`, `subscribeToTask(new TaskIdParams(id), ...)`, and v1 push-config parameter records. Do not call the package-level transport overload `sendMessage(MessageSendParams)` as if it were a one-argument public convenience method. +- Consumers use `BiConsumer` and inspect `MessageEvent`, `TaskEvent`, and `TaskUpdateEvent` with v1 getters. +- Event fixtures use v1 `Event`, `TaskStatusUpdateEvent`, `TaskArtifactUpdateEvent`, `TaskState`, and `TextPart` values. + +Implement the scenario groups below as separate red/green slices. For each slice, add only that group, run `mvn -pl tests/server-common -am test-compile`, correct compile/type failures, and commit before starting the next slice. The end-to-end red/green run becomes available after Task 5 adds the concrete subclasses; at that point run the focused reference-module command for the transport being added before marking its slice complete. + +- [ ] **Step 1: Add the synchronous v1 scenario groups** + +Translate these legacy methods into the new base, preserving their test names with a `Compatibility` suffix only when needed to prevent collisions: + +`testGetTaskSuccess`, `testGetTaskNotFound`, `testCancelTaskSuccess`, `testCancelTaskNotSupported`, `testCancelTaskNotFound`, `testSendMessageNewMessageSuccess`, `testRequestScopedBeanAvailableOnAgentExecutorThread`, `testSendMessageExistingTaskSuccess`, `testSetPushNotificationSuccess`, `testGetPushNotificationSuccess`, and `testError`. + +Use v1 errors in assertions, for example: + +```java +A2AClientException error = assertThrows(A2AClientException.class, + () -> getClient().getTask(new TaskQueryParams("non-existent-task"))); +assertInstanceOf(TaskNotFoundError.class, error.getCause()); +``` +Commit this slice as `test: cover v1 compatibility synchronous scenarios`. + +- [ ] **Step 2: Add streaming scenarios** + +Translate `testSendMessageStreamNewMessageSuccess` and `testSendMessageStreamExistingTaskSuccess`. Preserve the existing latches, queue setup, event ordering, and timeout values. Replace legacy callback types with v1 `ClientEvent` callbacks and replace legacy enum values with `TaskState.TASK_STATE_*` and v1 `StreamingEventKind` values. +Commit this slice as `test: cover v1 compatibility streaming scenarios`. + +- [ ] **Step 3: Add resubscription and queue-lifecycle scenarios** + +Translate `testResubscribeExistingTaskSuccess`, `testResubscribeNoExistingTaskError`, `testMainQueueReferenceCountingWithMultipleConsumers`, `testNonBlockingWithMultipleMessages`, `testMainQueueStaysOpenForNonFinalTasks`, and `testMainQueueClosesForFinalizedTasks`, preserving their latches, queue setup, event ordering, and timeout values. +Commit this slice as `test: cover v1 compatibility resubscription scenarios`. + +- [ ] **Step 4: Add supported push-config scenarios** + +Translate the config-id, no-config-id, empty-list, task-not-found, valid-delete, missing-delete, and delete-without-config-id cases. Use the v1 default constructor `new ListTaskPushNotificationConfigsParams(taskId)` for the supported non-paginated list operation and assert the v1 `ListTaskPushNotificationConfigsResult`. +Commit this slice as `test: cover v1 compatibility push configuration scenarios`. + +- [ ] **Step 5: Add explicit local rejection scenarios** + +Add tests that invoke the v1 client and assert `A2AClientException` with an `UnsupportedOperationError` cause before any server utility state changes: + +```java +assertThrows(A2AClientException.class, + () -> getClient().listTasks(new ListTasksParams())); +assertThrows(A2AClientException.class, + () -> getClient().getExtendedAgentCard()); +assertThrows(A2AClientException.class, + () -> getClient().getTask(new TaskQueryParams("task-123", null, "tenant-a"))); +assertThrows(A2AClientException.class, + () -> getClient().listTaskPushNotificationConfigurations( + new ListTaskPushNotificationConfigsParams("task-123", 10, "next", null))); +``` + +Assert the `UnsupportedOperationError` cause and use `Compat03ClientTransportSupportTest.rejectsUnsupportedOperationsBeforeDelegateUse` as the unit-level proof that the adapter rejects these calls before delegation. The end-to-end tests should only verify the public client behavior and should not add a second counting HTTP fixture to the shared server base. +Commit this slice as `test: cover v1 compatibility local rejections`. + +- [ ] **Step 6: Write the scenario matrix** + +Create `docs/superpowers/specs/2026-09-14-client-v03-compatibility-tests-matrix.md` with one row for every client-facing test or test group in the two legacy abstract bases. Each row must name the legacy source method, the compatibility test method or resolver/adapter test that replaces it, and whether it is included or intentionally excluded. Include explicit rows for the direct-wire exclusions, task-store exclusion, legacy card retrieval replacement, task authorization exclusion, transport-specific overrides, push operations, streaming/resubscription, and local unsupported operations. +Commit the matrix as `docs: record the v1 compatibility scenario matrix`. + +- [ ] **Step 7: Run the compile-level test** + +Run: + +```bash +mvn -pl tests/server-common -am test-compile +``` + +Expected: the translated test base and matrix compile without adding v0.3 imports to root test-common production or test code. + +- [ ] **Step 8: Confirm the completed scenario slices** + +```bash +git status --short +git diff --check +``` + +The preceding scenario slices and the matrix are already committed separately; do not create a broad catch-all commit here. + +### Task 4: Add the authenticated compatibility base + +**Files:** + +- Modify: `tests/server-common/src/test/java/org/a2aproject/sdk/server/apps/common/AbstractA2AServerCompatibilityWithAuthTest_v0_3.java` + +**Interfaces:** + +- `createAuthenticatedClient()` and `createUnauthenticatedClient()` use `Client.builder(v1Card)`, v1 `ClientConfig`, and the corresponding v1 transport configuration hook. +- Authenticated configuration uses `org.a2aproject.sdk.client.transport.spi.interceptors.auth.AuthInterceptor` and the existing v1 credential callback shape. + +- [ ] **Step 1: Add the auth-base scenario skeleton** + +Translate the three v1-client auth cases: + +1. unauthenticated `getTask` fails with `A2AClientException` whose message or cause identifies authentication failure; +2. authenticated `getTask` returns the expected v1 `Task`; +3. the public card endpoint is accessible without credentials for HTTP transports. + +Use `A2A.getAgentCard(..., Set.of("0.3"))` for the public-card assertion and HTTP auth-client bootstrap. Ensure the card’s projected `HTTPAuthSecurityScheme` is available to the v1 auth interceptor. The old raw `testBasicAuthWorksViaHttp` request is intentionally excluded from this v1-client suite; retain it only in the frozen legacy suite. + +- [ ] **Step 2: Compile the auth base before transport subclasses exist** + +Run: + +```bash +mvn -pl tests/server-common -am test-compile +``` + +Expected: the v1 auth base compiles independently of any compatibility adapter or Quarkus reference module. Its tests are expected to become runnable only after the concrete subclasses are added in Task 5. + +- [ ] **Step 3: Implement v1 auth translation** + +Use `AuthInterceptor` with the same scheme-name callback as the v0.3 suite, but configure it through the v1 transport config. Define `getAuthenticatedClient()` and `getUnauthenticatedClient()` as cached accessors over exact `createAuthenticatedClient()` and `createUnauthenticatedClient()` factories, and reset those caches in the shared cleanup. Do not add raw HTTP request tests to this base, and do not import `AuthInterceptor_v0_3`. + +- [ ] **Step 4: Commit** + +```bash +git add tests/server-common/src/test/java/org/a2aproject/sdk/server/apps/common/AbstractA2AServerCompatibilityWithAuthTest_v0_3.java +git commit -m "test: add v1 compatibility authentication coverage" +``` + +### Task 5: Add standalone JSON-RPC, REST, and gRPC compatibility subclasses and Maven wiring + +**Files:** + +- Create sibling compatibility subclasses under `compat-0.3/reference/jsonrpc/src/test/java/org/a2aproject/sdk/compat03/server/apps/quarkus/` for each existing non-task-authorization JSON-RPC client variant: JDK, Android, Vert.x, and authenticated JDK/Android/Vert.x variants. +- Create sibling compatibility subclasses under `compat-0.3/reference/rest/src/test/java/org/a2aproject/sdk/compat03/server/rest/quarkus/` for each existing non-task-authorization REST client variant: JDK, Android, Vert.x, and authenticated JDK/Android/Vert.x variants. +- Create: `compat-0.3/reference/grpc/src/test/java/org/a2aproject/sdk/compat03/server/grpc/quarkus/QuarkusA2AGrpc_v0_3_CompatibilityTest.java` +- Create: `compat-0.3/reference/grpc/src/test/java/org/a2aproject/sdk/compat03/server/grpc/quarkus/QuarkusA2AGrpc_v0_3_WithAuthCompatibilityTest.java` +- Modify: `compat-0.3/reference/jsonrpc/pom.xml` +- Modify: `compat-0.3/reference/rest/pom.xml` +- Modify: `compat-0.3/reference/grpc/pom.xml` + +Use the naming pattern `Compatibility` immediately before the transport/client suffix. For example, `QuarkusA2AJSONRPC_v0_3_JdkTest` becomes `QuarkusA2AJSONRPC_v0_3_CompatibilityJdkTest`, and `QuarkusA2AJSONRPC_v0_3_WithAuthVertxTest` becomes `QuarkusA2AJSONRPC_v0_3_WithAuthCompatibilityVertxTest`. + +The concrete classes are exact siblings of the current legacy classes: `QuarkusA2AJSONRPC_v0_3_CompatibilityJdkTest`, `...CompatibilityAndroidTest`, `...CompatibilityVertxTest`, `...WithAuthCompatibilityTest`, `...WithAuthCompatibilityAndroidTest`, and `...WithAuthCompatibilityVertxTest`; the same six names with `QuarkusA2ARest_v0_3_` in the REST module; and `QuarkusA2AGrpc_v0_3_CompatibilityTest` plus `QuarkusA2AGrpc_v0_3_WithAuthCompatibilityTest` in the gRPC module. Non-auth classes use `@QuarkusTest`; auth classes use both `@QuarkusTest` and `@TestProfile(AuthTestProfile_v0_3.class)`. Every class calls the same `super(8081)` constructor as its legacy sibling and extends the corresponding new compatibility base. + +**Maven dependency matrix:** add test-scoped dependencies while retaining existing legacy dependencies: + +| Module | v1 client/native transport | Compatibility adapter | +|---|---|---| +| `compat-0.3/reference/jsonrpc` | `a2a-java-sdk-client`, `a2a-java-sdk-client-transport-jsonrpc`, `a2a-java-sdk-tests-server-common` with `type=test-jar` | `a2a-java-sdk-compat-0.3-client-adapter`, `a2a-java-sdk-compat-0.3-client-adapter-jsonrpc` | +| `compat-0.3/reference/rest` | `a2a-java-sdk-client`, `a2a-java-sdk-client-transport-rest`, `a2a-java-sdk-tests-server-common` with `type=test-jar` | `a2a-java-sdk-compat-0.3-client-adapter`, `a2a-java-sdk-compat-0.3-client-adapter-rest` | +| `compat-0.3/reference/grpc` | `a2a-java-sdk-client`, `a2a-java-sdk-client-transport-grpc`, `a2a-java-sdk-tests-server-common` with `type=test-jar` | `a2a-java-sdk-compat-0.3-client-adapter`, `a2a-java-sdk-compat-0.3-client-adapter-grpc` | + +- [ ] **Step 1: Add the general JSON-RPC subclasses** + +For each JDK, Android, and Vert.x variant, implement `getTransportProtocol()` and `getTransportUrl()` using the same values as the legacy sibling, configure `JSONRPCTransport`, and use the same HTTP client implementation. The inherited `getAgentCard()` must fetch the standalone v0.3 card and select the adapter through `protocolVersion = "0.3"`. Run the JSON-RPC focused command before proceeding and commit this slice as `test: add v1 compatibility JSON-RPC subclasses`. + +- [ ] **Step 2: Add the general REST subclasses** + +For each JDK, Android, and Vert.x variant, configure `RestTransport` and the same HTTP client implementation as its legacy sibling. Run the REST focused command before proceeding and commit this slice as `test: add v1 compatibility REST subclasses`. + +- [ ] **Step 3: Add authenticated JSON-RPC and REST subclasses** + +Copy only the transport setup from each legacy auth sibling. Replace `AuthInterceptor_v0_3` with v1 `AuthInterceptor`, preserve the `basicAuth` scheme callback, and add it to the native v1 transport config. Keep the public-card assertion in the compatibility base; do not copy the legacy raw `testBasicAuthWorksViaHttp` request. Run both focused HTTP module commands before proceeding and commit this slice as `test: add v1 compatibility HTTP auth subclasses`. + +- [ ] **Step 4: Add the gRPC general subclass** + +Configure `GrpcTransport` with the existing channel factory pattern and return a v1 `AgentCard` containing one `new AgentInterface("GRPC", getTransportUrl(), null, "0.3")`. The fixture must also contain the required v1 card fields plus `securitySchemes` with `basicAuth` as an `HTTPAuthSecurityScheme` (including non-null `bearerFormat`, for example `"none"`) and a matching `securityRequirements` entry for the auth subclass. Do not call HTTP card discovery. Close the channel in `@AfterAll` and let the base close only clients. +Run the gRPC focused command and commit this slice as `test: add v1 compatibility gRPC subclass`. + +- [ ] **Step 5: Add the gRPC auth subclass** + +Use v1 `AuthInterceptor` in `GrpcTransportConfigBuilder`, preserve separate authenticated and unauthenticated channel factories, and disable the HTTP-only auth tests. Keep channel shutdown behavior identical to the existing legacy gRPC auth test. +Run the gRPC focused command and commit this slice as `test: add v1 compatibility gRPC auth subclass`. + +- [ ] **Step 6: Add exact POM dependencies and verify ServiceLoader discovery** + +Add the matrix artifacts with `${project.version}` where the compat parent does not already supply dependency management. Do not copy service files into test resources; the adapter JARs’ existing `META-INF/services/org.a2aproject.sdk.client.http.AgentCardCompatibilityParser` and `META-INF/services/org.a2aproject.sdk.client.VersionedClientTransportProvider` must be discovered from the test runtime classpath. +Run each focused command again after the POM changes and commit the exact POM edits as `build: wire v1 compatibility test dependencies`. + +- [ ] **Step 7: Run each reference module’s focused compatibility tests** + +Run: + +```bash +mvn -pl compat-0.3/reference/jsonrpc -am -Dtest='*Compatibility*' -Dsurefire.failIfNoSpecifiedTests=false test +mvn -pl compat-0.3/reference/rest -am -Dtest='*Compatibility*' -Dsurefire.failIfNoSpecifiedTests=false test +mvn -pl compat-0.3/reference/grpc -am -Dtest='*Compatibility*' -Dsurefire.failIfNoSpecifiedTests=false test +``` + +Expected: the new v1 clients discover/select the v0.3 adapter, complete supported operations, pass authentication tests, and preserve expected local rejection behavior. + +- [ ] **Step 8: Run the unchanged legacy reference tests** + +Run the same three module commands without the `-Dtest` selector. Expected: existing v0.3 tests still pass, demonstrating that the new v1 test dependencies and subclasses did not alter legacy behavior. + +- [ ] **Step 9: Confirm the standalone reference slices** + +```bash +git status --short +git diff --check +``` + +The transport and POM slices are already committed separately; do not create a broad catch-all commit here. + +### Task 6: Complete the test matrix + +**Files:** + +- Modify: `docs/superpowers/specs/2026-09-14-client-v03-compatibility-tests-matrix.md` + +- [ ] **Step 1: Compare the matrix against both legacy abstract bases** + +Use `rg -n '^ public void test'` on both legacy bases and ensure every client-facing method is represented. Mark direct-wire, task-store, legacy card, and task-authorization exclusions with their existing test location. Mark any scenario that is covered by adapter unit tests rather than end-to-end tests with its exact adapter test class. + +- [ ] **Step 2: Document the runnable compatibility test matrix** + +Record the three reference module commands, the required adapter artifacts, the standalone legacy-card requirement, the gRPC card-fixture exception, and the fact that the original v0.3 tests remain in place. + +- [ ] **Step 3: Commit** + +```bash +git add docs/superpowers/specs/2026-09-14-client-v03-compatibility-tests-matrix.md +git commit -m "docs: record v0.3 compatibility test matrix" +``` + +### Task 7: Run final verification and inspect dependency boundaries + +**Files:** + +- No production files should change in this task. +- Inspect: `compat-0.3/reference/jsonrpc/pom.xml`, `compat-0.3/reference/rest/pom.xml`, `compat-0.3/reference/grpc/pom.xml`, and the three compatibility test classpaths. + +- [ ] **Step 1: Run focused conversion, adapter, and builder tests** + +Run: + +```bash +mvn -pl compat-0.3/conversion,compat-0.3/client/adapter,client/base -am test +``` + +Expected: mapper, resolver, adapter, and version-aware builder tests pass. + +- [ ] **Step 2: Run the full standalone reference matrix** + +Run: + +```bash +mvn -pl compat-0.3/reference/jsonrpc,compat-0.3/reference/rest,compat-0.3/reference/grpc -am test +``` + +Expected: both the new compatibility subclasses and the unchanged legacy subclasses pass. + +- [ ] **Step 3: Run packaging/classpath verification from the existing implementation plan** + +The original client-compatibility implementation plan owns the isolated fixture/JVM packaging tests. Do not treat the existing multiversion Quarkus suites as proof of those cases. Confirm that the original plan’s dedicated tests exist, then run the exact command specified there. The cases must cover missing parser, missing binding adapter, duplicate provider, parser-not-requested, client-only dependency-tree behavior, and the standard v1-only classpath. + +Run the existing multiversion suites only as a supplementary regression check: + +```bash +mvn -pl tests/multiversion/jsonrpc,tests/multiversion/rest,tests/multiversion/grpc -am -Dsurefire.failIfNoSpecifiedTests=false test +``` + +Do not claim this command verifies the isolated parser/provider/dependency-boundary cases; those belong to the original implementation plan’s dedicated tests. + +- [ ] **Step 4: Inspect dependency trees** + +Run: + +```bash +mvn -pl compat-0.3/client/adapter dependency:tree +mvn -pl compat-0.3/client/adapter-jsonrpc dependency:tree +mvn -pl compat-0.3/client/adapter-rest dependency:tree +mvn -pl compat-0.3/client/adapter-grpc dependency:tree +``` + +Expected: no `a2a-java-sdk-server-common`, CDI, Quarkus, or reference-server artifact appears in client adapter dependency trees. + +- [ ] **Step 5: Run the complete reactor when the environment permits** + +```bash +mvn clean install +``` + +If environment constraints prevent the complete reactor, report the exact skipped or failing module and preserve the focused results from Steps 1 and 2. + +- [ ] **Step 6: Finish with a verification handoff** + +```bash +git status --short +git diff --check +git log -5 --oneline +``` + +Do not create a merge commit or modify unrelated worktree changes. This final step does not create another commit; the implementation session should hand off the commit list and focused/full test results. diff --git a/docs/superpowers/plans/2026-09-14-client-v03-compatibility.md b/docs/superpowers/plans/2026-09-14-client-v03-compatibility.md new file mode 100644 index 000000000..5e54c782e --- /dev/null +++ b/docs/superpowers/plans/2026-09-14-client-v03-compatibility.md @@ -0,0 +1,484 @@ +# 1.0 Client Support for A2A 0.3 Servers Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Allow applications to use the existing concrete 1.0 `Client` and 1.0 spec types against a 0.3 server when they explicitly opt in and install optional compat artifacts. + +**Architecture:** Keep `Client` as the public API and adapt at its existing `ClientTransport` boundary. Add a raw-card compatibility parser SPI below the client transport SPI, and a separate versioned transport-adapter SPI used by `ClientBuilder`; neither collides with the existing binding-only transport provider registry. Extract pure 0.3/1.0 mappers from the server conversion module into a neutral artifact consumed by both client and server code. + +**Tech Stack:** Java 17, Maven multi-module build, Gson/protobuf JSON parsing, MapStruct, `ServiceLoader`, JUnit 5, MockServer. + +**Spec:** `docs/superpowers/specs/2026-09-14-client-v03-compatibility-tests-design.md` + +## Global Constraints + +- The public client remains `org.a2aproject.sdk.client.Client`; callers use only 1.0 request, result, event, card, context, config, and interceptor types. +- Existing `A2A.getAgentCard(...)` overloads stay 1.0-only; a new overload explicitly receives supported protocol versions. +- Legacy fallback is enabled only when the caller includes `"0.3"` and the relevant optional compat JARs are installed. +- Canonical supported versions are `"1.0"` and `"0.3"`. One shared normalization routine accepts only `"1.0"`, `"1.0.0"`, `"0.3"`, and `"0.3.0"`; it normalizes the patch forms and rejects blank, malformed, older, newer, and unknown values. +- Preserve the client/server dependency separation: no client artifact may depend on `server-common`, CDI, Quarkus, or reference-server modules. +- Do not register a compat transport as the existing `ClientTransportProvider`; its registry is keyed only by protocol binding and would overwrite the native provider. +- Against a 0.3 binding, reject locally before I/O: `listTasks`, non-empty tenants, extended-agent-card retrieval, and non-default push-config pagination. +- Use conventional commits. This work is not related to a GitHub issue, so do not add a `This fixes #...` footer. + +## Implementation Clarifications and Checkpoints + +- In the interceptor payload contract, the REST delete operation must use the concrete 1.0 generated protobuf type `DeleteTaskPushNotificationConfigRequest` for replacement validation. Do not use the `DeleteTaskPushNotificationConfigRequestOrBuilder` interface as the required replacement type. +- “Generic 1.0 config parameters” means the map returned by `ClientTransportConfig.getParameters()`. Every 0.3 adapter must reject a non-empty map locally before invoking an interceptor or legacy delegate; an empty map is accepted. +- Treat these as context-reset checkpoints. Stop after completing and verifying each checkpoint, report the result, and wait for the user to start a fresh session before continuing: + 1. Task 1: neutral conversion extraction. + 2. Task 3: version-aware `ClientBuilder` selection. + 3. Task 4: shared 0.3 adapter behavior. + 4. Task 6: all binding adapters, including gRPC. + 5. Task 7: packaging, documentation, and end-to-end verification. +- If implementation reveals that one of these clarifications conflicts with an existing public API or generated type, stop at the current checkpoint and update this plan before proceeding. + +--- + +## Target file structure + +| Path | Responsibility | +|---|---| +| `compat-0.3/conversion/` | New neutral MapStruct conversion JAR: only 0.3 spec, 1.0 spec, and MapStruct dependencies. | +| `compat-0.3/server-conversion/` | Retains CDI `Convert_v0_3_To10RequestHandler` and server-only formatters; consumes neutral conversion JAR. | +| `http-client/.../AgentCardCompatibilityParser.java` | Low-level `ServiceLoader` SPI for optional raw-card parsing. | +| `http-client/.../A2ACardResolver.java` | Fetches once; invokes the SPI only when 0.3 is requested. | +| `client/base/.../A2A.java` | Adds explicit version-policy card-discovery overloads. | +| `client/base/.../VersionedClientTransportProvider.java` | Separate `ServiceLoader` SPI for 0.3 transport adapters. | +| `client/base/.../ClientBuilder.java` | Selects by `(protocolBinding, protocolVersion)` and builds native or adapted transport. | +| `compat-0.3/client/adapter/` | Legacy-card parser, error/context/interceptor invocation utilities, and shared adapter code. | +| `compat-0.3/client/adapter-{jsonrpc,rest,grpc}/` | Per-binding 1.0 `ClientTransport` adapters and provider registrations. | + +The production dependency graph is fixed as follows. Do not rely on accidental reactor transitivity: + +```text +compat-0.3/client/adapter + -> compat-0.3/conversion + -> a2a-java-sdk-spec + -> a2a-java-sdk-compat-0.3-spec + -> a2a-java-sdk-http-client + -> a2a-java-sdk-client-transport-spi + -> a2a-java-sdk-compat-0.3-client-transport-spi + +compat-0.3/client/adapter-jsonrpc + -> compat-0.3/client/adapter + -> a2a-java-sdk-client + -> a2a-java-sdk-client-transport-jsonrpc + -> a2a-java-sdk-compat-0.3-client-transport-jsonrpc + +compat-0.3/client/adapter-rest + -> compat-0.3/client/adapter + -> a2a-java-sdk-client + -> a2a-java-sdk-client-transport-rest + -> a2a-java-sdk-compat-0.3-client-transport-rest + +compat-0.3/client/adapter-grpc + -> compat-0.3/client/adapter + -> a2a-java-sdk-client + -> a2a-java-sdk-client-transport-grpc + -> a2a-java-sdk-compat-0.3-client-transport-grpc +``` + +The `compat-0.3` reactor module order is `conversion`, `server-conversion`, shared `client/adapter`, then the three binding adapter modules. + +### Task 1: Extract neutral conversion module + +**Files:** + +- Create: `compat-0.3/conversion/pom.xml` +- Create: `compat-0.3/conversion/src/main/java/org/a2aproject/sdk/compat03/conversion/mappers/**` +- Create: `compat-0.3/conversion/src/test/java/org/a2aproject/sdk/compat03/conversion/mappers/**` +- Modify: `compat-0.3/pom.xml` +- Modify: `compat-0.3/server-conversion/pom.xml` +- Modify: `compat-0.3/server-conversion/src/main/java/org/a2aproject/sdk/compat03/conversion/Convert_v0_3_To10RequestHandler.java` +- Modify: `compat-0.3/server-conversion/src/main/java/org/a2aproject/sdk/compat03/conversion/PushNotificationPayloadFormatter_v0_3.java` +- Modify: existing server-conversion tests whose imports move with the mappers. + +**Consumes:** Current pure mapper package at `compat-0.3/server-conversion/src/main/java/org/a2aproject/sdk/compat03/conversion/mappers/`. + +**Produces:** Artifact `a2a-java-sdk-compat-0.3-conversion`; all pure `toV10`/`fromV10` mappers compile there. Server conversion uses the new artifact and retains no client-facing CDI dependency. + +- [ ] **Step 1: Write failing module-isolation tests** + +Create mapper tests in the new module. Include existing `TaskMapper_v0_3_Test`, a round-trip `MessageSendParamsMapper_v0_3` test, and a new `AgentCardMapper_v0_3_Test`. The agent-card test must prove that a 0.3 card with primary URL, preferred transport, additional interfaces, streaming/push flags, security data needed for authentication, and signatures projects to a usable 1.0 card with `AgentInterface(..., "0.3")`, then converts back to a usable `AgentCard_v0_3`. + +- [ ] **Step 2: Run the new module test and confirm it fails** + +Run: `mvn -pl compat-0.3/conversion test` + +Expected: Maven reports that the module does not exist. + +- [ ] **Step 3: Create the neutral module and move only pure conversion code** + +Move `mappers/config`, `mappers/domain`, `mappers/params`, and `mappers/result` without changing package names. Create `AgentCardMapper_v0_3` and any focused nested card/security mappers needed by it. Move the pure error conversion utility only if it has no server dependency; otherwise create a distinct pure error-value mapper and leave server-specific exception handling in `server-conversion`. + +The new POM must depend only on `a2a-java-sdk-spec`, `a2a-java-sdk-compat-0.3-spec`, MapStruct, and test dependencies. Do not add CDI, `server-common`, or reference modules. + +- [ ] **Step 4: Rewire server conversion** + +Make `server-conversion` depend on the new neutral artifact. Keep `Convert_v0_3_To10RequestHandler`, `PushNotificationPayloadFormatter_v0_3`, CDI annotations, and server tests in `server-conversion`. Remove duplicate mapper sources from that module. + +- [ ] **Step 5: Run focused verification** + +Run: `mvn -pl compat-0.3/conversion,compat-0.3/server-conversion -am test` + +Expected: Mapper round trips and existing server conversion tests pass. + +- [ ] **Step 6: Commit** + +```bash +git add compat-0.3/conversion compat-0.3/pom.xml compat-0.3/server-conversion +git commit -m "refactor: extract 0.3 compatibility mappers" +``` + +### Task 2: Add explicit, optional legacy card discovery + +**Files:** + +- Create: `http-client/src/main/java/org/a2aproject/sdk/client/http/AgentCardCompatibilityParser.java` +- Modify: `http-client/src/main/java/org/a2aproject/sdk/client/http/A2ACardResolver.java` +- Modify: `http-client/src/test/java/org/a2aproject/sdk/client/http/A2ACardResolverTest.java` +- Modify: `client/base/src/main/java/org/a2aproject/sdk/A2A.java` +- Modify: `client/base/src/test/java/org/a2aproject/sdk/A2ATest.java` or create `client/base/src/test/java/org/a2aproject/sdk/A2AAgentCardResolutionTest.java` +- Create: `compat-0.3/client/adapter/pom.xml` +- Create: `compat-0.3/client/adapter/src/main/java/org/a2aproject/sdk/compat03/client/adapter/Compat03AgentCardCompatibilityParser.java` +- Create: `compat-0.3/client/adapter/src/main/resources/META-INF/services/org.a2aproject.sdk.client.http.AgentCardCompatibilityParser` +- Create: adapter parser tests and card fixtures. + +**Interfaces:** + +```java +// In http-client; it must reference no 0.3 class. +public interface AgentCardCompatibilityParser { + String supportedProtocolVersion(); + java.util.Optional parse( + String rawCardJson, + @Nullable AgentCard parsedV10Card, + java.util.Set requestedProtocolVersions); +} +``` + +Put `normalizeSupportedProtocolVersion(String)` in `http-client` and use it from both resolver and builder. `A2ACardResolver.Builder` gains `supportedProtocolVersions(Set)`, makes a defensive copy, normalizes it, and defaults to `Set.of("1.0")`. `A2A` adds exactly these overloads: + +```java +public static AgentCard getAgentCard( + String agentUrl, Set supportedProtocolVersions) + throws A2AClientError, A2AClientJSONError; + +public static AgentCard getAgentCard( + A2AHttpClient client, String agentUrl, Set supportedProtocolVersions) + throws A2AClientError, A2AClientJSONError; + +public static AgentCard getAgentCard( + String agentUrl, String path, Map headers, + Set supportedProtocolVersions) + throws A2AClientError, A2AClientJSONError; + +public static AgentCard getAgentCard( + A2AHttpClient client, String agentUrl, String path, Map headers, + Set supportedProtocolVersions) + throws A2AClientError, A2AClientJSONError; +``` + +- [ ] **Step 1: Write resolver tests first** + +Add tests using a fake `AgentCardCompatibilityParser` registered through the test classloader: + +```java +assertThrows(A2AClientJSONError.class, + () -> resolverFor(v03Json).getAgentCard()); + +assertEquals("0.3", resolverFor(v03Json) + .supportedProtocolVersions(Set.of("1.0", "0.3")) + .getAgentCard().supportedInterfaces().get(0).protocolVersion()); +``` + +Also test: raw body is parsed without a second fetch; a usable 1.0 card remains native; a dual-format card prefers its 1.0 interface; a requested 0.3 parser absent gives an `A2AClientJSONError` naming `a2a-java-sdk-compat-0.3-client-adapter`; invalid requested values fail before `createGet()`; declared `0.3.0` succeeds; declared `0.2.9`, `0.3.1`, blank, and malformed legacy versions fail before client construction; custom card path and authorization headers survive fallback. Preserve the existing 404 URL retry: "one fetch" means no second fetch merely to parse legacy JSON. + +- [ ] **Step 2: Run tests to verify failure** + +Run: `mvn -pl http-client,client/base -am -Dtest=A2ACardResolverTest,A2AAgentCardResolutionTest -Dsurefire.failIfNoSpecifiedTests=false test` + +Expected: compilation fails because the options/SPI do not exist. + +- [ ] **Step 3: Implement raw-card parsing without a second request** + +Refactor `A2ACardResolver.fetchAgentCard` so it retains the fetched response body, parses native 1.0 once, and only loads/invokes `AgentCardCompatibilityParser` if normalized requested versions include `"0.3"`. A native parse counts as usable only when it yields at least one eligible interface. Validate a legacy card's own declared protocol version through the shared normalizer before projecting it. Pass the same raw body to the parser; never re-fetch it merely for parsing. + +Implement the compat parser with `JsonUtil_v0_3`, then use `AgentCardMapper_v0_3` to project/merge 0.3 interfaces into the returned public 1.0 card. Filter returned interfaces to the requested versions. Prefer 1.0 over 0.3 for identical bindings. + +- [ ] **Step 4: Run focused verification** + +Run: `mvn -pl http-client,client/base,compat-0.3/client/adapter -am test` + +Expected: all old resolver tests and the new opt-in/fallback tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add http-client client/base compat-0.3/client/adapter compat-0.3/pom.xml +git commit -m "feat: add opt-in 0.3 agent card discovery" +``` + +### Task 3: Make `ClientBuilder` select a versioned delegate safely + +**Files:** + +- Create: `client/base/src/main/java/org/a2aproject/sdk/client/VersionedClientTransportProvider.java` +- Modify: `client/base/src/main/java/org/a2aproject/sdk/client/ClientBuilder.java` +- Modify: `client/base/src/test/java/org/a2aproject/sdk/client/ClientBuilderTest.java` +- Create: `client/base/src/test/java/org/a2aproject/sdk/client/VersionedClientTransportProviderTest.java` + +**Interfaces:** + +```java +public interface VersionedClientTransportProvider { + String protocolBinding(); + String protocolVersion(); + Class configuredTransportClass(); + ClientTransport create( + ClientTransportConfig config, AgentCard card, AgentInterface agentInterface) + throws A2AClientException; +} +``` + +**Produces:** `ClientBuilder` selects candidates by `(protocolBinding, normalized protocolVersion)` and uses the native `ClientTransportProvider` only for `"1.0"`; it uses the separate versioned SPI for `"0.3"`. + +- [ ] **Step 1: Write failing selection tests** + +Add cards with JSON-RPC interfaces in both orders: 0.3 then 1.0, and 1.0 then 0.3. Assert that native 1.0 is selected when both are available under both `useClientPreference` modes. Assert a manually constructed `AgentInterface(..., "0.3.0")` normalizes and selects a fake `VersionedClientTransportProvider`; assert no provider and no matching configured native transport produce distinct actionable errors. Assert duplicate providers for one canonical `(binding, version)` fail descriptively rather than using `ServiceLoader` order. + +- [ ] **Step 2: Run tests to verify failure** + +Run: `mvn -pl client/base -am -Dtest=ClientBuilderTest,VersionedClientTransportProviderTest -Dsurefire.failIfNoSpecifiedTests=false test` + +Expected: compilation fails because the versioned provider SPI and version-aware selection do not exist. + +- [ ] **Step 3: Implement version-aware candidate selection** + +Replace `getServerInterfacesMap()`'s binding-only deduplication with an ordered candidate list keyed by binding plus normalized version. Preserve server order between different candidates. For duplicate binding/version entries retain the first. When both versions are eligible for one binding, place the 1.0 candidate before 0.3 regardless of order. Apply the same native-first tie breaker in client-preference selection. + +Load `VersionedClientTransportProvider` separately from `ClientTransportProvider`; reject duplicate canonical `(binding, version)` claims during registry creation. Never add it to `transportProviderRegistry`. In `findBestClientTransport()`, validate a 1.0 candidate against the native registry and a 0.3 candidate against the versioned registry; unknown versions fail without falling through to native. In `buildClientTransport()`, use `configuredTransportClass()` to retrieve the ordinary 1.0 config supplied by `withTransport(...)`, then create and wrap the adapter like a native transport. Wrappers receive that unchanged ordinary 1.0 configuration. + +- [ ] **Step 4: Run focused verification** + +Run: `mvn -pl client/base -am test` + +Expected: current builder tests, new candidate-order tests, and wrapper behavior pass. + +- [ ] **Step 5: Commit** + +```bash +git add client/base +git commit -m "feat: route client transports by protocol version" +``` + +### Task 4: Implement the shared 0.3 adapter behavior + +**Files:** + +- Create: `compat-0.3/client/adapter/src/main/java/org/a2aproject/sdk/compat03/client/adapter/Compat03ClientTransportSupport.java` +- Create: `compat-0.3/client/adapter/src/main/java/org/a2aproject/sdk/compat03/client/adapter/Compat03ClientCallContextMapper.java` +- Create: `compat-0.3/client/adapter/src/main/java/org/a2aproject/sdk/compat03/client/adapter/Compat03ClientErrorMapper.java` +- Create: corresponding unit tests. + +**Consumes:** neutral mappers, 1.0 `ClientTransport` contract, 0.3 spec/client transport SPI. + +**Produces:** shared request/result/event/context/error conversion plus local validation used by each binding adapter. + +- [x] **Step 1: Write failing unit tests for local semantics** + +Test every 0.3-rejected call before mapper, interceptor, or delegate invocation: `listTasks`, `getExtendedAgentCard`, non-empty tenant on every tenant-bearing request and `TaskPushNotificationConfig`, non-default list-push page size/token. For accepted list-push defaults, assert conversion returns `nextPageToken = ""`. Test `CancelTaskParams`, `TaskQueryParams`, `TaskIdParams`, `MessageSendParams`, tasks, events, and push config in both directions. Test known 0.3 errors become 1.0 `A2AClientException` with a meaningful 1.0 cause. + +- [x] **Step 2: Run tests to verify failure** + +Run: `mvn -pl compat-0.3/client/adapter -am test` + +Expected: compilation fails because shared support classes do not exist. + +- [x] **Step 3: Implement shared support** + +Use neutral mappers for all domain conversions. Convert contexts by copying state and headers. Implement the exact method mapping: `cancelTask(CancelTaskParams)` → legacy `cancelTask(TaskIdParams_v0_3)`; `subscribeToTask` → legacy `resubscribe`; `createTaskPushNotificationConfiguration` → legacy `setTaskPushNotificationConfiguration`; legacy list-push `List` → 1.0 `ListTaskPushNotificationConfigsResult(configs, "")`. Make adapter `close()` idempotent; do not claim ownership of caller-supplied gRPC channels. Copy the HTTP client/channel factory but **do not install 1.0 interceptors on a legacy delegate**: each binding adapter invokes them before conversion, because several legacy delegates ignore replacement payloads. Generic 1.0 config `parameters` are unsupported and must cause a local error when non-empty rather than being discarded. + +- [x] **Step 4: Run focused verification** + +Run: `mvn -pl compat-0.3/client/adapter -am test` + +Expected: all conversion, error, validation, context, and interceptor bridge tests pass. + +- [x] **Step 5: Commit** + +```bash +git add compat-0.3/client/adapter +git commit -m "feat: add shared 0.3 client adapter support" +``` + +### Task 5: Add JSON-RPC and REST adapter transports + +**Files:** + +- Create: `compat-0.3/client/adapter-jsonrpc/pom.xml` +- Create: JSON-RPC adapter transport/provider and `META-INF/services/org.a2aproject.sdk.client.VersionedClientTransportProvider` +- Create: `compat-0.3/client/adapter-rest/pom.xml` +- Create: REST adapter transport/provider and service registration +- Create: unit/integration tests under each adapter module. +- Modify: `compat-0.3/pom.xml` dependency management and module list. + +**Produces:** 1.0 `ClientTransport` implementations delegating respectively to `JSONRPCTransport_v0_3` and `RestTransport_v0_3`, configured with ordinary `JSONRPCTransportConfig` and `RestTransportConfig`. + +Each binding module owns its interceptor invoker: `JsonRpcCompat03InterceptorBridge` in `adapter-jsonrpc` and `RestCompat03InterceptorBridge` in `adapter-rest`. Before calling a legacy delegate, it constructs the documented 1.0 protobuf payload, invokes the ordinary 1.0 interceptors with the public 1.0 card/context, validates a replacement has the exact expected 1.0 type, converts that replacement back to a 1.0 domain request and then a legacy 0.3 domain request, and supplies a copied legacy context containing the resulting headers. It must not install payload-mutating interceptors on the legacy delegate: gRPC delegates ignore replacement payloads, while REST derives several routes from its original domain parameters. Wrong replacement type, disallowed null replacement, and an attempted `A2A-Version` override are local failures. + +The following is the complete interceptor payload contract. `grpc.*` means the 1.0 generated protobuf class; `compat03.*` means a 0.3 domain type. Every entry has `null = forbidden`; the bridge invokes the ordinary 1.0 interceptor with the exact 1.0 method constant shown, requires the exact replacement type, converts the replacement through the 1.0 domain type to the listed legacy domain argument, then calls the legacy delegate. The JSON-RPC delegate subsequently creates its own envelope, so no envelope is passed through an interceptor bridge. + +| Binding | Client operation / 1.0 interceptor method | 1.0 payload and permitted replacement | Legacy delegate argument after mutation | +|---|---|---|---| +| JSON-RPC | `sendMessage` / `SendMessage` | `grpc.SendMessageRequest` | `compat03.MessageSendParams_v0_3` | +| JSON-RPC | `sendMessageStreaming` / `SendStreamingMessage` | `grpc.SendMessageRequest` | `compat03.MessageSendParams_v0_3` | +| JSON-RPC | `getTask` / `GetTask` | `grpc.GetTaskRequest` | `compat03.TaskQueryParams_v0_3` | +| JSON-RPC | `cancelTask` / `CancelTask` | `grpc.CancelTaskRequest` | `compat03.TaskIdParams_v0_3` | +| JSON-RPC | `createTaskPushNotificationConfiguration` / `CreateTaskPushNotificationConfig` | `grpc.TaskPushNotificationConfig` | `compat03.TaskPushNotificationConfig_v0_3` | +| JSON-RPC | `getTaskPushNotificationConfiguration` / `GetTaskPushNotificationConfig` | `grpc.GetTaskPushNotificationConfigRequest` | `compat03.GetTaskPushNotificationConfigParams_v0_3` | +| JSON-RPC | `listTaskPushNotificationConfigurations` / `ListTaskPushNotificationConfigs` | `grpc.ListTaskPushNotificationConfigsRequest` | `compat03.ListTaskPushNotificationConfigParams_v0_3` | +| JSON-RPC | `deleteTaskPushNotificationConfigurations` / `DeleteTaskPushNotificationConfig` | `grpc.DeleteTaskPushNotificationConfigRequest` | `compat03.DeleteTaskPushNotificationConfigParams_v0_3` | +| JSON-RPC | `subscribeToTask` / `SubscribeToTask` | `grpc.SubscribeToTaskRequest` | `compat03.TaskIdParams_v0_3` | +| REST | `sendMessage` / `SendMessage` | `grpc.SendMessageRequest.Builder` | `compat03.MessageSendParams_v0_3` | +| REST | `sendMessageStreaming` / `SendStreamingMessage` | `grpc.SendMessageRequest.Builder` | `compat03.MessageSendParams_v0_3` | +| REST | `getTask` / `GetTask` | `grpc.GetTaskRequest.Builder` | `compat03.TaskQueryParams_v0_3` | +| REST | `cancelTask` / `CancelTask` | `grpc.CancelTaskRequest.Builder` | `compat03.TaskIdParams_v0_3` | +| REST | `createTaskPushNotificationConfiguration` / `CreateTaskPushNotificationConfig` | `grpc.TaskPushNotificationConfig.Builder` | `compat03.TaskPushNotificationConfig_v0_3` | +| REST | `getTaskPushNotificationConfiguration` / `GetTaskPushNotificationConfig` | `grpc.GetTaskPushNotificationConfigRequest.Builder` | `compat03.GetTaskPushNotificationConfigParams_v0_3` | +| REST | `listTaskPushNotificationConfigurations` / `ListTaskPushNotificationConfigs` | `grpc.ListTaskPushNotificationConfigsRequest.Builder` | `compat03.ListTaskPushNotificationConfigParams_v0_3` | +| REST | `deleteTaskPushNotificationConfigurations` / `DeleteTaskPushNotificationConfig` | `grpc.DeleteTaskPushNotificationConfigRequestOrBuilder` | `compat03.DeleteTaskPushNotificationConfigParams_v0_3` | +| REST | `subscribeToTask` / `SubscribeToTask` | `grpc.SubscribeToTaskRequest.Builder` | `compat03.TaskIdParams_v0_3` | + +Header routing is deliberately binding-specific and matches the existing 0.3 transports: after every 1.0 interceptor returns, JSON-RPC and REST bridges remove `A2A-Version` case-insensitively from context and interceptor headers, and do not add it. The legacy 0.3 clients originally sent no version header, which routes to the 0.3 endpoint on a cohosted HTTP server. Any interceptor attempt to set that header is rejected rather than silently removed. All other interceptor headers survive. Task 5 wire tests must assert header absence for all nine operations on both bindings. + +- [ ] **Step 1: Write failing wire-contract tests** + +For each binding, use MockServer or the existing legacy transport fixture style. Build a normal 1.0 `Client` with a projected 0.3 `AgentCard`, ordinary 1.0 config, and the adapter module. Assert legacy request envelope and parameter field shape, REST path, and absence of `A2A-Version`; do not assert different JSON-RPC method names because many legacy method strings are identical. Assert absence of 1.0-only tenant/page fields. Test blocking send, streaming send, get, cancel, subscribe, and push-config operations. Test all streaming event variants: message, task, status update, artifact update. For every operation in the table, test interceptor observation, valid mutation, wrong-type replacement, forbidden null replacement, and version-header override. + +- [ ] **Step 2: Run tests to verify failure** + +Run: `mvn -pl compat-0.3/client/adapter-jsonrpc,compat-0.3/client/adapter-rest -am -Dsurefire.failIfNoSpecifiedTests=false test` + +Expected: Maven reports the adapter modules are absent. + +- [ ] **Step 3: Implement per-binding adapters** + +Each provider declares its binding (`JSONRPC` or `HTTP+JSON`), canonical `"0.3"` version, and the corresponding ordinary 1.0 configured transport class. Copy the HTTP client into the legacy config, retain the ordinary 1.0 interceptor list in the adapter, and pass no payload-mutating interceptors to the legacy transport. Each adapter implements every `ClientTransport` method, applying Task 4 validation, its own interceptor invocation, and conversion before delegating to the legacy transport. Do not call `Client_v0_3`. + +- [ ] **Step 4: Run focused verification** + +Run: `mvn -pl compat-0.3/client/adapter-jsonrpc,compat-0.3/client/adapter-rest -am test` + +Expected: legacy wire assertions and ordinary 1.0 client callbacks pass. + +- [ ] **Step 5: Commit** + +```bash +git add compat-0.3/client/adapter-jsonrpc compat-0.3/client/adapter-rest compat-0.3/pom.xml +git commit -m "feat: add 0.3 JSON-RPC and REST client adapters" +``` + +### Task 6: Add gRPC adapter transport + +**Files:** + +- Create: `compat-0.3/client/adapter-grpc/pom.xml` +- Create: gRPC adapter transport/provider and `META-INF/services/org.a2aproject.sdk.client.VersionedClientTransportProvider` +- Create: gRPC adapter tests using an in-process 0.3 gRPC service. +- Modify: `compat-0.3/pom.xml` dependency management and module list. + +**Produces:** A 1.0 `ClientTransport` adapter over `GrpcTransport_v0_3`, configured from ordinary `GrpcTransportConfig` and using the legacy `a2a.v1` service rather than the 1.0 `lf.a2a.v1` service. + +The module owns `GrpcCompat03InterceptorBridge`. Its complete contract is below. `grpc.*` means the 1.0 generated protobuf class; `compat03.*` the legacy domain class. Every row passes the specified immutable 1.0 protobuf payload to the ordinary 1.0 interceptor, permits only the same immutable type as a replacement, rejects null, converts it through the 1.0 domain type to the listed legacy domain argument, and then calls `GrpcTransport_v0_3`. Do not install interceptors on that delegate: it ignores a replacement payload returned from its own legacy interceptor chain. + +| Client operation / 1.0 interceptor method | 1.0 payload and permitted replacement | Legacy delegate argument after mutation | +|---|---|---| +| `sendMessage` / `SendMessage` | `grpc.SendMessageRequest` | `compat03.MessageSendParams_v0_3` | +| `sendMessageStreaming` / `SendStreamingMessage` | `grpc.SendMessageRequest` | `compat03.MessageSendParams_v0_3` | +| `getTask` / `GetTask` | `grpc.GetTaskRequest` | `compat03.TaskQueryParams_v0_3` | +| `cancelTask` / `CancelTask` | `grpc.CancelTaskRequest` | `compat03.TaskIdParams_v0_3` | +| `createTaskPushNotificationConfiguration` / `CreateTaskPushNotificationConfig` | `grpc.TaskPushNotificationConfig` | `compat03.TaskPushNotificationConfig_v0_3` | +| `getTaskPushNotificationConfiguration` / `GetTaskPushNotificationConfig` | `grpc.GetTaskPushNotificationConfigRequest` | `compat03.GetTaskPushNotificationConfigParams_v0_3` | +| `listTaskPushNotificationConfigurations` / `ListTaskPushNotificationConfigs` | `grpc.ListTaskPushNotificationConfigsRequest` | `compat03.ListTaskPushNotificationConfigParams_v0_3` | +| `deleteTaskPushNotificationConfigurations` / `DeleteTaskPushNotificationConfig` | `grpc.DeleteTaskPushNotificationConfigRequest` | `compat03.DeleteTaskPushNotificationConfigParams_v0_3` | +| `subscribeToTask` / `SubscribeToTask` | `grpc.SubscribeToTaskRequest` | `compat03.TaskIdParams_v0_3` | + +After each interceptor, remove `A2A-Version` case-insensitively and reject an interceptor which supplied it; do not add version metadata. Selection of the legacy `a2a.v1.A2AService` service, rather than a metadata header, is the gRPC version-routing mechanism. Preserve all other metadata. + +- [x] **Step 1: Write failing in-process gRPC tests** + +Start only the legacy generated gRPC service. Build the normal concrete 1.0 `Client` through the versioned adapter and verify blocking, streaming, get, cancel, subscribe, push config, error conversion, metadata/auth interceptor bridging, interceptor mutation failures, rejection of a version-header override, absence of `A2A-Version` metadata, and double `close()`. Assert a request reaches `a2a.v1.A2AService` and never the 1.0 `lf.a2a.v1` service. + +- [x] **Step 2: Run tests to verify failure** + +Run: `mvn -pl compat-0.3/client/adapter-grpc -am -Dsurefire.failIfNoSpecifiedTests=false test` + +Expected: Maven reports the adapter module is absent. + +- [x] **Step 3: Implement the adapter** + +Copy the ordinary 1.0 channel factory into `GrpcTransportConfig_v0_3`, pass no payload-mutating interceptors to the legacy delegate, and delegate every supported operation through the legacy gRPC transport after the adapter has invoked/mapped its ordinary 1.0 interceptors and copied their headers into the legacy context. Preserve caller ownership of the supplied channel and make only adapter-local close state idempotent. + +- [x] **Step 4: Run focused verification** + +Run: `mvn -pl compat-0.3/client/adapter-grpc -am test` + +Expected: all calls use the legacy service and consumers receive 1.0 events. + +- [x] **Step 5: Commit** + +```bash +git add compat-0.3/client/adapter-grpc compat-0.3/pom.xml +git commit -m "feat: add 0.3 gRPC client adapter" +``` + +### Task 7: Package, document, and run the end-to-end matrix + +**Files:** + +- Modify: `boms/sdk/pom.xml` to publish the neutral conversion and client compatibility adapter artifacts; do not add them to extras or reference BOMs. +- Modify: `docs/content/dev/compatibility.md` +- Create or modify: standalone-legacy test fixtures/modules under `compat-0.3/reference/{jsonrpc,rest,grpc}` (or another fixture that does not replace the legacy card with a cohosted 1.0 card), without using a cohosted 1.0 endpoint for legacy assertions. +- Modify: parent/compat reactor POMs for all new modules. + +- [ ] **Step 1: Write failing packaging and classpath tests** + +Add isolated forked-JVM or Maven fixture-project coverage for: parser absent; parser present with no binding adapter; each binding adapter; duplicate versioned providers; and standard client-only classpath. Do not try to add `META-INF/services` resources after `ClientBuilder` has loaded. Also test compat parser present but 0.3 not requested, 0.3 requested but binding adapter absent, and a client-only dependency-tree assertion that contains no server, CDI, Quarkus, or reference artifacts. + +- [ ] **Step 2: Run tests to verify failure** + +Run: `mvn -pl compat-0.3/reference/jsonrpc,compat-0.3/reference/rest,compat-0.3/reference/grpc -am -Dsurefire.failIfNoSpecifiedTests=false test` + +Expected: new standalone legacy/classpath scenarios are missing. + +- [ ] **Step 3: Document and implement packaging** + +Add all new compat artifacts to `compat-0.3/pom.xml` dependency management and add client-facing artifacts only to `boms/sdk/pom.xml`. If test modules use the new artifacts without explicit `${project.version}`, add them to root `pom.xml` dependency management first. Update the compatibility guide with the explicit `A2A.getAgentCard(..., Set.of("1.0", "0.3"))` call, the normal concrete `Client.builder(card)` usage, required optional artifacts per binding, unsupported-operation behavior, and the guarantee that client-only users do not import 0.3 types or server libraries. + +- [ ] **Step 4: Run full verification** + +Run: `mvn clean install` + +Expected: the complete reactor passes. If environment constraints prevent full integration tests, report the exact skipped/failing module and run all unaffected adapter, resolver, builder, and conversion module tests. + +- [ ] **Step 5: Inspect dependency boundaries** + +Run: + +```bash +mvn -pl compat-0.3/client/adapter dependency:tree +mvn -pl compat-0.3/client/adapter-jsonrpc dependency:tree +mvn -pl compat-0.3/client/adapter-rest dependency:tree +mvn -pl compat-0.3/client/adapter-grpc dependency:tree +``` + +Expected: no `a2a-java-sdk-server-common`, CDI, Quarkus, or reference-server artifact appears in the client adapter dependency trees. + +- [ ] **Step 6: Commit** + +```bash +git add pom.xml boms/sdk compat-0.3 docs/content/dev tests +git commit -m "build: publish 0.3 client compatibility artifacts" +``` diff --git a/docs/superpowers/specs/2026-09-14-client-v03-compatibility-design.md b/docs/superpowers/specs/2026-09-14-client-v03-compatibility-design.md new file mode 100644 index 000000000..40bdb4af3 --- /dev/null +++ b/docs/superpowers/specs/2026-09-14-client-v03-compatibility-design.md @@ -0,0 +1,111 @@ +# 1.0 Client Support for A2A 0.3 Servers + +## Status + +Design study only. This document authorizes no implementation. + +## Goal + +Let an application use the existing concrete `org.a2aproject.sdk.client.Client` and the 1.0 spec types when calling either 1.0 or 0.3 A2A servers. Protocol selection and 0.3 type translation are internal implementation details. + +The default remains a 1.0-only client. Legacy support is explicit and is only available when optional compatibility artifacts are on the classpath. + +## Public API Contract + +Existing 1.0 client construction remains valid: + +```java +AgentCard card = A2A.getAgentCard("https://agent.example"); + +Client client = Client.builder(card) + .withTransport(JSONRPCTransport.class, new JSONRPCTransportConfig()) + .build(); +``` + +Legacy discovery is explicit on `A2A.getAgentCard(...)`. An overload accepts a supported-version policy; its default supports only `"1.0"`: + +```java +AgentCard card = A2A.getAgentCard( + "https://agent.example", Set.of("1.0", "0.3")); +``` + +The version policy filters discovered interface candidates before `ClientBuilder` sees the returned card. `ClientBuilder` preserves its existing API while selecting the provider appropriate to each retained interface's protocol version. Including a compatibility JAR does not alter default 1.0-only discovery or cause automatic protocol downgrade. When a caller requests `"0.3"` but the corresponding compatibility provider is absent, card resolution fails with a descriptive error that names the required artifact. + +Applications continue to use only 1.0 `Client`, `AgentCard`, request/result, event, configuration, context, and interceptor classes. They do not import `Client_v0_3` or 0.3 spec types. + +## Card Resolution and Version Selection + +`A2A.getAgentCard(...)` remains the discovery entry point. Its existing overloads preserve 1.0-only behavior; new overloads or resolver options carry the explicit version policy. + +1. It fetches the card once. +2. It parses a usable 1.0 card normally. +3. Only when the caller opted into `"0.3"`, and a compatibility card resolver is available through `ServiceLoader`, a legacy-only card is parsed from the same raw JSON as `AgentCard_v0_3`. +4. The compatibility resolver projects the legacy card into a public 1.0 `AgentCard`, with selected interfaces marked `protocolVersion = "0.3"`. +5. The resolver filters candidates by the requested versions and returns a public card containing the eligible interfaces. +6. `ClientBuilder` selects a configured binding for an eligible interface. Its candidates are keyed by `(protocolBinding, protocolVersion)`, not by binding alone. + +The fallback is shape-based, not solely exception-based. The current 1.0 resolver uses the 1.0 protobuf mapper, which ignores the legacy top-level fields, so a legacy card can otherwise parse without providing usable 1.0 interfaces. The lower-level raw-card parser SPI belongs in `http-client` (or a new discovery module below the client transport SPI), so it cannot create a dependency cycle with `client-transport-spi`. + +For a cohosted server, the documented dual-format card remains unchanged. Its usable 1.0 interface is preferred over a 0.3 interface with the same binding, including when client transport preference is enabled. The legacy fields continue to support existing 0.3 clients. + +The compatibility projection must contain enough information to recreate the `AgentCard_v0_3` required by the underlying 0.3 transport. It must not use a global cache keyed by a public `AgentCard`. A new bidirectional agent-card mapper must preserve all fields required by legacy transport selection and authentication; unrepresentable features, including the legacy state-transition-history capability, are not advertised through the 1.0 projection. + +## Internal Delegate Design + +`Client` remains concrete and owns the existing 1.0 callback, `ClientEvent`, task-tracking, error-handler, and `close()` behavior. + +It already delegates wire operations to `ClientTransport`. A compat provider supplies a 1.0 `ClientTransport` implementation that wraps the matching 0.3 transport: + +```text +Client (1.0 public API) + -> ClientTransport + -> native 1.0 transport, or + -> 0.3 adapter transport -> 0.3 wire transport +``` + +The adapter maps 1.0 requests to 0.3, delegates the request, and maps responses and streaming events back to 1.0. It must not wrap `Client_v0_3` as its primary boundary: that client owns callback dispatch and its send methods do not return the raw values required by the 1.0 `ClientTransport` contract. + +The adapter maps 1.0 HTTP client/channel configuration to the equivalent 0.3 configuration. It invokes 1.0 interceptors in the adapter before request conversion, then translates the resulting payload and call context to 0.3, so users configure only normal 1.0 interceptors. It does not install payload-mutating 1.0 interceptors on a legacy delegate because some legacy delegates ignore replacement payloads. + +Interceptor bridging is binding- and operation-specific. The adapter defines the 1.0 protocol payload presented to each interceptor, validates any replacement payload before translating it back, copies the call context, and preserves headers. For a selected 0.3 HTTP/gRPC binding it removes `A2A-Version` and rejects an interceptor which tries to add it: existing legacy transports route by their no-version-header behavior (or by legacy gRPC service selection), and an interceptor cannot change the effective A2A version. + +## Compatibility Semantics + +Supported operations are message send (blocking and streaming), get task, cancel task, subscribe to task, and push-notification configuration operations, subject to 0.3 server support. + +The adapter fails locally, before making a request, for a 1.0 feature that cannot be faithfully represented by 0.3: + +- `listTasks()`; +- a non-empty tenant; +- extended-agent-card retrieval; mapping it to the 0.3 authenticated-card operation is outside this design's scope. + +For list-push-configuration requests, the only accepted pagination values are `pageSize <= 0` and an empty `pageToken`; the adapter returns an empty next-page token. Other pagination values fail locally. 0.3 client/transport exceptions require a client-side mapping to 1.0 `A2AClientException` and compatible 1.0 protocol-error causes. + +## Artifact and SPI Boundary + +Client and server compatibility code must remain independent. + +Create a neutral compatibility-conversion artifact containing the bidirectional 0.3/1.0 mappers. It depends only on the two spec artifacts and MapStruct. The existing `compat-0.3/server-conversion` module consumes it; it must not be a dependency of client applications. + +An optional client compatibility adapter artifact consumes the neutral conversion artifact plus the 1.0 client SPI and contributes two separate `ServiceLoader` providers: a raw-card parser at the discovery layer, and a version-aware transport adapter at the client-builder layer. It supplies legacy-card parsing and adapted 0.3 transports. It must not implement the existing binding-only `ClientTransportProvider`: its registry is keyed only by binding, so registering a second JSON-RPC, REST, or gRPC provider would overwrite the native provider. The actual 0.3 JSON-RPC, REST, and gRPC transport artifacts remain independently optional. Selecting an unavailable legacy binding reports the missing transport artifact clearly. + +The normal 1.0 client artifacts have no compile-time dependency on 0.3 client, server, CDI, Quarkus, or reference-server artifacts. + +## Acceptance Matrix + +| Scenario | Expected behavior | +|---|---| +| Standard 1.0 artifacts only | Existing behavior; no 0.3 discovery or routing. | +| Compat present, 0.3 not explicitly enabled | Existing 1.0-only discovery and behavior. | +| 0.3 requested for a legacy-only card, card parser absent | Discovery error naming `a2a-java-sdk-compat-0.3-client-adapter`. | +| Legacy card parsed, selected binding adapter absent | Construction error naming the binding-specific compatibility adapter artifact. | +| Standalone 0.3 card, adapter installed | Normal concrete `Client`, using 1.0 types and an adapted transport. | +| Dual-format/cohosted card | Native 1.0 interface preferred. | +| 0.3 JSON-RPC, REST, gRPC | Supported operations use 1.0 public types and correct legacy wire protocol. | +| 1.0-only operation/tenant against 0.3 | Local descriptive failure; no wire request. | +| Authentication/interceptors | Existing 1.0 interceptor API applies through the adapter. | +| Streaming error and close | Existing 1.0 error callback behavior; adapter close is idempotent and documents caller ownership of gRPC channels. | + +## Required Validation + +Tests must cover the matrix above and run against genuinely 0.3-only JSON-RPC, REST, and gRPC servers or wire fixtures. Cohosted reference servers are useful regression coverage but insufficient on their own, because their 1.0 endpoint can mask an invalid legacy request. Include card-shape fallback, explicit opt-in, absent-provider, unavailable-transport, auth/interceptor observation and mutation, all streaming-event variants, error-cause mapping, local rejection, custom card path/auth headers, both interface orders, and both client/server transport-preference modes. Classpath-absence tests must use isolated module/runtime classpaths because the current provider registry is initialized statically. diff --git a/docs/superpowers/specs/2026-09-14-client-v03-compatibility-tests-design.md b/docs/superpowers/specs/2026-09-14-client-v03-compatibility-tests-design.md new file mode 100644 index 000000000..480904088 --- /dev/null +++ b/docs/superpowers/specs/2026-09-14-client-v03-compatibility-tests-design.md @@ -0,0 +1,110 @@ +# v1 Client Compatibility Tests Against v0.3 Servers + +## Goal + +Add end-to-end tests proving that applications using the public v1 `Client` API can communicate with v0.3 servers through the new optional compatibility layer. Keep the existing v0.3 client tests unchanged as regression coverage for the legacy API. + +The two suites intentionally overlap. They validate different public client APIs and should remain independently readable. The v0.3 protocol is frozen, so duplicated scenarios are acceptable and may be preferable to a test facade that obscures the behavior under test. + +## Test architecture + +Create parallel abstract test bases for the compatibility client with distinct names, because the legacy bases already occupy the obvious v0.3 names: + +- `AbstractA2AServerCompatibilityTest_v0_3` for unauthenticated and general protocol behavior. +- `AbstractA2AServerCompatibilityWithAuthTest_v0_3` for authenticated and unauthenticated client behavior. + +Place these v1-only bases in the existing root `tests/server-common` test-jar, in the `org.a2aproject.sdk.server.apps.common` test-support package. The existing legacy bases remain in the compat-0.3 server-conversion test-jar and are not renamed or modified. + +The new bases use only v1 client-facing types: + +- `Client`, `ClientBuilder`, `ClientConfig`; +- v1 request, result, event, and `AgentCard` types; +- v1 transport configuration and interceptor types. + +They may share small test-only helpers with the existing suites when the helper has an obvious, stable responsibility—for example, constructing equivalent minimal v1 tasks or cleaning up server-side test data. Do not introduce a common client facade merely to remove duplicated test methods. The legacy bases and their subclasses remain unchanged. + +Both new bases must close every created v1 `Client` after each test or at test-instance teardown. Transport-specific resources such as caller-owned gRPC channels remain under the concrete subclass's ownership and cleanup. + +## Card setup and discovery + +HTTP-based compatibility subclasses (JSON-RPC and REST) obtain the card through the new opt-in discovery API: + +```java +A2A.getAgentCard(getTransportUrl(), Set.of("0.3")); +``` + +This validates the complete discovery path: one raw-card fetch, v0.3 parsing, projection into a v1 `AgentCard`, and version-aware transport selection. + +These discovery tests target the standalone legacy reference modules `compat-0.3/reference/jsonrpc` and `compat-0.3/reference/rest`. Their v0.3 route serves the legacy card because no non-default v1 `@PublicAgentCard` replaces it. The co-hosted `tests/multiversion/*` modules serve the v1 public card in their normal configuration, so their existing v0.3 subclasses remain legacy regression coverage and are not used to assert legacy-only card discovery. + +Before the end-to-end subclasses are enabled, focused mapper tests must prove that a legacy card with only its primary `url` and `preferredTransport` projects one usable v1 interface. The mapper must canonicalize the legacy binding values `jsonrpc`, `http`/`rest`, and `grpc` to `JSONRPC`, `HTTP+JSON`, and `GRPC` respectively, while preserving already canonical values. Authenticated HTTP tests must also use `HTTPAuthSecurityScheme_v0_3` with Basic authentication and prove projection to v1 `HTTPAuthSecurityScheme`. These tests currently expose missing behavior in `AgentCardMapper_v0_3`; the compatibility conversion layer must be fixed before the end-to-end subclasses are enabled. + +The gRPC compatibility subclass constructs a v1 `AgentCard` fixture directly with a `0.3` `AgentInterface`. gRPC has no agent-card HTTP endpoint, so this is consistent with the existing gRPC tests while still exercising the compatibility transport adapter. The fixture must contain the fields required for transport selection and authentication. + +The test code should not use `Client_v0_3` or v0.3 request/result/event types. Legacy types remain in the existing v0.3 suite and server-side fixtures only. + +## Transport subclasses + +Add compatibility-client subclasses for JSON-RPC, REST, and gRPC, plus authenticated variants where the existing v0.3 suite has them. Each subclass supplies: + +- the binding and endpoint URL; +- the native v1 transport class and configuration; +- transport-specific resource cleanup; +- the v1 `AuthInterceptor` for authenticated cases. + +The compatibility adapter is selected by the card interface’s `protocolVersion = "0.3"`; subclasses configure the ordinary v1 transport class and configuration expected by `ClientBuilder`. + +Transport-specific exceptions remain explicit. For example, gRPC subclasses can disable HTTP-only card/public-endpoint tests, matching the current test behavior. + +The new standalone compatibility subclasses live in the existing `compat-0.3/reference/jsonrpc`, `compat-0.3/reference/rest`, and `compat-0.3/reference/grpc` test modules. Each retains its legacy v0.3 test dependencies and adds the corresponding rows below. The new bases come from the existing root `tests/server-common` test-jar. The adapter artifacts must remain available on the test runtime classpath so their `ServiceLoader` registrations are visible during `ClientBuilder` initialization. + +| Test module | Explicit v1/client dependencies | Compatibility dependencies | Card strategy | +|---|---|---|---| +| `compat-0.3/reference/jsonrpc` | `a2a-java-sdk-client`, `a2a-java-sdk-client-transport-jsonrpc`, root `a2a-java-sdk-tests-server-common` test-jar | `a2a-java-sdk-compat-0.3-client-adapter`, `a2a-java-sdk-compat-0.3-client-adapter-jsonrpc` | Fetch standalone v0.3 card through `A2A.getAgentCard(..., Set.of("0.3"))` | +| `compat-0.3/reference/rest` | `a2a-java-sdk-client`, `a2a-java-sdk-client-transport-rest`, root `a2a-java-sdk-tests-server-common` test-jar | `a2a-java-sdk-compat-0.3-client-adapter`, `a2a-java-sdk-compat-0.3-client-adapter-rest` | Fetch standalone v0.3 card through `A2A.getAgentCard(..., Set.of("0.3"))` | +| `compat-0.3/reference/grpc` | `a2a-java-sdk-client`, `a2a-java-sdk-client-transport-grpc`, root `a2a-java-sdk-tests-server-common` test-jar | `a2a-java-sdk-compat-0.3-client-adapter`, `a2a-java-sdk-compat-0.3-client-adapter-grpc` | Construct a v1 card with a `0.3` interface; gRPC has no card endpoint | + +All entries are test-scoped where appropriate. The existing legacy client/transport artifacts remain because the old suite continues to compile and run in the same modules. + +## Scenario coverage + +Translate the client-facing portions of the existing v0.3 abstract scenarios to v1 types and retain their intent, including: + +- task retrieval, cancellation, and not-found/error behavior; +- non-streaming and streaming message flows; +- resubscription and event delivery; +- push-notification configuration behavior supported by v0.3; +- unsupported-operation behavior where the compatibility plan requires local rejection; +- authentication success, failure, and public-card behavior. + +Assertions should use v1 error and event types, while preserving the v0.3 wire-level expectations. Operations that v0.3 cannot represent—such as `listTasks`, non-empty tenants, extended-card retrieval, or non-default push-config pagination—should have explicit compatibility-client rejection tests rather than being silently omitted. + +The new suite does not duplicate tests whose subject is independent of the client API: + +- direct malformed-wire, HTTP-method, content-negotiation, and header tests; +- task-store utility sanity tests; +- direct SSE/wire-stream tests; +- the legacy client's `getAgentCard()` test, which is replaced by resolver tests and the HTTP end-to-end bootstrap; +- task-authorization coverage, which remains in the existing dedicated v0.3 and v1 task-authorization suites. + +The existing v0.3 suite remains responsible for those cases. A small scenario matrix should be recorded alongside the implementation so every omitted legacy test is intentional. + +Create `docs/superpowers/specs/2026-09-14-client-v03-compatibility-tests-matrix.md` during implementation. It must contain one row for every client-facing test or test group in the two legacy abstract bases, identifying the new compatibility test, an intentional exclusion with rationale, or a separate adapter/resolver test. Include explicit rows for push notifications, streaming/resubscription, authentication, transport-specific overrides, and unsupported v0.3 operations. + +The one-fetch guarantee is tested in `A2ACardResolver`/compatibility-parser unit tests with a counting HTTP client. End-to-end tests only need to prove that the resulting projected card can build and use the v1 client. + +## Verification + +Run focused Maven tests for the new compatibility subclasses in each binding, then run the existing v0.3 subclasses to confirm the legacy suite remains unchanged. At minimum, verify: + +1. Focused card-mapper tests cover primary URL fallback and HTTP authentication projection. +2. HTTP card discovery selects a v0.3 interface; resolver tests prove only one card fetch. +3. Each adapter maps ordinary v1 requests and responses successfully. +4. Streaming and resubscription events arrive as v1 events. +5. v1 authentication interceptors work through the adapters. +6. Expected unsupported operations fail locally. +7. Existing v0.3 client tests continue to pass. + +The plan's standalone classpath and packaging tests remain separate Task 7 coverage. They must still verify absent parser/adapter behavior, duplicate provider handling, client-only dependency boundaries, and the standard v1-only classpath; this end-to-end suite does not replace them. + +No production API changes are part of this test addition unless a test exposes a genuine compatibility-layer defect. diff --git a/docs/superpowers/specs/2026-09-14-client-v03-compatibility-tests-matrix.md b/docs/superpowers/specs/2026-09-14-client-v03-compatibility-tests-matrix.md new file mode 100644 index 000000000..2bda2fae9 --- /dev/null +++ b/docs/superpowers/specs/2026-09-14-client-v03-compatibility-tests-matrix.md @@ -0,0 +1,73 @@ +# v1 Client to v0.3 Compatibility Test Matrix + +The original v0.3 suites remain unchanged in `compat-0.3/server-conversion`. The +compatibility suites use the public v1 `Client` API against the standalone +`compat-0.3/reference/{jsonrpc,rest,grpc}` servers. HTTP and JSON-RPC tests +discover the standalone legacy card with `A2A.getAgentCard(url, Set.of("0.3"))`; +gRPC uses the equivalent v1 card fixture because gRPC has no card endpoint. + +| Legacy source | Compatibility replacement | Coverage | +|---|---|---| +| `testTaskStoreMethodsSanityTest` | None; server utility plumbing only | Intentionally excluded: task-store sanity is not client behavior | +| `testGetTaskSuccess` | `testGetTaskSuccess` | Included in `AbstractA2AServerCompatibilityTest_v0_3` | +| `testGetTaskNotFound` | `testGetTaskNotFound` | Included | +| `testCancelTaskSuccess` | `testCancelTaskSuccess` | Included | +| `testCancelTaskNotSupported` | `testCancelTaskNotSupported` | Included | +| `testCancelTaskNotFound` | `testCancelTaskNotFound` | Included | +| `testSendMessageNewMessageSuccess` | `testSendMessageNewMessageSuccess` | Included | +| `testRequestScopedBeanAvailableOnAgentExecutorThread` | Same-named v1 test | Included | +| `testSendMessageExistingTaskSuccess` | `testSendMessageExistingTaskSuccess` | Included | +| `testSetPushNotificationSuccess` | `testSetPushNotificationSuccess` | Included | +| `testGetPushNotificationSuccess` | `testGetPushNotificationSuccess` | Included | +| `testError` | `testError` | Included | +| `testGetAgentCard` | `getAgentCard()` resolver path plus parser tests | Replaced: v1 card projection is asserted by `AgentCardMapper_v0_3_Test` and `Compat03AgentCardCompatibilityParserTest` | +| `testSendMessageStreamNewMessageSuccess` | Same-named v1 test | Included | +| `testSendMessageStreamExistingTaskSuccess` | Same-named v1 test | Included | +| `testResubscribeExistingTaskSuccess` | Same-named v1 test | Included | +| `testResubscribeNoExistingTaskError` | Same-named v1 test | Included | +| `testMainQueueReferenceCountingWithMultipleConsumers` | Same-named v1 test | Included | +| `testNonBlockingWithMultipleMessages` | Same-named v1 test | Included | +| `testMainQueueStaysOpenForNonFinalTasks` | Same-named v1 test | Included | +| `testMainQueueClosesForFinalizedTasks` | Same-named v1 test | Included | +| `testListPushNotificationConfigWithConfigId` | `testListPushNotificationConfigsWithConfigId` | Included with v1 result/params | +| `testListPushNotificationConfigWithoutConfigId` | `testListPushNotificationConfigsWithoutConfigId` | Included | +| `testListPushNotificationConfigTaskNotFound` | `testListPushNotificationConfigsTaskNotFound` | Included | +| `testListPushNotificationConfigEmptyList` | `testListPushNotificationConfigsEmptyList` | Included | +| `testDeletePushNotificationConfigWithValidConfigId` | `testDeletePushNotificationConfigWithValidConfigId` | Included | +| `testDeletePushNotificationConfigWithNonExistingConfigId` | Same-named v1 test | Included | +| `testDeletePushNotificationConfigTaskNotFound` | Same-named v1 test | Included | +| `testDeletePushNotificationConfigSetWithoutConfigId` | Same-named v1 test | Included | +| `testMalformedJSONRPCRequest` | None | Intentionally excluded: direct wire coverage remains in frozen legacy suite | +| `testInvalidParamsJSONRPCRequest` | None | Intentionally excluded: direct wire coverage remains in frozen legacy suite | +| `testInvalidJSONRPCRequestMissingJsonrpc` | None | Intentionally excluded | +| `testInvalidJSONRPCRequestMissingMethod` | None | Intentionally excluded | +| `testInvalidJSONRPCRequestInvalidId` | None | Intentionally excluded | +| `testInvalidJSONRPCRequestNonExistentMethod` | None | Intentionally excluded | +| `testNonStreamingMethodWithAcceptHeader` | None | Intentionally excluded: transport wire behavior | +| `testStreamingMethodWithAcceptHeader` | None | Intentionally excluded: transport wire behavior | +| `testStreamingMethodWithoutAcceptHeader` | None | Intentionally excluded: transport wire behavior | +| `testSendStreamingMessage(boolean)` | `testSendMessageStream*` | Replaced by public v1 streaming calls | +| `testInputRequiredWorkflow`, `testAuthRequiredWorkflow` | None | Intentionally excluded: not part of the non-authorization compatibility slice | +| `testAgentToAgentDelegation`, `testAgentToAgentLocalHandling` | None | Intentionally excluded: reference-server-specific workflows | +| `testSendMessageWithHistoryLengthZero`, `testSendStreamingMessageWithHistoryLengthZero` | None | Intentionally excluded: no legacy-client compatibility requirement | +| `testGetTaskRequiresAuthenticationUnauthenticated` | Auth base same-named v1 test | Included using v1 `AuthInterceptor` | +| `testGetTaskWithAuthentication` | Auth base same-named v1 test | Included using v1 `AuthInterceptor` | +| `testGetAgentCardIsPublic` | Auth base public-card assertion | Included through v1 card discovery | +| `testBasicAuthWorksViaHttp` | None | Intentionally excluded: raw HTTP request remains in frozen legacy suite | +| adapter unsupported-operation resolver cases | `testUnsupportedOperationsAreRejectedLocally` plus `Compat03ClientTransportSupportTest.rejectsUnsupportedOperationsBeforeDelegateUse` | Included at public-client and adapter-unit levels | +| task authorization variants | None | Intentionally excluded: compatibility target is the non-task-authorization standalone variants | + +Runnable compatibility commands: + +```bash +mvn -pl compat-0.3/reference/jsonrpc -am -Dtest='*Compatibility*' -Dsurefire.failIfNoSpecifiedTests=false test +mvn -pl compat-0.3/reference/rest -am -Dtest='*Compatibility*' -Dsurefire.failIfNoSpecifiedTests=false test +mvn -pl compat-0.3/reference/grpc -am -Dtest='*Compatibility*' -Dsurefire.failIfNoSpecifiedTests=false test +``` + +The reference test classpaths must contain the v1 client and native transport, +the root server-common test-jar, and the matching compatibility adapter plus +binding adapter. Adapter JAR service descriptors provide parser and versioned +transport discovery; no test resource copies are required. The original v0.3 +tests remain present and are run separately without the `*Compatibility*` +selector. diff --git a/extras/http-client-vertx/pom.xml b/extras/http-client-vertx/pom.xml index a96c9ab78..e65364ab0 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 2c47f7cdb..6e5b9442b 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,11 @@ public class A2ACardResolver { private final String cardUrl; private final @Nullable String fallbackUrl; private final @Nullable Map authHeaders; + private final Set supportedProtocolVersions; - 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) throws A2AClientError { checkNotNullParam("httpClient", httpClient); checkNotNullParam("baseUrl", baseUrl); this.httpClient = httpClient; @@ -101,6 +107,7 @@ 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); LOGGER.debug("Initialized A2ACardResolver with cardUrl={}", cardUrl); } @@ -123,6 +130,7 @@ public static class Builder { private @Nullable String tenant; private @Nullable String agentCardPath; private @Nullable Map authHeaders; + private Set supportedProtocolVersions = Set.of("1.0"); private Builder() { } @@ -201,6 +209,26 @@ public Builder authHeader(String name, String value) { return this; } + /** + * Sets the protocol versions this resolver is allowed to discover. + * + * @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; + return this; + } + /** * Builds the A2ACardResolver instance. * @@ -213,8 +241,19 @@ 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); + } + } + + 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 +348,61 @@ 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.equals(Set.of("1.0"))) { + 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"); + } + } + + 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/test/java/org/a2aproject/sdk/client/http/A2ACardResolverTest.java b/http-client/src/test/java/org/a2aproject/sdk/client/http/A2ACardResolverTest.java index 344648e60..08b266df8 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,57 @@ 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 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 bfa68c4a2..58ab9df0c 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/spec-grpc/pom.xml b/spec-grpc/pom.xml index 62bf031e3..0388cd94c 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..97e1dc7cd --- /dev/null +++ b/tests/client-builder-classpath/pom.xml @@ -0,0 +1,40 @@ + + + 4.0.0 + + org.a2aproject.sdk + a2a-java-sdk-parent + 1.3.3.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/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..708dd79f9 --- /dev/null +++ b/tests/server-common/src/test/java/org/a2aproject/sdk/server/apps/common/AbstractA2AServerCompatibilityTest_v0_3.java @@ -0,0 +1,759 @@ +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.CountDownLatch; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Consumer; +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.DeleteTaskPushNotificationConfigParams; +import org.a2aproject.sdk.spec.Event; +import org.a2aproject.sdk.spec.GetTaskPushNotificationConfigParams; +import org.a2aproject.sdk.spec.Message; +import org.a2aproject.sdk.spec.Part; +import org.a2aproject.sdk.spec.ListTaskPushNotificationConfigsParams; +import org.a2aproject.sdk.spec.ListTaskPushNotificationConfigsResult; +import org.a2aproject.sdk.spec.ListTasksParams; +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 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 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 { + CountDownLatch latch = new CountDownLatch(2); + getPollingClient().sendMessage(Message.builder(MESSAGE).messageId("non-blocking-1").build(), + List.of((event, card) -> latch.countDown()), null); + getPollingClient().sendMessage(Message.builder(MESSAGE).messageId("non-blocking-2").build(), + List.of((event, card) -> latch.countDown()), null); + assertTrue(latch.await(30, TimeUnit.SECONDS)); + } + + @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 { + ensureQueueForTask(taskId); + CountDownLatch latch = new CountDownLatch(1); + getClient().subscribeToTask(new TaskIdParams(taskId), List.of((event, card) -> latch.countDown()), null); + enqueueEventOnServer(TaskStatusUpdateEvent.builder().taskId(taskId).contextId(task.contextId()) + .status(new org.a2aproject.sdk.spec.TaskStatus(TaskState.TASK_STATE_WORKING)).build()); + assertTrue(latch.await(30, TimeUnit.SECONDS)); + assertTrue(getChildQueueCount(taskId) >= 0); + } finally { + deleteTaskInTaskStore(taskId); + } + } + + @Test + public void testMainQueueClosesForFinalizedTasks() throws Exception { + String taskId = "finalized-task-integration"; + saveTaskInTaskStore(Task.builder(MINIMAL_TASK).id(taskId).build()); + try { + ensureQueueForTask(taskId); + CountDownLatch latch = new CountDownLatch(1); + CompletableFuture subscription = awaitStreamingSubscription(); + getClient().subscribeToTask(new TaskIdParams(taskId), List.of((event, card) -> latch.countDown()), null); + subscription.get(15, TimeUnit.SECONDS); + enqueueEventOnServer(TaskStatusUpdateEvent.builder().taskId(taskId).contextId(MINIMAL_TASK.contextId()) + .status(new org.a2aproject.sdk.spec.TaskStatus(TaskState.TASK_STATE_COMPLETED)).build()); + assertTrue(latch.await(30, TimeUnit.SECONDS)); + } finally { + deleteTaskInTaskStore(taskId); + } + } + + 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 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; } } -