From 9d384307c16552b44a96899b972efcd252b9d69d Mon Sep 17 00:00:00 2001 From: Kabir Khan Date: Wed, 16 Sep 2026 16:31:35 +0100 Subject: [PATCH 1/9] feat: add unified client with A2A 0.3 compatibility --- .claude/architecture/compatibility_0.3.md | 68 +- AGENTS.md | 2 +- boms/sdk/pom.xml | 27 + boms/sdk/src/it/sdk-usage-test/pom.xml | 22 + .../src/main/java/org/a2aproject/sdk/A2A.java | 25 + .../a2aproject/sdk/client/ClientBuilder.java | 143 +++- .../VersionedClientTransportProvider.java | 19 + .../VersionedClientTransportProviderTest.java | 170 ++++ ...dk.client.VersionedClientTransportProvider | 1 + compat-0.3/client/adapter-grpc/pom.xml | 46 ++ .../grpc/GrpcCompat03ClientTransport.java | 186 +++++ .../GrpcCompat03ClientTransportProvider.java | 48 ++ ...dk.client.VersionedClientTransportProvider | 1 + ...pcCompat03ClientTransportProviderTest.java | 119 +++ compat-0.3/client/adapter-jsonrpc/pom.xml | 41 + .../JSONRPCCompat03ClientTransport.java | 157 ++++ ...SONRPCCompat03ClientTransportProvider.java | 35 + ...dk.client.VersionedClientTransportProvider | 1 + .../JSONRPCCompat03ClientTransportTest.java | 119 +++ compat-0.3/client/adapter-rest/pom.xml | 41 + .../rest/RestCompat03ClientTransport.java | 157 ++++ .../RestCompat03ClientTransportProvider.java | 34 + ...dk.client.VersionedClientTransportProvider | 1 + .../rest/RestCompat03ClientTransportTest.java | 42 + compat-0.3/client/adapter/pom.xml | 46 ++ .../Compat03AgentCardCompatibilityParser.java | 41 + .../Compat03ClientCallContextMapper.java | 21 + .../adapter/Compat03ClientErrorMapper.java | 76 ++ .../adapter/Compat03ClientTransportBase.java | 151 ++++ .../Compat03ClientTransportSupport.java | 235 ++++++ .../adapter/Compat03InterceptorSupport.java | 45 ++ ...k.client.http.AgentCardCompatibilityParser | 1 + ...pat03AgentCardCompatibilityParserTest.java | 107 +++ .../Compat03ClientErrorMapperTest.java | 56 ++ .../Compat03ClientTransportSupportTest.java | 169 ++++ compat-0.3/conversion/pom.xml | 39 + .../mappers/config/A03ToV10MapperConfig.java | 0 .../mappers/config/A2AMappers_v0_3.java | 0 .../mappers/domain/AgentCardMapper_v0_3.java | 240 ++++++ .../mappers/domain/ArtifactMapper_v0_3.java | 0 .../domain/AuthenticationInfoMapper_v0_3.java | 0 .../mappers/domain/EventKindMapper_v0_3.java | 0 .../domain/FileContentMapper_v0_3.java | 0 .../mappers/domain/MessageMapper_v0_3.java | 0 .../mappers/domain/PartMapper_v0_3.java | 0 .../mappers/domain/RoleMapper_v0_3.java | 0 .../domain/StreamingEventKindMapper_v0_3.java | 0 .../TaskArtifactUpdateEventMapper_v0_3.java | 0 .../mappers/domain/TaskMapper_v0_3.java | 0 ...TaskPushNotificationConfigMapper_v0_3.java | 0 .../mappers/domain/TaskStateMapper_v0_3.java | 0 .../mappers/domain/TaskStatusMapper_v0_3.java | 0 .../TaskStatusUpdateEventMapper_v0_3.java | 0 .../params/CancelTaskParamsMapper_v0_3.java | 0 .../MessageSendConfigurationMapper_v0_3.java | 0 .../params/MessageSendParamsMapper_v0_3.java | 0 .../params/TaskIdParamsMapper_v0_3.java | 0 .../params/TaskQueryParamsMapper_v0_3.java | 0 ...hNotificationConfigsResultMapper_v0_3.java | 0 .../domain/AgentCardMapper_v0_3_Test.java | 194 +++++ .../domain/FileContentMapper_v0_3_Test.java | 0 .../mappers/domain/TaskMapper_v0_3_Test.java | 0 ...TaskStatusUpdateEventMapper_v0_3_Test.java | 0 .../MessageSendParamsMapper_v0_3_Test.java | 27 + compat-0.3/pom.xml | 32 +- compat-0.3/reference/grpc/pom.xml | 28 +- .../CompatibilityAuthTestProfile_v0_3.java | 30 + .../CompatibilityTestProfile_v0_3.java | 19 + ...QuarkusA2AGrpc_v0_3_CompatibilityTest.java | 60 ++ ...2AGrpc_v0_3_WithAuthCompatibilityTest.java | 60 ++ .../a2a-requesthandler-test.properties | 1 + .../src/test/resources/application.properties | 1 + compat-0.3/reference/jsonrpc/pom.xml | 26 + .../CompatibilityAuthTestProfile_v0_3.java | 30 + .../CompatibilityTestProfile_v0_3.java | 19 + ...JSONRPC_v0_3_CompatibilityAndroidTest.java | 21 + ...sA2AJSONRPC_v0_3_CompatibilityJdkTest.java | 21 + ...2AJSONRPC_v0_3_CompatibilityVertxTest.java | 24 + ...v0_3_WithAuthCompatibilityAndroidTest.java | 28 + ...SONRPC_v0_3_WithAuthCompatibilityTest.java | 28 + ...C_v0_3_WithAuthCompatibilityVertxTest.java | 31 + .../a2a-requesthandler-test.properties | 1 + .../src/test/resources/application.properties | 1 + compat-0.3/reference/rest/pom.xml | 26 + .../CompatibilityAuthTestProfile_v0_3.java | 30 + .../CompatibilityTestProfile_v0_3.java | 19 + ...A2ARest_v0_3_CompatibilityAndroidTest.java | 21 + ...rkusA2ARest_v0_3_CompatibilityJdkTest.java | 21 + ...usA2ARest_v0_3_CompatibilityVertxTest.java | 24 + ...v0_3_WithAuthCompatibilityAndroidTest.java | 28 + ...2ARest_v0_3_WithAuthCompatibilityTest.java | 28 + ...t_v0_3_WithAuthCompatibilityVertxTest.java | 31 + .../a2a-requesthandler-test.properties | 1 + .../src/test/resources/application.properties | 1 + compat-0.3/server-conversion/pom.xml | 12 +- .../TaskAuthorizationTestProfile_v0_3.java | 5 + compat-0.3/spec-grpc/pom.xml | 8 - docs/content/dev/client.md | 7 +- docs/content/dev/compatibility.md | 39 +- ...26-09-14-client-v03-compatibility-tests.md | 494 ++++++++++++ .../2026-09-14-client-v03-compatibility.md | 484 +++++++++++ ...6-09-14-client-v03-compatibility-design.md | 111 +++ ...4-client-v03-compatibility-tests-design.md | 110 +++ ...4-client-v03-compatibility-tests-matrix.md | 73 ++ extras/http-client-vertx/pom.xml | 8 + .../sdk/client/http/A2ACardResolver.java | 74 +- .../http/AgentCardCompatibilityParser.java | 15 + .../sdk/client/http/A2ACardResolverTest.java | 36 + .../TestAgentCardCompatibilityParser.java | 33 + ...k.client.http.AgentCardCompatibilityParser | 1 + spec-grpc/pom.xml | 8 - ...stractA2AServerCompatibilityTest_v0_3.java | 759 ++++++++++++++++++ ...AServerCompatibilityWithAuthTest_v0_3.java | 123 +++ .../server/apps/common/AgentCardProducer.java | 3 +- 114 files changed, 5964 insertions(+), 83 deletions(-) create mode 100644 client/base/src/main/java/org/a2aproject/sdk/client/VersionedClientTransportProvider.java create mode 100644 client/base/src/test/java/org/a2aproject/sdk/client/VersionedClientTransportProviderTest.java create mode 100644 client/base/src/test/resources/META-INF/services/org.a2aproject.sdk.client.VersionedClientTransportProvider create mode 100644 compat-0.3/client/adapter-grpc/pom.xml create mode 100644 compat-0.3/client/adapter-grpc/src/main/java/org/a2aproject/sdk/compat03/client/adapter/grpc/GrpcCompat03ClientTransport.java create mode 100644 compat-0.3/client/adapter-grpc/src/main/java/org/a2aproject/sdk/compat03/client/adapter/grpc/GrpcCompat03ClientTransportProvider.java create mode 100644 compat-0.3/client/adapter-grpc/src/main/resources/META-INF/services/org.a2aproject.sdk.client.VersionedClientTransportProvider create mode 100644 compat-0.3/client/adapter-grpc/src/test/java/org/a2aproject/sdk/compat03/client/adapter/grpc/GrpcCompat03ClientTransportProviderTest.java create mode 100644 compat-0.3/client/adapter-jsonrpc/pom.xml create mode 100644 compat-0.3/client/adapter-jsonrpc/src/main/java/org/a2aproject/sdk/compat03/client/adapter/jsonrpc/JSONRPCCompat03ClientTransport.java create mode 100644 compat-0.3/client/adapter-jsonrpc/src/main/java/org/a2aproject/sdk/compat03/client/adapter/jsonrpc/JSONRPCCompat03ClientTransportProvider.java create mode 100644 compat-0.3/client/adapter-jsonrpc/src/main/resources/META-INF/services/org.a2aproject.sdk.client.VersionedClientTransportProvider create mode 100644 compat-0.3/client/adapter-jsonrpc/src/test/java/org/a2aproject/sdk/compat03/client/adapter/jsonrpc/JSONRPCCompat03ClientTransportTest.java create mode 100644 compat-0.3/client/adapter-rest/pom.xml create mode 100644 compat-0.3/client/adapter-rest/src/main/java/org/a2aproject/sdk/compat03/client/adapter/rest/RestCompat03ClientTransport.java create mode 100644 compat-0.3/client/adapter-rest/src/main/java/org/a2aproject/sdk/compat03/client/adapter/rest/RestCompat03ClientTransportProvider.java create mode 100644 compat-0.3/client/adapter-rest/src/main/resources/META-INF/services/org.a2aproject.sdk.client.VersionedClientTransportProvider create mode 100644 compat-0.3/client/adapter-rest/src/test/java/org/a2aproject/sdk/compat03/client/adapter/rest/RestCompat03ClientTransportTest.java create mode 100644 compat-0.3/client/adapter/pom.xml create mode 100644 compat-0.3/client/adapter/src/main/java/org/a2aproject/sdk/compat03/client/adapter/Compat03AgentCardCompatibilityParser.java create mode 100644 compat-0.3/client/adapter/src/main/java/org/a2aproject/sdk/compat03/client/adapter/Compat03ClientCallContextMapper.java create mode 100644 compat-0.3/client/adapter/src/main/java/org/a2aproject/sdk/compat03/client/adapter/Compat03ClientErrorMapper.java create mode 100644 compat-0.3/client/adapter/src/main/java/org/a2aproject/sdk/compat03/client/adapter/Compat03ClientTransportBase.java create mode 100644 compat-0.3/client/adapter/src/main/java/org/a2aproject/sdk/compat03/client/adapter/Compat03ClientTransportSupport.java create mode 100644 compat-0.3/client/adapter/src/main/java/org/a2aproject/sdk/compat03/client/adapter/Compat03InterceptorSupport.java create mode 100644 compat-0.3/client/adapter/src/main/resources/META-INF/services/org.a2aproject.sdk.client.http.AgentCardCompatibilityParser create mode 100644 compat-0.3/client/adapter/src/test/java/org/a2aproject/sdk/compat03/client/adapter/Compat03AgentCardCompatibilityParserTest.java create mode 100644 compat-0.3/client/adapter/src/test/java/org/a2aproject/sdk/compat03/client/adapter/Compat03ClientErrorMapperTest.java create mode 100644 compat-0.3/client/adapter/src/test/java/org/a2aproject/sdk/compat03/client/adapter/Compat03ClientTransportSupportTest.java create mode 100644 compat-0.3/conversion/pom.xml rename compat-0.3/{server-conversion => conversion}/src/main/java/org/a2aproject/sdk/compat03/conversion/mappers/config/A03ToV10MapperConfig.java (100%) rename compat-0.3/{server-conversion => conversion}/src/main/java/org/a2aproject/sdk/compat03/conversion/mappers/config/A2AMappers_v0_3.java (100%) create mode 100644 compat-0.3/conversion/src/main/java/org/a2aproject/sdk/compat03/conversion/mappers/domain/AgentCardMapper_v0_3.java rename compat-0.3/{server-conversion => conversion}/src/main/java/org/a2aproject/sdk/compat03/conversion/mappers/domain/ArtifactMapper_v0_3.java (100%) rename compat-0.3/{server-conversion => conversion}/src/main/java/org/a2aproject/sdk/compat03/conversion/mappers/domain/AuthenticationInfoMapper_v0_3.java (100%) rename compat-0.3/{server-conversion => conversion}/src/main/java/org/a2aproject/sdk/compat03/conversion/mappers/domain/EventKindMapper_v0_3.java (100%) rename compat-0.3/{server-conversion => conversion}/src/main/java/org/a2aproject/sdk/compat03/conversion/mappers/domain/FileContentMapper_v0_3.java (100%) rename compat-0.3/{server-conversion => conversion}/src/main/java/org/a2aproject/sdk/compat03/conversion/mappers/domain/MessageMapper_v0_3.java (100%) rename compat-0.3/{server-conversion => conversion}/src/main/java/org/a2aproject/sdk/compat03/conversion/mappers/domain/PartMapper_v0_3.java (100%) rename compat-0.3/{server-conversion => conversion}/src/main/java/org/a2aproject/sdk/compat03/conversion/mappers/domain/RoleMapper_v0_3.java (100%) rename compat-0.3/{server-conversion => conversion}/src/main/java/org/a2aproject/sdk/compat03/conversion/mappers/domain/StreamingEventKindMapper_v0_3.java (100%) rename compat-0.3/{server-conversion => conversion}/src/main/java/org/a2aproject/sdk/compat03/conversion/mappers/domain/TaskArtifactUpdateEventMapper_v0_3.java (100%) rename compat-0.3/{server-conversion => conversion}/src/main/java/org/a2aproject/sdk/compat03/conversion/mappers/domain/TaskMapper_v0_3.java (100%) rename compat-0.3/{server-conversion => conversion}/src/main/java/org/a2aproject/sdk/compat03/conversion/mappers/domain/TaskPushNotificationConfigMapper_v0_3.java (100%) rename compat-0.3/{server-conversion => conversion}/src/main/java/org/a2aproject/sdk/compat03/conversion/mappers/domain/TaskStateMapper_v0_3.java (100%) rename compat-0.3/{server-conversion => conversion}/src/main/java/org/a2aproject/sdk/compat03/conversion/mappers/domain/TaskStatusMapper_v0_3.java (100%) rename compat-0.3/{server-conversion => conversion}/src/main/java/org/a2aproject/sdk/compat03/conversion/mappers/domain/TaskStatusUpdateEventMapper_v0_3.java (100%) rename compat-0.3/{server-conversion => conversion}/src/main/java/org/a2aproject/sdk/compat03/conversion/mappers/params/CancelTaskParamsMapper_v0_3.java (100%) rename compat-0.3/{server-conversion => conversion}/src/main/java/org/a2aproject/sdk/compat03/conversion/mappers/params/MessageSendConfigurationMapper_v0_3.java (100%) rename compat-0.3/{server-conversion => conversion}/src/main/java/org/a2aproject/sdk/compat03/conversion/mappers/params/MessageSendParamsMapper_v0_3.java (100%) rename compat-0.3/{server-conversion => conversion}/src/main/java/org/a2aproject/sdk/compat03/conversion/mappers/params/TaskIdParamsMapper_v0_3.java (100%) rename compat-0.3/{server-conversion => conversion}/src/main/java/org/a2aproject/sdk/compat03/conversion/mappers/params/TaskQueryParamsMapper_v0_3.java (100%) rename compat-0.3/{server-conversion => conversion}/src/main/java/org/a2aproject/sdk/compat03/conversion/mappers/result/ListTaskPushNotificationConfigsResultMapper_v0_3.java (100%) create mode 100644 compat-0.3/conversion/src/test/java/org/a2aproject/sdk/compat03/conversion/mappers/domain/AgentCardMapper_v0_3_Test.java rename compat-0.3/{server-conversion => conversion}/src/test/java/org/a2aproject/sdk/compat03/conversion/mappers/domain/FileContentMapper_v0_3_Test.java (100%) rename compat-0.3/{server-conversion => conversion}/src/test/java/org/a2aproject/sdk/compat03/conversion/mappers/domain/TaskMapper_v0_3_Test.java (100%) rename compat-0.3/{server-conversion => conversion}/src/test/java/org/a2aproject/sdk/compat03/conversion/mappers/domain/TaskStatusUpdateEventMapper_v0_3_Test.java (100%) create mode 100644 compat-0.3/conversion/src/test/java/org/a2aproject/sdk/compat03/conversion/mappers/params/MessageSendParamsMapper_v0_3_Test.java create mode 100644 compat-0.3/reference/grpc/src/test/java/org/a2aproject/sdk/compat03/server/grpc/quarkus/CompatibilityAuthTestProfile_v0_3.java create mode 100644 compat-0.3/reference/grpc/src/test/java/org/a2aproject/sdk/compat03/server/grpc/quarkus/CompatibilityTestProfile_v0_3.java create mode 100644 compat-0.3/reference/grpc/src/test/java/org/a2aproject/sdk/compat03/server/grpc/quarkus/QuarkusA2AGrpc_v0_3_CompatibilityTest.java create mode 100644 compat-0.3/reference/grpc/src/test/java/org/a2aproject/sdk/compat03/server/grpc/quarkus/QuarkusA2AGrpc_v0_3_WithAuthCompatibilityTest.java create mode 100644 compat-0.3/reference/grpc/src/test/resources/a2a-requesthandler-test.properties create mode 100644 compat-0.3/reference/jsonrpc/src/test/java/org/a2aproject/sdk/compat03/server/apps/quarkus/CompatibilityAuthTestProfile_v0_3.java create mode 100644 compat-0.3/reference/jsonrpc/src/test/java/org/a2aproject/sdk/compat03/server/apps/quarkus/CompatibilityTestProfile_v0_3.java create mode 100644 compat-0.3/reference/jsonrpc/src/test/java/org/a2aproject/sdk/compat03/server/apps/quarkus/QuarkusA2AJSONRPC_v0_3_CompatibilityAndroidTest.java create mode 100644 compat-0.3/reference/jsonrpc/src/test/java/org/a2aproject/sdk/compat03/server/apps/quarkus/QuarkusA2AJSONRPC_v0_3_CompatibilityJdkTest.java create mode 100644 compat-0.3/reference/jsonrpc/src/test/java/org/a2aproject/sdk/compat03/server/apps/quarkus/QuarkusA2AJSONRPC_v0_3_CompatibilityVertxTest.java create mode 100644 compat-0.3/reference/jsonrpc/src/test/java/org/a2aproject/sdk/compat03/server/apps/quarkus/QuarkusA2AJSONRPC_v0_3_WithAuthCompatibilityAndroidTest.java create mode 100644 compat-0.3/reference/jsonrpc/src/test/java/org/a2aproject/sdk/compat03/server/apps/quarkus/QuarkusA2AJSONRPC_v0_3_WithAuthCompatibilityTest.java create mode 100644 compat-0.3/reference/jsonrpc/src/test/java/org/a2aproject/sdk/compat03/server/apps/quarkus/QuarkusA2AJSONRPC_v0_3_WithAuthCompatibilityVertxTest.java create mode 100644 compat-0.3/reference/jsonrpc/src/test/resources/a2a-requesthandler-test.properties create mode 100644 compat-0.3/reference/rest/src/test/java/org/a2aproject/sdk/compat03/server/rest/quarkus/CompatibilityAuthTestProfile_v0_3.java create mode 100644 compat-0.3/reference/rest/src/test/java/org/a2aproject/sdk/compat03/server/rest/quarkus/CompatibilityTestProfile_v0_3.java create mode 100644 compat-0.3/reference/rest/src/test/java/org/a2aproject/sdk/compat03/server/rest/quarkus/QuarkusA2ARest_v0_3_CompatibilityAndroidTest.java create mode 100644 compat-0.3/reference/rest/src/test/java/org/a2aproject/sdk/compat03/server/rest/quarkus/QuarkusA2ARest_v0_3_CompatibilityJdkTest.java create mode 100644 compat-0.3/reference/rest/src/test/java/org/a2aproject/sdk/compat03/server/rest/quarkus/QuarkusA2ARest_v0_3_CompatibilityVertxTest.java create mode 100644 compat-0.3/reference/rest/src/test/java/org/a2aproject/sdk/compat03/server/rest/quarkus/QuarkusA2ARest_v0_3_WithAuthCompatibilityAndroidTest.java create mode 100644 compat-0.3/reference/rest/src/test/java/org/a2aproject/sdk/compat03/server/rest/quarkus/QuarkusA2ARest_v0_3_WithAuthCompatibilityTest.java create mode 100644 compat-0.3/reference/rest/src/test/java/org/a2aproject/sdk/compat03/server/rest/quarkus/QuarkusA2ARest_v0_3_WithAuthCompatibilityVertxTest.java create mode 100644 compat-0.3/reference/rest/src/test/resources/a2a-requesthandler-test.properties create mode 100644 docs/superpowers/plans/2026-09-14-client-v03-compatibility-tests.md create mode 100644 docs/superpowers/plans/2026-09-14-client-v03-compatibility.md create mode 100644 docs/superpowers/specs/2026-09-14-client-v03-compatibility-design.md create mode 100644 docs/superpowers/specs/2026-09-14-client-v03-compatibility-tests-design.md create mode 100644 docs/superpowers/specs/2026-09-14-client-v03-compatibility-tests-matrix.md create mode 100644 http-client/src/main/java/org/a2aproject/sdk/client/http/AgentCardCompatibilityParser.java create mode 100644 http-client/src/test/java/org/a2aproject/sdk/client/http/TestAgentCardCompatibilityParser.java create mode 100644 http-client/src/test/resources/META-INF/services/org.a2aproject.sdk.client.http.AgentCardCompatibilityParser create mode 100644 tests/server-common/src/test/java/org/a2aproject/sdk/server/apps/common/AbstractA2AServerCompatibilityTest_v0_3.java create mode 100644 tests/server-common/src/test/java/org/a2aproject/sdk/server/apps/common/AbstractA2AServerCompatibilityWithAuthTest_v0_3.java diff --git a/.claude/architecture/compatibility_0.3.md b/.claude/architecture/compatibility_0.3.md index f9dfcccdf..aec1482c6 100644 --- a/.claude/architecture/compatibility_0.3.md +++ b/.claude/architecture/compatibility_0.3.md @@ -16,7 +16,8 @@ The A2A protocol evolved from v0.3 to v1.0 with significant breaking changes. Ex - Dedicated `compat-0.3` Maven module structure containing **only** 0.3-specific code - gRPC code generation from the v0.3 `a2a.proto` -- Dedicated v0.3 client (`Client_v0_3`) exposing only features available in v0.3 +- Unified v1.0 client compatibility adapters that allow the normal `Client` API to communicate with v0.3 agents +- Legacy v0.3 client (`Client_v0_3`) exposing only features available in v0.3 - Server-side conversion layer (`Convert_v0_3_To10RequestHandler`) that accepts v0.3 requests and delegates to v1.0 server-common - Server-side transport handlers for v0.3 (JSON-RPC, gRPC, REST) - Bidirectional mapping layer between v0.3 and v1.0 domain objects @@ -30,7 +31,7 @@ The A2A protocol evolved from v0.3 to v1.0 with significant breaking changes. Ex ### Out of Scope - Changes to existing v1.0 modules (no regressions, no API changes) -- Automatic protocol version detection (client must explicitly choose API version) +- Implicit protocol downgrade or automatic version selection (the unified client requires the caller to explicitly request supported protocol versions during agent-card discovery) - Extras modules (OpenTelemetry, JPA stores, etc.) for v0.3 - Serving a separate v0.3-format agent card (the v1.0 card is served, with optional v0.3-compatible fields added by the user) @@ -107,13 +108,21 @@ All compat-0.3 classes use a `_v0_3` suffix to avoid naming conflicts with v1.0 ### Dedicated v0.3 Client -The compat layer exposes a **dedicated `Client_v0_3`** that only provides features available in v0.3: +The compat layer retains a **dedicated `Client_v0_3`** for applications that already use the v0.3 API directly. It only provides features available in v0.3: - No `listTasks()` method (absent in v0.3) - Method names reflect v0.3 semantics where they differ - The client is a standalone API, not a wrapper around the v1.0 `Client` -Users must explicitly check the `protocolVersion` field from the agent card and instantiate the correct client accordingly. No automatic version detection. +This is the legacy path. New applications should use the unified v1.0 `Client` path below instead. + +### Unified v1.0 Client with v0.3 Compatibility + +The recommended path for new applications is the normal v1.0 `Client` API with optional v0.3 compatibility artifacts. Applications continue to use v1.0 `AgentCard`, request, response, event, configuration, context, and interceptor types; they do not need to import `Client_v0_3` or v0.3 domain types. + +The caller explicitly requests the protocol versions it is willing to use during agent-card discovery. If a v0.3 interface is selected, the compatibility parser projects its card into a v1.0 `AgentCard`, and a version-aware transport adapter converts calls between v1.0 and v0.3. The unified client does not silently downgrade when v0.3 was not requested. + +The compatibility parser and the adapter for the selected binding are optional dependencies. The adapter rejects v1.0 operations that have no v0.3 equivalent, such as `listTasks`, before sending a request. If the requested compatibility parser or binding adapter is absent, discovery or client construction reports the missing artifact. ### Server-Side Conversion Layer @@ -231,9 +240,12 @@ compat-0.3/ │ └── ListTaskPushNotificationConfigsResultMapper_v0_3.java ├── tests/ # Test infrastructure │ └── server-common/ # Shared test base classes (AgentExecutorProducer_v0_3) -├── client/ # v0.3-compatible client -│ ├── base/ # Client_v0_3 — dedicated 0.3 API -│ │ └── pom.xml +├── client/ # Client compatibility support +│ ├── base/ # Client_v0_3 — legacy dedicated 0.3 API +│ ├── adapter/ # Unified v1.0 Client compatibility adapter +│ ├── adapter-jsonrpc/ # Unified JSON-RPC client adapter +│ ├── adapter-rest/ # Unified REST client adapter +│ ├── adapter-grpc/ # Unified gRPC client adapter │ └── transport/ │ ├── spi/ # Transport SPI │ │ └── pom.xml @@ -427,7 +439,38 @@ The `server-conversion` module produces a test-jar containing shared test infras ### Client: Talking to a v0.3 Agent -**1. Add the compat client dependency:** +New applications should use the unified v1.0 `Client` API. Add the compatibility parser and the adapter for the desired binding: + +```xml + + org.a2aproject.sdk + a2a-java-sdk-compat-0.3-client-adapter + + + + org.a2aproject.sdk + a2a-java-sdk-compat-0.3-client-adapter-jsonrpc + +``` + +Request the versions explicitly and build the ordinary client: + +```java +AgentCard agentCard = A2A.getAgentCard( + "http://localhost:1234", Set.of("1.0", "0.3")); + +Client client = Client.builder(agentCard) + .withTransport(JSONRPCTransport.class, new JSONRPCTransportConfigBuilder() + .httpClient(A2AHttpClientFactory.create()) + .build()) + .build(); +``` + +The returned card contains a v1.0 `AgentInterface` marked with protocol version `"0.3"`. The ordinary builder selects the matching compatibility adapter, while a native v1.0 interface continues to use the normal transport. + +Existing applications that use v0.3 domain types directly can continue using the legacy API: + +**1. Add the legacy compat client dependency:** ```xml @@ -441,7 +484,7 @@ The `server-conversion` module produces a test-jar containing shared test infras ``` -**2. Find the v0.3 interface and create the client:** +**2. Find the v0.3 interface and create the legacy client:** ```java AgentCard card = // ... fetch agent card from /.well-known/agent-card.json @@ -452,13 +495,13 @@ AgentInterface v03Interface = card.supportedInterfaces().stream() .findFirst() .orElseThrow(); -// Create the v0.3 compatibility client +// Create the legacy v0.3 client Client_v0_3 client = ClientBuilder_v0_3.forUrl(v03Interface.url()) .withTransport(JSONRPCTransport_v0_3.class, new JSONRPCTransportConfigBuilder_v0_3()) .build(); ``` -`Client_v0_3` exposes only operations available in v0.3. Return types are v0.3 `org.a2aproject.sdk.compat03.spec` domain objects. +`Client_v0_3` exposes only operations available in v0.3, and its return types are v0.3 `org.a2aproject.sdk.compat03.spec` domain objects. This path is useful for existing integrations but requires applications to use the legacy API and types directly. ### Server: Serving v0.3 Clients @@ -540,7 +583,8 @@ For JSON-RPC and REST, multi-version convenience modules are also available that | `Convert_v0_3_To10RequestHandler` | Integration tests | Via transport handler tests using real v1.0 backend | | Transport handlers | Unit + Integration | Handler-level tests + end-to-end via reference servers | | Client transports | Unit tests | Mocked v0.3 endpoints | -| `Client_v0_3` | Unit tests | API coverage, absence of v1.0-only methods | +| Unified v1.0 `Client` compatibility adapters | Unit + integration tests | v1.0 client API against v0.3 endpoints, including synchronous, streaming, resubscription, push configuration, authentication, and error behavior | +| `Client_v0_3` | Unit + integration tests | Legacy API coverage, absence of v1.0-only methods, and direct v0.3 request/response behavior | | Reference servers | Integration tests | Full request/response cycle with v0.3 client | | TCK | Conformance tests | Protocol conformance against v0.3 spec | diff --git a/AGENTS.md b/AGENTS.md index aed63a2fd..743701445 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -122,7 +122,7 @@ For detailed architectural documentation: - **[Request Flows](.claude/architecture/eventqueue/FLOWS.md)**: Non-streaming vs streaming, cleanup patterns - **[Usage Scenarios](.claude/architecture/eventqueue/SCENARIOS.md)**: Real-world patterns and common pitfalls - **Compatibility with previous protocol versions**: - - 0.3 protocol compatibility layer: `.claude/architecture/compatibility_0.3.md` + - 0.3 protocol compatibility layer, including the unified client adapters and legacy `Client_v0_3` API: `.claude/architecture/compatibility_0.3.md` > 💡 Deep-dive docs are loaded on-demand when working in related areas. diff --git a/boms/sdk/pom.xml b/boms/sdk/pom.xml index 7fd26cac6..6d0ebf1e5 100644 --- a/boms/sdk/pom.xml +++ b/boms/sdk/pom.xml @@ -120,6 +120,33 @@ ${project.version} + + + ${project.groupId} + a2a-java-sdk-compat-0.3-conversion + ${project.version} + + + ${project.groupId} + a2a-java-sdk-compat-0.3-client-adapter + ${project.version} + + + ${project.groupId} + a2a-java-sdk-compat-0.3-client-adapter-jsonrpc + ${project.version} + + + ${project.groupId} + a2a-java-sdk-compat-0.3-client-adapter-rest + ${project.version} + + + ${project.groupId} + a2a-java-sdk-compat-0.3-client-adapter-grpc + ${project.version} + + ${project.groupId} diff --git a/boms/sdk/src/it/sdk-usage-test/pom.xml b/boms/sdk/src/it/sdk-usage-test/pom.xml index dc8a23965..bb11137a0 100644 --- a/boms/sdk/src/it/sdk-usage-test/pom.xml +++ b/boms/sdk/src/it/sdk-usage-test/pom.xml @@ -113,6 +113,28 @@ a2a-java-sdk-compat-0.3-spec-grpc + + + org.a2aproject.sdk + a2a-java-sdk-compat-0.3-conversion + + + org.a2aproject.sdk + a2a-java-sdk-compat-0.3-client-adapter + + + org.a2aproject.sdk + a2a-java-sdk-compat-0.3-client-adapter-jsonrpc + + + org.a2aproject.sdk + a2a-java-sdk-compat-0.3-client-adapter-rest + + + org.a2aproject.sdk + a2a-java-sdk-compat-0.3-client-adapter-grpc + + org.a2aproject.sdk diff --git a/client/base/src/main/java/org/a2aproject/sdk/A2A.java b/client/base/src/main/java/org/a2aproject/sdk/A2A.java index d5643d305..6480c4dda 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,11 @@ public static AgentCard getAgentCard(String agentUrl) throws A2AClientError, A2A return getAgentCard(A2AHttpClientFactory.create(), agentUrl); } + 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 +321,11 @@ public static AgentCard getAgentCard(A2AHttpClient httpClient, String agentUrl) return getAgentCard(httpClient, agentUrl, null, null); } + 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 +371,13 @@ public static AgentCard getAgentCard(String agentUrl, String relativeCardPath, M return getAgentCard(A2AHttpClientFactory.create(), agentUrl, relativeCardPath, authHeaders); } + 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 +411,18 @@ 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")); + } + + 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..7ea312663 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; } @@ -380,13 +437,43 @@ AgentInterface findBestClientTransport() throws A2AClientException { if (matchedInterface == null) { 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 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..138e46575 --- /dev/null +++ b/compat-0.3/client/adapter-rest/src/main/java/org/a2aproject/sdk/compat03/client/adapter/rest/RestCompat03ClientTransport.java @@ -0,0 +1,157 @@ +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, + 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-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..c78d79cea --- /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..53a266b21 --- /dev/null +++ b/compat-0.3/client/adapter/src/main/java/org/a2aproject/sdk/compat03/client/adapter/Compat03InterceptorSupport.java @@ -0,0 +1,45 @@ +package org.a2aproject.sdk.compat03.client.adapter; + +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 = context == null ? Map.of() : context.getHeaders(); + PayloadAndHeaders result = new PayloadAndHeaders(payload, headers); + validateVersionHeader(result.getHeaders()); + for (ClientCallInterceptor interceptor : interceptors) { + result = interceptor.intercept(method, result.getPayload(), result.getHeaders(), card, context); + 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..0f6f95757 --- /dev/null +++ b/compat-0.3/client/adapter/src/test/java/org/a2aproject/sdk/compat03/client/adapter/Compat03ClientTransportSupportTest.java @@ -0,0 +1,169 @@ +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 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/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 87b7c1ca4..d7a7f9e4f 100644 --- a/compat-0.3/pom.xml +++ b/compat-0.3/pom.xml @@ -24,6 +24,31 @@ a2a-java-sdk-compat-0.3-spec ${project.version} + + ${project.groupId} + a2a-java-sdk-compat-0.3-conversion + ${project.version} + + + ${project.groupId} + a2a-java-sdk-compat-0.3-client-adapter + ${project.version} + + + ${project.groupId} + a2a-java-sdk-compat-0.3-client-adapter-jsonrpc + ${project.version} + + + ${project.groupId} + a2a-java-sdk-compat-0.3-client-adapter-rest + ${project.version} + + + ${project.groupId} + a2a-java-sdk-compat-0.3-client-adapter-grpc + ${project.version} + ${project.groupId} a2a-java-sdk-compat-0.3-spec-grpc @@ -118,8 +143,13 @@ spec spec-grpc - + + conversion server-conversion + client/adapter + client/adapter-jsonrpc + client/adapter-rest + client/adapter-grpc tests/server-common diff --git a/compat-0.3/reference/grpc/pom.xml b/compat-0.3/reference/grpc/pom.xml index 95ac53dec..62c9ac46f 100644 --- a/compat-0.3/reference/grpc/pom.xml +++ b/compat-0.3/reference/grpc/pom.xml @@ -15,6 +15,32 @@ Java SDK for the Agent2Agent Protocol (A2A) - A2A gRPC Reference Server (based on Quarkus) + + ${project.groupId} + a2a-java-sdk-client + test + + + ${project.groupId} + a2a-java-sdk-client-transport-grpc + test + + + ${project.groupId} + a2a-java-sdk-tests-server-common + test-jar + test + + + ${project.groupId} + a2a-java-sdk-compat-0.3-client-adapter + test + + + ${project.groupId} + a2a-java-sdk-compat-0.3-client-adapter-grpc + test + ${project.groupId} a2a-java-sdk-compat-0.3-spec @@ -136,4 +162,4 @@ - \ No newline at end of file + diff --git a/compat-0.3/reference/grpc/src/test/java/org/a2aproject/sdk/compat03/server/grpc/quarkus/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 a178e3e0f..7fe7b5568 100644 --- a/compat-0.3/reference/jsonrpc/pom.xml +++ b/compat-0.3/reference/jsonrpc/pom.xml @@ -18,6 +18,32 @@ Java SDK for the Agent2Agent Protocol (A2A) - A2A JSONRPC Reference Server (based on Quarkus) + + ${project.groupId} + a2a-java-sdk-client + test + + + ${project.groupId} + a2a-java-sdk-client-transport-jsonrpc + test + + + ${project.groupId} + a2a-java-sdk-tests-server-common + test-jar + test + + + ${project.groupId} + a2a-java-sdk-compat-0.3-client-adapter + test + + + ${project.groupId} + a2a-java-sdk-compat-0.3-client-adapter-jsonrpc + test + ${project.groupId} a2a-java-sdk-compat-0.3-spec diff --git a/compat-0.3/reference/jsonrpc/src/test/java/org/a2aproject/sdk/compat03/server/apps/quarkus/CompatibilityAuthTestProfile_v0_3.java b/compat-0.3/reference/jsonrpc/src/test/java/org/a2aproject/sdk/compat03/server/apps/quarkus/CompatibilityAuthTestProfile_v0_3.java new file mode 100644 index 000000000..45600fa6e --- /dev/null +++ b/compat-0.3/reference/jsonrpc/src/test/java/org/a2aproject/sdk/compat03/server/apps/quarkus/CompatibilityAuthTestProfile_v0_3.java @@ -0,0 +1,30 @@ +package org.a2aproject.sdk.compat03.server.apps.quarkus; + +import java.util.Map; + +import io.quarkus.test.junit.QuarkusTestProfile; + +public final class CompatibilityAuthTestProfile_v0_3 implements QuarkusTestProfile { + @Override + public Map getConfigOverrides() { + return Map.ofEntries( + Map.entry("test.identity.auto-auth", "false"), + Map.entry("quarkus.test.security.auth.enabled", "false"), + Map.entry("test.agent.security.enabled", "true"), + Map.entry("test.authorization.enabled", "true"), + Map.entry("quarkus.security.users.embedded.enabled", "true"), + Map.entry("quarkus.security.users.embedded.plain-text", "true"), + Map.entry("quarkus.security.users.embedded.users.testuser", "testpass"), + Map.entry("quarkus.security.users.embedded.roles.testuser", "user"), + Map.entry("quarkus.http.auth.basic", "true"), + Map.entry("quarkus.http.auth.proactive", "true"), + Map.entry( + "quarkus.arc.exclude-types", + "org.a2aproject.sdk.compat03.conversion.test.AgentExecutorProducer_v0_3")); + } + + @Override + public String getConfigProfile() { + return "test"; + } +} diff --git a/compat-0.3/reference/jsonrpc/src/test/java/org/a2aproject/sdk/compat03/server/apps/quarkus/CompatibilityTestProfile_v0_3.java b/compat-0.3/reference/jsonrpc/src/test/java/org/a2aproject/sdk/compat03/server/apps/quarkus/CompatibilityTestProfile_v0_3.java new file mode 100644 index 000000000..450fb665e --- /dev/null +++ b/compat-0.3/reference/jsonrpc/src/test/java/org/a2aproject/sdk/compat03/server/apps/quarkus/CompatibilityTestProfile_v0_3.java @@ -0,0 +1,19 @@ +package org.a2aproject.sdk.compat03.server.apps.quarkus; + +import java.util.Map; + +import io.quarkus.test.junit.QuarkusTestProfile; + +public final class CompatibilityTestProfile_v0_3 implements QuarkusTestProfile { + @Override + public Map getConfigOverrides() { + return Map.of( + "quarkus.arc.exclude-types", + "org.a2aproject.sdk.compat03.conversion.test.AgentExecutorProducer_v0_3"); + } + + @Override + public String getConfigProfile() { + return "test"; + } +} diff --git a/compat-0.3/reference/jsonrpc/src/test/java/org/a2aproject/sdk/compat03/server/apps/quarkus/QuarkusA2AJSONRPC_v0_3_CompatibilityAndroidTest.java b/compat-0.3/reference/jsonrpc/src/test/java/org/a2aproject/sdk/compat03/server/apps/quarkus/QuarkusA2AJSONRPC_v0_3_CompatibilityAndroidTest.java new file mode 100644 index 000000000..263ea463b --- /dev/null +++ b/compat-0.3/reference/jsonrpc/src/test/java/org/a2aproject/sdk/compat03/server/apps/quarkus/QuarkusA2AJSONRPC_v0_3_CompatibilityAndroidTest.java @@ -0,0 +1,21 @@ +package org.a2aproject.sdk.compat03.server.apps.quarkus; + +import io.quarkus.test.junit.QuarkusTest; +import io.quarkus.test.junit.TestProfile; +import org.a2aproject.sdk.client.ClientBuilder; +import org.a2aproject.sdk.client.http.android.AndroidA2AHttpClient; +import org.a2aproject.sdk.client.transport.jsonrpc.JSONRPCTransport; +import org.a2aproject.sdk.client.transport.jsonrpc.JSONRPCTransportConfigBuilder; +import org.a2aproject.sdk.server.apps.common.AbstractA2AServerCompatibilityTest_v0_3; + +@QuarkusTest +@TestProfile(CompatibilityTestProfile_v0_3.class) +public class QuarkusA2AJSONRPC_v0_3_CompatibilityAndroidTest extends AbstractA2AServerCompatibilityTest_v0_3 { + public QuarkusA2AJSONRPC_v0_3_CompatibilityAndroidTest() { super(8081); } + @Override protected String getTransportProtocol() { return "JSONRPC"; } + @Override protected String getTransportUrl() { return "http://localhost:8081"; } + @Override protected void configureTransport(ClientBuilder builder) { + builder.withTransport(JSONRPCTransport.class, new JSONRPCTransportConfigBuilder() + .httpClient(new AndroidA2AHttpClient())); + } +} diff --git a/compat-0.3/reference/jsonrpc/src/test/java/org/a2aproject/sdk/compat03/server/apps/quarkus/QuarkusA2AJSONRPC_v0_3_CompatibilityJdkTest.java b/compat-0.3/reference/jsonrpc/src/test/java/org/a2aproject/sdk/compat03/server/apps/quarkus/QuarkusA2AJSONRPC_v0_3_CompatibilityJdkTest.java new file mode 100644 index 000000000..d2289b5e5 --- /dev/null +++ b/compat-0.3/reference/jsonrpc/src/test/java/org/a2aproject/sdk/compat03/server/apps/quarkus/QuarkusA2AJSONRPC_v0_3_CompatibilityJdkTest.java @@ -0,0 +1,21 @@ +package org.a2aproject.sdk.compat03.server.apps.quarkus; + +import io.quarkus.test.junit.QuarkusTest; +import io.quarkus.test.junit.TestProfile; +import org.a2aproject.sdk.client.ClientBuilder; +import org.a2aproject.sdk.client.http.JdkA2AHttpClient; +import org.a2aproject.sdk.client.transport.jsonrpc.JSONRPCTransport; +import org.a2aproject.sdk.client.transport.jsonrpc.JSONRPCTransportConfigBuilder; +import org.a2aproject.sdk.server.apps.common.AbstractA2AServerCompatibilityTest_v0_3; + +@QuarkusTest +@TestProfile(CompatibilityTestProfile_v0_3.class) +public class QuarkusA2AJSONRPC_v0_3_CompatibilityJdkTest extends AbstractA2AServerCompatibilityTest_v0_3 { + public QuarkusA2AJSONRPC_v0_3_CompatibilityJdkTest() { super(8081); } + @Override protected String getTransportProtocol() { return "JSONRPC"; } + @Override protected String getTransportUrl() { return "http://localhost:8081"; } + @Override protected void configureTransport(ClientBuilder builder) { + builder.withTransport(JSONRPCTransport.class, new JSONRPCTransportConfigBuilder() + .httpClient(new JdkA2AHttpClient())); + } +} diff --git a/compat-0.3/reference/jsonrpc/src/test/java/org/a2aproject/sdk/compat03/server/apps/quarkus/QuarkusA2AJSONRPC_v0_3_CompatibilityVertxTest.java b/compat-0.3/reference/jsonrpc/src/test/java/org/a2aproject/sdk/compat03/server/apps/quarkus/QuarkusA2AJSONRPC_v0_3_CompatibilityVertxTest.java new file mode 100644 index 000000000..2f2009139 --- /dev/null +++ b/compat-0.3/reference/jsonrpc/src/test/java/org/a2aproject/sdk/compat03/server/apps/quarkus/QuarkusA2AJSONRPC_v0_3_CompatibilityVertxTest.java @@ -0,0 +1,24 @@ +package org.a2aproject.sdk.compat03.server.apps.quarkus; + +import io.quarkus.test.junit.QuarkusTest; +import io.quarkus.test.junit.TestProfile; +import io.vertx.core.Vertx; +import jakarta.inject.Inject; +import org.a2aproject.sdk.client.ClientBuilder; +import org.a2aproject.sdk.client.http.vertx.VertxA2AHttpClient; +import org.a2aproject.sdk.client.transport.jsonrpc.JSONRPCTransport; +import org.a2aproject.sdk.client.transport.jsonrpc.JSONRPCTransportConfigBuilder; +import org.a2aproject.sdk.server.apps.common.AbstractA2AServerCompatibilityTest_v0_3; + +@QuarkusTest +@TestProfile(CompatibilityTestProfile_v0_3.class) +public class QuarkusA2AJSONRPC_v0_3_CompatibilityVertxTest extends AbstractA2AServerCompatibilityTest_v0_3 { + @Inject Vertx vertx; + public QuarkusA2AJSONRPC_v0_3_CompatibilityVertxTest() { super(8081); } + @Override protected String getTransportProtocol() { return "JSONRPC"; } + @Override protected String getTransportUrl() { return "http://localhost:8081"; } + @Override protected void configureTransport(ClientBuilder builder) { + builder.withTransport(JSONRPCTransport.class, new JSONRPCTransportConfigBuilder() + .httpClient(new VertxA2AHttpClient(vertx))); + } +} diff --git a/compat-0.3/reference/jsonrpc/src/test/java/org/a2aproject/sdk/compat03/server/apps/quarkus/QuarkusA2AJSONRPC_v0_3_WithAuthCompatibilityAndroidTest.java b/compat-0.3/reference/jsonrpc/src/test/java/org/a2aproject/sdk/compat03/server/apps/quarkus/QuarkusA2AJSONRPC_v0_3_WithAuthCompatibilityAndroidTest.java new file mode 100644 index 000000000..6b2f33fb1 --- /dev/null +++ b/compat-0.3/reference/jsonrpc/src/test/java/org/a2aproject/sdk/compat03/server/apps/quarkus/QuarkusA2AJSONRPC_v0_3_WithAuthCompatibilityAndroidTest.java @@ -0,0 +1,28 @@ +package org.a2aproject.sdk.compat03.server.apps.quarkus; + +import io.quarkus.test.junit.QuarkusTest; +import io.quarkus.test.junit.TestProfile; +import org.a2aproject.sdk.client.ClientBuilder; +import org.a2aproject.sdk.client.http.android.AndroidA2AHttpClient; +import org.a2aproject.sdk.client.transport.jsonrpc.JSONRPCTransport; +import org.a2aproject.sdk.client.transport.jsonrpc.JSONRPCTransportConfigBuilder; +import org.a2aproject.sdk.client.transport.spi.interceptors.auth.AuthInterceptor; +import org.a2aproject.sdk.server.apps.common.AbstractA2AServerCompatibilityWithAuthTest_v0_3; + +@QuarkusTest +@TestProfile(CompatibilityAuthTestProfile_v0_3.class) +public class QuarkusA2AJSONRPC_v0_3_WithAuthCompatibilityAndroidTest + extends AbstractA2AServerCompatibilityWithAuthTest_v0_3 { + public QuarkusA2AJSONRPC_v0_3_WithAuthCompatibilityAndroidTest() { super(8081); } + @Override protected String getTransportProtocol() { return "JSONRPC"; } + @Override protected String getTransportUrl() { return "http://localhost:8081"; } + @Override protected void configureTransport(ClientBuilder builder) { + builder.withTransport(JSONRPCTransport.class, new JSONRPCTransportConfigBuilder() + .httpClient(new AndroidA2AHttpClient())); + } + @Override protected void configureTransportWithAuth(ClientBuilder builder) { + builder.withTransport(JSONRPCTransport.class, new JSONRPCTransportConfigBuilder() + .httpClient(new AndroidA2AHttpClient()).addInterceptor(new AuthInterceptor( + (scheme, context) -> BASIC_AUTH_SCHEME_NAME.equals(scheme) ? getEncodedCredentials() : null))); + } +} diff --git a/compat-0.3/reference/jsonrpc/src/test/java/org/a2aproject/sdk/compat03/server/apps/quarkus/QuarkusA2AJSONRPC_v0_3_WithAuthCompatibilityTest.java b/compat-0.3/reference/jsonrpc/src/test/java/org/a2aproject/sdk/compat03/server/apps/quarkus/QuarkusA2AJSONRPC_v0_3_WithAuthCompatibilityTest.java new file mode 100644 index 000000000..d13517e49 --- /dev/null +++ b/compat-0.3/reference/jsonrpc/src/test/java/org/a2aproject/sdk/compat03/server/apps/quarkus/QuarkusA2AJSONRPC_v0_3_WithAuthCompatibilityTest.java @@ -0,0 +1,28 @@ +package org.a2aproject.sdk.compat03.server.apps.quarkus; + +import io.quarkus.test.junit.QuarkusTest; +import io.quarkus.test.junit.TestProfile; +import org.a2aproject.sdk.client.ClientBuilder; +import org.a2aproject.sdk.client.http.JdkA2AHttpClient; +import org.a2aproject.sdk.client.transport.jsonrpc.JSONRPCTransport; +import org.a2aproject.sdk.client.transport.jsonrpc.JSONRPCTransportConfigBuilder; +import org.a2aproject.sdk.client.transport.spi.interceptors.auth.AuthInterceptor; +import org.a2aproject.sdk.server.apps.common.AbstractA2AServerCompatibilityWithAuthTest_v0_3; + +@QuarkusTest +@TestProfile(CompatibilityAuthTestProfile_v0_3.class) +public class QuarkusA2AJSONRPC_v0_3_WithAuthCompatibilityTest + extends AbstractA2AServerCompatibilityWithAuthTest_v0_3 { + public QuarkusA2AJSONRPC_v0_3_WithAuthCompatibilityTest() { super(8081); } + @Override protected String getTransportProtocol() { return "JSONRPC"; } + @Override protected String getTransportUrl() { return "http://localhost:8081"; } + @Override protected void configureTransport(ClientBuilder builder) { + builder.withTransport(JSONRPCTransport.class, new JSONRPCTransportConfigBuilder() + .httpClient(new JdkA2AHttpClient())); + } + @Override protected void configureTransportWithAuth(ClientBuilder builder) { + builder.withTransport(JSONRPCTransport.class, new JSONRPCTransportConfigBuilder() + .httpClient(new JdkA2AHttpClient()).addInterceptor(new AuthInterceptor( + (scheme, context) -> BASIC_AUTH_SCHEME_NAME.equals(scheme) ? getEncodedCredentials() : null))); + } +} diff --git a/compat-0.3/reference/jsonrpc/src/test/java/org/a2aproject/sdk/compat03/server/apps/quarkus/QuarkusA2AJSONRPC_v0_3_WithAuthCompatibilityVertxTest.java b/compat-0.3/reference/jsonrpc/src/test/java/org/a2aproject/sdk/compat03/server/apps/quarkus/QuarkusA2AJSONRPC_v0_3_WithAuthCompatibilityVertxTest.java new file mode 100644 index 000000000..d9cf3dbac --- /dev/null +++ b/compat-0.3/reference/jsonrpc/src/test/java/org/a2aproject/sdk/compat03/server/apps/quarkus/QuarkusA2AJSONRPC_v0_3_WithAuthCompatibilityVertxTest.java @@ -0,0 +1,31 @@ +package org.a2aproject.sdk.compat03.server.apps.quarkus; + +import io.quarkus.test.junit.QuarkusTest; +import io.quarkus.test.junit.TestProfile; +import io.vertx.core.Vertx; +import jakarta.inject.Inject; +import org.a2aproject.sdk.client.ClientBuilder; +import org.a2aproject.sdk.client.http.vertx.VertxA2AHttpClient; +import org.a2aproject.sdk.client.transport.jsonrpc.JSONRPCTransport; +import org.a2aproject.sdk.client.transport.jsonrpc.JSONRPCTransportConfigBuilder; +import org.a2aproject.sdk.client.transport.spi.interceptors.auth.AuthInterceptor; +import org.a2aproject.sdk.server.apps.common.AbstractA2AServerCompatibilityWithAuthTest_v0_3; + +@QuarkusTest +@TestProfile(CompatibilityAuthTestProfile_v0_3.class) +public class QuarkusA2AJSONRPC_v0_3_WithAuthCompatibilityVertxTest + extends AbstractA2AServerCompatibilityWithAuthTest_v0_3 { + @Inject Vertx vertx; + public QuarkusA2AJSONRPC_v0_3_WithAuthCompatibilityVertxTest() { super(8081); } + @Override protected String getTransportProtocol() { return "JSONRPC"; } + @Override protected String getTransportUrl() { return "http://localhost:8081"; } + @Override protected void configureTransport(ClientBuilder builder) { + builder.withTransport(JSONRPCTransport.class, new JSONRPCTransportConfigBuilder() + .httpClient(new VertxA2AHttpClient(vertx))); + } + @Override protected void configureTransportWithAuth(ClientBuilder builder) { + builder.withTransport(JSONRPCTransport.class, new JSONRPCTransportConfigBuilder() + .httpClient(new VertxA2AHttpClient(vertx)).addInterceptor(new AuthInterceptor( + (scheme, context) -> BASIC_AUTH_SCHEME_NAME.equals(scheme) ? getEncodedCredentials() : null))); + } +} diff --git a/compat-0.3/reference/jsonrpc/src/test/resources/a2a-requesthandler-test.properties b/compat-0.3/reference/jsonrpc/src/test/resources/a2a-requesthandler-test.properties new file mode 100644 index 000000000..2d2582df3 --- /dev/null +++ b/compat-0.3/reference/jsonrpc/src/test/resources/a2a-requesthandler-test.properties @@ -0,0 +1 @@ +preferred-transport=JSONRPC diff --git a/compat-0.3/reference/jsonrpc/src/test/resources/application.properties b/compat-0.3/reference/jsonrpc/src/test/resources/application.properties index fa8005c40..cdeb51244 100644 --- a/compat-0.3/reference/jsonrpc/src/test/resources/application.properties +++ b/compat-0.3/reference/jsonrpc/src/test/resources/application.properties @@ -1,4 +1,5 @@ # Index dependencies for CDI bean discovery +quarkus.arc.exclude-types=org.a2aproject.sdk.server.apps.common.AgentExecutorProducer quarkus.index-dependency.server-conversion.group-id=org.a2aproject.sdk quarkus.index-dependency.server-conversion.artifact-id=a2a-java-sdk-compat-0.3-server-conversion quarkus.index-dependency.server-conversion.classifier=tests diff --git a/compat-0.3/reference/rest/pom.xml b/compat-0.3/reference/rest/pom.xml index abf91a16e..e9de477da 100644 --- a/compat-0.3/reference/rest/pom.xml +++ b/compat-0.3/reference/rest/pom.xml @@ -18,6 +18,32 @@ Java SDK for the Agent2Agent Protocol (A2A) - A2A JSON+HTTP/REST Reference Server (based on Quarkus) + + ${project.groupId} + a2a-java-sdk-client + test + + + ${project.groupId} + a2a-java-sdk-client-transport-rest + test + + + ${project.groupId} + a2a-java-sdk-tests-server-common + test-jar + test + + + ${project.groupId} + a2a-java-sdk-compat-0.3-client-adapter + test + + + ${project.groupId} + a2a-java-sdk-compat-0.3-client-adapter-rest + test + ${project.groupId} a2a-java-sdk-compat-0.3-spec diff --git a/compat-0.3/reference/rest/src/test/java/org/a2aproject/sdk/compat03/server/rest/quarkus/CompatibilityAuthTestProfile_v0_3.java b/compat-0.3/reference/rest/src/test/java/org/a2aproject/sdk/compat03/server/rest/quarkus/CompatibilityAuthTestProfile_v0_3.java new file mode 100644 index 000000000..df7e78749 --- /dev/null +++ b/compat-0.3/reference/rest/src/test/java/org/a2aproject/sdk/compat03/server/rest/quarkus/CompatibilityAuthTestProfile_v0_3.java @@ -0,0 +1,30 @@ +package org.a2aproject.sdk.compat03.server.rest.quarkus; + +import java.util.Map; + +import io.quarkus.test.junit.QuarkusTestProfile; + +public final class CompatibilityAuthTestProfile_v0_3 implements QuarkusTestProfile { + @Override + public Map getConfigOverrides() { + return Map.ofEntries( + Map.entry("test.identity.auto-auth", "false"), + Map.entry("quarkus.test.security.auth.enabled", "false"), + Map.entry("test.agent.security.enabled", "true"), + Map.entry("test.authorization.enabled", "true"), + Map.entry("quarkus.security.users.embedded.enabled", "true"), + Map.entry("quarkus.security.users.embedded.plain-text", "true"), + Map.entry("quarkus.security.users.embedded.users.testuser", "testpass"), + Map.entry("quarkus.security.users.embedded.roles.testuser", "user"), + Map.entry("quarkus.http.auth.basic", "true"), + Map.entry("quarkus.http.auth.proactive", "true"), + Map.entry( + "quarkus.arc.exclude-types", + "org.a2aproject.sdk.compat03.conversion.test.AgentExecutorProducer_v0_3")); + } + + @Override + public String getConfigProfile() { + return "test"; + } +} diff --git a/compat-0.3/reference/rest/src/test/java/org/a2aproject/sdk/compat03/server/rest/quarkus/CompatibilityTestProfile_v0_3.java b/compat-0.3/reference/rest/src/test/java/org/a2aproject/sdk/compat03/server/rest/quarkus/CompatibilityTestProfile_v0_3.java new file mode 100644 index 000000000..820935fb1 --- /dev/null +++ b/compat-0.3/reference/rest/src/test/java/org/a2aproject/sdk/compat03/server/rest/quarkus/CompatibilityTestProfile_v0_3.java @@ -0,0 +1,19 @@ +package org.a2aproject.sdk.compat03.server.rest.quarkus; + +import java.util.Map; + +import io.quarkus.test.junit.QuarkusTestProfile; + +public final class CompatibilityTestProfile_v0_3 implements QuarkusTestProfile { + @Override + public Map getConfigOverrides() { + return Map.of( + "quarkus.arc.exclude-types", + "org.a2aproject.sdk.compat03.conversion.test.AgentExecutorProducer_v0_3"); + } + + @Override + public String getConfigProfile() { + return "test"; + } +} diff --git a/compat-0.3/reference/rest/src/test/java/org/a2aproject/sdk/compat03/server/rest/quarkus/QuarkusA2ARest_v0_3_CompatibilityAndroidTest.java b/compat-0.3/reference/rest/src/test/java/org/a2aproject/sdk/compat03/server/rest/quarkus/QuarkusA2ARest_v0_3_CompatibilityAndroidTest.java new file mode 100644 index 000000000..b4f2be082 --- /dev/null +++ b/compat-0.3/reference/rest/src/test/java/org/a2aproject/sdk/compat03/server/rest/quarkus/QuarkusA2ARest_v0_3_CompatibilityAndroidTest.java @@ -0,0 +1,21 @@ +package org.a2aproject.sdk.compat03.server.rest.quarkus; + +import io.quarkus.test.junit.QuarkusTest; +import io.quarkus.test.junit.TestProfile; +import org.a2aproject.sdk.client.ClientBuilder; +import org.a2aproject.sdk.client.http.android.AndroidA2AHttpClient; +import org.a2aproject.sdk.client.transport.rest.RestTransport; +import org.a2aproject.sdk.client.transport.rest.RestTransportConfigBuilder; +import org.a2aproject.sdk.server.apps.common.AbstractA2AServerCompatibilityTest_v0_3; + +@QuarkusTest +@TestProfile(CompatibilityTestProfile_v0_3.class) +public class QuarkusA2ARest_v0_3_CompatibilityAndroidTest extends AbstractA2AServerCompatibilityTest_v0_3 { + public QuarkusA2ARest_v0_3_CompatibilityAndroidTest() { super(8081); } + @Override protected String getTransportProtocol() { return "HTTP+JSON"; } + @Override protected String getTransportUrl() { return "http://localhost:8081"; } + @Override protected void configureTransport(ClientBuilder builder) { + builder.withTransport(RestTransport.class, new RestTransportConfigBuilder() + .httpClient(new AndroidA2AHttpClient())); + } +} diff --git a/compat-0.3/reference/rest/src/test/java/org/a2aproject/sdk/compat03/server/rest/quarkus/QuarkusA2ARest_v0_3_CompatibilityJdkTest.java b/compat-0.3/reference/rest/src/test/java/org/a2aproject/sdk/compat03/server/rest/quarkus/QuarkusA2ARest_v0_3_CompatibilityJdkTest.java new file mode 100644 index 000000000..6f6ffc65c --- /dev/null +++ b/compat-0.3/reference/rest/src/test/java/org/a2aproject/sdk/compat03/server/rest/quarkus/QuarkusA2ARest_v0_3_CompatibilityJdkTest.java @@ -0,0 +1,21 @@ +package org.a2aproject.sdk.compat03.server.rest.quarkus; + +import io.quarkus.test.junit.QuarkusTest; +import io.quarkus.test.junit.TestProfile; +import org.a2aproject.sdk.client.ClientBuilder; +import org.a2aproject.sdk.client.http.JdkA2AHttpClient; +import org.a2aproject.sdk.client.transport.rest.RestTransport; +import org.a2aproject.sdk.client.transport.rest.RestTransportConfigBuilder; +import org.a2aproject.sdk.server.apps.common.AbstractA2AServerCompatibilityTest_v0_3; + +@QuarkusTest +@TestProfile(CompatibilityTestProfile_v0_3.class) +public class QuarkusA2ARest_v0_3_CompatibilityJdkTest extends AbstractA2AServerCompatibilityTest_v0_3 { + public QuarkusA2ARest_v0_3_CompatibilityJdkTest() { super(8081); } + @Override protected String getTransportProtocol() { return "HTTP+JSON"; } + @Override protected String getTransportUrl() { return "http://localhost:8081"; } + @Override protected void configureTransport(ClientBuilder builder) { + builder.withTransport(RestTransport.class, new RestTransportConfigBuilder() + .httpClient(new JdkA2AHttpClient())); + } +} diff --git a/compat-0.3/reference/rest/src/test/java/org/a2aproject/sdk/compat03/server/rest/quarkus/QuarkusA2ARest_v0_3_CompatibilityVertxTest.java b/compat-0.3/reference/rest/src/test/java/org/a2aproject/sdk/compat03/server/rest/quarkus/QuarkusA2ARest_v0_3_CompatibilityVertxTest.java new file mode 100644 index 000000000..154b8f52b --- /dev/null +++ b/compat-0.3/reference/rest/src/test/java/org/a2aproject/sdk/compat03/server/rest/quarkus/QuarkusA2ARest_v0_3_CompatibilityVertxTest.java @@ -0,0 +1,24 @@ +package org.a2aproject.sdk.compat03.server.rest.quarkus; + +import io.quarkus.test.junit.QuarkusTest; +import io.quarkus.test.junit.TestProfile; +import io.vertx.core.Vertx; +import jakarta.inject.Inject; +import org.a2aproject.sdk.client.ClientBuilder; +import org.a2aproject.sdk.client.http.vertx.VertxA2AHttpClient; +import org.a2aproject.sdk.client.transport.rest.RestTransport; +import org.a2aproject.sdk.client.transport.rest.RestTransportConfigBuilder; +import org.a2aproject.sdk.server.apps.common.AbstractA2AServerCompatibilityTest_v0_3; + +@QuarkusTest +@TestProfile(CompatibilityTestProfile_v0_3.class) +public class QuarkusA2ARest_v0_3_CompatibilityVertxTest extends AbstractA2AServerCompatibilityTest_v0_3 { + @Inject Vertx vertx; + public QuarkusA2ARest_v0_3_CompatibilityVertxTest() { super(8081); } + @Override protected String getTransportProtocol() { return "HTTP+JSON"; } + @Override protected String getTransportUrl() { return "http://localhost:8081"; } + @Override protected void configureTransport(ClientBuilder builder) { + builder.withTransport(RestTransport.class, new RestTransportConfigBuilder() + .httpClient(new VertxA2AHttpClient(vertx))); + } +} diff --git a/compat-0.3/reference/rest/src/test/java/org/a2aproject/sdk/compat03/server/rest/quarkus/QuarkusA2ARest_v0_3_WithAuthCompatibilityAndroidTest.java b/compat-0.3/reference/rest/src/test/java/org/a2aproject/sdk/compat03/server/rest/quarkus/QuarkusA2ARest_v0_3_WithAuthCompatibilityAndroidTest.java new file mode 100644 index 000000000..8969fd439 --- /dev/null +++ b/compat-0.3/reference/rest/src/test/java/org/a2aproject/sdk/compat03/server/rest/quarkus/QuarkusA2ARest_v0_3_WithAuthCompatibilityAndroidTest.java @@ -0,0 +1,28 @@ +package org.a2aproject.sdk.compat03.server.rest.quarkus; + +import io.quarkus.test.junit.QuarkusTest; +import io.quarkus.test.junit.TestProfile; +import org.a2aproject.sdk.client.ClientBuilder; +import org.a2aproject.sdk.client.http.android.AndroidA2AHttpClient; +import org.a2aproject.sdk.client.transport.rest.RestTransport; +import org.a2aproject.sdk.client.transport.rest.RestTransportConfigBuilder; +import org.a2aproject.sdk.client.transport.spi.interceptors.auth.AuthInterceptor; +import org.a2aproject.sdk.server.apps.common.AbstractA2AServerCompatibilityWithAuthTest_v0_3; + +@QuarkusTest +@TestProfile(CompatibilityAuthTestProfile_v0_3.class) +public class QuarkusA2ARest_v0_3_WithAuthCompatibilityAndroidTest + extends AbstractA2AServerCompatibilityWithAuthTest_v0_3 { + public QuarkusA2ARest_v0_3_WithAuthCompatibilityAndroidTest() { super(8081); } + @Override protected String getTransportProtocol() { return "HTTP+JSON"; } + @Override protected String getTransportUrl() { return "http://localhost:8081"; } + @Override protected void configureTransport(ClientBuilder builder) { + builder.withTransport(RestTransport.class, new RestTransportConfigBuilder() + .httpClient(new AndroidA2AHttpClient())); + } + @Override protected void configureTransportWithAuth(ClientBuilder builder) { + builder.withTransport(RestTransport.class, new RestTransportConfigBuilder() + .httpClient(new AndroidA2AHttpClient()).addInterceptor(new AuthInterceptor( + (scheme, context) -> BASIC_AUTH_SCHEME_NAME.equals(scheme) ? getEncodedCredentials() : null))); + } +} diff --git a/compat-0.3/reference/rest/src/test/java/org/a2aproject/sdk/compat03/server/rest/quarkus/QuarkusA2ARest_v0_3_WithAuthCompatibilityTest.java b/compat-0.3/reference/rest/src/test/java/org/a2aproject/sdk/compat03/server/rest/quarkus/QuarkusA2ARest_v0_3_WithAuthCompatibilityTest.java new file mode 100644 index 000000000..771d369b9 --- /dev/null +++ b/compat-0.3/reference/rest/src/test/java/org/a2aproject/sdk/compat03/server/rest/quarkus/QuarkusA2ARest_v0_3_WithAuthCompatibilityTest.java @@ -0,0 +1,28 @@ +package org.a2aproject.sdk.compat03.server.rest.quarkus; + +import io.quarkus.test.junit.QuarkusTest; +import io.quarkus.test.junit.TestProfile; +import org.a2aproject.sdk.client.ClientBuilder; +import org.a2aproject.sdk.client.http.JdkA2AHttpClient; +import org.a2aproject.sdk.client.transport.rest.RestTransport; +import org.a2aproject.sdk.client.transport.rest.RestTransportConfigBuilder; +import org.a2aproject.sdk.client.transport.spi.interceptors.auth.AuthInterceptor; +import org.a2aproject.sdk.server.apps.common.AbstractA2AServerCompatibilityWithAuthTest_v0_3; + +@QuarkusTest +@TestProfile(CompatibilityAuthTestProfile_v0_3.class) +public class QuarkusA2ARest_v0_3_WithAuthCompatibilityTest + extends AbstractA2AServerCompatibilityWithAuthTest_v0_3 { + public QuarkusA2ARest_v0_3_WithAuthCompatibilityTest() { super(8081); } + @Override protected String getTransportProtocol() { return "HTTP+JSON"; } + @Override protected String getTransportUrl() { return "http://localhost:8081"; } + @Override protected void configureTransport(ClientBuilder builder) { + builder.withTransport(RestTransport.class, new RestTransportConfigBuilder() + .httpClient(new JdkA2AHttpClient())); + } + @Override protected void configureTransportWithAuth(ClientBuilder builder) { + builder.withTransport(RestTransport.class, new RestTransportConfigBuilder() + .httpClient(new JdkA2AHttpClient()).addInterceptor(new AuthInterceptor( + (scheme, context) -> BASIC_AUTH_SCHEME_NAME.equals(scheme) ? getEncodedCredentials() : null))); + } +} diff --git a/compat-0.3/reference/rest/src/test/java/org/a2aproject/sdk/compat03/server/rest/quarkus/QuarkusA2ARest_v0_3_WithAuthCompatibilityVertxTest.java b/compat-0.3/reference/rest/src/test/java/org/a2aproject/sdk/compat03/server/rest/quarkus/QuarkusA2ARest_v0_3_WithAuthCompatibilityVertxTest.java new file mode 100644 index 000000000..09fb123cf --- /dev/null +++ b/compat-0.3/reference/rest/src/test/java/org/a2aproject/sdk/compat03/server/rest/quarkus/QuarkusA2ARest_v0_3_WithAuthCompatibilityVertxTest.java @@ -0,0 +1,31 @@ +package org.a2aproject.sdk.compat03.server.rest.quarkus; + +import io.quarkus.test.junit.QuarkusTest; +import io.quarkus.test.junit.TestProfile; +import io.vertx.core.Vertx; +import jakarta.inject.Inject; +import org.a2aproject.sdk.client.ClientBuilder; +import org.a2aproject.sdk.client.http.vertx.VertxA2AHttpClient; +import org.a2aproject.sdk.client.transport.rest.RestTransport; +import org.a2aproject.sdk.client.transport.rest.RestTransportConfigBuilder; +import org.a2aproject.sdk.client.transport.spi.interceptors.auth.AuthInterceptor; +import org.a2aproject.sdk.server.apps.common.AbstractA2AServerCompatibilityWithAuthTest_v0_3; + +@QuarkusTest +@TestProfile(CompatibilityAuthTestProfile_v0_3.class) +public class QuarkusA2ARest_v0_3_WithAuthCompatibilityVertxTest + extends AbstractA2AServerCompatibilityWithAuthTest_v0_3 { + @Inject Vertx vertx; + public QuarkusA2ARest_v0_3_WithAuthCompatibilityVertxTest() { super(8081); } + @Override protected String getTransportProtocol() { return "HTTP+JSON"; } + @Override protected String getTransportUrl() { return "http://localhost:8081"; } + @Override protected void configureTransport(ClientBuilder builder) { + builder.withTransport(RestTransport.class, new RestTransportConfigBuilder() + .httpClient(new VertxA2AHttpClient(vertx))); + } + @Override protected void configureTransportWithAuth(ClientBuilder builder) { + builder.withTransport(RestTransport.class, new RestTransportConfigBuilder() + .httpClient(new VertxA2AHttpClient(vertx)).addInterceptor(new AuthInterceptor( + (scheme, context) -> BASIC_AUTH_SCHEME_NAME.equals(scheme) ? getEncodedCredentials() : null))); + } +} diff --git a/compat-0.3/reference/rest/src/test/resources/a2a-requesthandler-test.properties b/compat-0.3/reference/rest/src/test/resources/a2a-requesthandler-test.properties new file mode 100644 index 000000000..61696e179 --- /dev/null +++ b/compat-0.3/reference/rest/src/test/resources/a2a-requesthandler-test.properties @@ -0,0 +1 @@ +preferred-transport=HTTP+JSON diff --git a/compat-0.3/reference/rest/src/test/resources/application.properties b/compat-0.3/reference/rest/src/test/resources/application.properties index d5aaac10c..f221180de 100644 --- a/compat-0.3/reference/rest/src/test/resources/application.properties +++ b/compat-0.3/reference/rest/src/test/resources/application.properties @@ -3,6 +3,7 @@ quarkus.http.port=8081 quarkus.http.test-port=8081 # Index dependencies for CDI bean discovery +quarkus.arc.exclude-types=org.a2aproject.sdk.server.apps.common.AgentExecutorProducer quarkus.index-dependency.server-conversion.group-id=org.a2aproject.sdk quarkus.index-dependency.server-conversion.artifact-id=a2a-java-sdk-compat-0.3-server-conversion quarkus.index-dependency.server-conversion.classifier=tests diff --git a/compat-0.3/server-conversion/pom.xml b/compat-0.3/server-conversion/pom.xml index 80acaa031..72f3099fe 100644 --- a/compat-0.3/server-conversion/pom.xml +++ b/compat-0.3/server-conversion/pom.xml @@ -18,6 +18,12 @@ Java SDK for the Agent2Agent Protocol (A2A) - 0.3 to 1.0 Type Conversion Layer + + + ${project.groupId} + a2a-java-sdk-compat-0.3-conversion + + ${project.groupId} @@ -36,12 +42,6 @@ a2a-java-sdk-server-common - - - org.mapstruct - mapstruct - - jakarta.enterprise diff --git a/compat-0.3/server-conversion/src/test/java/org/a2aproject/sdk/compat03/conversion/TaskAuthorizationTestProfile_v0_3.java b/compat-0.3/server-conversion/src/test/java/org/a2aproject/sdk/compat03/conversion/TaskAuthorizationTestProfile_v0_3.java index 79f8784fc..909c667fb 100644 --- a/compat-0.3/server-conversion/src/test/java/org/a2aproject/sdk/compat03/conversion/TaskAuthorizationTestProfile_v0_3.java +++ b/compat-0.3/server-conversion/src/test/java/org/a2aproject/sdk/compat03/conversion/TaskAuthorizationTestProfile_v0_3.java @@ -13,6 +13,11 @@ public Map getConfigOverrides() { config.put("quarkus.security.users.embedded.users.userB", "passB"); config.put("quarkus.security.users.embedded.roles.userB", "user"); config.put("test.task-authorization.enabled", "true"); + // The compatibility test JAR contributes a v0.3 provider as well as the shared provider. + // Keep exactly one provider resolvable so DefaultRequestHandler can enforce ownership. + config.put("quarkus.arc.exclude-types", + "org.a2aproject.sdk.server.apps.common.AgentExecutorProducer," + + "org.a2aproject.sdk.compat03.conversion.test.TestTaskAuthorizationProvider_v0_3"); return config; } } diff --git a/compat-0.3/spec-grpc/pom.xml b/compat-0.3/spec-grpc/pom.xml index ccfcf1ca0..bd467aafa 100644 --- a/compat-0.3/spec-grpc/pom.xml +++ b/compat-0.3/spec-grpc/pom.xml @@ -36,14 +36,6 @@ grpc-stub provided - - jakarta.enterprise - jakarta.enterprise.cdi-api - - - jakarta.inject - jakarta.inject-api - com.google.api.grpc proto-google-common-protos diff --git a/docs/content/dev/client.md b/docs/content/dev/client.md index 5dcd64ce6..5bef2f9ce 100644 --- a/docs/content/dev/client.md +++ b/docs/content/dev/client.md @@ -248,7 +248,12 @@ Add distributed tracing and W3C Trace Context propagation to client calls with t ## Communicating with v0.3 Agents -See [Backward Compatibility](compatibility#client-communicating-with-v03-agents) for using `Client_v0_3` with older protocol agents. +The SDK supports two client APIs for communicating with v0.3 agents: + +- The recommended unified `Client` API uses the normal v1.0 types and an optional v0.3 compatibility adapter. See [Backward Compatibility](compatibility#client-communicating-with-v03-agents) for setup and supported operations. +- The legacy `Client_v0_3` API remains available for applications that already use v0.3 domain types and transport APIs directly. + +New applications should prefer the unified client; existing applications can continue using `Client_v0_3` without migrating immediately. ## Examples diff --git a/docs/content/dev/compatibility.md b/docs/content/dev/compatibility.md index 09ec47190..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 301d19eea..968964b99 100644 --- a/extras/http-client-vertx/pom.xml +++ b/extras/http-client-vertx/pom.xml @@ -33,6 +33,14 @@ vertx-web-client provided + + jakarta.enterprise + jakarta.enterprise.cdi-api + + + jakarta.inject + jakarta.inject-api + org.junit.jupiter diff --git a/http-client/src/main/java/org/a2aproject/sdk/client/http/A2ACardResolver.java b/http-client/src/main/java/org/a2aproject/sdk/client/http/A2ACardResolver.java index 90b87c992..d8337c33c 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,14 @@ public Builder authHeader(String name, String value) { return this; } + public Builder supportedProtocolVersions(Set supportedProtocolVersions) { + checkNotNullParam("supportedProtocolVersions", supportedProtocolVersions); + this.supportedProtocolVersions = supportedProtocolVersions.stream() + .map(A2ACardResolver::normalizeSupportedProtocolVersion) + .collect(java.util.stream.Collectors.toUnmodifiableSet()); + return this; + } + /** * Builds the A2ACardResolver instance. * @@ -213,8 +229,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 +336,49 @@ 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 && parsedV10Card.supportedInterfaces().stream() + .anyMatch(i -> supportedProtocolVersions.contains(normalizeCardVersion(i.protocolVersion())))) { + return filterInterfaces(parsedV10Card); + } + + if (supportedProtocolVersions.contains("0.3")) { + for (AgentCardCompatibilityParser parser : ServiceLoader.load(AgentCardCompatibilityParser.class)) { + if ("0.3".equals(normalizeSupportedProtocolVersion(parser.supportedProtocolVersion()))) { + Optional parsed = parser.parse(body, parsedV10Card, supportedProtocolVersions); + if (parsed.isPresent()) { + return filterInterfaces(parsed.get()); + } + } + } + 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 a4d4a3da4..9d19cf12a 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,40 @@ 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 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 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/spec-grpc/pom.xml b/spec-grpc/pom.xml index 63bac2c4f..4af4f5249 100644 --- a/spec-grpc/pom.xml +++ b/spec-grpc/pom.xml @@ -42,14 +42,6 @@ grpc-stub provided - - jakarta.enterprise - jakarta.enterprise.cdi-api - - - jakarta.inject - jakarta.inject-api - com.google.api.grpc proto-google-common-protos diff --git a/tests/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; } } - From 9748aca616a861c77e230dadae2721095e904954 Mon Sep 17 00:00:00 2001 From: Kabir Khan Date: Fri, 18 Sep 2026 08:28:12 +0100 Subject: [PATCH 2/9] Self review and fixes --- .../src/main/java/org/a2aproject/sdk/A2A.java | 14 ++++ .../rest/RestCompat03ClientTransport.java | 55 ++++++++------ .../transport/grpc/GrpcTransport_v0_3.java | 6 +- .../jsonrpc/sse/SSEEventListener_v0_3.java | 18 +++-- .../sse/SSEEventListener_v0_3_Test.java | 17 ++++- .../rest/sse/RestSSEEventListener_v0_3.java | 12 +++- .../sse/RestSSEEventListener_v0_3_Test.java | 71 +++++++++++++++++++ .../sdk/client/http/A2ACardResolver.java | 32 +++++++-- .../sdk/client/http/A2ACardResolverTest.java | 17 +++++ 9 files changed, 208 insertions(+), 34 deletions(-) create mode 100644 compat-0.3/client/transport/rest/src/test/java/org/a2aproject/sdk/compat03/client/transport/rest/sse/RestSSEEventListener_v0_3_Test.java 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 6480c4dda..35f3890de 100644 --- a/client/base/src/main/java/org/a2aproject/sdk/A2A.java +++ b/client/base/src/main/java/org/a2aproject/sdk/A2A.java @@ -290,6 +290,11 @@ 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); @@ -321,6 +326,9 @@ 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); @@ -371,6 +379,9 @@ 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 { @@ -414,6 +425,9 @@ public static AgentCard getAgentCard(A2AHttpClient httpClient, String agentUrl, 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 { 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 index 138e46575..e26dd55b2 100644 --- 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 @@ -41,10 +41,11 @@ public RestCompat03ClientTransport(RestTransport_v0_3 delegate, AgentCard card, 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); + 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) payload.getPayload())), + ((org.a2aproject.sdk.grpc.SendMessageRequest.Builder) payload.getPayload()).build())), Compat03ClientCallContextMapper.toV03(contextWithHeaders(context, payload))))); } @@ -53,10 +54,11 @@ public void sendMessageStreaming(MessageSendParams request, 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); + 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) payload.getPayload())), + ((org.a2aproject.sdk.grpc.SendMessageRequest.Builder) payload.getPayload()).build())), event -> events.accept(Compat03ClientTransportSupport.toV10(event)), Compat03ClientTransportSupport.mapAsyncError(errors), Compat03ClientCallContextMapper.toV03(contextWithHeaders(context, payload)))); @@ -65,22 +67,24 @@ public void sendMessageStreaming(MessageSendParams request, Consumer TaskMapper_v0_3.INSTANCE.toV10(delegate.getTask( Compat03ClientTransportSupport.toV03(ProtoUtils.FromProto.taskQueryParams( - (org.a2aproject.sdk.grpc.GetTaskRequest) payload.getPayload())), + ((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, ProtoUtils.ToProto.cancelTaskRequest(request), - org.a2aproject.sdk.grpc.CancelTaskRequest.class, context); + 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) payload.getPayload())), + ((org.a2aproject.sdk.grpc.CancelTaskRequest.Builder) payload.getPayload()).build())), Compat03ClientCallContextMapper.toV03(contextWithHeaders(context, payload))))); } @@ -89,10 +93,12 @@ public TaskPushNotificationConfig createTaskPushNotificationConfiguration(TaskPu @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); + 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) payload.getPayload())), + ProtoUtils.FromProto.taskPushNotificationConfig( + ((org.a2aproject.sdk.grpc.TaskPushNotificationConfig.Builder) payload.getPayload()).build())), Compat03ClientCallContextMapper.toV03(contextWithHeaders(context, payload))))); } @@ -101,10 +107,13 @@ public TaskPushNotificationConfig getTaskPushNotificationConfiguration(GetTaskPu @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); + 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) payload.getPayload())), + ProtoUtils.FromProto.getTaskPushNotificationConfigParams( + ((org.a2aproject.sdk.grpc.GetTaskPushNotificationConfigRequest.Builder) payload.getPayload()).build())), Compat03ClientCallContextMapper.toV03(contextWithHeaders(context, payload))))); } @@ -113,10 +122,13 @@ public ListTaskPushNotificationConfigsResult listTaskPushNotificationConfigurati 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); + 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) payload.getPayload())), + ProtoUtils.FromProto.listTaskPushNotificationConfigsParams( + ((org.a2aproject.sdk.grpc.ListTaskPushNotificationConfigsRequest.Builder) payload.getPayload()).build())), Compat03ClientCallContextMapper.toV03(contextWithHeaders(context, payload))))); } @@ -125,10 +137,12 @@ public void deleteTaskPushNotificationConfigurations(DeleteTaskPushNotificationC @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); + 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) payload.getPayload())), + ((org.a2aproject.sdk.grpc.DeleteTaskPushNotificationConfigRequest.Builder) payload.getPayload()).build())), Compat03ClientCallContextMapper.toV03(contextWithHeaders(context, payload)))); } @@ -137,10 +151,11 @@ public void subscribeToTask(TaskIdParams request, Consumer e 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); + 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) payload.getPayload())), + ((org.a2aproject.sdk.grpc.SubscribeToTaskRequest.Builder) payload.getPayload()).build())), event -> events.accept(Compat03ClientTransportSupport.toV10(event)), Compat03ClientTransportSupport.mapAsyncError(errors), Compat03ClientCallContextMapper.toV03(contextWithHeaders(context, payload)))); 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 991769a2d..7c2e70c4a 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 @@ -30,13 +30,11 @@ public void onMessage(String message, Future completableFuture) { try { handleMessage(JsonParser.parseString(message).getAsJsonObject(), completableFuture); } catch (JsonSyntaxException e) { - LOGGER.warning("Failed to parse JSON message: " + message); + fail(e, completableFuture); } catch (JsonProcessingException_v0_3 e) { - LOGGER.warning("Failed to process JSON message: " + message); + fail(e, completableFuture); } catch (IllegalArgumentException e) { - LOGGER.warning("Invalid message format: " + message); - signalTerminal(e); - completableFuture.cancel(true); // close SSE channel + fail(e, completableFuture); } } @@ -66,6 +64,9 @@ private void handleMessage(JsonObject jsonObject, Future future) throws Js if (jsonObject.has("error")) { JSONRPCError_v0_3 error = JsonUtil_v0_3.fromJson(jsonObject.get("error").toString(), JSONRPCError_v0_3.class); signalTerminal(error); + if (future != null) { + future.cancel(true); // close SSE channel + } } else if (jsonObject.has("result")) { // result can be a Task, Message, TaskStatusUpdateEvent, or TaskArtifactUpdateEvent String resultJson = jsonObject.get("result").toString(); @@ -80,4 +81,11 @@ private void handleMessage(JsonObject jsonObject, Future future) throws Js } } + private void fail(Throwable throwable, Future future) { + signalTerminal(throwable); + if (future != null) { + future.cancel(true); + } + } + } diff --git a/compat-0.3/client/transport/jsonrpc/src/test/java/org/a2aproject/sdk/compat03/client/transport/jsonrpc/sse/SSEEventListener_v0_3_Test.java b/compat-0.3/client/transport/jsonrpc/src/test/java/org/a2aproject/sdk/compat03/client/transport/jsonrpc/sse/SSEEventListener_v0_3_Test.java index 0f22828a5..375b000d4 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 @@ -160,7 +160,8 @@ public void testOnEventWithError() throws Exception { JsonStreamingMessages_v0_3.STREAMING_ERROR_EVENT.indexOf("{")); // Call onEvent method - listener.onMessage(eventData, null); + CancelCapturingFuture future = new CancelCapturingFuture(); + listener.onMessage(eventData, future); // Verify the error was processed correctly assertNotNull(receivedError.get()); @@ -169,6 +170,20 @@ 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 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..2e7bcf8e9 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 @@ -29,9 +29,10 @@ 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)); + cancel(completableFuture); } } @@ -44,7 +45,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 -> @@ -58,10 +59,17 @@ private void handleMessage(StreamResponse response) { default -> { LOGGER.warning("Invalid stream response " + response.getPayloadCase()); errorHandler.accept(new IllegalStateException("Invalid stream response from server: " + response.getPayloadCase())); + cancel(future); return; } } eventHandler.accept(event); } + 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..a61177c62 --- /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,71 @@ +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); + } + + 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/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 d8337c33c..a530264dd 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 @@ -209,11 +209,23 @@ 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); - this.supportedProtocolVersions = supportedProtocolVersions.stream() + 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; } @@ -347,20 +359,32 @@ private AgentCard fetchAgentCard(String url) throws A2AClientError, A2AClientJSO } } - if (parsedV10Card != null && parsedV10Card.supportedInterfaces().stream() - .anyMatch(i -> supportedProtocolVersions.contains(normalizeCardVersion(i.protocolVersion())))) { - return filterInterfaces(parsedV10Card); + 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"); } 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 9d19cf12a..6a258bddd 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 @@ -242,6 +242,12 @@ public void testSupportedProtocolVersions_normalizesPatchVersion() throws Except 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(); @@ -267,6 +273,17 @@ public void testModernCardWithOnlyRequestedLegacyInterfaceDoesNotUseLegacyParser 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, From 8a661586b57c33d24735bf28710f9743eeef5a49 Mon Sep 17 00:00:00 2001 From: Kabir Khan Date: Tue, 22 Sep 2026 17:25:51 +0100 Subject: [PATCH 3/9] fix: harden A2A 0.3 client compatibility --- .../a2aproject/sdk/client/ClientBuilder.java | 31 ++++++++ compat-0.3/client/adapter-grpc/pom.xml | 2 +- compat-0.3/client/adapter-jsonrpc/pom.xml | 2 +- compat-0.3/client/adapter-rest/pom.xml | 2 +- compat-0.3/client/adapter/pom.xml | 2 +- .../Compat03ClientTransportSupport.java | 2 +- .../adapter/Compat03InterceptorSupport.java | 15 +++- .../Compat03ClientTransportSupportTest.java | 6 ++ .../Compat03InterceptorSupportTest.java | 45 +++++++++++ .../jsonrpc/sse/SSEEventListener_v0_3.java | 23 ++++-- .../sse/SSEEventListener_v0_3_Test.java | 47 ++++++++++++ .../rest/sse/RestSSEEventListener_v0_3.java | 14 +++- .../sse/RestSSEEventListener_v0_3_Test.java | 53 +++++++++++++ compat-0.3/conversion/pom.xml | 2 +- pom.xml | 3 + tests/client-builder-classpath/pom.xml | 40 ++++++++++ .../MissingJsonRpcAdapterClasspathTest.java | 59 +++++++++++++++ .../fixture/MissingJsonRpcAdapterProbe.java | 74 +++++++++++++++++++ 18 files changed, 404 insertions(+), 18 deletions(-) create mode 100644 compat-0.3/client/adapter/src/test/java/org/a2aproject/sdk/compat03/client/adapter/Compat03InterceptorSupportTest.java create mode 100644 tests/client-builder-classpath/pom.xml create mode 100644 tests/client-builder-classpath/src/test/java/org/a2aproject/sdk/client/fixture/MissingJsonRpcAdapterClasspathTest.java create mode 100644 tests/client-builder-classpath/src/test/java/org/a2aproject/sdk/client/fixture/MissingJsonRpcAdapterProbe.java 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 7ea312663..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 @@ -435,6 +435,14 @@ && hasTransportProvider(iface)) } 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"); } String version = normalizeInterfaceVersionForClient(matchedInterface); @@ -463,6 +471,29 @@ private static boolean hasTransportProvider(AgentInterface agentInterface) { 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); diff --git a/compat-0.3/client/adapter-grpc/pom.xml b/compat-0.3/client/adapter-grpc/pom.xml index 92638bf54..e822a08a9 100644 --- a/compat-0.3/client/adapter-grpc/pom.xml +++ b/compat-0.3/client/adapter-grpc/pom.xml @@ -6,7 +6,7 @@ org.a2aproject.sdk a2a-java-sdk-compat-0.3-parent - 1.3.3.Final-SNAPSHOT + 1.4.1.Final-SNAPSHOT ../../pom.xml a2a-java-sdk-compat-0.3-client-adapter-grpc diff --git a/compat-0.3/client/adapter-jsonrpc/pom.xml b/compat-0.3/client/adapter-jsonrpc/pom.xml index b9e3b7fd6..ee608d323 100644 --- a/compat-0.3/client/adapter-jsonrpc/pom.xml +++ b/compat-0.3/client/adapter-jsonrpc/pom.xml @@ -6,7 +6,7 @@ org.a2aproject.sdk a2a-java-sdk-compat-0.3-parent - 1.3.3.Final-SNAPSHOT + 1.4.1.Final-SNAPSHOT ../../pom.xml a2a-java-sdk-compat-0.3-client-adapter-jsonrpc diff --git a/compat-0.3/client/adapter-rest/pom.xml b/compat-0.3/client/adapter-rest/pom.xml index b3c5d4262..dd09554af 100644 --- a/compat-0.3/client/adapter-rest/pom.xml +++ b/compat-0.3/client/adapter-rest/pom.xml @@ -6,7 +6,7 @@ org.a2aproject.sdk a2a-java-sdk-compat-0.3-parent - 1.3.3.Final-SNAPSHOT + 1.4.1.Final-SNAPSHOT ../../pom.xml a2a-java-sdk-compat-0.3-client-adapter-rest diff --git a/compat-0.3/client/adapter/pom.xml b/compat-0.3/client/adapter/pom.xml index a1d8ed941..dc0360677 100644 --- a/compat-0.3/client/adapter/pom.xml +++ b/compat-0.3/client/adapter/pom.xml @@ -6,7 +6,7 @@ org.a2aproject.sdk a2a-java-sdk-compat-0.3-parent - 1.3.3.Final-SNAPSHOT + 1.4.1.Final-SNAPSHOT ../.. a2a-java-sdk-compat-0.3-client-adapter 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 index c78d79cea..39346fc71 100644 --- 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 @@ -103,7 +103,7 @@ public static void validateDeletePush(org.a2aproject.sdk.spec.DeleteTaskPushNoti public static void validatePushList(ListTaskPushNotificationConfigsParams request) { validateTenant("listTaskPushNotificationConfigurations", request.tenant()); - if (request.pageSize() != 0 || (request.pageToken() != null && !request.pageToken().isEmpty())) { + if (request.pageSize() > 0 || (request.pageToken() != null && !request.pageToken().isEmpty())) { throw unsupported("listTaskPushNotificationConfigurations pagination"); } } 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 index 53a266b21..9eb6efa9e 100644 --- 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 @@ -1,5 +1,6 @@ package org.a2aproject.sdk.compat03.client.adapter; +import java.util.HashMap; import java.util.List; import java.util.Map; @@ -18,11 +19,19 @@ private Compat03InterceptorSupport() { public static PayloadAndHeaders apply(List interceptors, String method, Object payload, AgentCard card, @Nullable ClientCallContext context, Class expectedType) { - Map headers = context == null ? Map.of() : context.getHeaders(); + 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); - validateVersionHeader(result.getHeaders()); for (ClientCallInterceptor interceptor : interceptors) { - result = interceptor.intercept(method, result.getPayload(), result.getHeaders(), card, context); + 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); } 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 index 0f6f95757..9b2c3c6ec 100644 --- 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 @@ -55,6 +55,12 @@ void acceptsDefaultPushListAndReturnsNoPageToken() { new ListTaskPushNotificationConfigsParams("task", 0, "", null)); } + @Test + void acceptsNegativeDefaultPushListPageSize() { + Compat03ClientTransportSupport.validatePushList( + new ListTaskPushNotificationConfigsParams("task", -1, "", null)); + } + @Test void mapsContextsAndRequestParametersInBothDirections() { ClientCallContext context = new ClientCallContext( 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/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 7c2e70c4a..a9372a52b 100644 --- a/compat-0.3/client/transport/jsonrpc/src/main/java/org/a2aproject/sdk/compat03/client/transport/jsonrpc/sse/SSEEventListener_v0_3.java +++ b/compat-0.3/client/transport/jsonrpc/src/main/java/org/a2aproject/sdk/compat03/client/transport/jsonrpc/sse/SSEEventListener_v0_3.java @@ -7,7 +7,9 @@ import org.a2aproject.sdk.compat03.json.JsonUtil_v0_3; import org.a2aproject.sdk.compat03.spec.JSONRPCError_v0_3; import org.a2aproject.sdk.compat03.spec.StreamingEventKind_v0_3; +import org.a2aproject.sdk.compat03.spec.Task_v0_3; import org.a2aproject.sdk.compat03.spec.TaskStatusUpdateEvent_v0_3; +import org.jspecify.annotations.Nullable; import java.util.concurrent.Future; import java.util.concurrent.atomic.AtomicBoolean; @@ -26,7 +28,7 @@ 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) { @@ -35,12 +37,16 @@ public void onMessage(String message, Future completableFuture) { fail(e, completableFuture); } catch (IllegalArgumentException e) { fail(e, completableFuture); + } catch (IllegalStateException e) { + fail(e, completableFuture); } } - public void onError(Throwable throwable, Future future) { + public void onError(Throwable throwable, @Nullable Future future) { signalTerminal(throwable); - future.cancel(true); // close SSE channel + if (future != null) { + future.cancel(true); // close SSE channel + } } private void signalTerminal(Throwable error) { @@ -60,7 +66,7 @@ public void onComplete() { signalTerminal(null); } - private void handleMessage(JsonObject jsonObject, Future future) throws JsonProcessingException_v0_3 { + private void handleMessage(JsonObject jsonObject, @Nullable Future future) throws JsonProcessingException_v0_3 { if (jsonObject.has("error")) { JSONRPCError_v0_3 error = JsonUtil_v0_3.fromJson(jsonObject.get("error").toString(), JSONRPCError_v0_3.class); signalTerminal(error); @@ -72,16 +78,19 @@ private void handleMessage(JsonObject jsonObject, Future future) throws Js String resultJson = jsonObject.get("result").toString(); StreamingEventKind_v0_3 event = JsonUtil_v0_3.fromJson(resultJson, StreamingEventKind_v0_3.class); eventHandler.accept(event); - if (event instanceof TaskStatusUpdateEvent_v0_3 tsue && tsue.isFinal()) { + if ((event instanceof TaskStatusUpdateEvent_v0_3 tsue && tsue.isFinal()) + || (event instanceof Task_v0_3 task && task.status().state().isFinal())) { signalTerminal(null); - future.cancel(true); // close SSE channel + if (future != null) { + future.cancel(true); // close SSE channel + } } } else { throw new IllegalArgumentException("Unknown message type"); } } - private void fail(Throwable throwable, Future future) { + private void fail(Throwable throwable, @Nullable Future future) { signalTerminal(throwable); if (future != null) { future.cancel(true); diff --git a/compat-0.3/client/transport/jsonrpc/src/test/java/org/a2aproject/sdk/compat03/client/transport/jsonrpc/sse/SSEEventListener_v0_3_Test.java b/compat-0.3/client/transport/jsonrpc/src/test/java/org/a2aproject/sdk/compat03/client/transport/jsonrpc/sse/SSEEventListener_v0_3_Test.java index 375b000d4..5ea6b078a 100644 --- a/compat-0.3/client/transport/jsonrpc/src/test/java/org/a2aproject/sdk/compat03/client/transport/jsonrpc/sse/SSEEventListener_v0_3_Test.java +++ b/compat-0.3/client/transport/jsonrpc/src/test/java/org/a2aproject/sdk/compat03/client/transport/jsonrpc/sse/SSEEventListener_v0_3_Test.java @@ -60,6 +60,21 @@ public void testOnEventWithTaskResult() throws Exception { assertEquals(TaskState_v0_3.WORKING, task.status().state()); } + @Test + public void testFinalTaskResultCancelsStream() { + AtomicReference receivedEvent = new AtomicReference<>(); + SSEEventListener_v0_3 listener = new SSEEventListener_v0_3(receivedEvent::set, error -> {}); + CancelCapturingFuture future = new CancelCapturingFuture(); + + String eventData = JsonStreamingMessages_v0_3.STREAMING_TASK_EVENT + .replace("\"working\"", "\"completed\"") + .substring(JsonStreamingMessages_v0_3.STREAMING_TASK_EVENT.indexOf("{")); + listener.onMessage(eventData, future); + + assertInstanceOf(Task_v0_3.class, receivedEvent.get()); + assertTrue(future.cancelHandlerCalled); + } + @Test public void testOnEventWithMessageResult() throws Exception { // Set up event handler @@ -186,6 +201,19 @@ public void testMalformedEventReportsAndCancels() { 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 public void testOnFailure() { AtomicBoolean failureHandlerCalled = new AtomicBoolean(false); @@ -204,6 +232,14 @@ public void testOnFailure() { assertTrue(future.cancelHandlerCalled); } + @Test + public void testOnFailureWithNullFutureDoesNotThrow() { + SSEEventListener_v0_3 listener = new SSEEventListener_v0_3( + event -> {}, error -> {}); + + listener.onError(new RuntimeException("Test exception"), null); + } + @Test public void testFinalTaskStatusUpdateEventCancels() { TaskStatusUpdateEvent_v0_3 tsue = new TaskStatusUpdateEvent_v0_3.Builder() @@ -252,6 +288,17 @@ public void testOnEventWithFinalTaskStatusUpdateEventEventCancels() throws Excep assertTrue(future.cancelHandlerCalled); } + @Test + public void testFinalTaskStatusUpdateWithNullFutureDoesNotThrow() { + SSEEventListener_v0_3 listener = new SSEEventListener_v0_3( + event -> {}, error -> {}); + + String eventData = JsonStreamingMessages_v0_3.STREAMING_STATUS_UPDATE_EVENT_FINAL.substring( + JsonStreamingMessages_v0_3.STREAMING_STATUS_UPDATE_EVENT_FINAL.indexOf("{")); + + listener.onMessage(eventData, null); + } + @Test diff --git a/compat-0.3/client/transport/rest/src/main/java/org/a2aproject/sdk/compat03/client/transport/rest/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 2e7bcf8e9..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 { @@ -31,7 +33,9 @@ public void onMessage(String message, @Nullable Future completableFuture) JsonFormat.parser().merge(message, builder); 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); } } @@ -58,12 +62,18 @@ private void handleMessage(StreamResponse response, @Nullable Future futur 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) { 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 index a61177c62..89e4853f9 100644 --- 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 @@ -38,6 +38,59 @@ void invalidPayloadReportsAndCancels() { 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; diff --git a/compat-0.3/conversion/pom.xml b/compat-0.3/conversion/pom.xml index 047ed9837..a438195fa 100644 --- a/compat-0.3/conversion/pom.xml +++ b/compat-0.3/conversion/pom.xml @@ -6,7 +6,7 @@ org.a2aproject.sdk a2a-java-sdk-compat-0.3-parent - 1.3.3.Final-SNAPSHOT + 1.4.1.Final-SNAPSHOT .. a2a-java-sdk-compat-0.3-conversion diff --git a/pom.xml b/pom.xml index 34af82a92..21eb6a476 100644 --- a/pom.xml +++ b/pom.xml @@ -644,6 +644,9 @@ compat-0.3 + + tests/client-builder-classpath + reference/multiversion-jsonrpc reference/multiversion-rest diff --git a/tests/client-builder-classpath/pom.xml b/tests/client-builder-classpath/pom.xml new file mode 100644 index 000000000..0dab97879 --- /dev/null +++ b/tests/client-builder-classpath/pom.xml @@ -0,0 +1,40 @@ + + + 4.0.0 + + org.a2aproject.sdk + a2a-java-sdk-parent + 1.4.1.Final-SNAPSHOT + ../../pom.xml + + a2a-java-sdk-client-builder-classpath-fixture + Java SDK Client Builder Classpath Fixture + Classpath-isolated compatibility adapter diagnostics + + + + ${project.groupId} + a2a-java-sdk-client + + + ${project.groupId} + a2a-java-sdk-client-transport-jsonrpc + + + ${project.groupId} + a2a-java-sdk-http-client + + + ${project.groupId} + a2a-java-sdk-compat-0.3-client-adapter + ${project.version} + + + org.junit.jupiter + junit-jupiter + test + + + diff --git a/tests/client-builder-classpath/src/test/java/org/a2aproject/sdk/client/fixture/MissingJsonRpcAdapterClasspathTest.java b/tests/client-builder-classpath/src/test/java/org/a2aproject/sdk/client/fixture/MissingJsonRpcAdapterClasspathTest.java new file mode 100644 index 000000000..ed9bb847c --- /dev/null +++ b/tests/client-builder-classpath/src/test/java/org/a2aproject/sdk/client/fixture/MissingJsonRpcAdapterClasspathTest.java @@ -0,0 +1,59 @@ +package org.a2aproject.sdk.client.fixture; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.File; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Arrays; +import java.util.Comparator; +import java.util.stream.Collectors; + +import org.junit.jupiter.api.Test; + +class MissingJsonRpcAdapterClasspathTest { + + @Test + void reportsMissingJsonRpcAdapterWithCoreAdapterOnClasspath() throws Exception { + String[] entries = System.getProperty("java.class.path").split(java.util.regex.Pattern.quote(File.pathSeparator)); + assertTrue(Arrays.stream(entries).noneMatch(entry -> entry.contains("compat-0.3-client-adapter-jsonrpc")), + () -> "The fixture must not depend on the versioned JSON-RPC adapter: " + Arrays.toString(entries)); + + Path probeDirectory = Files.createTempDirectory("a2a-client-builder-probe"); + Path probeClass = probeDirectory.resolve("org/a2aproject/sdk/client/fixture/MissingJsonRpcAdapterProbe.class"); + Files.createDirectories(probeClass.getParent()); + Files.copy(Path.of("target/test-classes/org/a2aproject/sdk/client/fixture/MissingJsonRpcAdapterProbe.class"), + probeClass); + + String productionClassPath = Arrays.stream(entries) + .filter(entry -> !entry.endsWith("target/test-classes")) + .collect(Collectors.joining(File.pathSeparator)); + Process process = null; + try { + process = new ProcessBuilder( + Path.of(System.getProperty("java.home"), "bin", "java").toString(), + "-cp", probeDirectory + File.pathSeparator + productionClassPath, + MissingJsonRpcAdapterProbe.class.getName()) + .redirectErrorStream(true) + .start(); + String output; + try (var input = process.getInputStream()) { + output = new String(input.readAllBytes(), StandardCharsets.UTF_8); + } + + assertEquals(0, process.waitFor(), output); + assertTrue(output.contains("a2a-java-sdk-compat-0.3-client-adapter-jsonrpc"), output); + } finally { + if (process != null && process.isAlive()) { + process.destroyForcibly(); + } + try (var paths = Files.walk(probeDirectory)) { + for (Path path : paths.sorted(Comparator.reverseOrder()).toList()) { + Files.deleteIfExists(path); + } + } + } + } +} diff --git a/tests/client-builder-classpath/src/test/java/org/a2aproject/sdk/client/fixture/MissingJsonRpcAdapterProbe.java b/tests/client-builder-classpath/src/test/java/org/a2aproject/sdk/client/fixture/MissingJsonRpcAdapterProbe.java new file mode 100644 index 000000000..cdc349d20 --- /dev/null +++ b/tests/client-builder-classpath/src/test/java/org/a2aproject/sdk/client/fixture/MissingJsonRpcAdapterProbe.java @@ -0,0 +1,74 @@ +package org.a2aproject.sdk.client.fixture; + +import java.io.IOException; +import java.io.OutputStream; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.util.Set; + +import com.sun.net.httpserver.HttpServer; +import org.a2aproject.sdk.client.Client; +import org.a2aproject.sdk.client.http.A2ACardResolver; +import org.a2aproject.sdk.client.transport.jsonrpc.JSONRPCTransport; +import org.a2aproject.sdk.client.transport.jsonrpc.JSONRPCTransportConfigBuilder; +import org.a2aproject.sdk.spec.A2AClientException; +import org.a2aproject.sdk.spec.AgentCard; + +public final class MissingJsonRpcAdapterProbe { + + private static final String LEGACY_AGENT_CARD = """ + { + "protocolVersion": "0.3", + "name": "Legacy agent", + "description": "Legacy agent for classpath probing", + "url": "http://127.0.0.1/a2a", + "preferredTransport": "JSONRPC", + "version": "1.0.0", + "capabilities": {"streaming": false, "pushNotifications": false}, + "defaultInputModes": ["text/plain"], + "defaultOutputModes": ["text/plain"], + "skills": [] + }"""; + + private MissingJsonRpcAdapterProbe() { + } + + public static void main(String[] args) throws Exception { + HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext("/.well-known/agent-card.json", exchange -> { + byte[] response = LEGACY_AGENT_CARD.getBytes(StandardCharsets.UTF_8); + exchange.sendResponseHeaders(200, response.length); + try (OutputStream output = exchange.getResponseBody()) { + output.write(response); + } + }); + server.start(); + try { + String baseUrl = "http://127.0.0.1:" + server.getAddress().getPort(); + AgentCard card = A2ACardResolver.builder() + .baseUrl(baseUrl) + .supportedProtocolVersions(Set.of("0.3")) + .build() + .getAgentCard(); + if (card.supportedInterfaces().stream().noneMatch(agentInterface -> + "JSONRPC".equals(agentInterface.protocolBinding()) + && "0.3".equals(agentInterface.protocolVersion()))) { + throw new AssertionError("The 0.3 card was not resolved through the compatibility parser"); + } + + try { + Client.builder(card) + .withTransport(JSONRPCTransport.class, new JSONRPCTransportConfigBuilder()) + .build(); + throw new AssertionError("The missing JSON-RPC compatibility adapter was not reported"); + } catch (A2AClientException expected) { + if (!expected.getMessage().contains("a2a-java-sdk-compat-0.3-client-adapter-jsonrpc")) { + throw new AssertionError("Unexpected diagnostic: " + expected.getMessage(), expected); + } + System.out.println(expected.getMessage()); + } + } finally { + server.stop(0); + } + } +} From 3a6043310298353f7330485cf4bb471d1e5dd911 Mon Sep 17 00:00:00 2001 From: Kabir Khan Date: Mon, 5 Oct 2026 10:54:52 +0100 Subject: [PATCH 4/9] review fixes --- .../a2aproject/sdk/client/ClientBuilder.java | 3 ++ .../VersionedClientTransportProvider.java | 28 +++++++++++++++++- .../sdk/client/ClientBuilderTest.java | 15 ++++++++++ ...SONRPCCompat03ClientTransportProvider.java | 5 ++-- .../JSONRPCCompat03ClientTransportTest.java | 22 ++++++++++++++ .../RestCompat03ClientTransportProvider.java | 5 ++-- .../rest/RestCompat03ClientTransportTest.java | 29 +++++++++++++++++++ .../Compat03ClientCallContextMapper.java | 2 +- .../Compat03ClientTransportSupport.java | 6 ++-- .../Compat03ClientTransportSupportTest.java | 6 ++++ 10 files changed, 111 insertions(+), 10 deletions(-) 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 7526cd730..29d8076d5 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 @@ -180,6 +180,9 @@ public ClientBuilder withTransport(Class clazz, C * @return this builder for method chaining */ public ClientBuilder withTransport(Class clazz, ClientTransportConfig config) { + if (!transportProtocolMapping.containsKey(clazz)) { + throw new IllegalArgumentException("No ClientTransportProvider is registered for " + clazz.getName()); + } clientTransports.put(clazz, config); return this; 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 index 23f64d1d1..9f3650829 100644 --- a/client/base/src/main/java/org/a2aproject/sdk/client/VersionedClientTransportProvider.java +++ b/client/base/src/main/java/org/a2aproject/sdk/client/VersionedClientTransportProvider.java @@ -6,14 +6,40 @@ 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. */ +/** + * Provider SPI for client transports targeting a protocol version other than 1.0. + */ public interface VersionedClientTransportProvider { + /** + * Returns the protocol binding supported by this provider. + * + * @return the protocol binding name + */ String protocolBinding(); + /** + * Returns the protocol version supported by this provider. + * + * @return the protocol version + */ String protocolVersion(); + /** + * Returns the native transport class whose configuration this provider consumes. + * + * @return the configured transport class + */ Class configuredTransportClass(); + /** + * Creates a transport for the supplied agent interface. + * + * @param config the native transport configuration + * @param card the agent card + * @param agentInterface the selected agent interface + * @return the configured transport + * @throws A2AClientException if the configuration or agent interface is invalid + */ ClientTransport create(ClientTransportConfig config, AgentCard card, AgentInterface agentInterface) throws A2AClientException; } diff --git a/client/base/src/test/java/org/a2aproject/sdk/client/ClientBuilderTest.java b/client/base/src/test/java/org/a2aproject/sdk/client/ClientBuilderTest.java index 3f1c120cc..ed708b7a8 100644 --- a/client/base/src/test/java/org/a2aproject/sdk/client/ClientBuilderTest.java +++ b/client/base/src/test/java/org/a2aproject/sdk/client/ClientBuilderTest.java @@ -7,6 +7,8 @@ import org.a2aproject.sdk.client.http.A2AHttpClientFactory; import org.a2aproject.sdk.client.transport.grpc.GrpcTransport; import org.a2aproject.sdk.client.transport.grpc.GrpcTransportConfigBuilder; +import org.a2aproject.sdk.client.transport.spi.ClientTransport; +import org.a2aproject.sdk.client.transport.spi.ClientTransportConfig; import org.a2aproject.sdk.client.transport.jsonrpc.JSONRPCTransport; import org.a2aproject.sdk.client.transport.jsonrpc.JSONRPCTransportConfig; import org.a2aproject.sdk.client.transport.jsonrpc.JSONRPCTransportConfigBuilder; @@ -78,6 +80,16 @@ public void shouldNotFindConfigurationTransport() throws A2AClientException { Assertions.assertTrue(exception.getMessage() != null && exception.getMessage().startsWith("Missing required TransportConfig for")); } + @Test + public void shouldRejectUnregisteredTransportAtConfigurationTime() { + IllegalArgumentException exception = Assertions.assertThrows(IllegalArgumentException.class, + () -> Client.builder(card).withTransport(UnregisteredTransport.class, + new ClientTransportConfig() { })); + + Assertions.assertTrue(exception.getMessage().contains(UnregisteredTransport.class.getName())); + Assertions.assertTrue(exception.getMessage().contains("No ClientTransportProvider is registered")); + } + @Test public void shouldCreateJSONRPCClient() throws A2AClientException { Client client = Client @@ -172,4 +184,7 @@ public void shouldHaveNullTenantWhenNotSet() throws A2AClientException { Assertions.assertNull(selectedInterface.tenant()); } + + private abstract static class UnregisteredTransport implements ClientTransport { + } } diff --git a/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 index fa7bff6ab..6135b8937 100644 --- 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 @@ -24,8 +24,9 @@ public final class JSONRPCCompat03ClientTransportProvider implements VersionedCl public ClientTransport create(ClientTransportConfig config, AgentCard card, AgentInterface agentInterface) throws A2AClientException { Compat03ClientTransportSupport.validateAgentInterfaceTenant(agentInterface.tenant()); - JSONRPCTransportConfig nativeConfig = config == null ? new JSONRPCTransportConfig() : - (JSONRPCTransportConfig) config; + if (!(config instanceof JSONRPCTransportConfig nativeConfig)) { + throw new A2AClientException("Expected JSONRPCTransportConfig for the JSON-RPC 0.3 adapter"); + } Compat03ClientTransportSupport.validateConfig(nativeConfig); A2AHttpClient httpClient = nativeConfig.getHttpClient(); JSONRPCTransport_v0_3 legacy = new JSONRPCTransport_v0_3(httpClient, 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 index 6a3abf392..1ec267706 100644 --- 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 @@ -10,6 +10,7 @@ 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.spi.ClientTransportConfig; 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; @@ -62,6 +63,27 @@ void providerRejectsTenantOnLegacyAgentInterface() { assertTrue(exception.getMessage().contains("tenant")); } + @Test + void providerRejectsWrongConfigurationType() { + org.a2aproject.sdk.spec.A2AClientException exception = assertThrows( + org.a2aproject.sdk.spec.A2AClientException.class, + () -> new JSONRPCCompat03ClientTransportProvider().create( + new ClientTransportConfig() { }, + testCard(), testCard().supportedInterfaces().get(0))); + + assertTrue(exception.getMessage().contains("JSONRPCTransportConfig")); + } + + @Test + void providerRejectsNullConfiguration() { + org.a2aproject.sdk.spec.A2AClientException exception = assertThrows( + org.a2aproject.sdk.spec.A2AClientException.class, + () -> new JSONRPCCompat03ClientTransportProvider().create( + null, testCard(), testCard().supportedInterfaces().get(0))); + + assertTrue(exception.getMessage().contains("JSONRPCTransportConfig")); + } + @Test void rejectsUnsupportedHistoryLengthIntroducedByAnInterceptor() { ClientCallInterceptor interceptor = new ClientCallInterceptor() { 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 index 78c29aabe..cddae9e27 100644 --- 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 @@ -23,8 +23,9 @@ public final class RestCompat03ClientTransportProvider implements VersionedClien public ClientTransport create(ClientTransportConfig config, AgentCard card, AgentInterface agentInterface) throws A2AClientException { Compat03ClientTransportSupport.validateAgentInterfaceTenant(agentInterface.tenant()); - RestTransportConfig nativeConfig = config == null ? new RestTransportConfig() : - (RestTransportConfig) config; + if (!(config instanceof RestTransportConfig nativeConfig)) { + throw new A2AClientException("Expected RestTransportConfig for the REST 0.3 adapter"); + } Compat03ClientTransportSupport.validateConfig(nativeConfig); A2AHttpClient httpClient = nativeConfig.getHttpClient(); RestTransport_v0_3 legacy = new RestTransport_v0_3(httpClient, 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 index a986f0feb..c944916d8 100644 --- 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 @@ -5,6 +5,7 @@ import static org.junit.jupiter.api.Assertions.assertTrue; import org.a2aproject.sdk.client.transport.rest.RestTransport; +import org.a2aproject.sdk.client.transport.spi.ClientTransportConfig; import org.a2aproject.sdk.spec.A2AClientException; import org.a2aproject.sdk.spec.AgentCapabilities; import org.a2aproject.sdk.spec.AgentCard; @@ -31,6 +32,34 @@ void rejectsTenantOnLegacyAgentInterface() { assertTrue(exception.getMessage().contains("tenant")); } + @Test + void providerRejectsWrongConfigurationType() { + AgentCard card = card(); + A2AClientException exception = assertThrows(A2AClientException.class, + () -> new RestCompat03ClientTransportProvider().create( + new ClientTransportConfig() { }, card, card.supportedInterfaces().get(0))); + + assertTrue(exception.getMessage().contains("RestTransportConfig")); + } + + @Test + void providerRejectsNullConfiguration() { + AgentCard card = card(); + A2AClientException exception = assertThrows(A2AClientException.class, + () -> new RestCompat03ClientTransportProvider().create(null, card, card.supportedInterfaces().get(0))); + + assertTrue(exception.getMessage().contains("RestTransportConfig")); + } + + private static AgentCard card() { + return AgentCard.builder().name("agent").description("description").version("1") + .capabilities(AgentCapabilities.builder().build()) + .defaultInputModes(java.util.List.of("text")).defaultOutputModes(java.util.List.of("text")) + .skills(java.util.List.of()) + .supportedInterfaces(java.util.List.of(new AgentInterface("HTTP+JSON", "https://example.test", null, "0.3"))) + .build(); + } + private static AgentCard cardWithTenant() { return AgentCard.builder().name("agent").description("description").version("1") .capabilities(AgentCapabilities.builder().build()) 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 index bd50d042f..c3a2cd010 100644 --- 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 @@ -12,7 +12,7 @@ public final class Compat03ClientCallContextMapper { private Compat03ClientCallContextMapper() { } - public static ClientCallContext_v0_3 toV03(@Nullable ClientCallContext context) { + public static @Nullable ClientCallContext_v0_3 toV03(@Nullable ClientCallContext context) { if (context == null) { return null; } 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 index 39346fc71..bc8952e3b 100644 --- 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 @@ -116,7 +116,7 @@ public static void validateAgentInterfaceTenant(@Nullable String tenant) { validateTenant("AgentInterface", tenant); } - public static ClientCallContext_v0_3 toV03Context(@Nullable ClientCallContext context) { + public static @Nullable ClientCallContext_v0_3 toV03Context(@Nullable ClientCallContext context) { if (context == null) { return null; } @@ -181,9 +181,7 @@ public static TaskPushNotificationConfig toV10(TaskPushNotificationConfig_v0_3 c public static ListTaskPushNotificationConfigsResult toV10PushList( List configs) { - ListTaskPushNotificationConfigsResult result = - ListTaskPushNotificationConfigsResultMapper_v0_3.INSTANCE.toV10(configs); - return result; + return ListTaskPushNotificationConfigsResultMapper_v0_3.INSTANCE.toV10(configs); } public static A2AClientException mapLegacyException(A2AClientException_v0_3 exception) { 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 index 9b2c3c6ec..6b98565e4 100644 --- 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 @@ -84,6 +84,12 @@ void mapsContextsAndRequestParametersInBothDirections() { assertEquals(null, Compat03ClientTransportSupport.toV03(message).metadata()); } + @Test + void mapsNullContextsToNull() { + assertNull(Compat03ClientTransportSupport.toV03Context(null)); + assertNull(Compat03ClientCallContextMapper.toV03(null)); + } + @Test void rejectsExplicitZeroHistoryLengthBecauseLegacyZeroMeansUnlimited() { A2AClientException exception = assertThrows(A2AClientException.class, From b14ba92e73649f94b1fe5d77c9750210b3c94f2e Mon Sep 17 00:00:00 2001 From: Kabir Khan Date: Mon, 5 Oct 2026 13:09:22 +0100 Subject: [PATCH 5/9] fix: address unified client compatibility edge cases Convert inline push callbacks without standalone task ID validation and normalize omitted legacy file MIME types. Reject unsupported DataPart values at the client boundary and preserve non-object legacy error data. Add regression coverage for conversion, exception mapping, interceptor mutation, and compatibility transports. --- .../JSONRPCCompat03ClientTransportTest.java | 91 ++++++++++++++++++- .../adapter/Compat03ClientErrorMapper.java | 9 +- .../Compat03ClientTransportSupport.java | 13 ++- .../Compat03ClientErrorMapperTest.java | 26 ++++++ .../Compat03ClientTransportSupportTest.java | 30 ++++++ .../domain/FileContentMapper_v0_3.java | 8 +- ...TaskPushNotificationConfigMapper_v0_3.java | 22 +++-- .../MessageSendConfigurationMapper_v0_3.java | 7 +- .../domain/FileContentMapper_v0_3_Test.java | 32 ++++++- ...sageSendConfigurationMapper_v0_3_Test.java | 67 ++++++++++++++ ...stractA2AServerCompatibilityTest_v0_3.java | 66 +++++++++++++- 11 files changed, 335 insertions(+), 36 deletions(-) create mode 100644 compat-0.3/conversion/src/test/java/org/a2aproject/sdk/compat03/conversion/mappers/params/MessageSendConfigurationMapper_v0_3_Test.java 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 index 1ec267706..50254b6ec 100644 --- 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 @@ -2,24 +2,30 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import java.util.List; +import java.util.concurrent.atomic.AtomicReference; +import org.a2aproject.sdk.client.transport.jsonrpc.JSONRPCTransport; +import org.a2aproject.sdk.client.transport.spi.ClientTransportConfig; import org.a2aproject.sdk.client.transport.spi.interceptors.ClientCallContext; import org.a2aproject.sdk.client.transport.spi.interceptors.ClientCallInterceptor; import org.a2aproject.sdk.client.transport.spi.interceptors.PayloadAndHeaders; -import org.a2aproject.sdk.client.transport.spi.ClientTransportConfig; -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.json.JsonUtil_v0_3; import org.a2aproject.sdk.compat03.spec.AgentCard_v0_3; import org.a2aproject.sdk.compat03.spec.DeleteTaskPushNotificationConfigParams_v0_3; import org.a2aproject.sdk.compat03.spec.EventKind_v0_3; import org.a2aproject.sdk.compat03.spec.GetTaskPushNotificationConfigParams_v0_3; import org.a2aproject.sdk.compat03.spec.ListTaskPushNotificationConfigParams_v0_3; import org.a2aproject.sdk.compat03.spec.MessageSendParams_v0_3; +import org.a2aproject.sdk.compat03.spec.Message_v0_3; import org.a2aproject.sdk.compat03.spec.StreamingEventKind_v0_3; import org.a2aproject.sdk.compat03.spec.TaskIdParams_v0_3; import org.a2aproject.sdk.compat03.spec.TaskPushNotificationConfig_v0_3; @@ -27,7 +33,14 @@ import org.a2aproject.sdk.spec.AgentCapabilities; import org.a2aproject.sdk.spec.AgentCard; import org.a2aproject.sdk.spec.AgentInterface; +import org.a2aproject.sdk.spec.DataPart; +import org.a2aproject.sdk.spec.FilePart; +import org.a2aproject.sdk.spec.FileWithBytes; +import org.a2aproject.sdk.spec.Message; +import org.a2aproject.sdk.spec.MessageSendParams; import org.a2aproject.sdk.spec.TaskQueryParams; +import org.a2aproject.sdk.spec.TextPart; +import org.a2aproject.sdk.spec.UnsupportedOperationError; import org.junit.jupiter.api.Test; class JSONRPCCompat03ClientTransportTest { @@ -84,6 +97,67 @@ void providerRejectsNullConfiguration() { assertTrue(exception.getMessage().contains("JSONRPCTransportConfig")); } + @Test + void receivesInlineFilesWithoutMimeTypeForBlockingAndStreamingSends() throws Exception { + RecordingDelegate delegate = new RecordingDelegate(); + delegate.response = JsonUtil_v0_3.fromJson(""" + {"kind":"message","role":"agent","messageId":"response", + "parts":[{"kind":"file","file":{"bytes":"aGVsbG8="}}]} + """, Message_v0_3.class); + JSONRPCCompat03ClientTransport transport = new JSONRPCCompat03ClientTransport( + delegate, testCard(), List.of()); + MessageSendParams request = new MessageSendParams( + new Message(Message.Role.ROLE_USER, List.of(new TextPart("hello")), "request", + null, null, null, null, null), null, null); + + Message blocking = assertInstanceOf(Message.class, transport.sendMessage(request, null)); + AtomicReference received = new AtomicReference<>(); + AtomicReference error = new AtomicReference<>(); + transport.sendMessageStreaming(request, received::set, error::set, null); + + assertNull(error.get()); + Message streaming = assertInstanceOf(Message.class, received.get()); + for (Message response : List.of(blocking, streaming)) { + assertEquals("response", response.messageId()); + FilePart part = assertInstanceOf(FilePart.class, response.parts().get(0)); + FileWithBytes file = assertInstanceOf(FileWithBytes.class, part.file()); + assertEquals("", file.mimeType()); + assertEquals("", file.name()); + assertEquals("aGVsbG8=", file.bytes()); + } + } + + @Test + void rejectsNonObjectDataIntroducedByAnInterceptorBeforeDelegation() { + ClientCallInterceptor interceptor = new ClientCallInterceptor() { + @Override + public PayloadAndHeaders intercept(String method, Object payload, java.util.Map headers, + AgentCard card, ClientCallContext context) { + Message replacement = new Message(Message.Role.ROLE_USER, List.of(new DataPart(List.of("item"))), + "request", null, null, null, null, null); + org.a2aproject.sdk.grpc.SendMessageRequest request = + (org.a2aproject.sdk.grpc.SendMessageRequest) payload; + return new PayloadAndHeaders(request.toBuilder() + .setMessage(org.a2aproject.sdk.grpc.utils.ProtoUtils.ToProto.message(replacement)).build(), headers); + } + }; + RecordingDelegate delegate = new RecordingDelegate(); + JSONRPCCompat03ClientTransport transport = new JSONRPCCompat03ClientTransport( + delegate, testCard(), List.of(interceptor)); + MessageSendParams request = new MessageSendParams( + new Message(Message.Role.ROLE_USER, List.of(new TextPart("hello")), "request", + null, null, null, null, null), null, null); + + var blocking = assertThrows(org.a2aproject.sdk.spec.A2AClientException.class, + () -> transport.sendMessage(request, null)); + var streaming = assertThrows(org.a2aproject.sdk.spec.A2AClientException.class, + () -> transport.sendMessageStreaming(request, event -> { }, error -> { }, null)); + + assertInstanceOf(UnsupportedOperationError.class, blocking.getCause()); + assertInstanceOf(UnsupportedOperationError.class, streaming.getCause()); + assertFalse(delegate.called); + } + @Test void rejectsUnsupportedHistoryLengthIntroducedByAnInterceptor() { ClientCallInterceptor interceptor = new ClientCallInterceptor() { @@ -123,10 +197,19 @@ private static AgentCard cardWithInterfaceTenant() { private static final class RecordingDelegate implements ClientTransport_v0_3 { boolean called; + Message_v0_3 response; - @Override public EventKind_v0_3 sendMessage(MessageSendParams_v0_3 request, ClientCallContext_v0_3 context) { return null; } + @Override public EventKind_v0_3 sendMessage(MessageSendParams_v0_3 request, ClientCallContext_v0_3 context) { + called = true; + assertNotNull(response); + return response; + } @Override public void sendMessageStreaming(MessageSendParams_v0_3 request, java.util.function.Consumer events, - java.util.function.Consumer errors, ClientCallContext_v0_3 context) { } + java.util.function.Consumer errors, ClientCallContext_v0_3 context) { + called = true; + assertNotNull(response); + events.accept(response); + } @Override public org.a2aproject.sdk.compat03.spec.Task_v0_3 getTask(TaskQueryParams_v0_3 request, ClientCallContext_v0_3 context) { called = true; return null; } @Override public org.a2aproject.sdk.compat03.spec.Task_v0_3 cancelTask(TaskIdParams_v0_3 request, ClientCallContext_v0_3 context) { return null; } @Override public TaskPushNotificationConfig_v0_3 setTaskPushNotificationConfiguration(TaskPushNotificationConfig_v0_3 request, ClientCallContext_v0_3 context) { return null; } 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 index ba7fe6c94..c65e598fd 100644 --- 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 @@ -10,10 +10,10 @@ 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.InvalidParamsError; +import org.a2aproject.sdk.spec.InvalidRequestError; import org.a2aproject.sdk.spec.JSONParseError; import org.a2aproject.sdk.spec.MethodNotFoundError; import org.a2aproject.sdk.spec.PushNotificationNotSupportedError; @@ -62,9 +62,12 @@ public static A2AError toV10(JSONRPCError_v0_3 error) { } private static Map details(Object data) { - if (!(data instanceof Map rawDetails)) { + if (data == null) { return Map.of(); } + if (!(data instanceof Map rawDetails)) { + return Map.of("data", data); + } Map details = new LinkedHashMap<>(); rawDetails.forEach((key, value) -> { if (key instanceof String stringKey) { 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 index bc8952e3b..a0e146541 100644 --- 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 @@ -18,26 +18,24 @@ import org.a2aproject.sdk.compat03.spec.A2AClientException_v0_3; import org.a2aproject.sdk.compat03.spec.DeleteTaskPushNotificationConfigParams_v0_3; import org.a2aproject.sdk.compat03.spec.GetTaskPushNotificationConfigParams_v0_3; +import org.a2aproject.sdk.compat03.spec.JSONRPCError_v0_3; import org.a2aproject.sdk.compat03.spec.ListTaskPushNotificationConfigParams_v0_3; import org.a2aproject.sdk.compat03.spec.MessageSendParams_v0_3; import org.a2aproject.sdk.compat03.spec.StreamingEventKind_v0_3; -import org.a2aproject.sdk.compat03.spec.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.DataPart; import org.a2aproject.sdk.spec.ListTaskPushNotificationConfigsParams; import org.a2aproject.sdk.spec.ListTaskPushNotificationConfigsResult; import org.a2aproject.sdk.spec.ListTasksParams; import org.a2aproject.sdk.spec.MessageSendParams; +import org.a2aproject.sdk.spec.StreamingEventKind; import org.a2aproject.sdk.spec.TaskIdParams; import org.a2aproject.sdk.spec.TaskPushNotificationConfig; import org.a2aproject.sdk.spec.TaskQueryParams; -import org.a2aproject.sdk.spec.StreamingEventKind; import org.jspecify.annotations.Nullable; /** Shared, binding-independent behavior for adapters from the 1.0 client to 0.3 transports. */ @@ -68,6 +66,11 @@ public static void validateTenant(String operation, @Nullable String tenant) { public static void validateMessageSend(MessageSendParams request) { validateTenant("sendMessage", request.tenant()); + for (var part : request.message().parts()) { + if (part instanceof DataPart data && !(data.data() instanceof Map)) { + throw unsupported("sendMessage with non-object DataPart data"); + } + } if (request.configuration() != null && request.configuration().taskPushNotificationConfig() != null) { validateTenant("sendMessage with a task push notification configuration", 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 index 5de6e4f19..d13ca641e 100644 --- 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 @@ -1,9 +1,11 @@ package org.a2aproject.sdk.compat03.client.adapter; +import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertInstanceOf; import java.util.List; import java.util.Map; +import java.util.concurrent.atomic.AtomicReference; import org.a2aproject.sdk.compat03.spec.A2AClientException_v0_3; import org.a2aproject.sdk.compat03.spec.JSONRPCError_v0_3; @@ -25,6 +27,30 @@ void mapsUnsupportedOperationError() { assertInstanceOf(UnsupportedOperationError.class, mapped.getCause()); } + @Test + void preservesScalarAndArrayDataForSynchronousAndStreamingErrors() { + for (Object data : List.of("missingScope", 42, true, List.of("missingScope", "write"))) { + JSONRPCError_v0_3 legacy = new JSONRPCError_v0_3(-32602, "invalid parameters", data); + + A2AClientException synchronous = Compat03ClientErrorMapper.toV10( + new A2AClientException_v0_3("request failed", legacy)); + assertEquals(Map.of("data", data), + assertInstanceOf(A2AError.class, synchronous.getCause()).getDetails()); + + AtomicReference received = new AtomicReference<>(); + Compat03ClientTransportSupport.mapAsyncError(received::set).accept(legacy); + A2AClientException streaming = assertInstanceOf(A2AClientException.class, received.get()); + assertEquals(Map.of("data", data), + assertInstanceOf(A2AError.class, streaming.getCause()).getDetails()); + } + } + + @Test + void absentErrorDataRemainsEmpty() { + assertEquals(Map.of(), Compat03ClientErrorMapper.toV10( + new JSONRPCError_v0_3(-32602, "invalid parameters", null)).getDetails()); + } + @Test void mapsGenericJsonRpcErrorsByTheirLegacyCodes() { List cases = List.of( 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 index 6b98565e4..1386a81b4 100644 --- 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 @@ -8,6 +8,7 @@ import static org.junit.jupiter.api.Assertions.assertTrue; import java.io.IOException; +import java.util.List; import java.util.Map; import java.util.concurrent.atomic.AtomicReference; @@ -16,6 +17,7 @@ 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.DataPart; import org.a2aproject.sdk.spec.ListTaskPushNotificationConfigsParams; import org.a2aproject.sdk.spec.Message; import org.a2aproject.sdk.spec.MessageSendConfiguration; @@ -24,6 +26,7 @@ import org.a2aproject.sdk.spec.TaskPushNotificationConfig; import org.a2aproject.sdk.spec.TaskQueryParams; import org.a2aproject.sdk.spec.TextPart; +import org.a2aproject.sdk.spec.UnsupportedOperationError; import org.junit.jupiter.api.Test; class Compat03ClientTransportSupportTest { @@ -45,6 +48,33 @@ void rejectsUnsupportedOperationsBeforeDelegateUse() { new ListTaskPushNotificationConfigsParams("task", 10, "", null))); } + @Test + void rejectsNonObjectDataPartsThroughClientExceptionBoundary() { + for (Object data : List.of("value", 42, true, List.of("item"))) { + MessageSendParams request = new MessageSendParams( + new Message(Message.Role.ROLE_USER, List.of(new DataPart(data)), "message", null, null, + null, null, null), null, null); + + A2AClientException exception = assertThrows(A2AClientException.class, + () -> Compat03ClientTransportSupport.toV03(request)); + + assertInstanceOf(UnsupportedOperationError.class, exception.getCause()); + } + } + + @Test + void preservesObjectDataParts() { + MessageSendParams request = new MessageSendParams( + new Message(Message.Role.ROLE_USER, List.of(new DataPart(Map.of("key", "value"))), + "message", null, null, null, null, null), null, null); + + var legacy = Compat03ClientTransportSupport.toV03(request); + + var data = assertInstanceOf(org.a2aproject.sdk.compat03.spec.DataPart_v0_3.class, + legacy.message().parts().get(0)); + assertEquals(Map.of("key", "value"), data.data()); + } + @Test void acceptsDefaultPushListAndReturnsNoPageToken() { var result = Compat03ClientTransportSupport.toV10PushList(java.util.List.of()); diff --git a/compat-0.3/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 index a39f840cf..37fa0bf3f 100644 --- a/compat-0.3/conversion/src/main/java/org/a2aproject/sdk/compat03/conversion/mappers/domain/FileContentMapper_v0_3.java +++ b/compat-0.3/conversion/src/main/java/org/a2aproject/sdk/compat03/conversion/mappers/domain/FileContentMapper_v0_3.java @@ -1,7 +1,7 @@ package org.a2aproject.sdk.compat03.conversion.mappers.domain; -import org.a2aproject.sdk.compat03.conversion.mappers.config.A2AMappers_v0_3; import org.a2aproject.sdk.compat03.conversion.mappers.config.A03ToV10MapperConfig; +import org.a2aproject.sdk.compat03.conversion.mappers.config.A2AMappers_v0_3; import org.a2aproject.sdk.compat03.spec.FileContent_v0_3; import org.a2aproject.sdk.compat03.spec.FileWithBytes_v0_3; import org.a2aproject.sdk.compat03.spec.FileWithUri_v0_3; @@ -51,11 +51,13 @@ default FileContent toV10(FileContent_v0_3 v03) { } if (v03 instanceof FileWithBytes_v0_3 v03Bytes) { + String mimeType = v03Bytes.mimeType() != null ? v03Bytes.mimeType() : ""; String name = v03Bytes.name() != null ? v03Bytes.name() : ""; - return new FileWithBytes(v03Bytes.mimeType(), name, v03Bytes.bytes()); + return new FileWithBytes(mimeType, name, v03Bytes.bytes()); } else if (v03 instanceof FileWithUri_v0_3 v03Uri) { + String mimeType = v03Uri.mimeType() != null ? v03Uri.mimeType() : ""; String name = v03Uri.name() != null ? v03Uri.name() : ""; - return new FileWithUri(v03Uri.mimeType(), name, v03Uri.uri()); + return new FileWithUri(mimeType, name, v03Uri.uri()); } throw new InvalidRequestError(null, "Unrecognized FileContent type: " + v03.getClass().getName(), null); diff --git a/compat-0.3/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 index 0b9534a30..e91ff1108 100644 --- a/compat-0.3/conversion/src/main/java/org/a2aproject/sdk/compat03/conversion/mappers/domain/TaskPushNotificationConfigMapper_v0_3.java +++ b/compat-0.3/conversion/src/main/java/org/a2aproject/sdk/compat03/conversion/mappers/domain/TaskPushNotificationConfigMapper_v0_3.java @@ -1,7 +1,7 @@ package org.a2aproject.sdk.compat03.conversion.mappers.domain; -import org.a2aproject.sdk.compat03.conversion.mappers.config.A2AMappers_v0_3; import org.a2aproject.sdk.compat03.conversion.mappers.config.A03ToV10MapperConfig; +import org.a2aproject.sdk.compat03.conversion.mappers.config.A2AMappers_v0_3; import org.a2aproject.sdk.compat03.spec.PushNotificationConfig_v0_3; import org.a2aproject.sdk.compat03.spec.TaskPushNotificationConfig_v0_3; import org.a2aproject.sdk.spec.TaskPushNotificationConfig; @@ -73,17 +73,19 @@ default TaskPushNotificationConfig_v0_3 fromV10( return null; } - PushNotificationConfig_v0_3 pushConfig = - new PushNotificationConfig_v0_3( - v10.url(), - v10.token(), - AuthenticationInfoMapper_v0_3.INSTANCE.fromV10ToPushNotification(v10.authentication()), - v10.id() - ); - return new TaskPushNotificationConfig_v0_3( v10.taskId(), - pushConfig + fromV10PushNotificationConfig(v10) + ); + } + + /** Converts callback fields without requiring a task ID for inline message configuration. */ + default PushNotificationConfig_v0_3 fromV10PushNotificationConfig(TaskPushNotificationConfig v10) { + return new PushNotificationConfig_v0_3( + v10.url(), + v10.token(), + AuthenticationInfoMapper_v0_3.INSTANCE.fromV10ToPushNotification(v10.authentication()), + v10.id() ); } } diff --git a/compat-0.3/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 index c1228087e..f3aaa28ec 100644 --- a/compat-0.3/conversion/src/main/java/org/a2aproject/sdk/compat03/conversion/mappers/params/MessageSendConfigurationMapper_v0_3.java +++ b/compat-0.3/conversion/src/main/java/org/a2aproject/sdk/compat03/conversion/mappers/params/MessageSendConfigurationMapper_v0_3.java @@ -1,7 +1,7 @@ package org.a2aproject.sdk.compat03.conversion.mappers.params; -import org.a2aproject.sdk.compat03.conversion.mappers.config.A2AMappers_v0_3; import org.a2aproject.sdk.compat03.conversion.mappers.config.A03ToV10MapperConfig; +import org.a2aproject.sdk.compat03.conversion.mappers.config.A2AMappers_v0_3; import org.a2aproject.sdk.compat03.conversion.mappers.domain.TaskPushNotificationConfigMapper_v0_3; import org.a2aproject.sdk.compat03.spec.MessageSendConfiguration_v0_3; import org.a2aproject.sdk.compat03.spec.PushNotificationConfig_v0_3; @@ -89,9 +89,8 @@ default MessageSendConfiguration_v0_3 fromV10( // Extract PushNotificationConfig from TaskPushNotificationConfig if present PushNotificationConfig_v0_3 pushConfig = null; if (v10.taskPushNotificationConfig() != null) { - TaskPushNotificationConfig_v0_3 v03TaskConfig = - TaskPushNotificationConfigMapper_v0_3.INSTANCE.fromV10(v10.taskPushNotificationConfig()); - pushConfig = v03TaskConfig.pushNotificationConfig(); + pushConfig = TaskPushNotificationConfigMapper_v0_3.INSTANCE + .fromV10PushNotificationConfig(v10.taskPushNotificationConfig()); } // Convert returnImmediately to blocking (inverse semantics) diff --git a/compat-0.3/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 index 2b562ccc0..4070c6b05 100644 --- a/compat-0.3/conversion/src/test/java/org/a2aproject/sdk/compat03/conversion/mappers/domain/FileContentMapper_v0_3_Test.java +++ b/compat-0.3/conversion/src/test/java/org/a2aproject/sdk/compat03/conversion/mappers/domain/FileContentMapper_v0_3_Test.java @@ -1,5 +1,8 @@ package org.a2aproject.sdk.compat03.conversion.mappers.domain; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; + import org.a2aproject.sdk.compat03.spec.FileWithBytes_v0_3; import org.a2aproject.sdk.compat03.spec.FileWithUri_v0_3; import org.a2aproject.sdk.spec.FileContent; @@ -7,9 +10,6 @@ import org.a2aproject.sdk.spec.FileWithUri; import org.junit.jupiter.api.Test; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertInstanceOf; - class FileContentMapper_v0_3_Test { @Test @@ -50,6 +50,32 @@ void fileWithUriFromV10PreservesName() { assertEquals("file:///path/to/readme.txt", result.uri()); } + @Test + void inlineFileWithoutMimeTypeConvertsToV10() { + FileWithBytes_v0_3 legacy = new FileWithBytes_v0_3(null, null, "aGVsbG8="); + + FileWithBytes current = assertInstanceOf(FileWithBytes.class, + FileContentMapper_v0_3.INSTANCE.toV10(legacy)); + + assertEquals("", current.mimeType()); + assertEquals("", current.name()); + assertEquals("aGVsbG8=", current.bytes()); + assertEquals(new FileWithBytes_v0_3("", "", "aGVsbG8="), + FileContentMapper_v0_3.INSTANCE.fromV10(current)); + } + + @Test + void uriFileWithoutMimeTypeConvertsToV10() { + FileWithUri_v0_3 legacy = new FileWithUri_v0_3(null, null, "https://example.test/file"); + + FileWithUri current = assertInstanceOf(FileWithUri.class, + FileContentMapper_v0_3.INSTANCE.toV10(legacy)); + + assertEquals(new FileWithUri("", "", "https://example.test/file"), current); + assertEquals(new FileWithUri_v0_3("", "", "https://example.test/file"), + FileContentMapper_v0_3.INSTANCE.fromV10(current)); + } + @Test void nullNameInV03BecomesEmptyStringInV10() { FileWithBytes_v0_3 v03 = new FileWithBytes_v0_3("image/png", null, "aGVsbG8="); diff --git a/compat-0.3/conversion/src/test/java/org/a2aproject/sdk/compat03/conversion/mappers/params/MessageSendConfigurationMapper_v0_3_Test.java b/compat-0.3/conversion/src/test/java/org/a2aproject/sdk/compat03/conversion/mappers/params/MessageSendConfigurationMapper_v0_3_Test.java new file mode 100644 index 000000000..2f894c5f7 --- /dev/null +++ b/compat-0.3/conversion/src/test/java/org/a2aproject/sdk/compat03/conversion/mappers/params/MessageSendConfigurationMapper_v0_3_Test.java @@ -0,0 +1,67 @@ +package org.a2aproject.sdk.compat03.conversion.mappers.params; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.util.List; + +import org.a2aproject.sdk.compat03.conversion.mappers.domain.TaskPushNotificationConfigMapper_v0_3; +import org.a2aproject.sdk.compat03.spec.MessageSendConfiguration_v0_3; +import org.a2aproject.sdk.compat03.spec.PushNotificationAuthenticationInfo_v0_3; +import org.a2aproject.sdk.compat03.spec.PushNotificationConfig_v0_3; +import org.a2aproject.sdk.compat03.spec.TaskPushNotificationConfig_v0_3; +import org.a2aproject.sdk.spec.AuthenticationInfo; +import org.a2aproject.sdk.spec.MessageSendConfiguration; +import org.a2aproject.sdk.spec.TaskPushNotificationConfig; +import org.jspecify.annotations.Nullable; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.NullAndEmptySource; + +class MessageSendConfigurationMapper_v0_3_Test { + + @ParameterizedTest + @NullAndEmptySource + void inlinePushConfigurationDoesNotRequireTaskId(@Nullable String taskId) { + TaskPushNotificationConfig push = new TaskPushNotificationConfig( + "config", taskId, "https://callback.example.test", "token", + new AuthenticationInfo("Bearer", "credentials"), null); + MessageSendConfiguration current = new MessageSendConfiguration(List.of("text/plain"), 3, push, false); + + MessageSendConfiguration_v0_3 legacy = MessageSendConfigurationMapper_v0_3.INSTANCE.fromV10(current); + + assertEquals(new MessageSendConfiguration_v0_3(List.of("text/plain"), 3, + new PushNotificationConfig_v0_3("https://callback.example.test", "token", + new PushNotificationAuthenticationInfo_v0_3(List.of("Bearer"), "credentials"), "config"), + true), legacy); + MessageSendConfiguration roundTrip = MessageSendConfigurationMapper_v0_3.INSTANCE.toV10(legacy); + assertEquals(new TaskPushNotificationConfig("config", "", "https://callback.example.test", "token", + new AuthenticationInfo("Bearer", "credentials"), ""), roundTrip.taskPushNotificationConfig()); + assertEquals(List.of("text/plain"), roundTrip.acceptedOutputModes()); + assertEquals(3, roundTrip.historyLength()); + assertEquals(false, roundTrip.returnImmediately()); + } + + @Test + void standalonePushConfigurationStillRequiresTaskId() { + TaskPushNotificationConfig push = TaskPushNotificationConfig.builder() + .url("https://callback.example.test").build(); + + assertThrows(IllegalArgumentException.class, + () -> TaskPushNotificationConfigMapper_v0_3.INSTANCE.fromV10(push)); + } + + @Test + void standalonePushConfigurationRetainsTaskIdAndCallbackFields() { + TaskPushNotificationConfig current = new TaskPushNotificationConfig( + "config", "task", "https://callback.example.test", "token", + new AuthenticationInfo("Bearer", "credentials"), ""); + + TaskPushNotificationConfig_v0_3 legacy = TaskPushNotificationConfigMapper_v0_3.INSTANCE.fromV10(current); + + assertEquals(new TaskPushNotificationConfig_v0_3("task", + new PushNotificationConfig_v0_3("https://callback.example.test", "token", + new PushNotificationAuthenticationInfo_v0_3(List.of("Bearer"), "credentials"), "config")), legacy); + assertEquals(current, TaskPushNotificationConfigMapper_v0_3.INSTANCE.toV10(legacy)); + } +} diff --git a/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 index 708dd79f9..7bc737f7d 100644 --- 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 @@ -17,13 +17,13 @@ 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.CountDownLatch; 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 java.util.function.Consumer; import org.a2aproject.sdk.A2A; import org.a2aproject.sdk.client.Client; @@ -37,14 +37,17 @@ import org.a2aproject.sdk.spec.AgentCard; import org.a2aproject.sdk.spec.Artifact; import org.a2aproject.sdk.spec.CancelTaskParams; +import org.a2aproject.sdk.spec.DataPart; import org.a2aproject.sdk.spec.DeleteTaskPushNotificationConfigParams; import org.a2aproject.sdk.spec.Event; import org.a2aproject.sdk.spec.GetTaskPushNotificationConfigParams; -import org.a2aproject.sdk.spec.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.Message; +import org.a2aproject.sdk.spec.MessageSendConfiguration; +import org.a2aproject.sdk.spec.MessageSendParams; +import org.a2aproject.sdk.spec.Part; import org.a2aproject.sdk.spec.Task; import org.a2aproject.sdk.spec.TaskArtifactUpdateEvent; import org.a2aproject.sdk.spec.TaskIdParams; @@ -215,6 +218,46 @@ public void testSendMessageNewMessageSuccess() throws Exception { assertEquals("test message", ((TextPart) part).text()); } + @Test + public void testSendMessageWithInlinePushConfiguration() throws Exception { + sendMessageWithInlinePushConfiguration(false); + } + + @Test + public void testSendStreamingMessageWithInlinePushConfiguration() throws Exception { + sendMessageWithInlinePushConfiguration(true); + } + + private void sendMessageWithInlinePushConfiguration(boolean streaming) throws Exception { + Client sendingClient = streaming ? getClient() : getNonStreamingClient(); + for (String taskId : new String[] {null, ""}) { + TaskPushNotificationConfig push = new TaskPushNotificationConfig( + "inline-config", taskId, "http://localhost:" + serverPort + "/callback", null, null, null); + MessageSendParams request = new MessageSendParams(MESSAGE, + MessageSendConfiguration.builder().taskPushNotificationConfig(push).build(), null); + CountDownLatch latch = new CountDownLatch(1); + AtomicReference received = new AtomicReference<>(); + AtomicReference error = new AtomicReference<>(); + + sendingClient.sendMessage(request, List.of((event, card) -> { + if (event instanceof MessageEvent messageEvent) { + received.set(messageEvent.getMessage()); + latch.countDown(); + } + }), throwable -> { + if (throwable != null && !isStreamClosedError(throwable)) { + error.set(throwable); + latch.countDown(); + } + }, null); + + assertTrue(latch.await(10, TimeUnit.SECONDS)); + assertNull(error.get()); + assertNotNull(received.get()); + assertEquals(MESSAGE.messageId(), received.get().messageId()); + } + } + @Test public void testRequestScopedBeanAvailableOnAgentExecutorThread() throws Exception { Message message = Message.builder().messageId("request-scoped-test").role(Message.Role.ROLE_USER) @@ -646,6 +689,21 @@ private static TaskPushNotificationConfig pushConfig(String id, String url) { return TaskPushNotificationConfig.builder().id(id).url(url).build(); } + @Test + public void testNonObjectDataPartsAreRejectedForBlockingAndStreamingSends() throws Exception { + for (Client sendingClient : List.of(getNonStreamingClient(), getClient())) { + for (Object data : List.of("value", 42, true, List.of("item"))) { + Message message = Message.builder().messageId("unsupported-data").role(Message.Role.ROLE_USER) + .parts(new DataPart(data)).build(); + + A2AClientException exception = org.junit.jupiter.api.Assertions.assertThrows(A2AClientException.class, + () -> sendingClient.sendMessage(message)); + + assertInstanceOf(UnsupportedOperationError.class, exception.getCause()); + } + } + } + @Test public void testUnsupportedOperationsAreRejectedLocally() throws Exception { A2AClientException listError = org.junit.jupiter.api.Assertions.assertThrows(A2AClientException.class, From 97f5877422cc2e9800cc37de3d2cfd0b9b604aa2 Mon Sep 17 00:00:00 2001 From: Kabir Khan Date: Mon, 5 Oct 2026 14:03:58 +0100 Subject: [PATCH 6/9] fix: harden unified client protocol compatibility Preserve binary data, JSON nulls, empty objects, and message extensions when adapting A2A 0.3 transports. Reject unrepresentable REST/gRPC fields before and after interceptors. Align legacy REST subscription and push-config requests with the binding, and retain HTTP response details through the unified exception API. Preserve compatibility with older SDK request shapes on reference servers. Add independent wire and conversion regression coverage, and require lifecycle tests to observe specific updates and queue closure. Validation: staged diff passes whitespace checks. Tests and builds were not run at the user's request. --- .../grpc/GrpcCompat03ClientTransport.java | 12 +- .../GrpcCompat03UnsupportedFieldsTest.java | 105 ++++++++++++++++++ .../rest/RestCompat03ClientTransport.java | 12 +- .../RestCompat03UnsupportedFieldsTest.java | 96 ++++++++++++++++ .../adapter/Compat03ClientErrorMapper.java | 10 ++ .../Compat03ClientTransportSupport.java | 25 +++++ .../Compat03ClientErrorMapperTest.java | 30 +++++ .../Compat03ClientTransportSupportTest.java | 37 ++++++ .../transport/rest/RestErrorMapper_v0_3.java | 41 ++++--- .../transport/rest/RestTransport_v0_3.java | 73 ++++++------ .../transport/rest/JsonRestMessages_v0_3.java | 13 +-- .../rest/RestErrorMapper_v0_3_Test.java | 60 ++++++++++ .../rest/RestTransport_v0_3_Test.java | 89 ++++++++++++--- .../rest/quarkus/A2AServerRoutes_v0_3.java | 20 ++-- .../compat03/grpc/utils/ProtoUtils_v0_3.java | 39 ++++--- .../grpc/utils/ProtoJsonData_v0_3_Test.java | 80 +++++++++++++ .../grpc/utils/ToProto_v0_3_Test.java | 8 +- .../sdk/compat03/json/JsonUtil_v0_3.java | 87 +++++++++++---- .../spec/A2AClientHTTPError_v0_3.java | 24 +++- .../sdk/compat03/spec/Artifact_v0_3.java | 3 +- .../sdk/compat03/spec/DataPart_v0_3.java | 5 +- .../sdk/compat03/spec/FilePart_v0_3.java | 3 +- .../sdk/compat03/spec/Message_v0_3.java | 3 +- .../spec/TaskArtifactUpdateEvent_v0_3.java | 3 +- .../spec/TaskStatusUpdateEvent_v0_3.java | 5 +- .../sdk/compat03/spec/Task_v0_3.java | 3 +- .../sdk/compat03/spec/TextPart_v0_3.java | 3 +- .../sdk/compat03/util/Utils_v0_3.java | 15 ++- .../json/JsonNullValues_v0_3_Test.java | 89 +++++++++++++++ .../rest/handler/RestHandler_v0_3.java | 63 +++++++---- .../rest/handler/RestHandler_v0_3_Test.java | 29 ++++- docs/content/dev/compatibility.md | 5 +- .../sdk/client/http/JdkA2AHttpClient.java | 18 +-- .../rest/MultiVersionRestRoutes.java | 26 +++-- .../multiversion/grpc/A2ATestResource.java | 11 +- .../multiversion/jsonrpc/A2ATestRoutes.java | 15 ++- .../multiversion/rest/A2ATestRoutes.java | 13 ++- ...stractA2AServerCompatibilityTest_v0_3.java | 64 +++++++++-- .../sdk/server/apps/common/TestUtilsBean.java | 8 +- 39 files changed, 1034 insertions(+), 211 deletions(-) create mode 100644 compat-0.3/client/adapter-grpc/src/test/java/org/a2aproject/sdk/compat03/client/adapter/grpc/GrpcCompat03UnsupportedFieldsTest.java create mode 100644 compat-0.3/client/adapter-rest/src/test/java/org/a2aproject/sdk/compat03/client/adapter/rest/RestCompat03UnsupportedFieldsTest.java create mode 100644 compat-0.3/client/transport/rest/src/test/java/org/a2aproject/sdk/compat03/client/transport/rest/RestErrorMapper_v0_3_Test.java create mode 100644 compat-0.3/spec-grpc/src/test/java/org/a2aproject/sdk/compat03/grpc/utils/ProtoJsonData_v0_3_Test.java create mode 100644 compat-0.3/spec/src/test/java/org/a2aproject/sdk/compat03/json/JsonNullValues_v0_3_Test.java 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 index 74a2d0529..4b7e21127 100644 --- 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 @@ -39,22 +39,22 @@ public GrpcCompat03ClientTransport(GrpcTransport_v0_3 delegate, AgentCard card, @Override public EventKind sendMessage(MessageSendParams request, @Nullable ClientCallContext context) { - Compat03ClientTransportSupport.validateMessageSend(request); + Compat03ClientTransportSupport.validateProtobufMessageSend(request); PayloadAndHeaders payload = apply(A2AMethods.SEND_MESSAGE_METHOD, ProtoUtils.ToProto.sendMessageRequest(request), org.a2aproject.sdk.grpc.SendMessageRequest.class, context); return Compat03ClientTransportSupport.call(() -> Compat03ClientTransportSupport.toV10(delegate.sendMessage( - Compat03ClientTransportSupport.toV03(ProtoUtils.FromProto.messageSendParams(payloadPayload(payload))), + Compat03ClientTransportSupport.toV03Protobuf(ProtoUtils.FromProto.messageSendParams(payloadPayload(payload))), legacyContext(context, payload)))); } @Override public void sendMessageStreaming(MessageSendParams request, Consumer events, Consumer errors, @Nullable ClientCallContext context) { - Compat03ClientTransportSupport.validateMessageSend(request); + Compat03ClientTransportSupport.validateProtobufMessageSend(request); PayloadAndHeaders payload = apply(A2AMethods.SEND_STREAMING_MESSAGE_METHOD, ProtoUtils.ToProto.sendMessageRequest(request), org.a2aproject.sdk.grpc.SendMessageRequest.class, context); Compat03ClientTransportSupport.run(() -> delegate.sendMessageStreaming( - Compat03ClientTransportSupport.toV03(ProtoUtils.FromProto.messageSendParams(payloadPayload(payload))), + Compat03ClientTransportSupport.toV03Protobuf(ProtoUtils.FromProto.messageSendParams(payloadPayload(payload))), event -> events.accept(Compat03ClientTransportSupport.toV10(event)), Compat03ClientTransportSupport.mapAsyncError(errors), legacyContext(context, payload))); } @@ -71,11 +71,11 @@ public Task getTask(TaskQueryParams request, @Nullable ClientCallContext context @Override public Task cancelTask(CancelTaskParams request, @Nullable ClientCallContext context) { - Compat03ClientTransportSupport.validateCancel(request); + Compat03ClientTransportSupport.validateProtobufCancel(request); PayloadAndHeaders payload = apply(A2AMethods.CANCEL_TASK_METHOD, ProtoUtils.ToProto.cancelTaskRequest(request), org.a2aproject.sdk.grpc.CancelTaskRequest.class, context); return Compat03ClientTransportSupport.call(() -> TaskMapper_v0_3.INSTANCE.toV10(delegate.cancelTask( - Compat03ClientTransportSupport.toV03(ProtoUtils.FromProto.cancelTaskParams(cancelPayload(payload))), + Compat03ClientTransportSupport.toV03Protobuf(ProtoUtils.FromProto.cancelTaskParams(cancelPayload(payload))), legacyContext(context, payload)))); } diff --git a/compat-0.3/client/adapter-grpc/src/test/java/org/a2aproject/sdk/compat03/client/adapter/grpc/GrpcCompat03UnsupportedFieldsTest.java b/compat-0.3/client/adapter-grpc/src/test/java/org/a2aproject/sdk/compat03/client/adapter/grpc/GrpcCompat03UnsupportedFieldsTest.java new file mode 100644 index 000000000..de2cdad37 --- /dev/null +++ b/compat-0.3/client/adapter-grpc/src/test/java/org/a2aproject/sdk/compat03/client/adapter/grpc/GrpcCompat03UnsupportedFieldsTest.java @@ -0,0 +1,105 @@ +package org.a2aproject.sdk.compat03.client.adapter.grpc; + +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import java.util.Map; +import java.util.function.Consumer; + +import com.google.protobuf.Struct; +import com.google.protobuf.Value; +import io.grpc.ManagedChannel; +import io.grpc.inprocess.InProcessChannelBuilder; +import io.grpc.inprocess.InProcessServerBuilder; +import org.a2aproject.sdk.client.transport.spi.interceptors.ClientCallContext; +import org.a2aproject.sdk.client.transport.spi.interceptors.ClientCallInterceptor; +import org.a2aproject.sdk.client.transport.spi.interceptors.PayloadAndHeaders; +import org.a2aproject.sdk.compat03.client.adapter.Compat03ClientTransportBase; +import org.a2aproject.sdk.compat03.client.transport.grpc.GrpcTransport_v0_3; +import org.a2aproject.sdk.compat03.client.transport.spi.interceptors.ClientCallContext_v0_3; +import org.a2aproject.sdk.compat03.spec.EventKind_v0_3; +import org.a2aproject.sdk.compat03.spec.MessageSendParams_v0_3; +import org.a2aproject.sdk.compat03.spec.StreamingEventKind_v0_3; +import org.a2aproject.sdk.compat03.spec.TaskIdParams_v0_3; +import org.a2aproject.sdk.compat03.spec.Task_v0_3; +import org.a2aproject.sdk.spec.A2AClientException; +import org.a2aproject.sdk.spec.AgentCapabilities; +import org.a2aproject.sdk.spec.AgentCard; +import org.a2aproject.sdk.spec.AgentInterface; +import org.a2aproject.sdk.spec.CancelTaskParams; +import org.a2aproject.sdk.spec.Message; +import org.a2aproject.sdk.spec.MessageSendParams; +import org.a2aproject.sdk.spec.TextPart; +import org.a2aproject.sdk.spec.UnsupportedOperationError; +import org.jspecify.annotations.Nullable; +import org.junit.jupiter.api.Test; + +class GrpcCompat03UnsupportedFieldsTest { + @Test + void rejectsOriginalFieldsAndInterceptorMutationsBeforeDelegation() { + AgentCard card = AgentCard.builder().name("legacy").description("legacy").version("1") + .url("https://example.test").capabilities(AgentCapabilities.builder().build()) + .defaultInputModes(List.of("text")).defaultOutputModes(List.of("text")).skills(List.of()) + .supportedInterfaces(List.of(new AgentInterface("GRPC", "https://example.test", null, "0.3"))).build(); + ManagedChannel channel = InProcessChannelBuilder.forName(InProcessServerBuilder.generateName()) + .directExecutor().build(); + var delegate = new GrpcTransport_v0_3(channel, Compat03ClientTransportBase.legacyCard(card)) { + @Override + public EventKind_v0_3 sendMessage(MessageSendParams_v0_3 request, @Nullable ClientCallContext_v0_3 context) { + throw new AssertionError("Unsupported request reached the delegate"); + } + + @Override + public void sendMessageStreaming(MessageSendParams_v0_3 request, Consumer events, + Consumer errors, @Nullable ClientCallContext_v0_3 context) { + throw new AssertionError("Unsupported streaming request reached the delegate"); + } + + @Override + public Task_v0_3 cancelTask(TaskIdParams_v0_3 request, @Nullable ClientCallContext_v0_3 context) { + throw new AssertionError("Unsupported cancellation reached the delegate"); + } + }; + try { + for (boolean intercepted : List.of(false, true)) { + var transport = new GrpcCompat03ClientTransport(delegate, card, + intercepted ? List.of(unsupportedFieldsInterceptor()) : List.of()); + var request = new MessageSendParams(new Message(Message.Role.ROLE_USER, List.of(new TextPart("hello")), + "request", null, null, intercepted ? null : List.of("referenced-task"), null, null), null, null); + assertUnsupported("referenceTaskIds", () -> transport.sendMessage(request, null)); + assertUnsupported("referenceTaskIds", () -> transport.sendMessageStreaming(request, + event -> { }, error -> { throw new AssertionError(error); }, null)); + var cancel = new CancelTaskParams("task", null, intercepted ? Map.of() : Map.of("reason", "stop")); + assertUnsupported("metadata", () -> transport.cancelTask(cancel, null)); + } + } finally { + channel.shutdownNow(); + } + } + + private static void assertUnsupported(String field, Runnable call) { + A2AClientException error = assertThrows(A2AClientException.class, call::run); + assertInstanceOf(UnsupportedOperationError.class, error.getCause()); + assertTrue(error.getMessage().contains(field)); + } + + private static ClientCallInterceptor unsupportedFieldsInterceptor() { + return new ClientCallInterceptor() { + @Override + public PayloadAndHeaders intercept(String method, @Nullable Object payload, Map headers, + @Nullable AgentCard card, @Nullable ClientCallContext context) { + if (payload instanceof org.a2aproject.sdk.grpc.SendMessageRequest) { + var request = ((org.a2aproject.sdk.grpc.SendMessageRequest) payload).toBuilder(); + request.getMessageBuilder().addReferenceTaskIds("referenced-task"); + return new PayloadAndHeaders(request.build(), headers); + } + Struct metadata = Struct.newBuilder().putFields("reason", Value.newBuilder().setStringValue("stop").build()).build(); + var request = ((org.a2aproject.sdk.grpc.CancelTaskRequest) payload).toBuilder(); + request.setMetadata(metadata); + return new PayloadAndHeaders(request.build(), headers); + } + }; + } +} diff --git a/compat-0.3/client/adapter-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 index e26dd55b2..8a9c880d3 100644 --- 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 @@ -39,12 +39,12 @@ public RestCompat03ClientTransport(RestTransport_v0_3 delegate, AgentCard card, @Override public EventKind sendMessage(MessageSendParams request, @Nullable ClientCallContext context) { - Compat03ClientTransportSupport.validateMessageSend(request); + Compat03ClientTransportSupport.validateProtobufMessageSend(request); PayloadAndHeaders payload = apply(A2AMethods.SEND_MESSAGE_METHOD, org.a2aproject.sdk.grpc.SendMessageRequest.newBuilder(ProtoUtils.ToProto.sendMessageRequest(request)), org.a2aproject.sdk.grpc.SendMessageRequest.Builder.class, context); return Compat03ClientTransportSupport.call(() -> Compat03ClientTransportSupport.toV10(delegate.sendMessage( - Compat03ClientTransportSupport.toV03(ProtoUtils.FromProto.messageSendParams( + Compat03ClientTransportSupport.toV03Protobuf(ProtoUtils.FromProto.messageSendParams( ((org.a2aproject.sdk.grpc.SendMessageRequest.Builder) payload.getPayload()).build())), Compat03ClientCallContextMapper.toV03(contextWithHeaders(context, payload))))); } @@ -52,12 +52,12 @@ public EventKind sendMessage(MessageSendParams request, @Nullable ClientCallCont @Override public void sendMessageStreaming(MessageSendParams request, Consumer events, Consumer errors, @Nullable ClientCallContext context) { - Compat03ClientTransportSupport.validateMessageSend(request); + Compat03ClientTransportSupport.validateProtobufMessageSend(request); PayloadAndHeaders payload = apply(A2AMethods.SEND_STREAMING_MESSAGE_METHOD, org.a2aproject.sdk.grpc.SendMessageRequest.newBuilder(ProtoUtils.ToProto.sendMessageRequest(request)), org.a2aproject.sdk.grpc.SendMessageRequest.Builder.class, context); Compat03ClientTransportSupport.run(() -> delegate.sendMessageStreaming( - Compat03ClientTransportSupport.toV03(ProtoUtils.FromProto.messageSendParams( + Compat03ClientTransportSupport.toV03Protobuf(ProtoUtils.FromProto.messageSendParams( ((org.a2aproject.sdk.grpc.SendMessageRequest.Builder) payload.getPayload()).build())), event -> events.accept(Compat03ClientTransportSupport.toV10(event)), Compat03ClientTransportSupport.mapAsyncError(errors), @@ -78,12 +78,12 @@ public Task getTask(TaskQueryParams request, @Nullable ClientCallContext context @Override public Task cancelTask(CancelTaskParams request, @Nullable ClientCallContext context) { - Compat03ClientTransportSupport.validateCancel(request); + Compat03ClientTransportSupport.validateProtobufCancel(request); PayloadAndHeaders payload = apply(A2AMethods.CANCEL_TASK_METHOD, org.a2aproject.sdk.grpc.CancelTaskRequest.newBuilder(ProtoUtils.ToProto.cancelTaskRequest(request)), org.a2aproject.sdk.grpc.CancelTaskRequest.Builder.class, context); return Compat03ClientTransportSupport.call(() -> TaskMapper_v0_3.INSTANCE.toV10(delegate.cancelTask( - Compat03ClientTransportSupport.toV03(ProtoUtils.FromProto.cancelTaskParams( + Compat03ClientTransportSupport.toV03Protobuf(ProtoUtils.FromProto.cancelTaskParams( ((org.a2aproject.sdk.grpc.CancelTaskRequest.Builder) payload.getPayload()).build())), Compat03ClientCallContextMapper.toV03(contextWithHeaders(context, payload))))); } diff --git a/compat-0.3/client/adapter-rest/src/test/java/org/a2aproject/sdk/compat03/client/adapter/rest/RestCompat03UnsupportedFieldsTest.java b/compat-0.3/client/adapter-rest/src/test/java/org/a2aproject/sdk/compat03/client/adapter/rest/RestCompat03UnsupportedFieldsTest.java new file mode 100644 index 000000000..2e0493829 --- /dev/null +++ b/compat-0.3/client/adapter-rest/src/test/java/org/a2aproject/sdk/compat03/client/adapter/rest/RestCompat03UnsupportedFieldsTest.java @@ -0,0 +1,96 @@ +package org.a2aproject.sdk.compat03.client.adapter.rest; + +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import java.util.Map; +import java.util.function.Consumer; + +import com.google.protobuf.Struct; +import com.google.protobuf.Value; +import org.a2aproject.sdk.client.transport.spi.interceptors.ClientCallContext; +import org.a2aproject.sdk.client.transport.spi.interceptors.ClientCallInterceptor; +import org.a2aproject.sdk.client.transport.spi.interceptors.PayloadAndHeaders; +import org.a2aproject.sdk.compat03.client.adapter.Compat03ClientTransportBase; +import org.a2aproject.sdk.compat03.client.transport.rest.RestTransport_v0_3; +import org.a2aproject.sdk.compat03.client.transport.spi.interceptors.ClientCallContext_v0_3; +import org.a2aproject.sdk.compat03.spec.EventKind_v0_3; +import org.a2aproject.sdk.compat03.spec.MessageSendParams_v0_3; +import org.a2aproject.sdk.compat03.spec.StreamingEventKind_v0_3; +import org.a2aproject.sdk.compat03.spec.TaskIdParams_v0_3; +import org.a2aproject.sdk.compat03.spec.Task_v0_3; +import org.a2aproject.sdk.spec.A2AClientException; +import org.a2aproject.sdk.spec.AgentCapabilities; +import org.a2aproject.sdk.spec.AgentCard; +import org.a2aproject.sdk.spec.AgentInterface; +import org.a2aproject.sdk.spec.CancelTaskParams; +import org.a2aproject.sdk.spec.Message; +import org.a2aproject.sdk.spec.MessageSendParams; +import org.a2aproject.sdk.spec.TextPart; +import org.a2aproject.sdk.spec.UnsupportedOperationError; +import org.jspecify.annotations.Nullable; +import org.junit.jupiter.api.Test; + +class RestCompat03UnsupportedFieldsTest { + @Test + void rejectsOriginalFieldsAndInterceptorMutationsBeforeDelegation() { + AgentCard card = AgentCard.builder().name("legacy").description("legacy").version("1") + .url("https://example.test").capabilities(AgentCapabilities.builder().build()) + .defaultInputModes(List.of("text")).defaultOutputModes(List.of("text")).skills(List.of()) + .supportedInterfaces(List.of(new AgentInterface("HTTP+JSON", "https://example.test", null, "0.3"))).build(); + var delegate = new RestTransport_v0_3(null, Compat03ClientTransportBase.legacyCard(card), "https://example.test", List.of()) { + @Override + public EventKind_v0_3 sendMessage(MessageSendParams_v0_3 request, @Nullable ClientCallContext_v0_3 context) { + throw new AssertionError("Unsupported request reached the delegate"); + } + + @Override + public void sendMessageStreaming(MessageSendParams_v0_3 request, Consumer events, + Consumer errors, @Nullable ClientCallContext_v0_3 context) { + throw new AssertionError("Unsupported streaming request reached the delegate"); + } + + @Override + public Task_v0_3 cancelTask(TaskIdParams_v0_3 request, @Nullable ClientCallContext_v0_3 context) { + throw new AssertionError("Unsupported cancellation reached the delegate"); + } + }; + for (boolean intercepted : List.of(false, true)) { + var transport = new RestCompat03ClientTransport(delegate, card, + intercepted ? List.of(unsupportedFieldsInterceptor()) : List.of()); + var request = new MessageSendParams(new Message(Message.Role.ROLE_USER, List.of(new TextPart("hello")), + "request", null, null, intercepted ? null : List.of("referenced-task"), null, null), null, null); + assertUnsupported("referenceTaskIds", () -> transport.sendMessage(request, null)); + assertUnsupported("referenceTaskIds", () -> transport.sendMessageStreaming(request, + event -> { }, error -> { throw new AssertionError(error); }, null)); + var cancel = new CancelTaskParams("task", null, intercepted ? Map.of() : Map.of("reason", "stop")); + assertUnsupported("metadata", () -> transport.cancelTask(cancel, null)); + } + } + + private static void assertUnsupported(String field, Runnable call) { + A2AClientException error = assertThrows(A2AClientException.class, call::run); + assertInstanceOf(UnsupportedOperationError.class, error.getCause()); + assertTrue(error.getMessage().contains(field)); + } + + private static ClientCallInterceptor unsupportedFieldsInterceptor() { + return new ClientCallInterceptor() { + @Override + public PayloadAndHeaders intercept(String method, @Nullable Object payload, Map headers, + @Nullable AgentCard card, @Nullable ClientCallContext context) { + if (payload instanceof org.a2aproject.sdk.grpc.SendMessageRequest.Builder) { + var request = (org.a2aproject.sdk.grpc.SendMessageRequest.Builder) payload; + request.getMessageBuilder().addReferenceTaskIds("referenced-task"); + return new PayloadAndHeaders(request, headers); + } + Struct metadata = Struct.newBuilder().putFields("reason", Value.newBuilder().setStringValue("stop").build()).build(); + var request = (org.a2aproject.sdk.grpc.CancelTaskRequest.Builder) payload; + request.setMetadata(metadata); + return new PayloadAndHeaders(request, headers); + } + }; + } +} diff --git a/compat-0.3/client/adapter/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 index c65e598fd..ba30a8c55 100644 --- 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 @@ -1,12 +1,15 @@ package org.a2aproject.sdk.compat03.client.adapter; +import java.io.IOException; import java.util.LinkedHashMap; import java.util.Map; import org.a2aproject.sdk.compat03.spec.A2AClientException_v0_3; +import org.a2aproject.sdk.compat03.spec.A2AClientHTTPError_v0_3; import org.a2aproject.sdk.compat03.spec.A2AErrorCodes_v0_3; import org.a2aproject.sdk.compat03.spec.JSONRPCError_v0_3; import org.a2aproject.sdk.spec.A2AClientException; +import org.a2aproject.sdk.spec.A2AClientHTTPError; import org.a2aproject.sdk.spec.A2AError; import org.a2aproject.sdk.spec.ContentTypeNotSupportedError; import org.a2aproject.sdk.spec.ExtendedAgentCardNotConfiguredError; @@ -30,6 +33,13 @@ private Compat03ClientErrorMapper() { public static A2AClientException toV10(A2AClientException_v0_3 exception) { Throwable cause = exception.getCause(); String message = exception.getMessage() == null ? "A2A 0.3 client operation failed" : exception.getMessage(); + if (cause instanceof A2AClientHTTPError_v0_3 error) { + return new A2AClientException(message, new A2AClientHTTPError(error.getCode(), error.getMessage(), + error.getResponseBody(), error.getResponseHeaders())); + } + if (cause instanceof IOException && cause.getCause() instanceof A2AClientHTTPError error) { + return new A2AClientException(message, error); + } if (cause instanceof JSONRPCError_v0_3 error) { return new A2AClientException(message, toV10(error)); } 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 index a0e146541..69e1907c7 100644 --- 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 @@ -81,6 +81,21 @@ public static void validateMessageSend(MessageSendParams request) { } } + /** REST uses the same 0.3 protobuf schema as gRPC. */ + public static void validateProtobufMessageSend(MessageSendParams request) { + validateMessageSend(request); + if (request.message().referenceTaskIds() != null && !request.message().referenceTaskIds().isEmpty()) { + throw unsupported("sendMessage with referenceTaskIds over REST or gRPC"); + } + } + + public static void validateProtobufCancel(CancelTaskParams request) { + validateCancel(request); + if (request.metadata() != null && !request.metadata().isEmpty()) { + throw unsupported("cancelTask with metadata over REST or gRPC"); + } + } + public static void validateTaskQuery(TaskQueryParams request) { validateTenant("getTask", request.tenant()); if (Integer.valueOf(0).equals(request.historyLength())) { @@ -131,6 +146,16 @@ public static MessageSendParams_v0_3 toV03(MessageSendParams request) { return MessageSendParamsMapper_v0_3.INSTANCE.fromV10(request); } + public static MessageSendParams_v0_3 toV03Protobuf(MessageSendParams request) { + validateProtobufMessageSend(request); + return MessageSendParamsMapper_v0_3.INSTANCE.fromV10(request); + } + + public static TaskIdParams_v0_3 toV03Protobuf(CancelTaskParams request) { + validateProtobufCancel(request); + return CancelTaskParamsMapper_v0_3.INSTANCE.fromV10(request); + } + public static TaskQueryParams_v0_3 toV03(TaskQueryParams request) { validateTaskQuery(request); return TaskQueryParamsMapper_v0_3.INSTANCE.fromV10(request); 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 index d13ca641e..aec925158 100644 --- 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 @@ -3,14 +3,17 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import java.io.IOException; import java.util.List; import java.util.Map; import java.util.concurrent.atomic.AtomicReference; import org.a2aproject.sdk.compat03.spec.A2AClientException_v0_3; +import org.a2aproject.sdk.compat03.spec.A2AClientHTTPError_v0_3; import org.a2aproject.sdk.compat03.spec.JSONRPCError_v0_3; import org.a2aproject.sdk.compat03.spec.UnsupportedOperationError_v0_3; import org.a2aproject.sdk.spec.A2AClientException; +import org.a2aproject.sdk.spec.A2AClientHTTPError; import org.a2aproject.sdk.spec.A2AError; import org.a2aproject.sdk.spec.UnsupportedOperationError; import org.junit.jupiter.api.Test; @@ -27,6 +30,33 @@ void mapsUnsupportedOperationError() { assertInstanceOf(UnsupportedOperationError.class, mapped.getCause()); } + @Test + void exposesHttpErrorsWrappedByTheJdkClient() { + var httpError = new A2AClientHTTPError(401, "authentication failed", "unauthorized", + Map.of("WWW-Authenticate", List.of("Bearer"))); + var legacy = new A2AClientException_v0_3("getTask failed", new IOException("authentication failed", httpError)); + var exception = Compat03ClientErrorMapper.toV10(legacy); + var cause = assertInstanceOf(A2AClientHTTPError.class, exception.getCause()); + assertEquals(401, cause.getCode()); + assertEquals("unauthorized", cause.getResponseBody()); + assertEquals(List.of("Bearer"), cause.getResponseHeaders().get("www-authenticate")); + } + + @Test + void preservesHttpFailureDetailsForBlockingAndStreamingErrors() { + var legacy = new A2AClientException_v0_3("HTTP 429", new A2AClientHTTPError_v0_3( + 429, "HTTP 429", "rate limited", Map.of("Retry-After", List.of("30")))); + AtomicReference streaming = new AtomicReference<>(); + Compat03ClientTransportSupport.mapAsyncError(streaming::set).accept(legacy); + for (A2AClientException exception : List.of(Compat03ClientErrorMapper.toV10(legacy), + assertInstanceOf(A2AClientException.class, streaming.get()))) { + var cause = assertInstanceOf(A2AClientHTTPError.class, exception.getCause()); + assertEquals(429, cause.getCode()); + assertEquals("rate limited", cause.getResponseBody()); + assertEquals(List.of("30"), cause.getResponseHeaders().get("retry-after")); + } + } + @Test void preservesScalarAndArrayDataForSynchronousAndStreamingErrors() { for (Object data : List.of("missingScope", 42, true, List.of("missingScope", "write"))) { 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 index 1386a81b4..7f23a6515 100644 --- 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 @@ -75,6 +75,43 @@ void preservesObjectDataParts() { assertEquals(Map.of("key", "value"), data.data()); } + @Test + void preservesNullValuedDataAndMetadataAcrossVersionConversion() { + Map values = new java.util.LinkedHashMap<>(); + values.put("nil", null); + MessageSendParams request = new MessageSendParams( + new Message(Message.Role.ROLE_USER, List.of(new DataPart(values, values)), "message", + null, null, null, values, null), null, values); + var legacy = Compat03ClientTransportSupport.toV03(request); + var data = assertInstanceOf(org.a2aproject.sdk.compat03.spec.DataPart_v0_3.class, + legacy.message().parts().get(0)); + assertEquals(values, data.data()); + assertEquals(values, data.metadata()); + assertEquals(values, legacy.message().metadata()); + assertEquals(values, legacy.metadata()); + assertEquals(request.message(), Compat03ClientTransportSupport.toV10(legacy.message())); + } + + @Test + void rejectsFieldsMissingFromLegacyProtobufButPreservesThemForJsonRpc() { + MessageSendParams request = new MessageSendParams( + new Message(Message.Role.ROLE_USER, List.of(new TextPart("hello")), "message", + null, null, List.of("referenced-task"), null, null), null, null); + assertEquals(List.of("referenced-task"), Compat03ClientTransportSupport.toV03(request).message().referenceTaskIds()); + A2AClientException send = assertThrows(A2AClientException.class, + () -> Compat03ClientTransportSupport.toV03Protobuf(request)); + assertInstanceOf(UnsupportedOperationError.class, send.getCause()); + assertTrue(send.getMessage().contains("referenceTaskIds")); + + CancelTaskParams cancel = new CancelTaskParams("task", null, Map.of("reason", "stop")); + assertEquals(cancel.metadata(), Compat03ClientTransportSupport.toV03(cancel).metadata()); + A2AClientException cancellation = assertThrows(A2AClientException.class, + () -> Compat03ClientTransportSupport.toV03Protobuf(cancel)); + assertInstanceOf(UnsupportedOperationError.class, cancellation.getCause()); + assertTrue(cancellation.getMessage().contains("metadata")); + Compat03ClientTransportSupport.toV03Protobuf(new CancelTaskParams("task", null, Map.of())); + } + @Test void acceptsDefaultPushListAndReturnsNoPageToken() { var result = Compat03ClientTransportSupport.toV10PushList(java.util.List.of()); diff --git a/compat-0.3/client/transport/rest/src/main/java/org/a2aproject/sdk/compat03/client/transport/rest/RestErrorMapper_v0_3.java b/compat-0.3/client/transport/rest/src/main/java/org/a2aproject/sdk/compat03/client/transport/rest/RestErrorMapper_v0_3.java index d9b6d1368..601c78978 100644 --- a/compat-0.3/client/transport/rest/src/main/java/org/a2aproject/sdk/compat03/client/transport/rest/RestErrorMapper_v0_3.java +++ b/compat-0.3/client/transport/rest/src/main/java/org/a2aproject/sdk/compat03/client/transport/rest/RestErrorMapper_v0_3.java @@ -1,11 +1,15 @@ package org.a2aproject.sdk.compat03.client.transport.rest; +import java.util.List; +import java.util.Map; + import com.google.gson.JsonElement; import com.google.gson.JsonObject; +import org.a2aproject.sdk.client.http.A2AHttpResponse; import org.a2aproject.sdk.compat03.json.JsonProcessingException_v0_3; import org.a2aproject.sdk.compat03.json.JsonUtil_v0_3; -import org.a2aproject.sdk.client.http.A2AHttpResponse; import org.a2aproject.sdk.compat03.spec.A2AClientException_v0_3; +import org.a2aproject.sdk.compat03.spec.A2AClientHTTPError_v0_3; import org.a2aproject.sdk.compat03.spec.AuthenticatedExtendedCardNotConfiguredError_v0_3; import org.a2aproject.sdk.compat03.spec.ContentTypeNotSupportedError_v0_3; import org.a2aproject.sdk.compat03.spec.InternalError_v0_3; @@ -18,8 +22,6 @@ import org.a2aproject.sdk.compat03.spec.TaskNotCancelableError_v0_3; import org.a2aproject.sdk.compat03.spec.TaskNotFoundError_v0_3; import org.a2aproject.sdk.compat03.spec.UnsupportedOperationError_v0_3; -import java.util.logging.Level; -import java.util.logging.Logger; /** * Utility class to A2AHttpResponse to appropriate A2A error types @@ -27,22 +29,35 @@ public class RestErrorMapper_v0_3 { public static A2AClientException_v0_3 mapRestError(A2AHttpResponse response) { - return RestErrorMapper_v0_3.mapRestError(response.body(), response.status()); + return mapRestError(response.body(), response.status(), response.headers().toMap()); } public static A2AClientException_v0_3 mapRestError(String body, int code) { + return mapRestError(body, code, Map.of()); + } + + private static A2AClientException_v0_3 mapRestError(String body, int code, Map> headers) { try { if (body != null && !body.isBlank()) { - JsonObject node = JsonUtil_v0_3.fromJson(body, JsonObject.class); - String className = safeGetString(node, "error"); - String errorMessage = safeGetString(node, "message"); - return mapRestError(className, errorMessage, code); + JsonElement node = JsonUtil_v0_3.fromJson(body, JsonElement.class); + if (node != null && node.isJsonObject()) { + String className = safeGetString(node.getAsJsonObject(), "error"); + String errorMessage = safeGetString(node.getAsJsonObject(), "message"); + A2AClientException_v0_3 mapped = mapRestError(className, errorMessage, code); + if (!(mapped.getCause() instanceof A2AClientHTTPError_v0_3)) { + return mapped; + } + } } - return mapRestError("", "", code); - } catch (JsonProcessingException_v0_3 ex) { - Logger.getLogger(RestErrorMapper_v0_3.class.getName()).log(Level.SEVERE, null, ex); - return new A2AClientException_v0_3("Failed to parse error response: " + ex.getMessage()); + } catch (JsonProcessingException_v0_3 ignored) { + // A non-JSON error body is still an HTTP failure with useful response details. } + return httpError(body, code, headers); + } + + private static A2AClientException_v0_3 httpError(String body, int code, Map> headers) { + String message = "HTTP " + code + (body == null || body.isBlank() ? "" : ": " + body); + return new A2AClientException_v0_3(message, new A2AClientHTTPError_v0_3(code, message, body, headers)); } private static String safeGetString(JsonObject obj, String fieldName) { @@ -69,7 +84,7 @@ public static A2AClientException_v0_3 mapRestError(String className, String erro case "org.a2aproject.sdk.compat03.spec.PushNotificationNotSupportedError_v0_3" -> new A2AClientException_v0_3(errorMessage, new PushNotificationNotSupportedError_v0_3()); case "org.a2aproject.sdk.compat03.spec.TaskNotCancelableError_v0_3" -> new A2AClientException_v0_3(errorMessage, new TaskNotCancelableError_v0_3()); case "org.a2aproject.sdk.compat03.spec.UnsupportedOperationError_v0_3" -> new A2AClientException_v0_3(errorMessage, new UnsupportedOperationError_v0_3()); - default -> new A2AClientException_v0_3(errorMessage); + default -> httpError(errorMessage, code, Map.of()); }; } } diff --git a/compat-0.3/client/transport/rest/src/main/java/org/a2aproject/sdk/compat03/client/transport/rest/RestTransport_v0_3.java b/compat-0.3/client/transport/rest/src/main/java/org/a2aproject/sdk/compat03/client/transport/rest/RestTransport_v0_3.java index 7a8e6a1ec..62500921e 100644 --- a/compat-0.3/client/transport/rest/src/main/java/org/a2aproject/sdk/compat03/client/transport/rest/RestTransport_v0_3.java +++ b/compat-0.3/client/transport/rest/src/main/java/org/a2aproject/sdk/compat03/client/transport/rest/RestTransport_v0_3.java @@ -2,14 +2,24 @@ import static org.a2aproject.sdk.util.Assert.checkNotNullParam; -import org.a2aproject.sdk.compat03.json.JsonProcessingException_v0_3; +import java.io.IOException; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Consumer; +import java.util.logging.Level; +import java.util.logging.Logger; + import com.google.protobuf.InvalidProtocolBufferException; import com.google.protobuf.MessageOrBuilder; import com.google.protobuf.util.JsonFormat; import org.a2aproject.sdk.client.http.A2AHttpClient; import org.a2aproject.sdk.client.http.A2AHttpClientFactory; import org.a2aproject.sdk.client.http.A2AHttpResponse; -import org.a2aproject.sdk.client.http.ServerSentEvent; import org.a2aproject.sdk.compat03.client.http.A2ACardResolver_v0_3; import org.a2aproject.sdk.compat03.client.transport.rest.sse.RestSSEEventListener_v0_3; import org.a2aproject.sdk.compat03.client.transport.spi.ClientTransport_v0_3; @@ -18,44 +28,37 @@ import org.a2aproject.sdk.compat03.client.transport.spi.interceptors.PayloadAndHeaders_v0_3; import org.a2aproject.sdk.compat03.grpc.CancelTaskRequest; import org.a2aproject.sdk.compat03.grpc.CreateTaskPushNotificationConfigRequest; +import org.a2aproject.sdk.compat03.grpc.CreateTaskPushNotificationConfigRequestOrBuilder; import org.a2aproject.sdk.compat03.grpc.GetTaskPushNotificationConfigRequest; import org.a2aproject.sdk.compat03.grpc.GetTaskRequest; import org.a2aproject.sdk.compat03.grpc.ListTaskPushNotificationConfigRequest; -import org.a2aproject.sdk.compat03.spec.CancelTaskRequest_v0_3; -import org.a2aproject.sdk.compat03.spec.DeleteTaskPushNotificationConfigRequest_v0_3; -import org.a2aproject.sdk.compat03.spec.GetTaskPushNotificationConfigRequest_v0_3; -import org.a2aproject.sdk.compat03.spec.GetTaskRequest_v0_3; -import org.a2aproject.sdk.compat03.spec.ListTaskPushNotificationConfigRequest_v0_3; -import org.a2aproject.sdk.compat03.spec.SendMessageRequest_v0_3; -import org.a2aproject.sdk.compat03.spec.TaskPushNotificationConfig_v0_3; +import org.a2aproject.sdk.compat03.grpc.utils.ProtoJsonUtils_v0_3; +import org.a2aproject.sdk.compat03.grpc.utils.ProtoUtils_v0_3; +import org.a2aproject.sdk.compat03.json.JsonProcessingException_v0_3; +import org.a2aproject.sdk.compat03.json.JsonUtil_v0_3; +import org.a2aproject.sdk.compat03.spec.A2AClientError_v0_3; import org.a2aproject.sdk.compat03.spec.A2AClientException_v0_3; import org.a2aproject.sdk.compat03.spec.AgentCard_v0_3; +import org.a2aproject.sdk.compat03.spec.CancelTaskRequest_v0_3; import org.a2aproject.sdk.compat03.spec.DeleteTaskPushNotificationConfigParams_v0_3; +import org.a2aproject.sdk.compat03.spec.DeleteTaskPushNotificationConfigRequest_v0_3; import org.a2aproject.sdk.compat03.spec.EventKind_v0_3; import org.a2aproject.sdk.compat03.spec.GetAuthenticatedExtendedCardRequest_v0_3; import org.a2aproject.sdk.compat03.spec.GetTaskPushNotificationConfigParams_v0_3; +import org.a2aproject.sdk.compat03.spec.GetTaskPushNotificationConfigRequest_v0_3; +import org.a2aproject.sdk.compat03.spec.GetTaskRequest_v0_3; import org.a2aproject.sdk.compat03.spec.ListTaskPushNotificationConfigParams_v0_3; +import org.a2aproject.sdk.compat03.spec.ListTaskPushNotificationConfigRequest_v0_3; import org.a2aproject.sdk.compat03.spec.MessageSendParams_v0_3; +import org.a2aproject.sdk.compat03.spec.SendMessageRequest_v0_3; +import org.a2aproject.sdk.compat03.spec.SendStreamingMessageRequest_v0_3; +import org.a2aproject.sdk.compat03.spec.SetTaskPushNotificationConfigRequest_v0_3; import org.a2aproject.sdk.compat03.spec.StreamingEventKind_v0_3; -import org.a2aproject.sdk.compat03.spec.TaskResubscriptionRequest_v0_3; -import org.a2aproject.sdk.compat03.spec.Task_v0_3; import org.a2aproject.sdk.compat03.spec.TaskIdParams_v0_3; +import org.a2aproject.sdk.compat03.spec.TaskPushNotificationConfig_v0_3; import org.a2aproject.sdk.compat03.spec.TaskQueryParams_v0_3; -import org.a2aproject.sdk.compat03.grpc.utils.ProtoJsonUtils_v0_3; -import org.a2aproject.sdk.compat03.grpc.utils.ProtoUtils_v0_3; -import org.a2aproject.sdk.compat03.spec.A2AClientError_v0_3; -import org.a2aproject.sdk.compat03.spec.SendStreamingMessageRequest_v0_3; -import org.a2aproject.sdk.compat03.spec.SetTaskPushNotificationConfigRequest_v0_3; -import org.a2aproject.sdk.compat03.json.JsonUtil_v0_3; -import java.io.IOException; -import java.util.Collections; -import java.util.List; -import java.util.logging.Level; -import java.util.logging.Logger; -import java.util.Map; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.atomic.AtomicReference; -import java.util.function.Consumer; +import org.a2aproject.sdk.compat03.spec.TaskResubscriptionRequest_v0_3; +import org.a2aproject.sdk.compat03.spec.Task_v0_3; import org.a2aproject.sdk.grpc.utils.ProtoJsonUtils; import org.jspecify.annotations.Nullable; @@ -194,8 +197,14 @@ public TaskPushNotificationConfig_v0_3 setTaskPushNotificationConfiguration(Task builder.setConfigId(request.pushNotificationConfig().id()); } PayloadAndHeaders_v0_3 payloadAndHeaders = applyInterceptors(SetTaskPushNotificationConfigRequest_v0_3.METHOD, builder, agentCard, context); + CreateTaskPushNotificationConfigRequestOrBuilder payload = + checkNotNullParam("payload", (CreateTaskPushNotificationConfigRequestOrBuilder) payloadAndHeaders.getPayload()); + PayloadAndHeaders_v0_3 configBody = new PayloadAndHeaders_v0_3(payload.getConfig(), payloadAndHeaders.getHeaders()); + String configId = payload.getConfigId().isEmpty() ? request.taskId() : payload.getConfigId(); + String url = agentUrl + String.format("/v1/tasks/%1s/pushNotificationConfigs", request.taskId()) + + "?configId=" + URLEncoder.encode(configId, StandardCharsets.UTF_8); try { - String httpResponseBody = sendPostRequest(agentUrl + String.format("/v1/tasks/%1s/pushNotificationConfigs", request.taskId()), payloadAndHeaders); + String httpResponseBody = sendPostRequest(url, configBody); org.a2aproject.sdk.compat03.grpc.TaskPushNotificationConfig.Builder responseBuilder = org.a2aproject.sdk.compat03.grpc.TaskPushNotificationConfig.newBuilder(); JsonFormat.parser().merge(httpResponseBody, responseBuilder); return ProtoUtils_v0_3.FromProto.taskPushNotificationConfig(responseBuilder); @@ -305,8 +314,12 @@ public void resubscribe(TaskIdParams_v0_3 request, Consumer sseEventListener.onMessage(event.data(), ref.get()), throwable -> sseEventListener.onError(throwable, ref.get()), () -> { @@ -316,8 +329,6 @@ public void resubscribe(TaskIdParams_v0_3 request, Consumerrate limited", "null", "[]", "{\"message\":\"rate limited\"}", + "{\"error\":\"UNKNOWN\",\"message\":\"rate limited\"}")) { + var exception = RestErrorMapper_v0_3.mapRestError(response(429, body, + Map.of("Retry-After", List.of("30"), "X-Request-Id", List.of("request")))); + var cause = assertInstanceOf(A2AClientHTTPError_v0_3.class, exception.getCause()); + assertEquals(429, cause.getCode()); + assertEquals(body, cause.getResponseBody()); + assertEquals(List.of("30"), cause.getResponseHeaders().get("retry-after")); + assertTrue(exception.getMessage().contains("429")); + } + var unauthorized = RestErrorMapper_v0_3.mapRestError(response(401, "", + Map.of("WWW-Authenticate", List.of("Bearer")))); + var cause = assertInstanceOf(A2AClientHTTPError_v0_3.class, unauthorized.getCause()); + assertEquals(401, cause.getCode()); + assertEquals(List.of("Bearer"), cause.getResponseHeaders().get("www-authenticate")); + } + + @Test + void retainsRecognizedProtocolErrors() { + var exception = RestErrorMapper_v0_3.mapRestError(response(404, + "{\"error\":\"org.a2aproject.sdk.compat03.spec.TaskNotFoundError_v0_3\",\"message\":\"missing\"}", Map.of())); + assertInstanceOf(TaskNotFoundError_v0_3.class, exception.getCause()); + assertEquals("missing", exception.getMessage()); + } + + @Test + void bodyAndClassNameOverloadsRetainHttpStatus() { + assertEquals(503, assertInstanceOf(A2AClientHTTPError_v0_3.class, + RestErrorMapper_v0_3.mapRestError("unavailable", 503).getCause()).getCode()); + assertEquals(401, assertInstanceOf(A2AClientHTTPError_v0_3.class, + RestErrorMapper_v0_3.mapRestError("unknown", "unauthorized", 401).getCause()).getCode()); + } + + private static A2AHttpResponse response(int status, String body, Map> headers) { + return new A2AHttpResponse() { + @Override public int status() { return status; } + @Override public boolean success() { return false; } + @Override public String body() { return body; } + @Override public A2AHttpHeaders headers() { return A2AHttpHeaders.of(headers); } + }; + } +} diff --git a/compat-0.3/client/transport/rest/src/test/java/org/a2aproject/sdk/compat03/client/transport/rest/RestTransport_v0_3_Test.java b/compat-0.3/client/transport/rest/src/test/java/org/a2aproject/sdk/compat03/client/transport/rest/RestTransport_v0_3_Test.java index af841eac6..6b88ff023 100644 --- a/compat-0.3/client/transport/rest/src/test/java/org/a2aproject/sdk/compat03/client/transport/rest/RestTransport_v0_3_Test.java +++ b/compat-0.3/client/transport/rest/src/test/java/org/a2aproject/sdk/compat03/client/transport/rest/RestTransport_v0_3_Test.java @@ -6,10 +6,10 @@ import static org.a2aproject.sdk.compat03.client.transport.rest.JsonRestMessages_v0_3.GET_TASK_PUSH_NOTIFICATION_CONFIG_TEST_RESPONSE; import static org.a2aproject.sdk.compat03.client.transport.rest.JsonRestMessages_v0_3.GET_TASK_TEST_RESPONSE; import static org.a2aproject.sdk.compat03.client.transport.rest.JsonRestMessages_v0_3.LIST_TASK_PUSH_NOTIFICATION_CONFIG_TEST_RESPONSE; +import static org.a2aproject.sdk.compat03.client.transport.rest.JsonRestMessages_v0_3.SEND_MESSAGE_STREAMING_TEST_REQUEST; import static org.a2aproject.sdk.compat03.client.transport.rest.JsonRestMessages_v0_3.SEND_MESSAGE_STREAMING_TEST_RESPONSE; import static org.a2aproject.sdk.compat03.client.transport.rest.JsonRestMessages_v0_3.SEND_MESSAGE_TEST_REQUEST; import static org.a2aproject.sdk.compat03.client.transport.rest.JsonRestMessages_v0_3.SEND_MESSAGE_TEST_RESPONSE; -import static org.a2aproject.sdk.compat03.client.transport.rest.JsonRestMessages_v0_3.SEND_MESSAGE_STREAMING_TEST_REQUEST; import static org.a2aproject.sdk.compat03.client.transport.rest.JsonRestMessages_v0_3.SET_TASK_PUSH_NOTIFICATION_CONFIG_TEST_REQUEST; import static org.a2aproject.sdk.compat03.client.transport.rest.JsonRestMessages_v0_3.SET_TASK_PUSH_NOTIFICATION_CONFIG_TEST_RESPONSE; import static org.a2aproject.sdk.compat03.client.transport.rest.JsonRestMessages_v0_3.TASK_RESUBSCRIPTION_REQUEST_TEST_RESPONSE; @@ -17,11 +17,25 @@ import static org.junit.jupiter.api.Assertions.assertInstanceOf; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockserver.model.HttpRequest.request; import static org.mockserver.model.HttpResponse.response; +import java.io.IOException; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Consumer; +import java.util.logging.Logger; + import org.a2aproject.sdk.compat03.client.transport.spi.interceptors.ClientCallContext_v0_3; +import org.a2aproject.sdk.compat03.client.transport.spi.interceptors.ClientCallInterceptor_v0_3; +import org.a2aproject.sdk.compat03.client.transport.spi.interceptors.PayloadAndHeaders_v0_3; +import org.a2aproject.sdk.compat03.spec.A2AClientException_v0_3; import org.a2aproject.sdk.compat03.spec.AgentCapabilities_v0_3; import org.a2aproject.sdk.compat03.spec.AgentCard_v0_3; import org.a2aproject.sdk.compat03.spec.AgentSkill_v0_3; @@ -33,29 +47,21 @@ import org.a2aproject.sdk.compat03.spec.FileWithUri_v0_3; import org.a2aproject.sdk.compat03.spec.GetTaskPushNotificationConfigParams_v0_3; import org.a2aproject.sdk.compat03.spec.ListTaskPushNotificationConfigParams_v0_3; -import org.a2aproject.sdk.compat03.spec.Message_v0_3; import org.a2aproject.sdk.compat03.spec.MessageSendConfiguration_v0_3; import org.a2aproject.sdk.compat03.spec.MessageSendParams_v0_3; -import org.a2aproject.sdk.compat03.spec.Part_v0_3; +import org.a2aproject.sdk.compat03.spec.Message_v0_3; import org.a2aproject.sdk.compat03.spec.Part_v0_3.Kind; +import org.a2aproject.sdk.compat03.spec.Part_v0_3; import org.a2aproject.sdk.compat03.spec.PushNotificationAuthenticationInfo_v0_3; import org.a2aproject.sdk.compat03.spec.PushNotificationConfig_v0_3; import org.a2aproject.sdk.compat03.spec.StreamingEventKind_v0_3; -import org.a2aproject.sdk.compat03.spec.Task_v0_3; import org.a2aproject.sdk.compat03.spec.TaskIdParams_v0_3; import org.a2aproject.sdk.compat03.spec.TaskPushNotificationConfig_v0_3; import org.a2aproject.sdk.compat03.spec.TaskQueryParams_v0_3; import org.a2aproject.sdk.compat03.spec.TaskState_v0_3; +import org.a2aproject.sdk.compat03.spec.Task_v0_3; import org.a2aproject.sdk.compat03.spec.TextPart_v0_3; -import java.io.IOException; -import java.util.Collections; -import java.util.HashMap; -import java.util.List; -import java.util.concurrent.CountDownLatch; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicReference; -import java.util.function.Consumer; -import java.util.logging.Logger; +import org.a2aproject.sdk.spec.A2AClientHTTPError; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -293,7 +299,8 @@ public void testSetTaskPushNotificationConfiguration() throws Exception { request() .withMethod("POST") .withPath("/v1/tasks/de38c76d-d54c-436c-8b9f-4c2703648d64/pushNotificationConfigs") - .withBody(JsonBody.json(SET_TASK_PUSH_NOTIFICATION_CONFIG_TEST_REQUEST, MatchType.ONLY_MATCHING_FIELDS)) + .withQueryStringParameter("configId", "de38c76d-d54c-436c-8b9f-4c2703648d64") + .withBody(JsonBody.json(SET_TASK_PUSH_NOTIFICATION_CONFIG_TEST_REQUEST, MatchType.STRICT)) ) .respond( response() @@ -401,6 +408,49 @@ public void testDeleteTaskPushNotificationConfigurations() throws Exception { instance.deleteTaskPushNotificationConfigurations(new DeleteTaskPushNotificationConfigParams_v0_3("de38c76d-d54c-436c-8b9f-4c2703648d64", "10"), context); } + @Test + public void testJdkAuthenticationFailuresRetainResponseDetails() { + for (int code : List.of(401, 403)) { + String taskId = "auth-" + code; + server.when(request().withMethod("GET").withPath("/v1/tasks/" + taskId)) + .respond(response().withStatusCode(code).withBody("unauthorized") + .withHeader("WWW-Authenticate", "Bearer")); + var client = new RestTransport_v0_3(CARD); + var exception = assertThrows(A2AClientException_v0_3.class, + () -> client.getTask(new TaskQueryParams_v0_3(taskId), null)); + var io = assertInstanceOf(IOException.class, exception.getCause()); + var cause = assertInstanceOf(A2AClientHTTPError.class, io.getCause()); + assertEquals(code, cause.getCode()); + assertEquals("unauthorized", cause.getResponseBody()); + assertEquals(List.of("Bearer"), cause.getResponseHeaders().get("www-authenticate")); + } + } + + @Test + public void testSetPushConfigPreservesIdAndImmutableInterceptorPayload() throws Exception { + String body = """ + {"name":"tasks/task/pushNotificationConfigs/config-1", + "pushNotificationConfig":{"id":"config-1","url":"https://example.com/callback"}} + """; + server.when(request().withMethod("POST").withPath("/v1/tasks/task/pushNotificationConfigs") + .withQueryStringParameter("configId", "config-1") + .withHeader("X-Trace", "trace") + .withBody(JsonBody.json(body, MatchType.STRICT))) + .respond(response().withStatusCode(201).withBody(body)); + ClientCallInterceptor_v0_3 interceptor = new ClientCallInterceptor_v0_3() { + @Override + public PayloadAndHeaders_v0_3 intercept(String method, Object payload, java.util.Map headers, + AgentCard_v0_3 card, ClientCallContext_v0_3 context) { + var builder = (org.a2aproject.sdk.compat03.grpc.CreateTaskPushNotificationConfigRequest.Builder) payload; + return new PayloadAndHeaders_v0_3(builder.build(), java.util.Map.of("X-Trace", "trace")); + } + }; + var client = new RestTransport_v0_3(null, CARD, CARD.url(), List.of(interceptor)); + var result = client.setTaskPushNotificationConfiguration(new TaskPushNotificationConfig_v0_3("task", + new PushNotificationConfig_v0_3.Builder().id("config-1").url("https://example.com/callback").build()), null); + assertEquals("config-1", result.pushNotificationConfig().id()); + } + /** * Test of resubscribe method, of class JSONRestTransport. */ @@ -408,9 +458,11 @@ public void testDeleteTaskPushNotificationConfigurations() throws Exception { public void testResubscribe() throws Exception { LOGGER.info("Testing resubscribe"); + this.server.when(request().withMethod("POST").withPath("/v1/tasks/task-1234:subscribe")) + .respond(response().withStatusCode(405)); this.server.when( request() - .withMethod("POST") + .withMethod("GET") .withPath("/v1/tasks/task-1234:subscribe") ) .respond( @@ -429,10 +481,15 @@ public void testResubscribe() throws Exception { receivedEvent.set(event); latch.countDown(); }; - Consumer errorHandler = error -> {}; + AtomicReference failure = new AtomicReference<>(); + Consumer errorHandler = error -> { + failure.set(error); + latch.countDown(); + }; client.resubscribe(taskIdParams, eventHandler, errorHandler, null); boolean eventReceived = latch.await(10, TimeUnit.SECONDS); + assertNull(failure.get()); assertTrue(eventReceived); StreamingEventKind_v0_3 eventKind = receivedEvent.get();; diff --git a/compat-0.3/reference/rest/src/main/java/org/a2aproject/sdk/compat03/server/rest/quarkus/A2AServerRoutes_v0_3.java b/compat-0.3/reference/rest/src/main/java/org/a2aproject/sdk/compat03/server/rest/quarkus/A2AServerRoutes_v0_3.java index a22b0de93..9c3e98109 100644 --- a/compat-0.3/reference/rest/src/main/java/org/a2aproject/sdk/compat03/server/rest/quarkus/A2AServerRoutes_v0_3.java +++ b/compat-0.3/reference/rest/src/main/java/org/a2aproject/sdk/compat03/server/rest/quarkus/A2AServerRoutes_v0_3.java @@ -12,12 +12,6 @@ import java.util.concurrent.atomic.AtomicLong; import java.util.function.Consumer; -import jakarta.annotation.Priority; -import jakarta.enterprise.event.Observes; -import jakarta.enterprise.inject.Instance; -import jakarta.inject.Inject; -import jakarta.inject.Singleton; - import io.quarkus.security.Authenticated; import io.quarkus.security.ForbiddenException; import io.quarkus.security.UnauthorizedException; @@ -26,6 +20,11 @@ import io.vertx.ext.web.Router; import io.vertx.ext.web.RoutingContext; import io.vertx.ext.web.handler.BodyHandler; +import jakarta.annotation.Priority; +import jakarta.enterprise.event.Observes; +import jakarta.enterprise.inject.Instance; +import jakarta.inject.Inject; +import jakarta.inject.Singleton; import org.a2aproject.sdk.compat03.common.A2AHeaders_v0_3; import org.a2aproject.sdk.compat03.conversion.A2AProtocol_v0_3; import org.a2aproject.sdk.compat03.spec.CancelTaskRequest_v0_3; @@ -40,9 +39,9 @@ import org.a2aproject.sdk.compat03.spec.SendStreamingMessageRequest_v0_3; import org.a2aproject.sdk.compat03.spec.SetTaskPushNotificationConfigRequest_v0_3; import org.a2aproject.sdk.compat03.spec.TaskResubscriptionRequest_v0_3; -import org.a2aproject.sdk.compat03.transport.rest.handler.RestHandler_v0_3; import org.a2aproject.sdk.compat03.transport.rest.handler.RestHandler_v0_3.HTTPRestResponse; import org.a2aproject.sdk.compat03.transport.rest.handler.RestHandler_v0_3.HTTPRestStreamingResponse; +import org.a2aproject.sdk.compat03.transport.rest.handler.RestHandler_v0_3; import org.a2aproject.sdk.server.PublicAgentCard; import org.a2aproject.sdk.server.ServerCallContext; import org.a2aproject.sdk.server.auth.AuthenticatedUser; @@ -95,6 +94,11 @@ void setupRouter(@Observes @Priority(10) Router router) { .order(1) .blockingHandler(authenticated(this::cancelTask), false); + // GET /v1/tasks/{id}:subscribe (0.3 HTTP binding) + router.getWithRegex("^\\/v1\\/tasks\\/([^/]+):subscribe$") + .order(0) + .blockingHandler(authenticatedStreaming(this::resubscribeTask), false); + // POST /v1/tasks/{id}:subscribe router.postWithRegex("^\\/v1\\/tasks\\/([^/]+):subscribe$") .order(1) @@ -308,7 +312,7 @@ public void setTaskPushNotificationConfiguration(String body, RoutingContext rc) if (taskId == null || taskId.isEmpty()) { response = jsonRestHandler.createErrorResponse(new InvalidParamsError_v0_3("bad task id")); } else { - response = jsonRestHandler.setTaskPushNotificationConfiguration(taskId, body, context); + response = jsonRestHandler.setTaskPushNotificationConfiguration(taskId, body, rc.request().getParam("configId"), context); } } catch (Throwable t) { response = jsonRestHandler.createErrorResponse(new InternalError_v0_3(t.getMessage())); diff --git a/compat-0.3/spec-grpc/src/main/java/org/a2aproject/sdk/compat03/grpc/utils/ProtoUtils_v0_3.java b/compat-0.3/spec-grpc/src/main/java/org/a2aproject/sdk/compat03/grpc/utils/ProtoUtils_v0_3.java index 7a4e6f284..42c24c0a9 100644 --- a/compat-0.3/spec-grpc/src/main/java/org/a2aproject/sdk/compat03/grpc/utils/ProtoUtils_v0_3.java +++ b/compat-0.3/spec-grpc/src/main/java/org/a2aproject/sdk/compat03/grpc/utils/ProtoUtils_v0_3.java @@ -1,23 +1,25 @@ package org.a2aproject.sdk.compat03.grpc.utils; -import java.nio.charset.StandardCharsets; import java.time.Instant; import java.time.OffsetDateTime; import java.time.ZoneOffset; import java.util.ArrayList; +import java.util.Base64; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.stream.Collectors; import com.google.protobuf.ByteString; +import com.google.protobuf.NullValue; import com.google.protobuf.Struct; import com.google.protobuf.Value; import org.a2aproject.sdk.compat03.grpc.StreamResponse; import org.a2aproject.sdk.compat03.spec.APIKeySecurityScheme_v0_3; import org.a2aproject.sdk.compat03.spec.AgentCapabilities_v0_3; -import org.a2aproject.sdk.compat03.spec.AgentCard_v0_3; import org.a2aproject.sdk.compat03.spec.AgentCardSignature_v0_3; +import org.a2aproject.sdk.compat03.spec.AgentCard_v0_3; import org.a2aproject.sdk.compat03.spec.AgentExtension_v0_3; import org.a2aproject.sdk.compat03.spec.AgentInterface_v0_3; import org.a2aproject.sdk.compat03.spec.AgentProvider_v0_3; @@ -37,9 +39,9 @@ import org.a2aproject.sdk.compat03.spec.ImplicitOAuthFlow_v0_3; import org.a2aproject.sdk.compat03.spec.InvalidRequestError_v0_3; import org.a2aproject.sdk.compat03.spec.ListTaskPushNotificationConfigParams_v0_3; -import org.a2aproject.sdk.compat03.spec.Message_v0_3; import org.a2aproject.sdk.compat03.spec.MessageSendConfiguration_v0_3; import org.a2aproject.sdk.compat03.spec.MessageSendParams_v0_3; +import org.a2aproject.sdk.compat03.spec.Message_v0_3; import org.a2aproject.sdk.compat03.spec.MutualTLSSecurityScheme_v0_3; import org.a2aproject.sdk.compat03.spec.OAuth2SecurityScheme_v0_3; import org.a2aproject.sdk.compat03.spec.OAuthFlows_v0_3; @@ -50,14 +52,14 @@ import org.a2aproject.sdk.compat03.spec.PushNotificationConfig_v0_3; import org.a2aproject.sdk.compat03.spec.SecurityScheme_v0_3; import org.a2aproject.sdk.compat03.spec.StreamingEventKind_v0_3; -import org.a2aproject.sdk.compat03.spec.Task_v0_3; import org.a2aproject.sdk.compat03.spec.TaskArtifactUpdateEvent_v0_3; import org.a2aproject.sdk.compat03.spec.TaskIdParams_v0_3; import org.a2aproject.sdk.compat03.spec.TaskPushNotificationConfig_v0_3; import org.a2aproject.sdk.compat03.spec.TaskQueryParams_v0_3; import org.a2aproject.sdk.compat03.spec.TaskState_v0_3; -import org.a2aproject.sdk.compat03.spec.TaskStatus_v0_3; import org.a2aproject.sdk.compat03.spec.TaskStatusUpdateEvent_v0_3; +import org.a2aproject.sdk.compat03.spec.TaskStatus_v0_3; +import org.a2aproject.sdk.compat03.spec.Task_v0_3; import org.a2aproject.sdk.compat03.spec.TextPart_v0_3; import org.jspecify.annotations.Nullable; @@ -163,6 +165,9 @@ public static org.a2aproject.sdk.compat03.grpc.Message message(Message_v0_3 mess if (message.parts() != null) { builder.addAllContent(message.parts().stream().map(ToProto::part).collect(Collectors.toList())); } + if (message.extensions() != null) { + builder.addAllExtensions(message.extensions()); + } builder.setMetadata(struct(message.metadata())); return builder.build(); } @@ -266,7 +271,7 @@ private static org.a2aproject.sdk.compat03.grpc.FilePart filePart(FilePart_v0_3 org.a2aproject.sdk.compat03.grpc.FilePart.Builder builder = org.a2aproject.sdk.compat03.grpc.FilePart.newBuilder(); FileContent_v0_3 fileContent = filePart.file(); if (fileContent instanceof FileWithBytes_v0_3) { - builder.setFileWithBytes(ByteString.copyFrom(((FileWithBytes_v0_3) fileContent).bytes(), StandardCharsets.UTF_8)); + builder.setFileWithBytes(ByteString.copyFrom(Base64.getDecoder().decode(((FileWithBytes_v0_3) fileContent).bytes()))); } else if (fileContent instanceof FileWithUri_v0_3) { builder.setFileWithUri(((FileWithUri_v0_3) fileContent).uri()); } @@ -636,9 +641,11 @@ public static Struct struct(@Nullable Map map) { return structBuilder.build(); } - private static Value value(Object value) { + private static Value value(@Nullable Object value) { Value.Builder valueBuilder = Value.newBuilder(); - if (value instanceof String) { + if (value == null) { + valueBuilder.setNullValue(NullValue.NULL_VALUE); + } else if (value instanceof String) { valueBuilder.setStringValue((String) value); } else if (value instanceof Number) { valueBuilder.setNumberValue(((Number) value).doubleValue()); @@ -939,7 +946,7 @@ private static FilePart_v0_3 filePart(org.a2aproject.sdk.compat03.grpc.FilePartO @Nullable Map metadata) { String name = filePart.getName().isEmpty() ? null : filePart.getName(); if (filePart.hasFileWithBytes()) { - return new FilePart_v0_3(new FileWithBytes_v0_3(filePart.getMimeType(), name, filePart.getFileWithBytes().toStringUtf8()), metadata); + return new FilePart_v0_3(new FileWithBytes_v0_3(filePart.getMimeType(), name, Base64.getEncoder().encodeToString(filePart.getFileWithBytes().toByteArray())), metadata); } else if (filePart.hasFileWithUri()) { return new FilePart_v0_3(new FileWithUri_v0_3(filePart.getMimeType(), name, filePart.getFileWithUri()), metadata); } @@ -948,7 +955,7 @@ private static FilePart_v0_3 filePart(org.a2aproject.sdk.compat03.grpc.FilePartO private static DataPart_v0_3 dataPart(org.a2aproject.sdk.compat03.grpc.DataPartOrBuilder dataPart, @Nullable Map metadata) { - return new DataPart_v0_3(struct(dataPart.getData()), metadata); + return new DataPart_v0_3(dataStruct(dataPart.getData()), metadata); } private static @Nullable TaskStatus_v0_3 taskStatus(org.a2aproject.sdk.compat03.grpc.TaskStatusOrBuilder taskStatus) { @@ -1009,14 +1016,19 @@ private static DataPart_v0_3 dataPart(org.a2aproject.sdk.compat03.grpc.DataPartO if (struct == null || struct.getFieldsCount() == 0) { return null; } - return struct.getFieldsMap().entrySet().stream() - .collect(Collectors.toMap(Map.Entry::getKey, e -> value(e.getValue()))); + return dataStruct(struct); + } + + private static Map dataStruct(Struct struct) { + Map result = new LinkedHashMap<>(); + struct.getFieldsMap().forEach((key, field) -> result.put(key, value(field))); + return result; } private static @Nullable Object value(Value value) { switch (value.getKindCase()) { case STRUCT_VALUE: - return struct(value.getStructValue()); + return dataStruct(value.getStructValue()); case LIST_VALUE: return value.getListValue().getValuesList().stream() .map(FromProto::value) @@ -1028,6 +1040,7 @@ private static DataPart_v0_3 dataPart(org.a2aproject.sdk.compat03.grpc.DataPartO case STRING_VALUE: return value.getStringValue(); case NULL_VALUE: + return null; default: throw new InvalidRequestError_v0_3(); } diff --git a/compat-0.3/spec-grpc/src/test/java/org/a2aproject/sdk/compat03/grpc/utils/ProtoJsonData_v0_3_Test.java b/compat-0.3/spec-grpc/src/test/java/org/a2aproject/sdk/compat03/grpc/utils/ProtoJsonData_v0_3_Test.java new file mode 100644 index 000000000..69a9a91ce --- /dev/null +++ b/compat-0.3/spec-grpc/src/test/java/org/a2aproject/sdk/compat03/grpc/utils/ProtoJsonData_v0_3_Test.java @@ -0,0 +1,80 @@ +package org.a2aproject.sdk.compat03.grpc.utils; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNull; + +import java.util.Arrays; +import java.util.Base64; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import com.google.protobuf.ByteString; +import com.google.protobuf.ListValue; +import com.google.protobuf.NullValue; +import com.google.protobuf.Struct; +import com.google.protobuf.Value; +import org.a2aproject.sdk.compat03.grpc.DataPart; +import org.a2aproject.sdk.compat03.grpc.FilePart; +import org.a2aproject.sdk.compat03.grpc.Part; +import org.a2aproject.sdk.compat03.spec.DataPart_v0_3; +import org.a2aproject.sdk.compat03.spec.FilePart_v0_3; +import org.a2aproject.sdk.compat03.spec.FileWithBytes_v0_3; +import org.a2aproject.sdk.compat03.spec.Message_v0_3; +import org.a2aproject.sdk.compat03.spec.TextPart_v0_3; +import org.junit.jupiter.api.Test; + +class ProtoJsonData_v0_3_Test { + @Test + void convertsBinaryFilesUsingRawProtobufBytes() { + byte[] bytes = {0, (byte) 0xff, (byte) 0x80, 42}; + String encoded = Base64.getEncoder().encodeToString(bytes); + var outgoing = ProtoUtils_v0_3.ToProto.part(new FilePart_v0_3( + new FileWithBytes_v0_3("application/octet-stream", "binary", encoded))); + assertArrayEquals(bytes, outgoing.getFile().getFileWithBytes().toByteArray()); + + Part incoming = Part.newBuilder().setFile(FilePart.newBuilder() + .setMimeType("application/octet-stream").setFileWithBytes(ByteString.copyFrom(bytes))).build(); + var file = assertInstanceOf(FilePart_v0_3.class, ProtoUtils_v0_3.FromProto.part(incoming)); + assertEquals(encoded, assertInstanceOf(FileWithBytes_v0_3.class, file.file()).bytes()); + } + + @Test + void readsEmptyDataObjectWithoutTreatingItAsAbsent() { + Part incoming = Part.newBuilder().setData(DataPart.newBuilder().setData(Struct.getDefaultInstance())).build(); + var data = assertInstanceOf(DataPart_v0_3.class, ProtoUtils_v0_3.FromProto.part(incoming)); + assertEquals(Map.of(), data.data()); + } + + @Test + void preservesNestedEmptyObjectsAndJsonNullsInBothDirections() { + Value empty = Value.newBuilder().setStructValue(Struct.getDefaultInstance()).build(); + Value nil = Value.newBuilder().setNullValue(NullValue.NULL_VALUE).build(); + Struct fixture = Struct.newBuilder().putFields("empty", empty).putFields("nil", nil) + .putFields("items", Value.newBuilder().setListValue(ListValue.newBuilder() + .addValues(empty).addValues(nil)).build()).build(); + Map expected = new LinkedHashMap<>(); + expected.put("empty", Map.of()); + expected.put("nil", null); + expected.put("items", Arrays.asList(Map.of(), null)); + + var incoming = Part.newBuilder().setData(DataPart.newBuilder().setData(fixture)).setMetadata(fixture).build(); + var data = assertInstanceOf(DataPart_v0_3.class, ProtoUtils_v0_3.FromProto.part(incoming)); + assertEquals(expected, data.data()); + assertEquals(expected, data.metadata()); + var outgoing = ProtoUtils_v0_3.ToProto.part(new DataPart_v0_3(expected, expected)); + assertEquals(fixture, outgoing.getData().getData()); + assertEquals(fixture, outgoing.getMetadata()); + } + + @Test + void retainsAbsentMessageMetadataAndPreservesExtensions() { + var message = new Message_v0_3.Builder().role(Message_v0_3.Role.USER).messageId("message") + .parts(List.of(new TextPart_v0_3("hello"))).extensions(List.of("https://example.test/ext")).build(); + var proto = ProtoUtils_v0_3.ToProto.message(message); + assertEquals(List.of("https://example.test/ext"), proto.getExtensionsList()); + assertNull(ProtoUtils_v0_3.FromProto.message(proto).metadata()); + } +} diff --git a/compat-0.3/spec-grpc/src/test/java/org/a2aproject/sdk/compat03/grpc/utils/ToProto_v0_3_Test.java b/compat-0.3/spec-grpc/src/test/java/org/a2aproject/sdk/compat03/grpc/utils/ToProto_v0_3_Test.java index 7a27827e4..fbefc36c1 100644 --- a/compat-0.3/spec-grpc/src/test/java/org/a2aproject/sdk/compat03/grpc/utils/ToProto_v0_3_Test.java +++ b/compat-0.3/spec-grpc/src/test/java/org/a2aproject/sdk/compat03/grpc/utils/ToProto_v0_3_Test.java @@ -20,16 +20,16 @@ import org.a2aproject.sdk.compat03.spec.FileWithBytes_v0_3; import org.a2aproject.sdk.compat03.spec.FileWithUri_v0_3; import org.a2aproject.sdk.compat03.spec.HTTPAuthSecurityScheme_v0_3; -import org.a2aproject.sdk.compat03.spec.Message_v0_3; import org.a2aproject.sdk.compat03.spec.MessageSendConfiguration_v0_3; +import org.a2aproject.sdk.compat03.spec.Message_v0_3; import org.a2aproject.sdk.compat03.spec.PushNotificationAuthenticationInfo_v0_3; import org.a2aproject.sdk.compat03.spec.PushNotificationConfig_v0_3; -import org.a2aproject.sdk.compat03.spec.Task_v0_3; import org.a2aproject.sdk.compat03.spec.TaskArtifactUpdateEvent_v0_3; import org.a2aproject.sdk.compat03.spec.TaskPushNotificationConfig_v0_3; import org.a2aproject.sdk.compat03.spec.TaskState_v0_3; -import org.a2aproject.sdk.compat03.spec.TaskStatus_v0_3; import org.a2aproject.sdk.compat03.spec.TaskStatusUpdateEvent_v0_3; +import org.a2aproject.sdk.compat03.spec.TaskStatus_v0_3; +import org.a2aproject.sdk.compat03.spec.Task_v0_3; import org.a2aproject.sdk.compat03.spec.TextPart_v0_3; import org.junit.jupiter.api.Test; @@ -296,7 +296,7 @@ public void convertFilePartWithBytesRoundTrip() { assertEquals(true, proto.hasFile()); assertEquals("image/png", proto.getFile().getMimeType()); assertEquals("photo.png", proto.getFile().getName()); - assertEquals("aGVsbG8=", proto.getFile().getFileWithBytes().toStringUtf8()); + assertEquals("hello", proto.getFile().getFileWithBytes().toStringUtf8()); FilePart_v0_3 roundTripped = (FilePart_v0_3) ProtoUtils_v0_3.FromProto.part(proto); FileWithBytes_v0_3 fileContent = (FileWithBytes_v0_3) roundTripped.file(); assertEquals("image/png", fileContent.mimeType()); diff --git a/compat-0.3/spec/src/main/java/org/a2aproject/sdk/compat03/json/JsonUtil_v0_3.java b/compat-0.3/spec/src/main/java/org/a2aproject/sdk/compat03/json/JsonUtil_v0_3.java index 4239cd2e5..9c3dba8ab 100644 --- a/compat-0.3/spec/src/main/java/org/a2aproject/sdk/compat03/json/JsonUtil_v0_3.java +++ b/compat-0.3/spec/src/main/java/org/a2aproject/sdk/compat03/json/JsonUtil_v0_3.java @@ -13,6 +13,14 @@ import static org.a2aproject.sdk.compat03.spec.A2AErrorCodes_v0_3.TASK_NOT_FOUND_ERROR_CODE; import static org.a2aproject.sdk.compat03.spec.A2AErrorCodes_v0_3.UNSUPPORTED_OPERATION_ERROR_CODE; +import java.lang.reflect.ParameterizedType; +import java.lang.reflect.Type; +import java.time.OffsetDateTime; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; +import java.util.List; +import java.util.Map; + import com.google.gson.Gson; import com.google.gson.GsonBuilder; import com.google.gson.JsonSyntaxException; @@ -24,43 +32,38 @@ import com.google.gson.stream.JsonWriter; import org.a2aproject.sdk.compat03.spec.APIKeySecurityScheme_v0_3; import org.a2aproject.sdk.compat03.spec.AgentCapabilities_v0_3; -import org.a2aproject.sdk.compat03.spec.EventKind_v0_3; -import org.a2aproject.sdk.compat03.spec.JSONRPCResponse_v0_3; +import org.a2aproject.sdk.compat03.spec.AuthenticatedExtendedCardNotConfiguredError_v0_3; +import org.a2aproject.sdk.compat03.spec.ContentTypeNotSupportedError_v0_3; import org.a2aproject.sdk.compat03.spec.DataPart_v0_3; +import org.a2aproject.sdk.compat03.spec.EventKind_v0_3; import org.a2aproject.sdk.compat03.spec.FileContent_v0_3; import org.a2aproject.sdk.compat03.spec.FilePart_v0_3; import org.a2aproject.sdk.compat03.spec.FileWithBytes_v0_3; import org.a2aproject.sdk.compat03.spec.FileWithUri_v0_3; import org.a2aproject.sdk.compat03.spec.HTTPAuthSecurityScheme_v0_3; +import org.a2aproject.sdk.compat03.spec.InternalError_v0_3; +import org.a2aproject.sdk.compat03.spec.InvalidAgentResponseError_v0_3; +import org.a2aproject.sdk.compat03.spec.InvalidParamsError_v0_3; +import org.a2aproject.sdk.compat03.spec.InvalidRequestError_v0_3; +import org.a2aproject.sdk.compat03.spec.JSONParseError_v0_3; import org.a2aproject.sdk.compat03.spec.JSONRPCError_v0_3; +import org.a2aproject.sdk.compat03.spec.JSONRPCResponse_v0_3; import org.a2aproject.sdk.compat03.spec.Message_v0_3; +import org.a2aproject.sdk.compat03.spec.MethodNotFoundError_v0_3; import org.a2aproject.sdk.compat03.spec.MutualTLSSecurityScheme_v0_3; import org.a2aproject.sdk.compat03.spec.OAuth2SecurityScheme_v0_3; import org.a2aproject.sdk.compat03.spec.OpenIdConnectSecurityScheme_v0_3; import org.a2aproject.sdk.compat03.spec.Part_v0_3; +import org.a2aproject.sdk.compat03.spec.PushNotificationNotSupportedError_v0_3; import org.a2aproject.sdk.compat03.spec.SecurityScheme_v0_3; import org.a2aproject.sdk.compat03.spec.StreamingEventKind_v0_3; -import org.a2aproject.sdk.compat03.spec.Task_v0_3; import org.a2aproject.sdk.compat03.spec.TaskArtifactUpdateEvent_v0_3; +import org.a2aproject.sdk.compat03.spec.TaskNotCancelableError_v0_3; +import org.a2aproject.sdk.compat03.spec.TaskNotFoundError_v0_3; import org.a2aproject.sdk.compat03.spec.TaskState_v0_3; import org.a2aproject.sdk.compat03.spec.TaskStatusUpdateEvent_v0_3; +import org.a2aproject.sdk.compat03.spec.Task_v0_3; import org.a2aproject.sdk.compat03.spec.TextPart_v0_3; -import java.lang.reflect.Type; -import java.time.OffsetDateTime; -import java.time.format.DateTimeFormatter; -import java.time.format.DateTimeParseException; -import java.util.List; -import org.a2aproject.sdk.compat03.spec.AuthenticatedExtendedCardNotConfiguredError_v0_3; -import org.a2aproject.sdk.compat03.spec.ContentTypeNotSupportedError_v0_3; -import org.a2aproject.sdk.compat03.spec.InternalError_v0_3; -import org.a2aproject.sdk.compat03.spec.InvalidAgentResponseError_v0_3; -import org.a2aproject.sdk.compat03.spec.InvalidParamsError_v0_3; -import org.a2aproject.sdk.compat03.spec.InvalidRequestError_v0_3; -import org.a2aproject.sdk.compat03.spec.JSONParseError_v0_3; -import org.a2aproject.sdk.compat03.spec.MethodNotFoundError_v0_3; -import org.a2aproject.sdk.compat03.spec.PushNotificationNotSupportedError_v0_3; -import org.a2aproject.sdk.compat03.spec.TaskNotCancelableError_v0_3; -import org.a2aproject.sdk.compat03.spec.TaskNotFoundError_v0_3; import org.a2aproject.sdk.compat03.spec.UnsupportedOperationError_v0_3; import org.jspecify.annotations.Nullable; @@ -71,6 +74,7 @@ public class JsonUtil_v0_3 { private static GsonBuilder createBaseGsonBuilder() { return new GsonBuilder() .setObjectToNumberStrategy(ToNumberPolicy.LONG_OR_DOUBLE) + .registerTypeAdapterFactory(new JsonMapTypeAdapterFactory()) .registerTypeAdapter(OffsetDateTime.class, new OffsetDateTimeTypeAdapter()) // Register JSONRPCError hierarchy adapter for all error subclasses .registerTypeAdapterFactory(new JSONRPCErrorTypeAdapterFactory()) @@ -583,6 +587,51 @@ public void write(JsonWriter out, Part_v0_3.Kind value) throws java.io.IOExcepti } } + /** Preserve null entries inside JSON objects without emitting absent protocol fields. */ + static class JsonMapTypeAdapterFactory implements TypeAdapterFactory { + @Override + public @Nullable TypeAdapter create(Gson gson, TypeToken type) { + if (!Map.class.isAssignableFrom(type.getRawType())) { + return null; + } + TypeAdapter delegate = gson.getDelegateAdapter(this, type); + Type valueType = type.getRawType() == Map.class + && type.getType() instanceof ParameterizedType parameterized + ? parameterized.getActualTypeArguments()[1] : Object.class; + return new TypeAdapter() { + @Override + public void write(JsonWriter out, T value) throws java.io.IOException { + if (value == null) { + out.nullValue(); + return; + } + out.beginObject(); + for (Map.Entry entry : ((Map) value).entrySet()) { + out.name(String.valueOf(entry.getKey())); + Object entryValue = entry.getValue(); + if (entryValue == null) { + boolean previous = out.getSerializeNulls(); + out.setSerializeNulls(true); + try { + out.nullValue(); + } finally { + out.setSerializeNulls(previous); + } + } else { + gson.toJson(entryValue, valueType, out); + } + } + out.endObject(); + } + + @Override + public T read(JsonReader in) throws java.io.IOException { + return delegate.read(in); + } + }; + } + } + /** * Gson TypeAdapter for serializing and deserializing {@link Part_v0_3} and its subclasses. *

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

@@ -40,6 +40,23 @@ public class RestHandler_v0_3_Test extends AbstractA2ARequestHandlerTest_v0_3 { // GetTask Tests // ======================================== + @Test + public void testSetPushNotificationConfigWithSchemaBody() throws Exception { + RestHandler_v0_3 handler = new RestHandler_v0_3(CARD, internalExecutor, convert03To10Handler); + taskStore.save(TaskMapper_v0_3.INSTANCE.toV10(MINIMAL_TASK), false); + String body = """ + {"pushNotificationConfig":{"id":"body-id","url":"https://example.com/callback"}} + """; + RestHandler_v0_3.HTTPRestResponse response = handler.setTaskPushNotificationConfiguration( + MINIMAL_TASK.id(), body, "requested-id", callContext); + assertEquals(201, response.getStatusCode(), response.toString()); + assertNotNull(response.getBody()); + var json = com.google.gson.JsonParser.parseString(response.getBody()).getAsJsonObject(); + assertEquals("tasks/" + MINIMAL_TASK.id() + "/pushNotificationConfigs/requested-id", json.get("name").getAsString()); + assertEquals("requested-id", json.getAsJsonObject("pushNotificationConfig").get("id").getAsString()); + assertEquals("application/json", response.getContentType()); + } + @Test public void testGetTaskSuccess() { RestHandler_v0_3 handler = new RestHandler_v0_3(CARD, internalExecutor, convert03To10Handler); diff --git a/docs/content/dev/compatibility.md b/docs/content/dev/compatibility.md index cef3a9455..58e66e4d2 100644 --- a/docs/content/dev/compatibility.md +++ b/docs/content/dev/compatibility.md @@ -128,7 +128,10 @@ 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. +1.0 transport parameters are also unsupported for 0.3 adapters. REST and gRPC +also reject non-empty `Message.referenceTaskIds` and cancellation metadata, +which the 0.3 protobuf schema cannot represent. JSON-RPC preserves these fields. +Validation also applies to requests modified by interceptors. 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 diff --git a/http-client/src/main/java/org/a2aproject/sdk/client/http/JdkA2AHttpClient.java b/http-client/src/main/java/org/a2aproject/sdk/client/http/JdkA2AHttpClient.java index a5307ee24..0ce0dfd95 100644 --- a/http-client/src/main/java/org/a2aproject/sdk/client/http/JdkA2AHttpClient.java +++ b/http-client/src/main/java/org/a2aproject/sdk/client/http/JdkA2AHttpClient.java @@ -1,19 +1,19 @@ package org.a2aproject.sdk.client.http; -import static org.a2aproject.sdk.util.Assert.checkNotNullParam; import static java.net.HttpURLConnection.HTTP_FORBIDDEN; import static java.net.HttpURLConnection.HTTP_MULT_CHOICE; import static java.net.HttpURLConnection.HTTP_OK; import static java.net.HttpURLConnection.HTTP_UNAUTHORIZED; +import static org.a2aproject.sdk.util.Assert.checkNotNullParam; import java.io.IOException; import java.net.URI; import java.net.http.HttpClient; import java.net.http.HttpRequest; -import java.net.http.HttpResponse; import java.net.http.HttpResponse.BodyHandler; import java.net.http.HttpResponse.BodyHandlers; import java.net.http.HttpResponse.BodySubscribers; +import java.net.http.HttpResponse; import java.nio.ByteBuffer; import java.nio.charset.StandardCharsets; import java.util.HashMap; @@ -23,10 +23,10 @@ import java.util.concurrent.Flow; import java.util.concurrent.atomic.AtomicBoolean; import java.util.function.Consumer; -import org.jspecify.annotations.Nullable; import org.a2aproject.sdk.common.A2AErrorMessages; import org.a2aproject.sdk.spec.A2AClientHTTPError; +import org.jspecify.annotations.Nullable; /** * Default HTTP client implementation using JDK 11+ {@link HttpClient}. @@ -367,11 +367,11 @@ public A2AHttpResponse get() throws IOException, InterruptedException { if (response.statusCode() == HTTP_UNAUTHORIZED) { throw new IOException(A2AErrorMessages.AUTHENTICATION_FAILED, new A2AClientHTTPError(HTTP_UNAUTHORIZED, A2AErrorMessages.AUTHENTICATION_FAILED, - null, response.headers().map())); + response.body(), response.headers().map())); } else if (response.statusCode() == HTTP_FORBIDDEN) { throw new IOException(A2AErrorMessages.AUTHORIZATION_FAILED, new A2AClientHTTPError(HTTP_FORBIDDEN, A2AErrorMessages.AUTHORIZATION_FAILED, - null, response.headers().map())); + response.body(), response.headers().map())); } return new JdkHttpResponse(response); @@ -400,11 +400,11 @@ public A2AHttpResponse delete() throws IOException, InterruptedException { if (response.statusCode() == HTTP_UNAUTHORIZED) { throw new IOException(A2AErrorMessages.AUTHENTICATION_FAILED, new A2AClientHTTPError(HTTP_UNAUTHORIZED, A2AErrorMessages.AUTHENTICATION_FAILED, - null, response.headers().map())); + response.body(), response.headers().map())); } else if (response.statusCode() == HTTP_FORBIDDEN) { throw new IOException(A2AErrorMessages.AUTHORIZATION_FAILED, new A2AClientHTTPError(HTTP_FORBIDDEN, A2AErrorMessages.AUTHORIZATION_FAILED, - null, response.headers().map())); + response.body(), response.headers().map())); } return new JdkHttpResponse(response); @@ -468,11 +468,11 @@ public A2AHttpResponse post() throws IOException, InterruptedException { if (response.statusCode() == HTTP_UNAUTHORIZED) { throw new IOException(A2AErrorMessages.AUTHENTICATION_FAILED, new A2AClientHTTPError(HTTP_UNAUTHORIZED, A2AErrorMessages.AUTHENTICATION_FAILED, - null, response.headers().map())); + response.body(), response.headers().map())); } else if (response.statusCode() == HTTP_FORBIDDEN) { throw new IOException(A2AErrorMessages.AUTHORIZATION_FAILED, new A2AClientHTTPError(HTTP_FORBIDDEN, A2AErrorMessages.AUTHORIZATION_FAILED, - null, response.headers().map())); + response.body(), response.headers().map())); } return new JdkHttpResponse(response); diff --git a/reference/multiversion-rest/src/main/java/org/a2aproject/sdk/server/multiversion/rest/MultiVersionRestRoutes.java b/reference/multiversion-rest/src/main/java/org/a2aproject/sdk/server/multiversion/rest/MultiVersionRestRoutes.java index 816f2639a..6ec25e79c 100644 --- a/reference/multiversion-rest/src/main/java/org/a2aproject/sdk/server/multiversion/rest/MultiVersionRestRoutes.java +++ b/reference/multiversion-rest/src/main/java/org/a2aproject/sdk/server/multiversion/rest/MultiVersionRestRoutes.java @@ -5,26 +5,24 @@ import java.util.function.BiConsumer; import java.util.function.Consumer; -import jakarta.annotation.Priority; -import jakarta.enterprise.event.Observes; -import jakarta.inject.Inject; -import jakarta.inject.Singleton; - import com.google.gson.JsonObject; +import io.quarkus.security.ForbiddenException; +import io.quarkus.security.UnauthorizedException; import io.vertx.ext.web.Router; import io.vertx.ext.web.RoutingContext; import io.vertx.ext.web.handler.BodyHandler; +import jakarta.annotation.Priority; +import jakarta.enterprise.event.Observes; +import jakarta.inject.Inject; +import jakarta.inject.Singleton; +import org.a2aproject.sdk.compat03.server.rest.quarkus.A2AServerRoutes_v0_3; import org.a2aproject.sdk.server.common.quarkus.VersionRouter; import org.a2aproject.sdk.server.common.quarkus.VertxSecurityHelper; import org.a2aproject.sdk.server.rest.quarkus.A2AServerRoutes; -import org.a2aproject.sdk.compat03.server.rest.quarkus.A2AServerRoutes_v0_3; import org.a2aproject.sdk.spec.A2AError; import org.a2aproject.sdk.spec.A2AErrorCodes; import org.a2aproject.sdk.spec.VersionNotSupportedError; -import io.quarkus.security.ForbiddenException; -import io.quarkus.security.UnauthorizedException; - @Singleton public class MultiVersionRestRoutes { @@ -73,6 +71,16 @@ void setupRoutes(@Observes @Priority(5) Router router) { (body, ctx) -> v10Routes.cancelTask(body, ctx), (body, ctx) -> v03Routes.cancelTask(ctx)), false); + // GET /v1/tasks/{taskId}:subscribe for the 0.3 HTTP binding. + router.getWithRegex("^\\/v1\\/tasks\\/(?[^/]+):subscribe$") + .order(-1) + .blockingHandler(versionDispatchNoBody(true, + ctx -> { bridgeTenant(ctx); bridgeTaskId(ctx); }, + ctx -> { + throw new VersionNotSupportedError(null, "GET task subscription is only supported by A2A 0.3", null); + }, + ctx -> v03Routes.resubscribeTask(ctx)), false); + // POST /v1/tasks/{taskId}:subscribe (deferred CDI context destruction) router.postWithRegex("^\\/v1\\/tasks\\/(?[^/]+):subscribe$") .order(-1) diff --git a/tests/multiversion/grpc/src/test/java/org/a2aproject/sdk/tests/multiversion/grpc/A2ATestResource.java b/tests/multiversion/grpc/src/test/java/org/a2aproject/sdk/tests/multiversion/grpc/A2ATestResource.java index d30fa6e9f..d9de9ebe0 100644 --- a/tests/multiversion/grpc/src/test/java/org/a2aproject/sdk/tests/multiversion/grpc/A2ATestResource.java +++ b/tests/multiversion/grpc/src/test/java/org/a2aproject/sdk/tests/multiversion/grpc/A2ATestResource.java @@ -15,10 +15,10 @@ import jakarta.ws.rs.Path; import jakarta.ws.rs.PathParam; import jakarta.ws.rs.Produces; +import jakarta.ws.rs.QueryParam; import jakarta.ws.rs.core.HttpHeaders; import jakarta.ws.rs.core.MediaType; import jakarta.ws.rs.core.Response; - import org.a2aproject.sdk.compat03.transport.grpc.handler.GrpcHandler_v0_3; import org.a2aproject.sdk.jsonrpc.common.json.JsonUtil; import org.a2aproject.sdk.server.apps.common.TestUtilsBean; @@ -80,8 +80,13 @@ public Response deleteTask(@PathParam("taskId") String taskId) { @POST @Path("/queue/ensure/{taskId}") - public Response ensureQueue(@PathParam("taskId") String taskId) { - testUtilsBean.ensureQueue(taskId); + public Response ensureQueue(@PathParam("taskId") String taskId, + @QueryParam("withoutConsumer") boolean withoutConsumer) { + if (withoutConsumer) { + testUtilsBean.ensureQueueWithoutConsumer(taskId); + } else { + testUtilsBean.ensureQueue(taskId); + } return Response.ok().build(); } diff --git a/tests/multiversion/jsonrpc/src/test/java/org/a2aproject/sdk/tests/multiversion/jsonrpc/A2ATestRoutes.java b/tests/multiversion/jsonrpc/src/test/java/org/a2aproject/sdk/tests/multiversion/jsonrpc/A2ATestRoutes.java index 1c06793c6..347207406 100644 --- a/tests/multiversion/jsonrpc/src/test/java/org/a2aproject/sdk/tests/multiversion/jsonrpc/A2ATestRoutes.java +++ b/tests/multiversion/jsonrpc/src/test/java/org/a2aproject/sdk/tests/multiversion/jsonrpc/A2ATestRoutes.java @@ -6,18 +6,17 @@ import java.util.concurrent.atomic.AtomicInteger; +import io.vertx.ext.web.Router; +import io.vertx.ext.web.RoutingContext; +import io.vertx.ext.web.handler.BodyHandler; import jakarta.annotation.PostConstruct; import jakarta.enterprise.event.Observes; import jakarta.inject.Inject; import jakarta.inject.Singleton; - -import io.vertx.ext.web.Router; -import io.vertx.ext.web.RoutingContext; -import io.vertx.ext.web.handler.BodyHandler; +import org.a2aproject.sdk.compat03.server.apps.quarkus.A2AServerRoutes_v0_3; import org.a2aproject.sdk.jsonrpc.common.json.JsonUtil; import org.a2aproject.sdk.server.apps.common.TestUtilsBean; import org.a2aproject.sdk.server.apps.quarkus.A2AServerRoutes; -import org.a2aproject.sdk.compat03.server.apps.quarkus.A2AServerRoutes_v0_3; import org.a2aproject.sdk.spec.Task; import org.a2aproject.sdk.spec.TaskArtifactUpdateEvent; import org.a2aproject.sdk.spec.TaskPushNotificationConfig; @@ -164,7 +163,11 @@ public void deleteTask(String taskId, RoutingContext rc) { public void ensureTaskQueue(String taskId, RoutingContext rc) { try { - testUtilsBean.ensureQueue(taskId); + if (Boolean.parseBoolean(rc.request().getParam("withoutConsumer"))) { + testUtilsBean.ensureQueueWithoutConsumer(taskId); + } else { + testUtilsBean.ensureQueue(taskId); + } rc.response().setStatusCode(200).end(); } catch (Throwable t) { errorResponse(t, rc); diff --git a/tests/multiversion/rest/src/test/java/org/a2aproject/sdk/tests/multiversion/rest/A2ATestRoutes.java b/tests/multiversion/rest/src/test/java/org/a2aproject/sdk/tests/multiversion/rest/A2ATestRoutes.java index 038ac6e96..c1b1d5f87 100644 --- a/tests/multiversion/rest/src/test/java/org/a2aproject/sdk/tests/multiversion/rest/A2ATestRoutes.java +++ b/tests/multiversion/rest/src/test/java/org/a2aproject/sdk/tests/multiversion/rest/A2ATestRoutes.java @@ -6,15 +6,14 @@ import java.util.concurrent.atomic.AtomicInteger; +import io.vertx.ext.web.Router; +import io.vertx.ext.web.RoutingContext; +import io.vertx.ext.web.handler.BodyHandler; import jakarta.annotation.PostConstruct; import jakarta.annotation.Priority; import jakarta.enterprise.event.Observes; import jakarta.inject.Inject; import jakarta.inject.Singleton; - -import io.vertx.ext.web.Router; -import io.vertx.ext.web.RoutingContext; -import io.vertx.ext.web.handler.BodyHandler; import org.a2aproject.sdk.compat03.server.rest.quarkus.A2AServerRoutes_v0_3; import org.a2aproject.sdk.jsonrpc.common.json.JsonUtil; import org.a2aproject.sdk.server.apps.common.TestUtilsBean; @@ -216,7 +215,11 @@ public void deleteTask(String taskId, RoutingContext rc) { public void ensureTaskQueue(String taskId, RoutingContext rc) { try { - testUtilsBean.ensureQueue(taskId); + if (Boolean.parseBoolean(rc.request().getParam("withoutConsumer"))) { + testUtilsBean.ensureQueueWithoutConsumer(taskId); + } else { + testUtilsBean.ensureQueue(taskId); + } rc.response() .setStatusCode(200) .end(); diff --git a/tests/server-common/src/test/java/org/a2aproject/sdk/server/apps/common/AbstractA2AServerCompatibilityTest_v0_3.java b/tests/server-common/src/test/java/org/a2aproject/sdk/server/apps/common/AbstractA2AServerCompatibilityTest_v0_3.java index 7bc737f7d..4af1d7e96 100644 --- 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 @@ -23,7 +23,6 @@ import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicReference; import java.util.function.BiConsumer; -import java.util.function.Consumer; import org.a2aproject.sdk.A2A; import org.a2aproject.sdk.client.Client; @@ -533,13 +532,31 @@ public void testMainQueueStaysOpenForNonFinalTasks() throws Exception { 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); + ensureQueueWithoutConsumer(taskId); + assertEquals(0, getChildQueueCount(taskId), "Non-final task queue must stay open with no consumers"); + CountDownLatch updateReceived = new CountDownLatch(1); + AtomicReference error = new AtomicReference<>(); + CompletableFuture subscription = awaitStreamingSubscription(); + getClient().subscribeToTask(new TaskIdParams(taskId), List.of((event, card) -> { + if (event instanceof TaskUpdateEvent update + && update.getUpdateEvent() instanceof TaskStatusUpdateEvent status + && taskId.equals(status.taskId()) + && status.status().state() == TaskState.TASK_STATE_WORKING) { + updateReceived.countDown(); + } + }), failure -> { + if (failure != null && !isStreamClosedError(failure)) { + error.set(failure); + updateReceived.countDown(); + } + }); + subscription.get(15, TimeUnit.SECONDS); + assertEquals(1, getChildQueueCount(taskId), "Late subscription must create an active consumer"); enqueueEventOnServer(TaskStatusUpdateEvent.builder().taskId(taskId).contextId(task.contextId()) .status(new org.a2aproject.sdk.spec.TaskStatus(TaskState.TASK_STATE_WORKING)).build()); - assertTrue(latch.await(30, TimeUnit.SECONDS)); - assertTrue(getChildQueueCount(taskId) >= 0); + assertTrue(updateReceived.await(30, TimeUnit.SECONDS), "Must receive the enqueued status update"); + assertNull(error.get(), "Subscription must not fail"); + assertEquals(1, getChildQueueCount(taskId), "Non-final update must leave the consumer and main queue open"); } finally { deleteTaskInTaskStore(taskId); } @@ -550,19 +567,46 @@ 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); + ensureQueueWithoutConsumer(taskId); + assertEquals(0, getChildQueueCount(taskId)); + CountDownLatch finalUpdateReceived = new CountDownLatch(1); + AtomicReference error = new AtomicReference<>(); CompletableFuture subscription = awaitStreamingSubscription(); - getClient().subscribeToTask(new TaskIdParams(taskId), List.of((event, card) -> latch.countDown()), null); + getClient().subscribeToTask(new TaskIdParams(taskId), List.of((event, card) -> { + if (event instanceof TaskUpdateEvent update + && update.getUpdateEvent() instanceof TaskStatusUpdateEvent status + && taskId.equals(status.taskId()) + && status.status().state() == TaskState.TASK_STATE_COMPLETED + && status.isFinal()) { + finalUpdateReceived.countDown(); + } + }), failure -> { + if (failure != null && !isStreamClosedError(failure)) { + error.set(failure); + finalUpdateReceived.countDown(); + } + }); subscription.get(15, TimeUnit.SECONDS); + assertEquals(1, getChildQueueCount(taskId), "Subscription must be active before finalization"); enqueueEventOnServer(TaskStatusUpdateEvent.builder().taskId(taskId).contextId(MINIMAL_TASK.contextId()) .status(new org.a2aproject.sdk.spec.TaskStatus(TaskState.TASK_STATE_COMPLETED)).build()); - assertTrue(latch.await(30, TimeUnit.SECONDS)); + assertTrue(finalUpdateReceived.await(30, TimeUnit.SECONDS), "Must receive the final status update"); + assertNull(error.get(), "Subscription must not fail before delivering the final update"); + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(15); + while (getChildQueueCount(taskId) != -1 && System.nanoTime() < deadline) { + Thread.sleep(50); + } + assertEquals(-1, getChildQueueCount(taskId), "Finalized task must close or remove its main queue"); + assertNull(error.get(), "Subscription must close without an unexpected error"); } finally { deleteTaskInTaskStore(taskId); } } + private void ensureQueueWithoutConsumer(String taskId) throws Exception { + sendTestRequest("/test/queue/ensure/" + taskId + "?withoutConsumer=true", "POST", "", 200); + } + private CompletableFuture awaitStreamingSubscription() { int initial = getStreamingSubscribedCount(); return CompletableFuture.runAsync(() -> { diff --git a/tests/server-common/src/test/java/org/a2aproject/sdk/server/apps/common/TestUtilsBean.java b/tests/server-common/src/test/java/org/a2aproject/sdk/server/apps/common/TestUtilsBean.java index a7360bbd3..722ca11fa 100644 --- a/tests/server-common/src/test/java/org/a2aproject/sdk/server/apps/common/TestUtilsBean.java +++ b/tests/server-common/src/test/java/org/a2aproject/sdk/server/apps/common/TestUtilsBean.java @@ -2,13 +2,12 @@ import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; - import org.a2aproject.sdk.server.events.QueueManager; import org.a2aproject.sdk.server.tasks.PushNotificationConfigStore; import org.a2aproject.sdk.server.tasks.TaskStore; import org.a2aproject.sdk.spec.Event; -import org.a2aproject.sdk.spec.TaskPushNotificationConfig; import org.a2aproject.sdk.spec.Task; +import org.a2aproject.sdk.spec.TaskPushNotificationConfig; /** * Contains utilities to interact with the server side for the tests. @@ -46,6 +45,11 @@ public void ensureQueue(String taskId) { queueManager.createOrTap(taskId); } + public void ensureQueueWithoutConsumer(String taskId) { + // Release the setup child so queue lifecycle assertions observe only real subscribers. + queueManager.createOrTap(taskId).close(); + } + public void enqueueEvent(String taskId, Event event) { queueManager.get(taskId).enqueueEvent(event); } From 4343831f3a1f3c2035a91bff7b93f4e97bfca3fb Mon Sep 17 00:00:00 2001 From: Kabir Khan Date: Mon, 5 Oct 2026 15:35:57 +0100 Subject: [PATCH 7/9] fix: address unified client compatibility review findings --- ...pcCompat03ClientTransportProviderTest.java | 89 ++++++- .../JSONRPCCompat03ClientTransport.java | 42 +++- .../JSONRPCCompat03PayloadSupport.java | 148 ++++++++++++ .../JSONRPCCompat03ClientTransportTest.java | 219 +++++++++++++++++- .../Compat03ClientCallContextMapper.java | 18 +- .../Compat03ClientTransportSupport.java | 5 +- ...pat03AgentCardCompatibilityParserTest.java | 51 +++- .../Compat03ClientCallContextMapperTest.java | 48 ++++ .../Compat03ClientTransportSupportTest.java | 18 +- .../transport/grpc/GrpcTransport_v0_3.java | 13 +- .../rest/RestTransport_v0_3_Test.java | 2 +- .../grpc/quarkus/A2ATestResource_v0_3.java | 10 +- ...QuarkusA2AGrpc_v0_3_CompatibilityTest.java | 26 ++- ...2AGrpc_v0_3_WithAuthCompatibilityTest.java | 35 +-- .../apps/quarkus/A2ATestRoutes_v0_3.java | 6 +- .../rest/quarkus/A2ATestRoutes_v0_3.java | 6 +- .../conversion/TestUtilsBean_v0_3.java | 22 +- docs/content/dev/compatibility.md | 21 +- ...6-09-14-client-v03-compatibility-design.md | 4 +- ...stractA2AServerCompatibilityTest_v0_3.java | 160 +++++++++++-- .../apps/common/AgentExecutorProducer.java | 27 +++ 21 files changed, 880 insertions(+), 90 deletions(-) create mode 100644 compat-0.3/client/adapter-jsonrpc/src/main/java/org/a2aproject/sdk/compat03/client/adapter/jsonrpc/JSONRPCCompat03PayloadSupport.java create mode 100644 compat-0.3/client/adapter/src/test/java/org/a2aproject/sdk/compat03/client/adapter/Compat03ClientCallContextMapperTest.java 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 index ca3aa4917..134d0a6e8 100644 --- 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 @@ -6,11 +6,23 @@ import static org.junit.jupiter.api.Assertions.assertTrue; import java.util.List; +import java.util.Map; +import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; +import io.grpc.ManagedChannel; +import io.grpc.Server; +import io.grpc.Status; +import io.grpc.inprocess.InProcessChannelBuilder; +import io.grpc.inprocess.InProcessServerBuilder; +import io.grpc.stub.StreamObserver; import org.a2aproject.sdk.client.transport.grpc.GrpcTransport; import org.a2aproject.sdk.client.transport.grpc.GrpcTransportConfigBuilder; import org.a2aproject.sdk.client.transport.spi.ClientTransport; +import org.a2aproject.sdk.client.transport.spi.interceptors.ClientCallContext; +import org.a2aproject.sdk.client.transport.spi.interceptors.ClientCallInterceptor; +import org.a2aproject.sdk.client.transport.spi.interceptors.PayloadAndHeaders; import org.a2aproject.sdk.compat03.grpc.A2AServiceGrpc; import org.a2aproject.sdk.compat03.grpc.Message; import org.a2aproject.sdk.compat03.grpc.Part; @@ -20,18 +32,83 @@ import org.a2aproject.sdk.spec.AgentCard; import org.a2aproject.sdk.spec.AgentInterface; import org.a2aproject.sdk.spec.AgentSkill; +import org.a2aproject.sdk.spec.GetTaskPushNotificationConfigParams; import org.a2aproject.sdk.spec.MessageSendParams; import org.a2aproject.sdk.spec.TextPart; import org.a2aproject.sdk.spec.TransportProtocol; -import 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 sendsCompletePushConfigurationResourceNamesIncludingDefaultAndClearedIds() throws Exception { + String name = InProcessServerBuilder.generateName(); + AtomicReference receivedName = new AtomicReference<>(); + Server server = InProcessServerBuilder.forName(name).directExecutor() + .addService(new A2AServiceGrpc.A2AServiceImplBase() { + @Override + public void getTaskPushNotificationConfig( + org.a2aproject.sdk.compat03.grpc.GetTaskPushNotificationConfigRequest request, + StreamObserver observer) { + receivedName.set(request.getName()); + // Validate the contract directly rather than using the permissive reference parser. + if (!request.getName().matches("tasks/task-123/pushNotificationConfigs/(task-123|specific)")) { + observer.onError(Status.INVALID_ARGUMENT.withDescription("Invalid config resource name") + .asRuntimeException()); + return; + } + String configId = request.getName().substring(request.getName().lastIndexOf('/') + 1); + observer.onNext(org.a2aproject.sdk.compat03.grpc.TaskPushNotificationConfig.newBuilder() + .setName(request.getName()) + .setPushNotificationConfig(org.a2aproject.sdk.compat03.grpc.PushNotificationConfig.newBuilder() + .setId(configId).setUrl("https://example.test/callback")) + .build()); + observer.onCompleted(); + } + }).build().start(); + ManagedChannel channel = InProcessChannelBuilder.forName(name).directExecutor().build(); + try { + AgentCard card = card(name); + var provider = new GrpcCompat03ClientTransportProvider(); + ClientTransport transport = provider.create( + new GrpcTransportConfigBuilder().channelFactory(ignored -> channel).build(), card, + card.supportedInterfaces().get(0)); + try { + assertEquals("task-123", transport.getTaskPushNotificationConfiguration( + new GetTaskPushNotificationConfigParams("task-123"), null).id()); + assertEquals("tasks/task-123/pushNotificationConfigs/task-123", receivedName.get()); + assertEquals("specific", transport.getTaskPushNotificationConfiguration( + new GetTaskPushNotificationConfigParams("task-123", "specific"), null).id()); + assertEquals("tasks/task-123/pushNotificationConfigs/specific", receivedName.get()); + } finally { + transport.close(); + } + + ClientCallInterceptor clearingInterceptor = new ClientCallInterceptor() { + @Override + public PayloadAndHeaders intercept(String method, Object payload, Map headers, + AgentCard agentCard, ClientCallContext context) { + var request = (org.a2aproject.sdk.grpc.GetTaskPushNotificationConfigRequest) payload; + return new PayloadAndHeaders(request.toBuilder().clearId().build(), headers); + } + }; + ClientTransport intercepted = provider.create(new GrpcTransportConfigBuilder() + .channelFactory(ignored -> channel).addInterceptor(clearingInterceptor).build(), card, + card.supportedInterfaces().get(0)); + try { + assertEquals("task-123", intercepted.getTaskPushNotificationConfiguration( + new GetTaskPushNotificationConfigParams("task-123", "specific"), null).id()); + assertEquals("tasks/task-123/pushNotificationConfigs/task-123", receivedName.get()); + } finally { + intercepted.close(); + } + } finally { + channel.shutdownNow(); + server.shutdownNow(); + assertTrue(channel.awaitTermination(5, TimeUnit.SECONDS)); + assertTrue(server.awaitTermination(5, TimeUnit.SECONDS)); + } + } + @Test void providerTargetsLegacyGrpcBindingAndVersion() { GrpcCompat03ClientTransportProvider provider = new GrpcCompat03ClientTransportProvider(); 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 index c6d3a1d50..37015cfc9 100644 --- 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 @@ -13,6 +13,8 @@ import org.a2aproject.sdk.compat03.client.adapter.Compat03InterceptorSupport; import org.a2aproject.sdk.compat03.client.transport.spi.ClientTransport_v0_3; import org.a2aproject.sdk.compat03.conversion.mappers.domain.TaskMapper_v0_3; +import org.a2aproject.sdk.compat03.spec.MessageSendParams_v0_3; +import org.a2aproject.sdk.compat03.spec.TaskIdParams_v0_3; import org.a2aproject.sdk.grpc.utils.ProtoUtils; import org.a2aproject.sdk.spec.A2AMethods; import org.a2aproject.sdk.spec.AgentCard; @@ -40,11 +42,11 @@ public JSONRPCCompat03ClientTransport(ClientTransport_v0_3 delegate, AgentCard c @Override public EventKind sendMessage(MessageSendParams request, @Nullable ClientCallContext context) { Compat03ClientTransportSupport.validateMessageSend(request); + org.a2aproject.sdk.grpc.SendMessageRequest original = ProtoUtils.ToProto.sendMessageRequest(request); PayloadAndHeaders payload = apply(A2AMethods.SEND_MESSAGE_METHOD, - ProtoUtils.ToProto.sendMessageRequest(request), org.a2aproject.sdk.grpc.SendMessageRequest.class, context); + original, 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())), + adaptMessage(request, original, (org.a2aproject.sdk.grpc.SendMessageRequest) payload.getPayload()), Compat03ClientCallContextMapper.toV03(contextWithHeaders(context, payload))))); } @@ -52,11 +54,11 @@ public EventKind sendMessage(MessageSendParams request, @Nullable ClientCallCont public void sendMessageStreaming(MessageSendParams request, Consumer events, Consumer errors, @Nullable ClientCallContext context) { Compat03ClientTransportSupport.validateMessageSend(request); + org.a2aproject.sdk.grpc.SendMessageRequest original = ProtoUtils.ToProto.sendMessageRequest(request); PayloadAndHeaders payload = apply(A2AMethods.SEND_STREAMING_MESSAGE_METHOD, - ProtoUtils.ToProto.sendMessageRequest(request), org.a2aproject.sdk.grpc.SendMessageRequest.class, context); + original, org.a2aproject.sdk.grpc.SendMessageRequest.class, context); Compat03ClientTransportSupport.run(() -> delegate.sendMessageStreaming( - Compat03ClientTransportSupport.toV03(ProtoUtils.FromProto.messageSendParams( - (org.a2aproject.sdk.grpc.SendMessageRequest) payload.getPayload())), + adaptMessage(request, original, (org.a2aproject.sdk.grpc.SendMessageRequest) payload.getPayload()), event -> events.accept(Compat03ClientTransportSupport.toV10(event)), Compat03ClientTransportSupport.mapAsyncError(errors), Compat03ClientCallContextMapper.toV03(contextWithHeaders(context, payload)))); @@ -76,11 +78,11 @@ public Task getTask(TaskQueryParams request, @Nullable ClientCallContext context @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 original = ProtoUtils.ToProto.cancelTaskRequest(request); + PayloadAndHeaders payload = apply(A2AMethods.CANCEL_TASK_METHOD, original, org.a2aproject.sdk.grpc.CancelTaskRequest.class, context); return Compat03ClientTransportSupport.call(() -> TaskMapper_v0_3.INSTANCE.toV10(delegate.cancelTask( - Compat03ClientTransportSupport.toV03(ProtoUtils.FromProto.cancelTaskParams( - (org.a2aproject.sdk.grpc.CancelTaskRequest) payload.getPayload())), + adaptCancel(request, original, (org.a2aproject.sdk.grpc.CancelTaskRequest) payload.getPayload()), Compat03ClientCallContextMapper.toV03(contextWithHeaders(context, payload))))); } @@ -146,6 +148,28 @@ public void subscribeToTask(TaskIdParams request, Consumer e Compat03ClientCallContextMapper.toV03(contextWithHeaders(context, payload)))); } + private static MessageSendParams_v0_3 adaptMessage(MessageSendParams request, + org.a2aproject.sdk.grpc.SendMessageRequest original, org.a2aproject.sdk.grpc.SendMessageRequest modified) { + MessageSendParams_v0_3 legacy = Compat03ClientTransportSupport.toV03(request); + if (original.equals(modified)) { + return legacy; + } + return JSONRPCCompat03PayloadSupport.preserveUnchangedValues(legacy, + Compat03ClientTransportSupport.toV03(ProtoUtils.FromProto.messageSendParams(original)), + Compat03ClientTransportSupport.toV03(ProtoUtils.FromProto.messageSendParams(modified))); + } + + private static TaskIdParams_v0_3 adaptCancel(CancelTaskParams request, + org.a2aproject.sdk.grpc.CancelTaskRequest original, org.a2aproject.sdk.grpc.CancelTaskRequest modified) { + TaskIdParams_v0_3 legacy = Compat03ClientTransportSupport.toV03(request); + if (original.equals(modified)) { + return legacy; + } + return JSONRPCCompat03PayloadSupport.preserveUnchangedValues(legacy, + Compat03ClientTransportSupport.toV03(ProtoUtils.FromProto.cancelTaskParams(original)), + Compat03ClientTransportSupport.toV03(ProtoUtils.FromProto.cancelTaskParams(modified))); + } + private PayloadAndHeaders apply(String method, Object payload, Class expected, @Nullable ClientCallContext context) { return Compat03InterceptorSupport.apply(interceptors, method, payload, agentCard, context, expected); diff --git a/compat-0.3/client/adapter-jsonrpc/src/main/java/org/a2aproject/sdk/compat03/client/adapter/jsonrpc/JSONRPCCompat03PayloadSupport.java b/compat-0.3/client/adapter-jsonrpc/src/main/java/org/a2aproject/sdk/compat03/client/adapter/jsonrpc/JSONRPCCompat03PayloadSupport.java new file mode 100644 index 000000000..8feabfd0b --- /dev/null +++ b/compat-0.3/client/adapter-jsonrpc/src/main/java/org/a2aproject/sdk/compat03/client/adapter/jsonrpc/JSONRPCCompat03PayloadSupport.java @@ -0,0 +1,148 @@ +package org.a2aproject.sdk.compat03.client.adapter.jsonrpc; + +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +import org.a2aproject.sdk.compat03.spec.DataPart_v0_3; +import org.a2aproject.sdk.compat03.spec.FilePart_v0_3; +import org.a2aproject.sdk.compat03.spec.MessageSendParams_v0_3; +import org.a2aproject.sdk.compat03.spec.Message_v0_3; +import org.a2aproject.sdk.compat03.spec.Part_v0_3; +import org.a2aproject.sdk.compat03.spec.TaskIdParams_v0_3; +import org.a2aproject.sdk.compat03.spec.TextPart_v0_3; +import org.a2aproject.sdk.spec.A2AClientException; +import org.jspecify.annotations.Nullable; + +/** Retains original JSON values in fields that native protobuf interceptors have not changed. */ +final class JSONRPCCompat03PayloadSupport { + private JSONRPCCompat03PayloadSupport() { + } + + static MessageSendParams_v0_3 preserveUnchangedValues(MessageSendParams_v0_3 original, + MessageSendParams_v0_3 before, MessageSendParams_v0_3 after) { + Message_v0_3 source = original.message(); + Message_v0_3 baseline = before.message(); + Message_v0_3 modified = after.message(); + List> parts = modified.parts(); + if (baseline.parts().equals(modified.parts())) { + parts = source.parts(); + } else if (source.parts().size() == 1 && baseline.parts().size() == 1 && modified.parts().size() == 1) { + parts = List.of(restorePart(source.parts().get(0), baseline.parts().get(0), modified.parts().get(0))); + } else if (hasLossyNumbers(source.parts(), baseline.parts())) { + throw ambiguousArray(); + } + Message_v0_3 message = new Message_v0_3(modified.role(), parts, modified.messageId(), modified.contextId(), + modified.taskId(), modified.referenceTaskIds(), + restoreMap(source.metadata(), baseline.metadata(), modified.metadata()), modified.extensions()); + return new MessageSendParams_v0_3(message, after.configuration(), + restoreMap(original.metadata(), before.metadata(), after.metadata())); + } + + static TaskIdParams_v0_3 preserveUnchangedValues(TaskIdParams_v0_3 original, + TaskIdParams_v0_3 before, TaskIdParams_v0_3 after) { + return new TaskIdParams_v0_3(after.id(), restoreMap(original.metadata(), before.metadata(), after.metadata())); + } + + private static Part_v0_3 restorePart(Part_v0_3 original, Part_v0_3 before, Part_v0_3 after) { + if (before.equals(after)) { + return original; + } + @Nullable Map metadata = restoreMap(partMetadata(original), partMetadata(before), partMetadata(after)); + if (after instanceof DataPart_v0_3 modified) { + Map data = modified.data(); + if (original instanceof DataPart_v0_3 source && before instanceof DataPart_v0_3 baseline) { + data = Objects.requireNonNull(restoreMap(source.data(), baseline.data(), modified.data())); + } + return new DataPart_v0_3(data, metadata); + } + if (after instanceof TextPart_v0_3 modified) { + return new TextPart_v0_3(modified.text(), metadata); + } + FilePart_v0_3 modified = (FilePart_v0_3) after; + return new FilePart_v0_3(modified.file(), metadata); + } + + private static @Nullable Map partMetadata(Part_v0_3 part) { + if (part instanceof DataPart_v0_3 data) { + return data.metadata(); + } + if (part instanceof TextPart_v0_3 text) { + return text.metadata(); + } + return ((FilePart_v0_3) part).metadata(); + } + + @SuppressWarnings("unchecked") + private static @Nullable Map restoreMap(@Nullable Map original, + @Nullable Map before, @Nullable Map after) { + return (Map) restore(original, before, after); + } + + private static @Nullable Object restore(@Nullable Object original, @Nullable Object before, @Nullable Object after) { + // Struct/Value uses doubles. Restore unchanged values directly from the original objects, + // avoiding another JSON parse that could itself round large integers or decimal numbers. + if (Objects.equals(before, after)) { + return original; + } + if (original instanceof Map source && before instanceof Map baseline + && after instanceof Map modified) { + Map result = new LinkedHashMap<>(); + modified.forEach((key, value) -> result.put(key, source.containsKey(key) && baseline.containsKey(key) + ? restore(source.get(key), baseline.get(key), value) : value)); + return result; + } + if (original instanceof List source && before instanceof List baseline && after instanceof List modified) { + if (source.size() == 1 && baseline.size() == 1 && modified.size() == 1) { + // Array slots have no identity. Only a singleton has an unambiguous slot. + List<@Nullable Object> result = new ArrayList<>(); + result.add(restore(source.get(0), baseline.get(0), modified.get(0))); + return result; + } + if (hasLossyNumbers(source, baseline)) { + throw ambiguousArray(); + } + } + return after; + } + + private static boolean hasLossyNumbers(@Nullable Object original, @Nullable Object projected) { + if (original instanceof Number source && projected instanceof Number baseline) { + return !Double.isFinite(baseline.doubleValue()) + || new BigDecimal(source.toString()).compareTo(new BigDecimal(baseline.toString())) != 0; + } + if (original instanceof DataPart_v0_3 source && projected instanceof DataPart_v0_3 baseline) { + return hasLossyNumbers(source.metadata(), baseline.metadata()) + || hasLossyNumbers(source.data(), baseline.data()); + } + if (original instanceof TextPart_v0_3 source && projected instanceof TextPart_v0_3 baseline) { + return hasLossyNumbers(source.metadata(), baseline.metadata()); + } + if (original instanceof FilePart_v0_3 source && projected instanceof FilePart_v0_3 baseline) { + return hasLossyNumbers(source.metadata(), baseline.metadata()); + } + if (original instanceof Map source && projected instanceof Map baseline) { + for (var entry : source.entrySet()) { + if (hasLossyNumbers(entry.getValue(), baseline.get(entry.getKey()))) { + return true; + } + } + } + if (original instanceof List source && projected instanceof List baseline) { + for (int i = 0; i < source.size() && i < baseline.size(); i++) { + if (hasLossyNumbers(source.get(i), baseline.get(i))) { + return true; + } + } + } + return false; + } + + private static A2AClientException ambiguousArray() { + return new A2AClientException("Cannot preserve exact JSON numbers after a protobuf interceptor edits " + + "a non-singleton array. Keep that array unchanged or use strings for exact numeric identifiers."); + } +} diff --git a/compat-0.3/client/adapter-jsonrpc/src/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 index 50254b6ec..fa43cbee4 100644 --- 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 @@ -8,7 +8,9 @@ import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; +import java.math.BigDecimal; import java.util.List; +import java.util.Map; import java.util.concurrent.atomic.AtomicReference; import org.a2aproject.sdk.client.transport.jsonrpc.JSONRPCTransport; @@ -33,6 +35,7 @@ import org.a2aproject.sdk.spec.AgentCapabilities; import org.a2aproject.sdk.spec.AgentCard; import org.a2aproject.sdk.spec.AgentInterface; +import org.a2aproject.sdk.spec.CancelTaskParams; import org.a2aproject.sdk.spec.DataPart; import org.a2aproject.sdk.spec.FilePart; import org.a2aproject.sdk.spec.FileWithBytes; @@ -44,6 +47,207 @@ import org.junit.jupiter.api.Test; class JSONRPCCompat03ClientTransportTest { + @Test + void preservesSingletonArrayNumbersAndAppliesMapAdditionsRemovalsAndReplacements() { + long id = 9007199254740993L; + double projectedId = (double) id; + var original = new TaskIdParams_v0_3("task", Map.of( + "items", List.of(Map.of("id", id, "counter", 1L)), "remove", "old", "replace", id)); + var before = new TaskIdParams_v0_3("task", Map.of( + "items", List.of(Map.of("id", projectedId, "counter", 1.0)), "remove", "old", "replace", projectedId)); + var after = new TaskIdParams_v0_3("task", Map.of( + "items", List.of(Map.of("id", projectedId, "counter", 2.0)), "add", true, "replace", 42.0)); + var result = JSONRPCCompat03PayloadSupport.preserveUnchangedValues(original, before, after); + assertEquals(Map.of("items", List.of(Map.of("id", id, "counter", 2.0)), + "add", true, "replace", 42.0), result.metadata()); + } + + @Test + void acceptsLosslessArrayEditsAndTreatsIdenticalProtobufNumbersAsUnchanged() { + var original = new TaskIdParams_v0_3("task", Map.of("ids", List.of(1L, 7L))); + var before = new TaskIdParams_v0_3("task", Map.of("ids", List.of(1.0, 7.0))); + var after = new TaskIdParams_v0_3("task", Map.of("ids", List.of(7.0))); + assertEquals(after, JSONRPCCompat03PayloadSupport.preserveUnchangedValues(original, before, after)); + + // The interceptor contract treats identical protobuf values as unchanged, even for replacements. + var exact = new TaskIdParams_v0_3("task", Map.of("id", 9007199254740993L)); + var projected = new TaskIdParams_v0_3("task", Map.of("id", 9007199254740992.0)); + assertEquals(exact, JSONRPCCompat03PayloadSupport.preserveUnchangedValues(exact, projected, projected)); + } + + @Test + void preservesMetadataWhenAnInterceptorChangesASingletonPartKind() { + ClientCallInterceptor interceptor = new ClientCallInterceptor() { + @Override + public PayloadAndHeaders intercept(String method, Object payload, Map headers, + AgentCard card, ClientCallContext context) { + var request = (org.a2aproject.sdk.grpc.SendMessageRequest) payload; + var part = request.getMessage().getParts(0).toBuilder().setData( + com.google.protobuf.Value.newBuilder().setStructValue( + com.google.protobuf.Struct.newBuilder().putFields("new", + com.google.protobuf.Value.newBuilder().setBoolValue(true).build()))); + return new PayloadAndHeaders(request.toBuilder().setMessage(request.getMessage().toBuilder() + .setParts(0, part)).build(), headers); + } + }; + RecordingDelegate delegate = new RecordingDelegate(); + delegate.response = new Message_v0_3.Builder().role(Message_v0_3.Role.AGENT) + .messageId("response").parts(List.of(new org.a2aproject.sdk.compat03.spec.TextPart_v0_3("ok"))).build(); + var transport = new JSONRPCCompat03ClientTransport(delegate, testCard(), List.of(interceptor)); + var request = new MessageSendParams(new Message(Message.Role.ROLE_USER, + List.of(new TextPart("original", Map.of("id", 9007199254740993L))), + "request", null, null, null, null, null), null, null); + transport.sendMessage(request, null); + var part = assertInstanceOf(org.a2aproject.sdk.compat03.spec.DataPart_v0_3.class, + delegate.sent.message().parts().get(0)); + assertEquals(Map.of("new", true), part.data()); + assertEquals(Map.of("id", 9007199254740993L), part.metadata()); + transport.sendMessageStreaming(request, event -> { }, failure -> { }, null); + part = assertInstanceOf(org.a2aproject.sdk.compat03.spec.DataPart_v0_3.class, + delegate.sent.message().parts().get(0)); + assertEquals(Map.of("id", 9007199254740993L), part.metadata()); + } + + @Test + void rejectsAmbiguousArrayEditsBeforeBlockingOrStreamingDelegation() { + // Removing A and editing B must neither round B's untouched ID nor restore A's stale ID. + assertAmbiguousMutationRejected( + List.of(new DataPart(Map.of("id", 1L, "counter", 1L)), + new DataPart(Map.of("id", 9007199254740993L, "counter", 1L))), + List.of(new DataPart(Map.of("id", 9007199254740992L, "counter", 2L)))); + assertAmbiguousMutationRejected( + List.of(new DataPart(Map.of("id", 9007199254740993L, "counter", 1L)), + new DataPart(Map.of("id", 7L, "counter", 1L))), + List.of(new DataPart(Map.of("id", 9007199254740992L, "counter", 2L)))); + // Both original integers project to the same double, so exact matching cannot identify the survivor. + assertAmbiguousMutationRejected( + List.of(new DataPart(Map.of("nested", List.of(9007199254740992L, 9007199254740993L)))), + List.of(new DataPart(Map.of("nested", List.of(9007199254740992L))))); + assertAmbiguousMutationRejected( + List.of(new DataPart(Map.of("id", 9007199254740993L)), new TextPart("move me")), + List.of(new TextPart("move me"), new DataPart(Map.of("id", 9007199254740992L)))); + } + + private static void assertAmbiguousMutationRejected(List> before, + List> after) { + Message message = new Message(Message.Role.ROLE_USER, before, "request", null, null, null, null, null); + Message replacement = Message.builder(message).parts(after).build(); + ClientCallInterceptor interceptor = new ClientCallInterceptor() { + @Override + public PayloadAndHeaders intercept(String method, Object payload, Map headers, + AgentCard card, ClientCallContext context) { + var request = (org.a2aproject.sdk.grpc.SendMessageRequest) payload; + return new PayloadAndHeaders(request.toBuilder() + .setMessage(org.a2aproject.sdk.grpc.utils.ProtoUtils.ToProto.message(replacement)).build(), headers); + } + }; + RecordingDelegate delegate = new RecordingDelegate(); + var transport = new JSONRPCCompat03ClientTransport(delegate, testCard(), List.of(interceptor)); + var request = new MessageSendParams(message, null, null); + var error = assertThrows(org.a2aproject.sdk.spec.A2AClientException.class, + () -> transport.sendMessage(request, null)); + assertTrue(error.getMessage().contains("non-singleton array")); + assertThrows(org.a2aproject.sdk.spec.A2AClientException.class, + () -> transport.sendMessageStreaming(request, event -> { }, failure -> { }, null)); + assertNull(delegate.sent); + assertFalse(delegate.called); + } + + @Test + void rejectsAmbiguousCancellationMetadataArrayEditsBeforeDelegation() { + ClientCallInterceptor interceptor = new ClientCallInterceptor() { + @Override + public PayloadAndHeaders intercept(String method, Object payload, Map headers, + AgentCard card, ClientCallContext context) { + var request = (org.a2aproject.sdk.grpc.CancelTaskRequest) payload; + var values = request.getMetadata().getFieldsOrThrow("ids").getListValue(); + var remaining = com.google.protobuf.Value.newBuilder().setListValue( + com.google.protobuf.ListValue.newBuilder().addValues(values.getValues(1))).build(); + return new PayloadAndHeaders(request.toBuilder().setMetadata(request.getMetadata().toBuilder() + .putFields("ids", remaining)).build(), headers); + } + }; + RecordingDelegate delegate = new RecordingDelegate(); + var transport = new JSONRPCCompat03ClientTransport(delegate, testCard(), List.of(interceptor)); + assertThrows(org.a2aproject.sdk.spec.A2AClientException.class, () -> transport.cancelTask( + new CancelTaskParams("task", null, Map.of("ids", List.of(9007199254740992L, 9007199254740993L))), null)); + assertNull(delegate.cancelled); + assertFalse(delegate.called); + } + + @Test + void preservesLargeIntegersForBlockingStreamingAndCancellation() throws Exception { + checkNumericPrecision(List.of(), false, false); + checkNumericPrecision(List.of(new ClientCallInterceptor() { + @Override + public PayloadAndHeaders intercept(String method, Object payload, Map headers, + AgentCard card, ClientCallContext context) { + return new PayloadAndHeaders(payload, Map.of("A2A-Extensions", "urn:example:extension")); + } + }), false, true); + checkNumericPrecision(List.of(new ClientCallInterceptor() { + @Override + public PayloadAndHeaders intercept(String method, Object payload, Map headers, + AgentCard card, ClientCallContext context) { + var value = com.google.protobuf.Value.newBuilder().setNumberValue(2).build(); + if (payload instanceof org.a2aproject.sdk.grpc.SendMessageRequest request) { + return new PayloadAndHeaders(request.toBuilder().setMessage(request.getMessage().toBuilder() + .setMessageId("changed")) + .setMetadata(request.getMetadata().toBuilder().putFields("counter", value)).build(), headers); + } + var request = (org.a2aproject.sdk.grpc.CancelTaskRequest) payload; + return new PayloadAndHeaders(request.toBuilder().setId("changed-task") + .setMetadata(request.getMetadata().toBuilder().putFields("counter", value)).build(), headers); + } + }), true, false); + } + + private static void checkNumericPrecision(List interceptors, + boolean changedPayload, boolean extensionHeader) throws Exception { + long id = 9007199254740993L; + Map metadata = Map.of("id", id); + BigDecimal decimal = new BigDecimal("0.12345678901234567890123456789"); + Map requestMetadata = Map.of("id", id, "counter", 1L, "decimal", decimal); + RecordingDelegate delegate = new RecordingDelegate(); + delegate.response = new Message_v0_3.Builder().role(Message_v0_3.Role.AGENT) + .messageId("response").parts(List.of(new org.a2aproject.sdk.compat03.spec.TextPart_v0_3("ok"))).build(); + var transport = new JSONRPCCompat03ClientTransport(delegate, testCard(), interceptors); + var message = new Message(Message.Role.ROLE_USER, + List.of(new DataPart(Map.of("nested", List.of(metadata)), metadata)), + "request", null, null, null, metadata, null); + var request = new MessageSendParams(message, null, requestMetadata); + + transport.sendMessage(request, null); + assertExactNumbers(delegate.sent, id); + assertEquals(changedPayload ? "changed" : "request", delegate.sent.message().messageId()); + assertEquals(changedPayload ? 2.0 : 1.0, ((Number) delegate.sent.metadata().get("counter")).doubleValue()); + assertEquals(decimal, delegate.sent.metadata().get("decimal")); + transport.sendMessageStreaming(request, event -> { }, error -> { throw new AssertionError(error); }, null); + assertExactNumbers(delegate.sent, id); + assertEquals(changedPayload ? "changed" : "request", delegate.sent.message().messageId()); + assertEquals(changedPayload ? 2.0 : 1.0, ((Number) delegate.sent.metadata().get("counter")).doubleValue()); + assertEquals(decimal, delegate.sent.metadata().get("decimal")); + transport.cancelTask(new CancelTaskParams("task", null, requestMetadata), null); + assertEquals(id, delegate.cancelled.metadata().get("id")); + assertEquals(decimal, delegate.cancelled.metadata().get("decimal")); + assertEquals(changedPayload ? 2.0 : 1.0, ((Number) delegate.cancelled.metadata().get("counter")).doubleValue()); + assertTrue(JsonUtil_v0_3.toJson(delegate.cancelled).contains("9007199254740993")); + assertEquals(changedPayload ? "changed-task" : "task", delegate.cancelled.id()); + if (extensionHeader) { + assertEquals("urn:example:extension", delegate.context.getHeaders().get("X-A2A-Extensions")); + } + assertFalse(delegate.context.getHeaders().containsKey("A2A-Extensions")); + } + + private static void assertExactNumbers(MessageSendParams_v0_3 request, long id) throws Exception { + assertEquals(id, request.metadata().get("id")); + assertEquals(Map.of("id", id), request.message().metadata()); + var part = assertInstanceOf(org.a2aproject.sdk.compat03.spec.DataPart_v0_3.class, request.message().parts().get(0)); + assertEquals(Map.of("id", id), part.metadata()); + assertEquals(Map.of("nested", List.of(Map.of("id", id))), part.data()); + assertTrue(JsonUtil_v0_3.toJson(request).contains("9007199254740993")); + } + @Test void providerTargetsJsonRpcAndOrdinaryJsonRpcConfiguration() { JSONRPCCompat03ClientTransportProvider provider = new JSONRPCCompat03ClientTransportProvider(); @@ -198,20 +402,33 @@ private static AgentCard cardWithInterfaceTenant() { private static final class RecordingDelegate implements ClientTransport_v0_3 { boolean called; Message_v0_3 response; + MessageSendParams_v0_3 sent; + TaskIdParams_v0_3 cancelled; + ClientCallContext_v0_3 context; @Override public EventKind_v0_3 sendMessage(MessageSendParams_v0_3 request, ClientCallContext_v0_3 context) { called = true; + sent = request; + this.context = context; assertNotNull(response); return response; } @Override public void sendMessageStreaming(MessageSendParams_v0_3 request, java.util.function.Consumer events, java.util.function.Consumer errors, ClientCallContext_v0_3 context) { called = true; + sent = request; + this.context = context; assertNotNull(response); events.accept(response); } @Override public org.a2aproject.sdk.compat03.spec.Task_v0_3 getTask(TaskQueryParams_v0_3 request, ClientCallContext_v0_3 context) { called = true; return null; } - @Override public org.a2aproject.sdk.compat03.spec.Task_v0_3 cancelTask(TaskIdParams_v0_3 request, ClientCallContext_v0_3 context) { return null; } + @Override public org.a2aproject.sdk.compat03.spec.Task_v0_3 cancelTask(TaskIdParams_v0_3 request, ClientCallContext_v0_3 context) { + cancelled = request; + this.context = context; + return new org.a2aproject.sdk.compat03.spec.Task_v0_3(request.id(), "context", + new org.a2aproject.sdk.compat03.spec.TaskStatus_v0_3(org.a2aproject.sdk.compat03.spec.TaskState_v0_3.CANCELED), + List.of(), List.of(), null); + } @Override public TaskPushNotificationConfig_v0_3 setTaskPushNotificationConfiguration(TaskPushNotificationConfig_v0_3 request, ClientCallContext_v0_3 context) { return null; } @Override public TaskPushNotificationConfig_v0_3 getTaskPushNotificationConfiguration(GetTaskPushNotificationConfigParams_v0_3 request, ClientCallContext_v0_3 context) { return null; } @Override public List listTaskPushNotificationConfigurations(ListTaskPushNotificationConfigParams_v0_3 request, ClientCallContext_v0_3 context) { return List.of(); } 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 index c3a2cd010..c3eb74691 100644 --- 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 @@ -1,8 +1,11 @@ package org.a2aproject.sdk.compat03.client.adapter; +import java.util.HashMap; import java.util.Map; import org.a2aproject.sdk.client.transport.spi.interceptors.ClientCallContext; +import org.a2aproject.sdk.common.A2AHeaders; +import org.a2aproject.sdk.compat03.common.A2AHeaders_v0_3; import org.a2aproject.sdk.compat03.client.transport.spi.interceptors.ClientCallContext_v0_3; import org.jspecify.annotations.Nullable; @@ -16,6 +19,19 @@ private Compat03ClientCallContextMapper() { if (context == null) { return null; } - return new ClientCallContext_v0_3(Map.copyOf(context.getState()), Map.copyOf(context.getHeaders())); + Map headers = new HashMap<>(); + context.getHeaders().forEach((name, value) -> { + if (!A2AHeaders.A2A_EXTENSIONS.equalsIgnoreCase(name)) { + headers.put(A2AHeaders_v0_3.X_A2A_EXTENSIONS.equalsIgnoreCase(name) + ? A2AHeaders_v0_3.X_A2A_EXTENSIONS : name, value); + } + }); + // The native header, including interceptor replacements, takes precedence over a legacy alias. + context.getHeaders().forEach((name, value) -> { + if (A2AHeaders.A2A_EXTENSIONS.equalsIgnoreCase(name)) { + headers.put(A2AHeaders_v0_3.X_A2A_EXTENSIONS, value); + } + }); + return new ClientCallContext_v0_3(Map.copyOf(context.getState()), Map.copyOf(headers)); } } diff --git a/compat-0.3/client/adapter/src/main/java/org/a2aproject/sdk/compat03/client/adapter/Compat03ClientTransportSupport.java b/compat-0.3/client/adapter/src/main/java/org/a2aproject/sdk/compat03/client/adapter/Compat03ClientTransportSupport.java index 69e1907c7..f7cdbe6e8 100644 --- 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 @@ -179,7 +179,10 @@ public static TaskPushNotificationConfig_v0_3 toV03(TaskPushNotificationConfig r public static GetTaskPushNotificationConfigParams_v0_3 toV03( org.a2aproject.sdk.spec.GetTaskPushNotificationConfigParams request) { validateGetPush(request); - return new GetTaskPushNotificationConfigParams_v0_3(request.taskId(), request.id()); + @Nullable String id = request.id(); + // An omitted optional protobuf string is read back as empty after interceptors. + return new GetTaskPushNotificationConfigParams_v0_3(request.taskId(), + id == null || id.isEmpty() ? null : id); } public static ListTaskPushNotificationConfigParams_v0_3 toV03( 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 index a516f2985..5f60e8f0c 100644 --- 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 @@ -4,15 +4,20 @@ import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; import java.util.List; import java.util.Map; import java.util.Set; +import java.util.concurrent.atomic.AtomicInteger; +import com.sun.net.httpserver.HttpServer; +import org.a2aproject.sdk.client.http.A2ACardResolver; import org.a2aproject.sdk.compat03.json.JsonUtil_v0_3; import org.a2aproject.sdk.compat03.spec.AgentCapabilities_v0_3; import org.a2aproject.sdk.compat03.spec.AgentCard_v0_3; -import org.a2aproject.sdk.compat03.spec.AgentSkill_v0_3; import org.a2aproject.sdk.compat03.spec.AgentInterface_v0_3; +import org.a2aproject.sdk.compat03.spec.AgentSkill_v0_3; import org.a2aproject.sdk.compat03.spec.HTTPAuthSecurityScheme_v0_3; import org.a2aproject.sdk.compat03.spec.ImplicitOAuthFlow_v0_3; import org.a2aproject.sdk.compat03.spec.OAuth2SecurityScheme_v0_3; @@ -23,6 +28,50 @@ import org.junit.jupiter.api.Test; class Compat03AgentCardCompatibilityParserTest { + @Test + void resolverUsesProductionParserWithARealLegacyCardAndOneFetch() throws Exception { + AtomicInteger fetches = new AtomicInteger(); + AgentCard card = resolveCard(JsonUtil_v0_3.toJson(primaryOnlyCard("rest")), fetches); + assertEquals("legacy", card.name()); + assertEquals("HTTP+JSON", card.supportedInterfaces().get(0).protocolBinding()); + assertEquals("0.3", card.supportedInterfaces().get(0).protocolVersion()); + assertEquals(1, fetches.get()); + } + + @Test + void resolverRejectsMalformedShapeAndUnrequestedLegacyVersion() throws Exception { + AtomicInteger fetches = new AtomicInteger(); + assertThrows(A2AClientJSONError.class, () -> resolveCard("{\"legacy\":true}", fetches)); + assertEquals(1, fetches.get()); + fetches.set(0); + String unsupported = JsonUtil_v0_3.toJson(primaryOnlyCard("rest")) + .replace("\"0.3.0\"", "\"0.2.0\""); + assertThrows(A2AClientJSONError.class, () -> resolveCard(unsupported, fetches)); + assertEquals(1, fetches.get()); + } + + private static AgentCard resolveCard(String rawCard, AtomicInteger fetches) throws Exception { + HttpServer server = HttpServer.create(new InetSocketAddress("localhost", 0), 0); + server.createContext("/.well-known/agent-card.json", exchange -> { + fetches.incrementAndGet(); + byte[] body = rawCard.getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().set("Content-Type", "application/json"); + exchange.sendResponseHeaders(200, body.length); + try (var output = exchange.getResponseBody()) { + output.write(body); + } finally { + exchange.close(); + } + }); + server.start(); + try { + return A2ACardResolver.builder().baseUrl("http://localhost:" + server.getAddress().getPort()) + .supportedProtocolVersions(Set.of("1.0", "0.3")).build().getAgentCard(); + } finally { + server.stop(0); + } + } + @Test void parsesDeclaredPatchVersionAndProjectsInterface() throws Exception { AgentCard_v0_3 card = new AgentCard_v0_3.Builder() diff --git a/compat-0.3/client/adapter/src/test/java/org/a2aproject/sdk/compat03/client/adapter/Compat03ClientCallContextMapperTest.java b/compat-0.3/client/adapter/src/test/java/org/a2aproject/sdk/compat03/client/adapter/Compat03ClientCallContextMapperTest.java new file mode 100644 index 000000000..f942c5281 --- /dev/null +++ b/compat-0.3/client/adapter/src/test/java/org/a2aproject/sdk/compat03/client/adapter/Compat03ClientCallContextMapperTest.java @@ -0,0 +1,48 @@ +package org.a2aproject.sdk.compat03.client.adapter; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.util.List; +import java.util.Map; + +import org.a2aproject.sdk.client.transport.spi.interceptors.ClientCallContext; +import org.a2aproject.sdk.client.transport.spi.interceptors.ClientCallInterceptor; +import org.a2aproject.sdk.client.transport.spi.interceptors.PayloadAndHeaders; +import org.a2aproject.sdk.spec.AgentCard; +import org.junit.jupiter.api.Test; + +class Compat03ClientCallContextMapperTest { + @Test + void translatesExtensionHeadersWithoutChangingTheNativeContext() { + for (String name : List.of("A2A-Extensions", "a2a-extensions", "A2A-EXTENSIONS")) { + ClientCallContext context = new ClientCallContext(Map.of("request-id", "request-1"), + Map.of(name, "urn:example:extension", "Authorization", "Bearer token")); + + var legacy = Compat03ClientCallContextMapper.toV03(context); + + assertEquals(Map.of("X-A2A-Extensions", "urn:example:extension", + "Authorization", "Bearer token"), legacy.getHeaders()); + assertEquals(context.getState(), legacy.getState()); + assertEquals("urn:example:extension", context.getHeaders().get(name)); + } + } + + @Test + void translatesExtensionsAfterNativeInterceptorsAndPrefersTheNativeHeader() { + ClientCallInterceptor interceptor = new ClientCallInterceptor() { + @Override + public PayloadAndHeaders intercept(String method, Object payload, Map headers, + AgentCard card, ClientCallContext context) { + assertEquals("urn:original", headers.get("A2A-Extensions")); + return new PayloadAndHeaders(payload, + Map.of("a2a-extensions", "urn:replacement", "x-a2a-extensions", "urn:stale")); + } + }; + var result = Compat03InterceptorSupport.apply(List.of(interceptor), "message/send", "payload", null, + new ClientCallContext(Map.of(), Map.of("A2A-Extensions", "urn:original")), String.class); + + var legacy = Compat03ClientCallContextMapper.toV03(new ClientCallContext(Map.of(), result.getHeaders())); + + assertEquals(Map.of("X-A2A-Extensions", "urn:replacement"), legacy.getHeaders()); + } +} diff --git a/compat-0.3/client/adapter/src/test/java/org/a2aproject/sdk/compat03/client/adapter/Compat03ClientTransportSupportTest.java b/compat-0.3/client/adapter/src/test/java/org/a2aproject/sdk/compat03/client/adapter/Compat03ClientTransportSupportTest.java index 7f23a6515..f25208ca9 100644 --- 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 @@ -15,6 +15,7 @@ import org.a2aproject.sdk.client.transport.spi.interceptors.ClientCallContext; import org.a2aproject.sdk.compat03.client.transport.spi.interceptors.ClientCallContext_v0_3; import org.a2aproject.sdk.compat03.spec.A2AClientException_v0_3; +import org.a2aproject.sdk.compat03.spec.EventKind_v0_3; import org.a2aproject.sdk.spec.A2AClientException; import org.a2aproject.sdk.spec.CancelTaskParams; import org.a2aproject.sdk.spec.DataPart; @@ -31,6 +32,21 @@ class Compat03ClientTransportSupportTest { + @Test + void normalizesDefaultPushConfigurationIdAfterProtobufConversion() { + var original = new org.a2aproject.sdk.spec.GetTaskPushNotificationConfigParams("task"); + var proto = org.a2aproject.sdk.grpc.utils.ProtoUtils.ToProto.getTaskPushNotificationConfigRequest(original); + assertNull(Compat03ClientTransportSupport.toV03( + org.a2aproject.sdk.grpc.utils.ProtoUtils.FromProto.getTaskPushNotificationConfigParams(proto)) + .pushNotificationConfigId()); + assertEquals("specific", Compat03ClientTransportSupport.toV03( + org.a2aproject.sdk.grpc.utils.ProtoUtils.FromProto.getTaskPushNotificationConfigParams( + proto.toBuilder().setId("specific").build())).pushNotificationConfigId()); + assertNull(Compat03ClientTransportSupport.toV03( + org.a2aproject.sdk.grpc.utils.ProtoUtils.FromProto.getTaskPushNotificationConfigParams( + proto.toBuilder().setId("specific").clearId().build())).pushNotificationConfigId()); + } + @Test void rejectsUnsupportedOperationsBeforeDelegateUse() { assertThrows(A2AClientException.class, @@ -89,7 +105,7 @@ void preservesNullValuedDataAndMetadataAcrossVersionConversion() { assertEquals(values, data.metadata()); assertEquals(values, legacy.message().metadata()); assertEquals(values, legacy.metadata()); - assertEquals(request.message(), Compat03ClientTransportSupport.toV10(legacy.message())); + assertEquals(request.message(), Compat03ClientTransportSupport.toV10((EventKind_v0_3) legacy.message())); } @Test 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 22ce73660..40d9bdadb 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 @@ -362,17 +362,8 @@ private String getTaskPushNotificationConfigName(GetTaskPushNotificationConfigPa } private String getTaskPushNotificationConfigName(String taskId, @Nullable String pushNotificationConfigId) { - StringBuilder name = new StringBuilder(); - name.append("tasks/"); - name.append(taskId); - if (pushNotificationConfigId != null) { - name.append("/pushNotificationConfigs/"); - name.append(pushNotificationConfigId); - } - //name.append("/pushNotificationConfigs/"); - // Use taskId as default config ID if none provided - //name.append(pushNotificationConfigId != null ? pushNotificationConfigId : taskId); - return name.toString(); + String configId = pushNotificationConfigId != null ? pushNotificationConfigId : taskId; + return "tasks/" + taskId + "/pushNotificationConfigs/" + configId; } private PayloadAndHeaders_v0_3 applyInterceptors(String methodName, Object payload, diff --git a/compat-0.3/client/transport/rest/src/test/java/org/a2aproject/sdk/compat03/client/transport/rest/RestTransport_v0_3_Test.java b/compat-0.3/client/transport/rest/src/test/java/org/a2aproject/sdk/compat03/client/transport/rest/RestTransport_v0_3_Test.java index 6b88ff023..cf9de55f1 100644 --- a/compat-0.3/client/transport/rest/src/test/java/org/a2aproject/sdk/compat03/client/transport/rest/RestTransport_v0_3_Test.java +++ b/compat-0.3/client/transport/rest/src/test/java/org/a2aproject/sdk/compat03/client/transport/rest/RestTransport_v0_3_Test.java @@ -233,7 +233,7 @@ public void testGetTask() throws Exception { assertEquals(Kind.FILE, part.kind()); assertEquals("text/plain", part.file().mimeType()); assertEquals("greeting.txt", part.file().name()); - assertEquals("hello", ((FileWithBytes_v0_3) part.file()).bytes()); + assertEquals("aGVsbG8=", ((FileWithBytes_v0_3) part.file()).bytes()); assertNull(history.metadata()); assertNull(history.referenceTaskIds()); } diff --git a/compat-0.3/reference/grpc/src/test/java/org/a2aproject/sdk/compat03/server/grpc/quarkus/A2ATestResource_v0_3.java b/compat-0.3/reference/grpc/src/test/java/org/a2aproject/sdk/compat03/server/grpc/quarkus/A2ATestResource_v0_3.java index 971d09afe..ebdf0a9ff 100644 --- a/compat-0.3/reference/grpc/src/test/java/org/a2aproject/sdk/compat03/server/grpc/quarkus/A2ATestResource_v0_3.java +++ b/compat-0.3/reference/grpc/src/test/java/org/a2aproject/sdk/compat03/server/grpc/quarkus/A2ATestResource_v0_3.java @@ -14,6 +14,7 @@ import jakarta.ws.rs.Path; import jakarta.ws.rs.PathParam; import jakarta.ws.rs.Produces; +import jakarta.ws.rs.QueryParam; import jakarta.ws.rs.core.HttpHeaders; import jakarta.ws.rs.core.MediaType; import jakarta.ws.rs.core.Response; @@ -81,8 +82,13 @@ public Response deleteTask(@PathParam("taskId") String taskId) { @POST @Path("/queue/ensure/{taskId}") - public Response ensureQueue(@PathParam("taskId") String taskId) { - testUtilsBean.ensureQueue(taskId); + public Response ensureQueue(@PathParam("taskId") String taskId, + @QueryParam("withoutConsumer") boolean withoutConsumer) { + if (withoutConsumer) { + testUtilsBean.ensureQueueWithoutConsumer(taskId); + } else { + testUtilsBean.ensureQueue(taskId); + } return Response.ok().build(); } diff --git a/compat-0.3/reference/grpc/src/test/java/org/a2aproject/sdk/compat03/server/grpc/quarkus/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 index bc2dbbb79..1f1469170 100644 --- 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 @@ -2,11 +2,13 @@ import java.util.List; import java.util.Map; +import java.util.concurrent.CopyOnWriteArrayList; import java.util.concurrent.TimeUnit; import io.grpc.ManagedChannel; import io.grpc.ManagedChannelBuilder; import io.quarkus.test.junit.QuarkusTest; +import io.quarkus.test.junit.TestProfile; import org.a2aproject.sdk.client.ClientBuilder; import org.a2aproject.sdk.client.transport.grpc.GrpcTransport; import org.a2aproject.sdk.client.transport.grpc.GrpcTransportConfigBuilder; @@ -15,22 +17,22 @@ 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; + private static final List channels = new CopyOnWriteArrayList<>(); public QuarkusA2AGrpc_v0_3_CompatibilityTest() { super(8081); } @Override protected String getTransportProtocol() { return TransportProtocol.GRPC.asString(); } @Override protected String getTransportUrl() { return "localhost:8081"; } @Override protected void configureTransport(ClientBuilder builder) { builder.withTransport(GrpcTransport.class, new GrpcTransportConfigBuilder().channelFactory(target -> { - channel = ManagedChannelBuilder.forTarget(target).usePlaintext().build(); - return channel; + ManagedChannel created = ManagedChannelBuilder.forTarget(target).usePlaintext().build(); + channels.add(created); + return created; })); } @Override protected AgentCard getAgentCard() { return card(false); } @@ -50,11 +52,17 @@ static AgentCard card(boolean auth) { } @AfterAll - public static void closeChannel() { - if (channel != null) { - channel.shutdownNow(); - try { channel.awaitTermination(10, TimeUnit.SECONDS); } - catch (InterruptedException e) { Thread.currentThread().interrupt(); } + public static void closeChannels() { + // Shut down every caller-owned channel before waiting for any one of them. + channels.forEach(ManagedChannel::shutdownNow); + try { + for (ManagedChannel channel : channels) { + channel.awaitTermination(10, TimeUnit.SECONDS); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } finally { + channels.clear(); } } } diff --git a/compat-0.3/reference/grpc/src/test/java/org/a2aproject/sdk/compat03/server/grpc/quarkus/QuarkusA2AGrpc_v0_3_WithAuthCompatibilityTest.java b/compat-0.3/reference/grpc/src/test/java/org/a2aproject/sdk/compat03/server/grpc/quarkus/QuarkusA2AGrpc_v0_3_WithAuthCompatibilityTest.java index 560da8dd0..2f424187b 100644 --- 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 @@ -1,5 +1,7 @@ package org.a2aproject.sdk.compat03.server.grpc.quarkus; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; import java.util.concurrent.TimeUnit; import io.grpc.ManagedChannel; @@ -10,7 +12,6 @@ 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; @@ -20,8 +21,7 @@ @TestProfile(CompatibilityAuthTestProfile_v0_3.class) public class QuarkusA2AGrpc_v0_3_WithAuthCompatibilityTest extends AbstractA2AServerCompatibilityWithAuthTest_v0_3 { - private static ManagedChannel authenticatedChannel; - private static ManagedChannel unauthenticatedChannel; + private static final List channels = new CopyOnWriteArrayList<>(); public QuarkusA2AGrpc_v0_3_WithAuthCompatibilityTest() { super(8081); } @Override protected String getTransportProtocol() { return TransportProtocol.GRPC.asString(); } @@ -31,30 +31,33 @@ public class QuarkusA2AGrpc_v0_3_WithAuthCompatibilityTest } @Override protected void configureTransport(ClientBuilder builder) { builder.withTransport(GrpcTransport.class, new GrpcTransportConfigBuilder().channelFactory(target -> { - unauthenticatedChannel = ManagedChannelBuilder.forTarget(target).usePlaintext().build(); - return unauthenticatedChannel; + ManagedChannel created = ManagedChannelBuilder.forTarget(target).usePlaintext().build(); + channels.add(created); + return created; })); } @Override protected void configureTransportWithAuth(ClientBuilder builder) { builder.withTransport(GrpcTransport.class, new GrpcTransportConfigBuilder() .channelFactory(target -> { - authenticatedChannel = ManagedChannelBuilder.forTarget(target).usePlaintext().build(); - return authenticatedChannel; + ManagedChannel created = ManagedChannelBuilder.forTarget(target).usePlaintext().build(); + channels.add(created); + return created; }).addInterceptor(new AuthInterceptor( (scheme, context) -> BASIC_AUTH_SCHEME_NAME.equals(scheme) ? getEncodedCredentials() : null))); } @AfterAll public static void closeChannels() { - 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(); } + // Shut down every caller-owned channel before waiting for any one of them. + channels.forEach(ManagedChannel::shutdownNow); + try { + for (ManagedChannel channel : channels) { + channel.awaitTermination(10, TimeUnit.SECONDS); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } finally { + channels.clear(); } } } diff --git a/compat-0.3/reference/jsonrpc/src/test/java/org/a2aproject/sdk/compat03/server/apps/quarkus/A2ATestRoutes_v0_3.java b/compat-0.3/reference/jsonrpc/src/test/java/org/a2aproject/sdk/compat03/server/apps/quarkus/A2ATestRoutes_v0_3.java index ab460d8b1..94f8d430d 100644 --- a/compat-0.3/reference/jsonrpc/src/test/java/org/a2aproject/sdk/compat03/server/apps/quarkus/A2ATestRoutes_v0_3.java +++ b/compat-0.3/reference/jsonrpc/src/test/java/org/a2aproject/sdk/compat03/server/apps/quarkus/A2ATestRoutes_v0_3.java @@ -173,7 +173,11 @@ public void deleteTask(String taskId, RoutingContext rc) { public void ensureTaskQueue(String taskId, RoutingContext rc) { try { - testUtilsBean.ensureQueue(taskId); + if (Boolean.parseBoolean(rc.request().getParam("withoutConsumer"))) { + testUtilsBean.ensureQueueWithoutConsumer(taskId); + } else { + testUtilsBean.ensureQueue(taskId); + } rc.response() .setStatusCode(200) .end(); diff --git a/compat-0.3/reference/rest/src/test/java/org/a2aproject/sdk/compat03/server/rest/quarkus/A2ATestRoutes_v0_3.java b/compat-0.3/reference/rest/src/test/java/org/a2aproject/sdk/compat03/server/rest/quarkus/A2ATestRoutes_v0_3.java index 753cca76f..823fa4a11 100644 --- a/compat-0.3/reference/rest/src/test/java/org/a2aproject/sdk/compat03/server/rest/quarkus/A2ATestRoutes_v0_3.java +++ b/compat-0.3/reference/rest/src/test/java/org/a2aproject/sdk/compat03/server/rest/quarkus/A2ATestRoutes_v0_3.java @@ -173,7 +173,11 @@ public void deleteTask(String taskId, RoutingContext rc) { public void ensureTaskQueue(String taskId, RoutingContext rc) { try { - testUtilsBean.ensureQueue(taskId); + if (Boolean.parseBoolean(rc.request().getParam("withoutConsumer"))) { + testUtilsBean.ensureQueueWithoutConsumer(taskId); + } else { + testUtilsBean.ensureQueue(taskId); + } rc.response() .setStatusCode(200) .end(); diff --git a/compat-0.3/server-conversion/src/test/java/org/a2aproject/sdk/compat03/conversion/TestUtilsBean_v0_3.java b/compat-0.3/server-conversion/src/test/java/org/a2aproject/sdk/compat03/conversion/TestUtilsBean_v0_3.java index 3800880d4..7e8c61756 100644 --- a/compat-0.3/server-conversion/src/test/java/org/a2aproject/sdk/compat03/conversion/TestUtilsBean_v0_3.java +++ b/compat-0.3/server-conversion/src/test/java/org/a2aproject/sdk/compat03/conversion/TestUtilsBean_v0_3.java @@ -3,6 +3,8 @@ import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; +import org.a2aproject.sdk.server.events.EventQueue; +import org.a2aproject.sdk.server.events.NoTaskQueueException; import org.a2aproject.sdk.server.events.QueueManager; import org.a2aproject.sdk.server.tasks.PushNotificationConfigStore; import org.a2aproject.sdk.server.tasks.TaskStore; @@ -44,13 +46,31 @@ public Task getTask(String taskId) { } public void deleteTask(String taskId) { - taskStore.delete(taskId); + EventQueue queue = queueManager.get(taskId); + try { + if (queue != null) { + // Stop every child consumer, including subscriptions to non-final tasks. + queue.close(true); + } + try { + queueManager.close(taskId); + } catch (NoTaskQueueException ignored) { + // Finalization may already have removed the queue, or none was created. + } + } finally { + taskStore.delete(taskId); + } } public void ensureQueue(String taskId) { queueManager.createOrTap(taskId); } + public void ensureQueueWithoutConsumer(String taskId) { + // createOrTap returns a child; release only that setup child and retain the non-final main queue. + queueManager.createOrTap(taskId).close(); + } + public void enqueueEvent(String taskId, Event event) { queueManager.get(taskId).enqueueEvent(event); } diff --git a/docs/content/dev/compatibility.md b/docs/content/dev/compatibility.md index 58e66e4d2..171a042a3 100644 --- a/docs/content/dev/compatibility.md +++ b/docs/content/dev/compatibility.md @@ -122,8 +122,13 @@ Client client = Client.builder(agentCard) 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 versioned transport-provider SPI. With the default server preference, +the builder selects the first usable interface in the card's order. Requesting +both versions does not give 1.0 priority over an earlier 0.3 interface. With +client transport preference enabled, the builder first restricts selection to +usable 1.0 interfaces if any configured binding provides one, then applies +configured binding order. It considers 0.3 only when no such 1.0 interface is +available. The adapter rejects 1.0 operations that have no 0.3 equivalent (such as `listTasks`), non-empty tenant values, extended-agent-card retrieval, and @@ -133,6 +138,18 @@ also reject non-empty `Message.referenceTaskIds` and cancellation metadata, which the 0.3 protobuf schema cannot represent. JSON-RPC preserves these fields. Validation also applies to requests modified by interceptors. +JSON-RPC retains the original JSON numbers in fields whose protobuf values +interceptors leave unchanged. Interceptors see protobuf doubles. Fields with +identical protobuf values are treated as unchanged, including replacements or +reordering of numbers with the same double representation. Such mutations are +unsupported; use a string for +an identifier that must be edited with exact precision. If an interceptor edits +an array containing numbers that protobuf cannot represent exactly, the adapter +rejects the request unless both the original and modified array contain a single +element. This also applies to message parts and arrays in data or metadata. +Unchanged arrays retain their original values. REST and gRPC use protobuf +numeric precision throughout. + If 0.3 is not requested, the optional parser is not used. If it is requested but the parser or binding adapter is absent, discovery or client construction fails with an actionable error identifying the missing optional artifact. 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 index 40bdb4af3..4e993171e 100644 --- a/docs/superpowers/specs/2026-09-14-client-v03-compatibility-design.md +++ b/docs/superpowers/specs/2026-09-14-client-v03-compatibility-design.md @@ -46,7 +46,7 @@ Applications continue to use only 1.0 `Client`, `AgentCard`, request/result, eve 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. +For a cohosted server, the documented dual-format card remains unchanged. With the default server preference, the first usable interface in card order wins, including a 0.3 interface before a 1.0 interface. With client transport preference enabled, selection is restricted to usable 1.0 interfaces across configured bindings if any exist, and configured binding order then determines the match. Only when no such native interface exists does this mode consider 0.3. The legacy fields continue to support existing 0.3 clients. This reflects the implemented ordering, replacing the original design's unconditional native preference. 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. @@ -100,7 +100,7 @@ The normal 1.0 client artifacts have no compile-time dependency on 0.3 client, s | 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. | +| Dual-format/cohosted card | Server preference respects card interface order; client preference prioritizes usable native interfaces across configured bindings. | | 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. | 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 index 4af1d7e96..ca4df9af0 100644 --- 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 @@ -232,28 +232,44 @@ private void sendMessageWithInlinePushConfiguration(boolean streaming) throws Ex for (String taskId : new String[] {null, ""}) { TaskPushNotificationConfig push = new TaskPushNotificationConfig( "inline-config", taskId, "http://localhost:" + serverPort + "/callback", null, null, null); - MessageSendParams request = new MessageSendParams(MESSAGE, + Message taskMessage = Message.builder(MESSAGE).parts(new TextPart("#a2a-delegated#inline push")).build(); + MessageSendParams request = new MessageSendParams(taskMessage, MessageSendConfiguration.builder().taskPushNotificationConfig(push).build(), null); CountDownLatch latch = new CountDownLatch(1); - AtomicReference received = new AtomicReference<>(); + AtomicReference received = new AtomicReference<>(); AtomicReference error = new AtomicReference<>(); - - sendingClient.sendMessage(request, List.of((event, card) -> { - if (event instanceof MessageEvent messageEvent) { - received.set(messageEvent.getMessage()); - latch.countDown(); - } - }), throwable -> { - if (throwable != null && !isStreamClosedError(throwable)) { - error.set(throwable); - latch.countDown(); + try { + sendingClient.sendMessage(request, List.of((event, card) -> { + Task task = event instanceof TaskEvent initial ? initial.getTask() + : event instanceof TaskUpdateEvent update ? update.getTask() : null; + if (task != null) { + received.set(task); + if (task.status().state() == TaskState.TASK_STATE_COMPLETED) { + latch.countDown(); + } + } + }), throwable -> { + if (throwable != null && !isStreamClosedError(throwable)) { + error.set(throwable); + latch.countDown(); + } + }, null); + + assertTrue(latch.await(10, TimeUnit.SECONDS)); + assertNull(error.get()); + assertNotNull(received.get()); + assertEquals(TaskState.TASK_STATE_COMPLETED, received.get().status().state()); + TaskPushNotificationConfig stored = getClient().getTaskPushNotificationConfiguration( + new GetTaskPushNotificationConfigParams(received.get().id(), "inline-config")); + assertEquals("inline-config", stored.id()); + assertEquals(received.get().id(), stored.taskId()); + assertEquals(push.url(), stored.url()); + } finally { + if (received.get() != null) { + deletePushNotificationConfigInStore(received.get().id(), "inline-config"); + deleteTaskInTaskStore(received.get().id()); } - }, null); - - assertTrue(latch.await(10, TimeUnit.SECONDS)); - assertNull(error.get()); - assertNotNull(received.get()); - assertEquals(MESSAGE.messageId(), received.get().messageId()); + } } } @@ -337,6 +353,34 @@ public void testGetPushNotificationSuccess() throws Exception { } } + @Test + public void testDefaultPushConfigurationLookupWithSeveralConfigurations() throws Exception { + saveTaskInTaskStore(MINIMAL_TASK); + try { + savePushNotificationConfigInStore(MINIMAL_TASK.id(), pushConfig("other-1", "http://example.com/one")); + savePushNotificationConfigInStore(MINIMAL_TASK.id(), pushConfig("other-2", "http://example.com/two")); + // REST/gRPC turn the default into an explicit ID on the server, permitting legacy fallback. + // JSON-RPC preserves omission and must not take that explicit-ID fallback. + if ("JSONRPC".equals(getTransportProtocol())) { + A2AClientException missing = org.junit.jupiter.api.Assertions.assertThrows(A2AClientException.class, + () -> getClient().getTaskPushNotificationConfiguration( + new GetTaskPushNotificationConfigParams(MINIMAL_TASK.id()))); + assertInstanceOf(TaskNotFoundError.class, missing.getCause()); + } + savePushNotificationConfigInStore(MINIMAL_TASK.id(), + pushConfig(MINIMAL_TASK.id(), "http://example.com/default")); + TaskPushNotificationConfig result = getClient().getTaskPushNotificationConfiguration( + new GetTaskPushNotificationConfigParams(MINIMAL_TASK.id())); + assertEquals(MINIMAL_TASK.id(), result.id()); + assertEquals("http://example.com/default", result.url()); + } finally { + deletePushNotificationConfigInStore(MINIMAL_TASK.id(), "other-1"); + deletePushNotificationConfigInStore(MINIMAL_TASK.id(), "other-2"); + deletePushNotificationConfigInStore(MINIMAL_TASK.id(), MINIMAL_TASK.id()); + deleteTaskInTaskStore(MINIMAL_TASK.id()); + } + } + @Test public void testError() throws Exception { saveTaskInTaskStore(SEND_MESSAGE_NOT_SUPPORTED); @@ -517,12 +561,80 @@ public void testMainQueueReferenceCountingWithMultipleConsumers() throws Excepti @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)); + Client polling = getPollingClient(); + List> calls = new ArrayList<>(); + List tasks = new ArrayList<>(); + try { + for (int i = 0; i < 2; i++) { + Task task = Task.builder(MINIMAL_TASK).id("compat-held-task-" + i).build(); + tasks.add(task); + saveTaskInTaskStore(task); + Message message = Message.builder(MESSAGE).messageId("non-blocking-" + i) + .taskId(task.id()).contextId(task.contextId()).parts(new TextPart("compat-hold:")).build(); + calls.add(CompletableFuture.runAsync(() -> polling.sendMessage(message, + List.of((event, card) -> assertInstanceOf(TaskEvent.class, event)), null))); + } + for (int i = 0; i < calls.size(); i++) { + // The executor is still held; a blocking request cannot complete within this deadline. + calls.get(i).get(10, TimeUnit.SECONDS); + Task working = awaitTaskState(tasks.get(i).id(), TaskState.TASK_STATE_WORKING); + assertEquals(TaskState.TASK_STATE_WORKING, working.status().state()); + } + } finally { + cleanupHeldTasks(tasks, calls); + } + } + + private void cleanupHeldTasks(List tasks, List> calls) throws Exception { + List failures = new ArrayList<>(); + try { + // Independent release markers cannot be overwritten by an executor's task update. + // Release all executors before waiting for any individual request to finish. + for (Task task : tasks) { + try { + saveTaskInTaskStore(Task.builder(task).id(task.id() + "-release").build()); + } catch (Exception e) { + failures.add(e); + } + } + for (int i = 0; i < calls.size(); i++) { + try { + calls.get(i).get(10, TimeUnit.SECONDS); + awaitTaskState(tasks.get(i).id(), TaskState.TASK_STATE_COMPLETED); + } catch (Exception | AssertionError e) { + failures.add(e); + } + } + } finally { + for (Task task : tasks) { + for (String id : List.of(task.id(), task.id() + "-release")) { + try { + deleteTaskInTaskStore(id); + } catch (Exception e) { + failures.add(e); + } + } + } + } + if (!failures.isEmpty()) { + Exception failure = new Exception("Could not clean up held compatibility tasks"); + failures.forEach(failure::addSuppressed); + throw failure; + } + } + + private Task awaitTaskState(String taskId, TaskState expected) throws Exception { + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10); + Task task; + do { + task = getTaskFromTaskStore(taskId); + if (task != null && task.status().state() == expected) { + return task; + } + Thread.sleep(25); + } while (System.nanoTime() < deadline); + fail("Task " + taskId + " did not reach " + expected); + throw new AssertionError("unreachable"); } @Test diff --git a/tests/server-common/src/test/java/org/a2aproject/sdk/server/apps/common/AgentExecutorProducer.java b/tests/server-common/src/test/java/org/a2aproject/sdk/server/apps/common/AgentExecutorProducer.java index b46519e30..b9d9866b3 100644 --- a/tests/server-common/src/test/java/org/a2aproject/sdk/server/apps/common/AgentExecutorProducer.java +++ b/tests/server-common/src/test/java/org/a2aproject/sdk/server/apps/common/AgentExecutorProducer.java @@ -3,6 +3,7 @@ import static org.a2aproject.sdk.server.ServerCallContext.TRANSPORT_KEY; import java.util.List; +import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicReference; import jakarta.enterprise.context.ApplicationScoped; @@ -19,6 +20,7 @@ import org.a2aproject.sdk.server.agentexecution.AgentExecutor; import org.a2aproject.sdk.server.agentexecution.RequestContext; import org.a2aproject.sdk.server.tasks.AgentEmitter; +import org.a2aproject.sdk.server.tasks.TaskStore; import org.a2aproject.sdk.spec.A2AClientException; import org.a2aproject.sdk.spec.A2AError; import org.a2aproject.sdk.spec.AgentCard; @@ -45,6 +47,9 @@ public class AgentExecutorProducer { @Inject RequestScopedBean requestScopedBean; + @Inject + TaskStore taskStore; + @Produces public AgentExecutor agentExecutor() { return new AgentExecutor() { @@ -53,6 +58,28 @@ public void execute(RequestContext context, AgentEmitter agentEmitter) throws A2 String taskId = context.getTaskId(); String input = context.getMessage() != null ? extractTextFromMessage(context.getMessage()) : ""; + if (input.startsWith("compat-hold:")) { + agentEmitter.startWork(); + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(60); + while (System.nanoTime() < deadline) { + Task task = taskStore.get(taskId); + if (task == null) { + throw new InternalError("Held compatibility task was removed"); + } + if (taskStore.get(taskId + "-release") != null) { + agentEmitter.complete(); + return; + } + try { + Thread.sleep(25); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new InternalError("Held compatibility task interrupted"); + } + } + throw new InternalError("Held compatibility task was not released"); + } + // Agent-to-agent communication test (routed by message content prefix) if (input.startsWith("delegate:") || input.startsWith("a2a-local:")) { handleAgentToAgentTest(context, agentEmitter); From 1513a2da81bd3daa1a4b9bd81faa067ae33e1c1d Mon Sep 17 00:00:00 2001 From: Kabir Khan Date: Mon, 5 Oct 2026 18:43:41 +0100 Subject: [PATCH 8/9] fix: preserve legacy agent card fallback --- .../src/main/java/org/a2aproject/sdk/A2A.java | 8 +++++- .../sdk/client/http/A2ACardResolver.java | 19 ++++++++++--- .../sdk/client/http/A2ACardResolverTest.java | 27 +++++++++++++++++++ 3 files changed, 50 insertions(+), 4 deletions(-) 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 35f3890de..e0b9d08b3 100644 --- a/client/base/src/main/java/org/a2aproject/sdk/A2A.java +++ b/client/base/src/main/java/org/a2aproject/sdk/A2A.java @@ -422,7 +422,13 @@ public static AgentCard getAgentCard(String agentUrl, String relativeCardPath, * @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")); + A2ACardResolver resolver = A2ACardResolver.builder() + .httpClient(httpClient) + .baseUrl(agentUrl) + .agentCardPath(relativeCardPath) + .authHeaders(authHeaders) + .build(); + return resolver.getAgentCard(); } /** 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 a530264dd..00060f0bd 100644 --- a/http-client/src/main/java/org/a2aproject/sdk/client/http/A2ACardResolver.java +++ b/http-client/src/main/java/org/a2aproject/sdk/client/http/A2ACardResolver.java @@ -89,10 +89,11 @@ public class A2ACardResolver { private final @Nullable String fallbackUrl; private final @Nullable Map authHeaders; private final Set supportedProtocolVersions; + private final boolean protocolVersionsExplicitlySet; private A2ACardResolver(A2AHttpClient httpClient, String baseUrl, @Nullable String tenant, @Nullable String agentCardPath, @Nullable Map authHeaders, - Set supportedProtocolVersions) throws A2AClientError { + Set supportedProtocolVersions, boolean protocolVersionsExplicitlySet) throws A2AClientError { checkNotNullParam("httpClient", httpClient); checkNotNullParam("baseUrl", baseUrl); this.httpClient = httpClient; @@ -108,6 +109,7 @@ private A2ACardResolver(A2AHttpClient httpClient, String baseUrl, @Nullable Stri } this.authHeaders = authHeaders != null ? Map.copyOf(authHeaders) : null; this.supportedProtocolVersions = Set.copyOf(supportedProtocolVersions); + this.protocolVersionsExplicitlySet = protocolVersionsExplicitlySet; LOGGER.debug("Initialized A2ACardResolver with cardUrl={}", cardUrl); } @@ -131,6 +133,7 @@ public static class Builder { private @Nullable String agentCardPath; private @Nullable Map authHeaders; private Set supportedProtocolVersions = Set.of("1.0"); + private boolean protocolVersionsExplicitlySet; private Builder() { } @@ -211,6 +214,9 @@ public Builder authHeader(String name, String value) { /** * Sets the protocol versions this resolver is allowed to discover. + * Calling this method makes protocol selection explicit. When omitted, discovery defaults + * to v1.0 but returns a parsed card with no supported interfaces so legacy callers can + * select their v0.3 client. * * @param supportedProtocolVersions non-empty set of supported versions, such as {@code 1.0} * or {@code 0.3}; patch forms are normalized @@ -226,6 +232,7 @@ public Builder supportedProtocolVersions(Set supportedProtocolVersions) throw new IllegalArgumentException("supportedProtocolVersions must not be empty"); } this.supportedProtocolVersions = normalizedVersions; + this.protocolVersionsExplicitlySet = true; return this; } @@ -241,7 +248,8 @@ public A2ACardResolver build() throws A2AClientError { if (baseUrl == null) { throw new IllegalArgumentException("baseUrl must not be null"); } - return new A2ACardResolver(client, baseUrl, tenant, agentCardPath, authHeaders, supportedProtocolVersions); + return new A2ACardResolver(client, baseUrl, tenant, agentCardPath, authHeaders, + supportedProtocolVersions, protocolVersionsExplicitlySet); } } @@ -354,7 +362,7 @@ private AgentCard fetchAgentCard(String url) throws A2AClientError, A2AClientJSO JSONRPCUtils.parseJsonString(body, agentCardBuilder, "", true); parsedV10Card = ProtoUtils.FromProto.agentCard(agentCardBuilder); } catch (JsonProcessingException | RuntimeException e) { - if (supportedProtocolVersions.equals(Set.of("1.0"))) { + if (!supportedProtocolVersions.contains("0.3")) { throw new A2AClientJSONError("Could not unmarshal agent card response", e); } } @@ -369,6 +377,11 @@ private AgentCard fetchAgentCard(String url) throws A2AClientError, A2AClientJSO if (!parsedV10Card.supportedInterfaces().isEmpty()) { throw new A2AClientJSONError("Agent card does not expose a requested protocol version"); } + // The default discovery API predates protocol-version selection. Preserve its + // legacy signal: callers can detect an empty interface list and use their v0.3 client. + if (!protocolVersionsExplicitlySet) { + return parsedV10Card; + } } if (supportedProtocolVersions.contains("0.3")) { 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 6a258bddd..cfd302add 100644 --- a/http-client/src/test/java/org/a2aproject/sdk/client/http/A2ACardResolverTest.java +++ b/http-client/src/test/java/org/a2aproject/sdk/client/http/A2ACardResolverTest.java @@ -242,6 +242,33 @@ public void testSupportedProtocolVersions_normalizesPatchVersion() throws Except assertEquals("http://example.com" + AGENT_CARD_PATH, client.url); } + @Test + public void testDefaultProtocolDiscoveryReturnsLegacyCardWithoutInterfaces() throws Exception { + TestHttpClient client = createTestClient(); + client.body = JsonMessages.AGENT_CARD.replaceFirst("(?s)\"supportedInterfaces\"\\s*:\\s*\\[.*?\\]", + "\"supportedInterfaces\": []"); + + AgentCard card = A2ACardResolver.builder().httpClient(client).baseUrl("http://example.com") + .build().getAgentCard(); + + assertEquals("GeoSpatial Route Planner Agent", card.name()); + assertTrue(card.supportedInterfaces().isEmpty()); + } + + @Test + public void testExplicitProtocolDiscoveryRejectsCardWithoutRequestedInterface() { + TestHttpClient client = createTestClient(); + client.body = JsonMessages.AGENT_CARD.replaceFirst("(?s)\"supportedInterfaces\"\\s*:\\s*\\[.*?\\]", + "\"supportedInterfaces\": []"); + + A2AClientJSONError error = assertThrows(A2AClientJSONError.class, () -> A2ACardResolver.builder() + .httpClient(client).baseUrl("http://example.com") + .supportedProtocolVersions(Set.of("1.0")) + .build().getAgentCard()); + + assertTrue(error.getMessage().contains("does not expose a requested protocol version")); + } + @Test public void testSupportedProtocolVersions_rejectsEmptySet() { assertThrows(IllegalArgumentException.class, () -> A2ACardResolver.builder() From 201138298fa67a85ba1757050e869e92194a0474 Mon Sep 17 00:00:00 2001 From: Kabir Khan Date: Tue, 6 Oct 2026 10:54:13 +0100 Subject: [PATCH 9/9] More review --- .../a2aproject/sdk/client/ClientBuilder.java | 6 +- .../JSONRPCCompat03ClientTransport.java | 20 +- .../Compat03AgentCardCompatibilityParser.java | 28 +- .../adapter/Compat03ClientTransportBase.java | 7 +- .../Compat03ClientTransportSupport.java | 4 + .../transport/grpc/GrpcTransport_v0_3.java | 6 +- .../transport/rest/RestErrorMapper_v0_3.java | 6 +- .../transport/rest/RestTransport_v0_3.java | 14 +- .../rest/RestTransport_v0_3_Test.java | 13 +- ...26-09-14-client-v03-compatibility-tests.md | 494 ------------------ .../2026-09-14-client-v03-compatibility.md | 484 ----------------- ...6-09-14-client-v03-compatibility-design.md | 111 ---- ...4-client-v03-compatibility-tests-design.md | 110 ---- ...4-client-v03-compatibility-tests-matrix.md | 73 --- 14 files changed, 59 insertions(+), 1317 deletions(-) delete mode 100644 docs/superpowers/plans/2026-09-14-client-v03-compatibility-tests.md delete mode 100644 docs/superpowers/plans/2026-09-14-client-v03-compatibility.md delete mode 100644 docs/superpowers/specs/2026-09-14-client-v03-compatibility-design.md delete mode 100644 docs/superpowers/specs/2026-09-14-client-v03-compatibility-tests-design.md delete mode 100644 docs/superpowers/specs/2026-09-14-client-v03-compatibility-tests-matrix.md 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 29d8076d5..1e3d24d3b 100644 --- a/client/base/src/main/java/org/a2aproject/sdk/client/ClientBuilder.java +++ b/client/base/src/main/java/org/a2aproject/sdk/client/ClientBuilder.java @@ -12,12 +12,12 @@ import java.util.stream.Collectors; import org.a2aproject.sdk.client.config.ClientConfig; +import org.a2aproject.sdk.client.http.A2ACardResolver; import org.a2aproject.sdk.client.transport.spi.ClientTransport; import org.a2aproject.sdk.client.transport.spi.ClientTransportConfig; import org.a2aproject.sdk.client.transport.spi.ClientTransportConfigBuilder; 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; @@ -384,6 +384,10 @@ && normalizeInterfaceVersion(existing).equals(version))) { ordered.add(iface); } } + if (ordered.isEmpty()) { + throw new A2AClientException( + "AgentCard does not advertise a supported protocol version (supported: 1.0 and 0.3)"); + } return ordered; } 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 index 37015cfc9..0b5960f64 100644 --- 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 @@ -13,6 +13,7 @@ import org.a2aproject.sdk.compat03.client.adapter.Compat03InterceptorSupport; import org.a2aproject.sdk.compat03.client.transport.spi.ClientTransport_v0_3; import org.a2aproject.sdk.compat03.conversion.mappers.domain.TaskMapper_v0_3; +import org.a2aproject.sdk.compat03.conversion.mappers.params.MessageSendParamsMapper_v0_3; import org.a2aproject.sdk.compat03.spec.MessageSendParams_v0_3; import org.a2aproject.sdk.compat03.spec.TaskIdParams_v0_3; import org.a2aproject.sdk.grpc.utils.ProtoUtils; @@ -41,24 +42,26 @@ public JSONRPCCompat03ClientTransport(ClientTransport_v0_3 delegate, AgentCard c @Override public EventKind sendMessage(MessageSendParams request, @Nullable ClientCallContext context) { - Compat03ClientTransportSupport.validateMessageSend(request); + MessageSendParams_v0_3 legacyRequest = Compat03ClientTransportSupport.toV03(request); org.a2aproject.sdk.grpc.SendMessageRequest original = ProtoUtils.ToProto.sendMessageRequest(request); PayloadAndHeaders payload = apply(A2AMethods.SEND_MESSAGE_METHOD, original, org.a2aproject.sdk.grpc.SendMessageRequest.class, context); return Compat03ClientTransportSupport.call(() -> Compat03ClientTransportSupport.toV10(delegate.sendMessage( - adaptMessage(request, original, (org.a2aproject.sdk.grpc.SendMessageRequest) payload.getPayload()), + adaptMessage(legacyRequest, original, + (org.a2aproject.sdk.grpc.SendMessageRequest) payload.getPayload()), Compat03ClientCallContextMapper.toV03(contextWithHeaders(context, payload))))); } @Override public void sendMessageStreaming(MessageSendParams request, Consumer events, Consumer errors, @Nullable ClientCallContext context) { - Compat03ClientTransportSupport.validateMessageSend(request); + MessageSendParams_v0_3 legacyRequest = Compat03ClientTransportSupport.toV03(request); org.a2aproject.sdk.grpc.SendMessageRequest original = ProtoUtils.ToProto.sendMessageRequest(request); PayloadAndHeaders payload = apply(A2AMethods.SEND_STREAMING_MESSAGE_METHOD, original, org.a2aproject.sdk.grpc.SendMessageRequest.class, context); Compat03ClientTransportSupport.run(() -> delegate.sendMessageStreaming( - adaptMessage(request, original, (org.a2aproject.sdk.grpc.SendMessageRequest) payload.getPayload()), + adaptMessage(legacyRequest, original, + (org.a2aproject.sdk.grpc.SendMessageRequest) payload.getPayload()), event -> events.accept(Compat03ClientTransportSupport.toV10(event)), Compat03ClientTransportSupport.mapAsyncError(errors), Compat03ClientCallContextMapper.toV03(contextWithHeaders(context, payload)))); @@ -148,14 +151,13 @@ public void subscribeToTask(TaskIdParams request, Consumer e Compat03ClientCallContextMapper.toV03(contextWithHeaders(context, payload)))); } - private static MessageSendParams_v0_3 adaptMessage(MessageSendParams request, + private static MessageSendParams_v0_3 adaptMessage(MessageSendParams_v0_3 legacyRequest, org.a2aproject.sdk.grpc.SendMessageRequest original, org.a2aproject.sdk.grpc.SendMessageRequest modified) { - MessageSendParams_v0_3 legacy = Compat03ClientTransportSupport.toV03(request); if (original.equals(modified)) { - return legacy; + return legacyRequest; } - return JSONRPCCompat03PayloadSupport.preserveUnchangedValues(legacy, - Compat03ClientTransportSupport.toV03(ProtoUtils.FromProto.messageSendParams(original)), + return JSONRPCCompat03PayloadSupport.preserveUnchangedValues(legacyRequest, + MessageSendParamsMapper_v0_3.INSTANCE.fromV10(ProtoUtils.FromProto.messageSendParams(original)), Compat03ClientTransportSupport.toV03(ProtoUtils.FromProto.messageSendParams(modified))); } 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 index 1ca2dde11..13971dccb 100644 --- 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 @@ -3,8 +3,10 @@ import java.util.Optional; import java.util.Set; +import org.a2aproject.sdk.client.http.A2ACardResolver; import org.a2aproject.sdk.client.http.AgentCardCompatibilityParser; import org.a2aproject.sdk.compat03.conversion.mappers.domain.AgentCardMapper_v0_3; +import org.a2aproject.sdk.compat03.json.JsonProcessingException_v0_3; import org.a2aproject.sdk.compat03.json.JsonUtil_v0_3; import org.a2aproject.sdk.compat03.spec.AgentCard_v0_3; import org.a2aproject.sdk.spec.A2AClientJSONError; @@ -21,21 +23,21 @@ public String supportedProtocolVersion() { @Override public Optional parse(String rawCardJson, @Nullable AgentCard parsedV10Card, Set requestedProtocolVersions) { + final AgentCard_v0_3 legacyCard; 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) { + legacyCard = JsonUtil_v0_3.fromJson(rawCardJson, AgentCard_v0_3.class); + } catch (JsonProcessingException_v0_3 e) { throw new A2AClientJSONError("Could not convert A2A 0.3 agent card to the unified client model", e); } - } - - private static String normalize(String version) { - return switch (version) { - case "0.3", "0.3.0" -> "0.3"; - default -> version; - }; + final String version; + try { + version = A2ACardResolver.normalizeSupportedProtocolVersion(legacyCard.protocolVersion()); + } catch (IllegalArgumentException e) { + return Optional.empty(); + } + if (!"0.3".equals(version)) { + return Optional.empty(); + } + return Optional.of(AgentCardMapper_v0_3.INSTANCE.toV10(legacyCard)); } } diff --git a/compat-0.3/client/adapter/src/main/java/org/a2aproject/sdk/compat03/client/adapter/Compat03ClientTransportBase.java b/compat-0.3/client/adapter/src/main/java/org/a2aproject/sdk/compat03/client/adapter/Compat03ClientTransportBase.java index 714c9c79c..0c06cfa06 100644 --- 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 @@ -13,7 +13,6 @@ 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; @@ -82,8 +81,7 @@ public Task cancelTask(CancelTaskParams request, @Nullable ClientCallContext con @Override public ListTasksResult listTasks(ListTasksParams request, @Nullable ClientCallContext context) { - Compat03ClientTransportSupport.validateListTasks(request); - throw new A2AClientException("listTasks is not supported by A2A protocol 0.3"); + return Compat03ClientTransportSupport.unsupportedOperation("listTasks"); } @Override @@ -134,8 +132,7 @@ public void subscribeToTask(TaskIdParams request, Consumer e @Override public AgentCard getExtendedAgentCard(GetExtendedAgentCardParams params, @Nullable ClientCallContext context) { - Compat03ClientTransportSupport.validateExtendedAgentCard(params); - throw new A2AClientException("getExtendedAgentCard is not supported by A2A protocol 0.3"); + return Compat03ClientTransportSupport.unsupportedOperation("getExtendedAgentCard"); } @Override 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 index f7cdbe6e8..18e3b7b1d 100644 --- 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 @@ -58,6 +58,10 @@ public static void validateExtendedAgentCard(@Nullable Object request) { throw unsupported("getExtendedAgentCard"); } + public static T unsupportedOperation(String operation) { + throw unsupported(operation); + } + public static void validateTenant(String operation, @Nullable String tenant) { if (tenant != null && !tenant.isEmpty()) { throw unsupported(operation + " with a tenant"); 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 40d9bdadb..c5509d1f1 100644 --- a/compat-0.3/client/transport/grpc/src/main/java/org/a2aproject/sdk/compat03/client/transport/grpc/GrpcTransport_v0_3.java +++ b/compat-0.3/client/transport/grpc/src/main/java/org/a2aproject/sdk/compat03/client/transport/grpc/GrpcTransport_v0_3.java @@ -362,8 +362,10 @@ private String getTaskPushNotificationConfigName(GetTaskPushNotificationConfigPa } private String getTaskPushNotificationConfigName(String taskId, @Nullable String pushNotificationConfigId) { - String configId = pushNotificationConfigId != null ? pushNotificationConfigId : taskId; - return "tasks/" + taskId + "/pushNotificationConfigs/" + configId; + if (pushNotificationConfigId == null) { + return "tasks/" + taskId; + } + return "tasks/" + taskId + "/pushNotificationConfigs/" + pushNotificationConfigId; } private PayloadAndHeaders_v0_3 applyInterceptors(String methodName, Object payload, diff --git a/compat-0.3/client/transport/rest/src/main/java/org/a2aproject/sdk/compat03/client/transport/rest/RestErrorMapper_v0_3.java b/compat-0.3/client/transport/rest/src/main/java/org/a2aproject/sdk/compat03/client/transport/rest/RestErrorMapper_v0_3.java index 601c78978..b66387831 100644 --- a/compat-0.3/client/transport/rest/src/main/java/org/a2aproject/sdk/compat03/client/transport/rest/RestErrorMapper_v0_3.java +++ b/compat-0.3/client/transport/rest/src/main/java/org/a2aproject/sdk/compat03/client/transport/rest/RestErrorMapper_v0_3.java @@ -2,6 +2,8 @@ import java.util.List; import java.util.Map; +import java.util.logging.Level; +import java.util.logging.Logger; import com.google.gson.JsonElement; import com.google.gson.JsonObject; @@ -27,6 +29,7 @@ * Utility class to A2AHttpResponse to appropriate A2A error types */ public class RestErrorMapper_v0_3 { + private static final Logger LOGGER = Logger.getLogger(RestErrorMapper_v0_3.class.getName()); public static A2AClientException_v0_3 mapRestError(A2AHttpResponse response) { return mapRestError(response.body(), response.status(), response.headers().toMap()); @@ -49,8 +52,9 @@ private static A2AClientException_v0_3 mapRestError(String body, int code, Map sseEventListener.onMessage(event.data(), ref.get()), throwable -> sseEventListener.onError(throwable, ref.get()), () -> { // We don't need to do anything special on completion })); } catch (IOException e) { - throw new A2AClientException_v0_3("Failed to send streaming message request: " + e, e); + throw new A2AClientException_v0_3("Failed to send task resubscription request: " + e, e); } catch (InterruptedException e) { - throw new A2AClientException_v0_3("Send streaming message request timed out: " + e, e); + throw new A2AClientException_v0_3("Task resubscription request timed out: " + e, e); + } catch (JsonProcessingException_v0_3 e) { + throw new A2AClientException_v0_3("Failed to process JSON for task resubscription request: " + e, e); } } diff --git a/compat-0.3/client/transport/rest/src/test/java/org/a2aproject/sdk/compat03/client/transport/rest/RestTransport_v0_3_Test.java b/compat-0.3/client/transport/rest/src/test/java/org/a2aproject/sdk/compat03/client/transport/rest/RestTransport_v0_3_Test.java index cf9de55f1..c39610b0f 100644 --- a/compat-0.3/client/transport/rest/src/test/java/org/a2aproject/sdk/compat03/client/transport/rest/RestTransport_v0_3_Test.java +++ b/compat-0.3/client/transport/rest/src/test/java/org/a2aproject/sdk/compat03/client/transport/rest/RestTransport_v0_3_Test.java @@ -458,13 +458,14 @@ public PayloadAndHeaders_v0_3 intercept(String method, Object payload, java.util public void testResubscribe() throws Exception { LOGGER.info("Testing resubscribe"); - this.server.when(request().withMethod("POST").withPath("/v1/tasks/task-1234:subscribe")) + this.server.when(request() + .withMethod("GET") + .withPath("/v1/tasks/task-1234:subscribe")) .respond(response().withStatusCode(405)); - this.server.when( - request() - .withMethod("GET") - .withPath("/v1/tasks/task-1234:subscribe") - ) + this.server.when(request() + .withMethod("POST") + .withPath("/v1/tasks/task-1234:subscribe") + .withBody(JsonBody.json("{\"name\":\"tasks/task-1234\"}", MatchType.ONLY_MATCHING_FIELDS))) .respond( response() .withStatusCode(200) 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 deleted file mode 100644 index ecf4d18e3..000000000 --- a/docs/superpowers/plans/2026-09-14-client-v03-compatibility-tests.md +++ /dev/null @@ -1,494 +0,0 @@ -# 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 deleted file mode 100644 index 5e54c782e..000000000 --- a/docs/superpowers/plans/2026-09-14-client-v03-compatibility.md +++ /dev/null @@ -1,484 +0,0 @@ -# 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 deleted file mode 100644 index 4e993171e..000000000 --- a/docs/superpowers/specs/2026-09-14-client-v03-compatibility-design.md +++ /dev/null @@ -1,111 +0,0 @@ -# 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. With the default server preference, the first usable interface in card order wins, including a 0.3 interface before a 1.0 interface. With client transport preference enabled, selection is restricted to usable 1.0 interfaces across configured bindings if any exist, and configured binding order then determines the match. Only when no such native interface exists does this mode consider 0.3. The legacy fields continue to support existing 0.3 clients. This reflects the implemented ordering, replacing the original design's unconditional native preference. - -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 | Server preference respects card interface order; client preference prioritizes usable native interfaces across configured bindings. | -| 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 deleted file mode 100644 index 480904088..000000000 --- a/docs/superpowers/specs/2026-09-14-client-v03-compatibility-tests-design.md +++ /dev/null @@ -1,110 +0,0 @@ -# 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 deleted file mode 100644 index 2bda2fae9..000000000 --- a/docs/superpowers/specs/2026-09-14-client-v03-compatibility-tests-matrix.md +++ /dev/null @@ -1,73 +0,0 @@ -# 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.