From 26d78550bcf9c4241249eed2fef8c64003dec892 Mon Sep 17 00:00:00 2001 From: Kabir Khan Date: Mon, 22 Dec 2025 11:24:47 +0000 Subject: [PATCH 1/2] feat! Rework error classes for spec 1.0 update Test the errors that were introduced since last time This also renames some error classes --- .../transport/grpc/GrpcErrorMapper.java | 9 + .../client/transport/grpc/GrpcTransport.java | 10 +- .../transport/grpc/GrpcErrorMapperTest.java | 200 ++++++++ .../jsonrpc/JSONRPCTransportTest.java | 115 +++++ .../transport/rest/RestErrorMapper.java | 9 +- .../transport/rest/RestTransportTest.java | 165 ++++++- .../main/java/io/a2a/common/A2AHeaders.java | 6 + .../GetAuthenticatedExtendedCardRequest.java | 4 +- .../GetAuthenticatedExtendedCardResponse.java | 6 +- .../quarkus/A2AExtensionsInterceptor.java | 12 +- .../server/apps/quarkus/A2AServerRoutes.java | 5 +- .../server/rest/quarkus/A2AServerRoutes.java | 5 +- .../java/io/a2a/server/ServerCallContext.java | 11 + .../a2a/server/extensions/A2AExtensions.java | 25 + .../DefaultRequestHandler.java | 1 + .../server/version/A2AVersionValidator.java | 106 ++++ .../version/A2AVersionValidatorTest.java | 168 +++++++ .../java/io/a2a/grpc/utils/JSONRPCUtils.java | 12 + .../main/java/io/a2a/spec/A2AErrorCodes.java | 12 +- .../java/io/a2a/spec/A2AProtocolError.java | 15 + .../spec/ContentTypeNotSupportedError.java | 5 +- ...va => ExtendedCardNotConfiguredError.java} | 11 +- .../spec/ExtensionSupportRequiredError.java | 50 ++ .../a2a/spec/InvalidAgentResponseError.java | 5 +- .../PushNotificationNotSupportedError.java | 5 +- .../io/a2a/spec/TaskNotCancelableError.java | 5 +- .../java/io/a2a/spec/TaskNotFoundError.java | 5 +- .../a2a/spec/UnsupportedOperationError.java | 5 +- .../io/a2a/spec/VersionNotSupportedError.java | 49 ++ .../grpc/context/GrpcContextKeys.java | 9 +- .../transport/grpc/handler/GrpcHandler.java | 46 +- .../grpc/handler/GrpcHandlerTest.java | 339 +++++++++++++ .../jsonrpc/handler/JSONRPCHandler.java | 12 +- .../jsonrpc/handler/JSONRPCHandlerTest.java | 367 +++++++++++++- .../transport/rest/handler/RestHandler.java | 22 +- .../rest/handler/RestHandlerTest.java | 455 ++++++++++++++++++ 36 files changed, 2241 insertions(+), 45 deletions(-) create mode 100644 client/transport/grpc/src/test/java/io/a2a/client/transport/grpc/GrpcErrorMapperTest.java create mode 100644 server-common/src/main/java/io/a2a/server/version/A2AVersionValidator.java create mode 100644 server-common/src/test/java/io/a2a/server/version/A2AVersionValidatorTest.java create mode 100644 spec/src/main/java/io/a2a/spec/A2AProtocolError.java rename spec/src/main/java/io/a2a/spec/{AuthenticatedExtendedCardNotConfiguredError.java => ExtendedCardNotConfiguredError.java} (78%) create mode 100644 spec/src/main/java/io/a2a/spec/ExtensionSupportRequiredError.java create mode 100644 spec/src/main/java/io/a2a/spec/VersionNotSupportedError.java diff --git a/client/transport/grpc/src/main/java/io/a2a/client/transport/grpc/GrpcErrorMapper.java b/client/transport/grpc/src/main/java/io/a2a/client/transport/grpc/GrpcErrorMapper.java index cf245553b..462d77b40 100644 --- a/client/transport/grpc/src/main/java/io/a2a/client/transport/grpc/GrpcErrorMapper.java +++ b/client/transport/grpc/src/main/java/io/a2a/client/transport/grpc/GrpcErrorMapper.java @@ -3,6 +3,8 @@ import io.a2a.common.A2AErrorMessages; import io.a2a.spec.A2AClientException; import io.a2a.spec.ContentTypeNotSupportedError; +import io.a2a.spec.ExtendedCardNotConfiguredError; +import io.a2a.spec.ExtensionSupportRequiredError; import io.a2a.spec.InvalidAgentResponseError; import io.a2a.spec.InvalidParamsError; import io.a2a.spec.InvalidRequestError; @@ -12,6 +14,7 @@ import io.a2a.spec.TaskNotCancelableError; import io.a2a.spec.TaskNotFoundError; import io.a2a.spec.UnsupportedOperationError; +import io.a2a.spec.VersionNotSupportedError; import io.grpc.Status; /** @@ -52,6 +55,12 @@ public static A2AClientException mapGrpcError(Throwable e, String errorPrefix) { return new A2AClientException(errorPrefix + description, new ContentTypeNotSupportedError(null, description, null)); } else if (description.contains("InvalidAgentResponseError")) { return new A2AClientException(errorPrefix + description, new InvalidAgentResponseError(null, description, null)); + } else if (description.contains("ExtendedCardNotConfiguredError")) { + return new A2AClientException(errorPrefix + description, new ExtendedCardNotConfiguredError(null, description, null)); + } else if (description.contains("ExtensionSupportRequiredError")) { + return new A2AClientException(errorPrefix + description, new ExtensionSupportRequiredError(null, description, null)); + } else if (description.contains("VersionNotSupportedError")) { + return new A2AClientException(errorPrefix + description, new VersionNotSupportedError(null, description, null)); } } diff --git a/client/transport/grpc/src/main/java/io/a2a/client/transport/grpc/GrpcTransport.java b/client/transport/grpc/src/main/java/io/a2a/client/transport/grpc/GrpcTransport.java index 207ccc45b..2913a08c7 100644 --- a/client/transport/grpc/src/main/java/io/a2a/client/transport/grpc/GrpcTransport.java +++ b/client/transport/grpc/src/main/java/io/a2a/client/transport/grpc/GrpcTransport.java @@ -59,6 +59,9 @@ public class GrpcTransport implements ClientTransport { private static final Metadata.Key EXTENSIONS_KEY = Metadata.Key.of( A2AHeaders.X_A2A_EXTENSIONS, Metadata.ASCII_STRING_MARSHALLER); + private static final Metadata.Key VERSION_KEY = Metadata.Key.of( + A2AHeaders.X_A2A_VERSION, + Metadata.ASCII_STRING_MARSHALLER); private final A2AServiceBlockingV2Stub blockingStub; private final A2AServiceStub asyncStub; private final @Nullable List interceptors; @@ -366,6 +369,12 @@ private Metadata createGrpcMetadata(@Nullable ClientCallContext context, @Nullab Metadata metadata = new Metadata(); if (context != null && context.getHeaders() != null) { + // Set X-A2A-Version header if present + String versionHeader = context.getHeaders().get(A2AHeaders.X_A2A_VERSION); + if (versionHeader != null) { + metadata.put(VERSION_KEY, versionHeader); + } + // Set X-A2A-Extensions header if present String extensionsHeader = context.getHeaders().get(A2AHeaders.X_A2A_EXTENSIONS); if (extensionsHeader != null) { @@ -373,7 +382,6 @@ private Metadata createGrpcMetadata(@Nullable ClientCallContext context, @Nullab } // Add other headers as needed in the future - // For now, we only handle X-A2A-Extensions } if (payloadAndHeaders != null && payloadAndHeaders.getHeaders() != null) { // Handle all headers from interceptors (including auth headers) diff --git a/client/transport/grpc/src/test/java/io/a2a/client/transport/grpc/GrpcErrorMapperTest.java b/client/transport/grpc/src/test/java/io/a2a/client/transport/grpc/GrpcErrorMapperTest.java new file mode 100644 index 000000000..b68f7c958 --- /dev/null +++ b/client/transport/grpc/src/test/java/io/a2a/client/transport/grpc/GrpcErrorMapperTest.java @@ -0,0 +1,200 @@ +package io.a2a.client.transport.grpc; + +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 io.a2a.spec.A2AClientException; +import io.a2a.spec.ContentTypeNotSupportedError; +import io.a2a.spec.ExtendedCardNotConfiguredError; +import io.a2a.spec.ExtensionSupportRequiredError; +import io.a2a.spec.InvalidAgentResponseError; +import io.a2a.spec.InvalidParamsError; +import io.a2a.spec.InvalidRequestError; +import io.a2a.spec.JSONParseError; +import io.a2a.spec.MethodNotFoundError; +import io.a2a.spec.PushNotificationNotSupportedError; +import io.a2a.spec.TaskNotCancelableError; +import io.a2a.spec.TaskNotFoundError; +import io.a2a.spec.UnsupportedOperationError; +import io.a2a.spec.VersionNotSupportedError; +import io.grpc.Status; +import io.grpc.StatusRuntimeException; +import org.junit.jupiter.api.Test; + +/** + * Tests for GrpcErrorMapper - verifies correct unmarshalling of gRPC errors to A2A error types + */ +public class GrpcErrorMapperTest { + + @Test + public void testExtensionSupportRequiredErrorUnmarshalling() { + // Create a gRPC StatusRuntimeException with ExtensionSupportRequiredError in description + String errorMessage = "ExtensionSupportRequiredError: Extension required: https://example.com/test-extension"; + StatusRuntimeException grpcException = Status.FAILED_PRECONDITION + .withDescription(errorMessage) + .asRuntimeException(); + + // Map the gRPC error to A2A error + A2AClientException result = GrpcErrorMapper.mapGrpcError(grpcException); + + // Verify the result + assertNotNull(result); + assertNotNull(result.getCause()); + assertInstanceOf(ExtensionSupportRequiredError.class, result.getCause()); + + ExtensionSupportRequiredError extensionError = (ExtensionSupportRequiredError) result.getCause(); + assertNotNull(extensionError.getMessage()); + assertTrue(extensionError.getMessage().contains("https://example.com/test-extension")); + assertTrue(result.getMessage().contains(errorMessage)); + } + + @Test + public void testVersionNotSupportedErrorUnmarshalling() { + // Create a gRPC StatusRuntimeException with VersionNotSupportedError in description + String errorMessage = "VersionNotSupportedError: Version 2.0 is not supported"; + StatusRuntimeException grpcException = Status.FAILED_PRECONDITION + .withDescription(errorMessage) + .asRuntimeException(); + + // Map the gRPC error to A2A error + A2AClientException result = GrpcErrorMapper.mapGrpcError(grpcException); + + // Verify the result + assertNotNull(result); + assertNotNull(result.getCause()); + assertInstanceOf(VersionNotSupportedError.class, result.getCause()); + + VersionNotSupportedError versionError = (VersionNotSupportedError) result.getCause(); + assertNotNull(versionError.getMessage()); + assertTrue(versionError.getMessage().contains("Version 2.0 is not supported")); + } + + @Test + public void testExtendedCardNotConfiguredErrorUnmarshalling() { + // Create a gRPC StatusRuntimeException with ExtendedCardNotConfiguredError in description + String errorMessage = "ExtendedCardNotConfiguredError: Extended card not configured for this agent"; + StatusRuntimeException grpcException = Status.FAILED_PRECONDITION + .withDescription(errorMessage) + .asRuntimeException(); + + // Map the gRPC error to A2A error + A2AClientException result = GrpcErrorMapper.mapGrpcError(grpcException); + + // Verify the result + assertNotNull(result); + assertNotNull(result.getCause()); + assertInstanceOf(ExtendedCardNotConfiguredError.class, result.getCause()); + + ExtendedCardNotConfiguredError extendedCardError = (ExtendedCardNotConfiguredError) result.getCause(); + assertNotNull(extendedCardError.getMessage()); + assertTrue(extendedCardError.getMessage().contains("Extended card not configured")); + } + + @Test + public void testTaskNotFoundErrorUnmarshalling() { + // Create a gRPC StatusRuntimeException with TaskNotFoundError in description + String errorMessage = "TaskNotFoundError: Task task-123 not found"; + StatusRuntimeException grpcException = Status.NOT_FOUND + .withDescription(errorMessage) + .asRuntimeException(); + + // Map the gRPC error to A2A error + A2AClientException result = GrpcErrorMapper.mapGrpcError(grpcException); + + // Verify the result + assertNotNull(result); + assertNotNull(result.getCause()); + assertInstanceOf(TaskNotFoundError.class, result.getCause()); + } + + @Test + public void testUnsupportedOperationErrorUnmarshalling() { + // Create a gRPC StatusRuntimeException with UnsupportedOperationError in description + String errorMessage = "UnsupportedOperationError: Operation not supported"; + StatusRuntimeException grpcException = Status.UNIMPLEMENTED + .withDescription(errorMessage) + .asRuntimeException(); + + // Map the gRPC error to A2A error + A2AClientException result = GrpcErrorMapper.mapGrpcError(grpcException); + + // Verify the result + assertNotNull(result); + assertNotNull(result.getCause()); + assertInstanceOf(UnsupportedOperationError.class, result.getCause()); + } + + @Test + public void testInvalidParamsErrorUnmarshalling() { + // Create a gRPC StatusRuntimeException with InvalidParamsError in description + String errorMessage = "InvalidParamsError: Invalid parameters provided"; + StatusRuntimeException grpcException = Status.INVALID_ARGUMENT + .withDescription(errorMessage) + .asRuntimeException(); + + // Map the gRPC error to A2A error + A2AClientException result = GrpcErrorMapper.mapGrpcError(grpcException); + + // Verify the result + assertNotNull(result); + assertNotNull(result.getCause()); + assertInstanceOf(InvalidParamsError.class, result.getCause()); + } + + @Test + public void testContentTypeNotSupportedErrorUnmarshalling() { + // Create a gRPC StatusRuntimeException with ContentTypeNotSupportedError in description + String errorMessage = "ContentTypeNotSupportedError: Content type application/xml not supported"; + StatusRuntimeException grpcException = Status.FAILED_PRECONDITION + .withDescription(errorMessage) + .asRuntimeException(); + + // Map the gRPC error to A2A error + A2AClientException result = GrpcErrorMapper.mapGrpcError(grpcException); + + // Verify the result + assertNotNull(result); + assertNotNull(result.getCause()); + assertInstanceOf(ContentTypeNotSupportedError.class, result.getCause()); + + ContentTypeNotSupportedError contentTypeError = (ContentTypeNotSupportedError) result.getCause(); + assertNotNull(contentTypeError.getMessage()); + assertTrue(contentTypeError.getMessage().contains("Content type application/xml not supported")); + } + + @Test + public void testFallbackToStatusCodeMapping() { + // Create a gRPC StatusRuntimeException without specific error type in description + StatusRuntimeException grpcException = Status.NOT_FOUND + .withDescription("Generic not found error") + .asRuntimeException(); + + // Map the gRPC error to A2A error + A2AClientException result = GrpcErrorMapper.mapGrpcError(grpcException); + + // Verify fallback to status code mapping + assertNotNull(result); + assertNotNull(result.getCause()); + assertInstanceOf(TaskNotFoundError.class, result.getCause()); + } + + @Test + public void testCustomErrorPrefix() { + // Create a gRPC StatusRuntimeException + String errorMessage = "ExtensionSupportRequiredError: Extension required: https://example.com/ext"; + StatusRuntimeException grpcException = Status.FAILED_PRECONDITION + .withDescription(errorMessage) + .asRuntimeException(); + + // Map with custom error prefix + String customPrefix = "Custom Error: "; + A2AClientException result = GrpcErrorMapper.mapGrpcError(grpcException, customPrefix); + + // Verify custom prefix is used + assertNotNull(result); + assertTrue(result.getMessage().startsWith(customPrefix)); + assertInstanceOf(ExtensionSupportRequiredError.class, result.getCause()); + } +} diff --git a/client/transport/jsonrpc/src/test/java/io/a2a/client/transport/jsonrpc/JSONRPCTransportTest.java b/client/transport/jsonrpc/src/test/java/io/a2a/client/transport/jsonrpc/JSONRPCTransportTest.java index 078546860..3344420a0 100644 --- a/client/transport/jsonrpc/src/test/java/io/a2a/client/transport/jsonrpc/JSONRPCTransportTest.java +++ b/client/transport/jsonrpc/src/test/java/io/a2a/client/transport/jsonrpc/JSONRPCTransportTest.java @@ -41,6 +41,8 @@ import io.a2a.spec.A2AClientException; import io.a2a.spec.AgentCard; +import io.a2a.spec.ExtensionSupportRequiredError; +import io.a2a.spec.VersionNotSupportedError; import io.a2a.spec.AgentInterface; import io.a2a.spec.AgentSkill; import io.a2a.spec.Artifact; @@ -680,4 +682,117 @@ public void testA2AClientSendMessageWithMixedParts() throws Exception { assertEquals("Analyzed chart image and data: Bar chart showing quarterly data with values [10, 20, 30, 40].", ((TextPart) part).text()); assertTrue(task.metadata().isEmpty()); } + + /** + * Test that ExtensionSupportRequiredError is properly unmarshalled from JSON-RPC error response. + */ + @Test + public void testExtensionSupportRequiredErrorUnmarshalling() throws Exception { + // Mock server returns JSON-RPC error with code -32008 (EXTENSION_SUPPORT_REQUIRED_ERROR) + String errorResponseBody = """ + { + "jsonrpc": "2.0", + "id": 1, + "error": { + "code": -32008, + "message": "Extension required: https://example.com/test-extension" + } + } + """; + + this.server.when( + request() + .withMethod("POST") + .withPath("/") + ) + .respond( + response() + .withStatusCode(200) + .withBody(errorResponseBody) + ); + + JSONRPCTransport client = new JSONRPCTransport("http://localhost:4001"); + Message message = Message.builder() + .role(Message.Role.USER) + .parts(Collections.singletonList(new TextPart("test message"))) + .contextId("context-test") + .messageId("message-test") + .build(); + MessageSendConfiguration configuration = MessageSendConfiguration.builder() + .acceptedOutputModes(List.of("text")) + .blocking(true) + .build(); + MessageSendParams params = MessageSendParams.builder() + .message(message) + .configuration(configuration) + .build(); + + // Should throw A2AClientException with ExtensionSupportRequiredError as cause + try { + client.sendMessage(params, null); + fail("Expected A2AClientException to be thrown"); + } catch (A2AClientException e) { + // Verify the cause is ExtensionSupportRequiredError + assertInstanceOf(ExtensionSupportRequiredError.class, e.getCause()); + ExtensionSupportRequiredError extensionError = (ExtensionSupportRequiredError) e.getCause(); + assertTrue(extensionError.getMessage().contains("https://example.com/test-extension")); + } + } + + /** + * Test that VersionNotSupportedError is properly unmarshalled from JSON-RPC error response. + */ + @Test + public void testVersionNotSupportedErrorUnmarshalling() throws Exception { + // Mock server returns JSON-RPC error with code -32009 (VERSION_NOT_SUPPORTED_ERROR) + String errorResponseBody = """ + { + "jsonrpc": "2.0", + "id": 1, + "error": { + "code": -32009, + "message": "Protocol version 2.0 is not supported. This agent supports version 1.0" + } + } + """; + + this.server.when( + request() + .withMethod("POST") + .withPath("/") + ) + .respond( + response() + .withStatusCode(200) + .withBody(errorResponseBody) + ); + + JSONRPCTransport client = new JSONRPCTransport("http://localhost:4001"); + Message message = Message.builder() + .role(Message.Role.USER) + .parts(Collections.singletonList(new TextPart("test message"))) + .contextId("context-test") + .messageId("message-test") + .build(); + MessageSendConfiguration configuration = MessageSendConfiguration.builder() + .acceptedOutputModes(List.of("text")) + .blocking(true) + .build(); + MessageSendParams params = MessageSendParams.builder() + .message(message) + .configuration(configuration) + .build(); + + // Should throw A2AClientException with VersionNotSupportedError as cause + try { + client.sendMessage(params, null); + fail("Expected A2AClientException to be thrown"); + } catch (A2AClientException e) { + // Verify the cause is VersionNotSupportedError + assertInstanceOf(VersionNotSupportedError.class, e.getCause()); + VersionNotSupportedError versionError = (VersionNotSupportedError) e.getCause(); + assertTrue(versionError.getMessage().contains("2.0")); + assertTrue(versionError.getMessage().contains("1.0")); + } + } } \ No newline at end of file diff --git a/client/transport/rest/src/main/java/io/a2a/client/transport/rest/RestErrorMapper.java b/client/transport/rest/src/main/java/io/a2a/client/transport/rest/RestErrorMapper.java index c6cb181b4..76e6df404 100644 --- a/client/transport/rest/src/main/java/io/a2a/client/transport/rest/RestErrorMapper.java +++ b/client/transport/rest/src/main/java/io/a2a/client/transport/rest/RestErrorMapper.java @@ -8,8 +8,9 @@ import io.a2a.jsonrpc.common.json.JsonProcessingException; import io.a2a.jsonrpc.common.json.JsonUtil; import io.a2a.spec.A2AClientException; -import io.a2a.spec.AuthenticatedExtendedCardNotConfiguredError; +import io.a2a.spec.ExtendedCardNotConfiguredError; import io.a2a.spec.ContentTypeNotSupportedError; +import io.a2a.spec.ExtensionSupportRequiredError; import io.a2a.spec.InternalError; import io.a2a.spec.InvalidAgentResponseError; import io.a2a.spec.InvalidParamsError; @@ -20,6 +21,7 @@ import io.a2a.spec.TaskNotCancelableError; import io.a2a.spec.TaskNotFoundError; import io.a2a.spec.UnsupportedOperationError; +import io.a2a.spec.VersionNotSupportedError; /** * Utility class to A2AHttpResponse to appropriate A2A error types @@ -48,7 +50,8 @@ public static A2AClientException mapRestError(String body, int code) { public static A2AClientException mapRestError(String className, String errorMessage, int code) { return switch (className) { case "io.a2a.spec.TaskNotFoundError" -> new A2AClientException(errorMessage, new TaskNotFoundError()); - case "io.a2a.spec.AuthenticatedExtendedCardNotConfiguredError" -> new A2AClientException(errorMessage, new AuthenticatedExtendedCardNotConfiguredError(null, errorMessage, null)); + case "io.a2a.spec.AuthenticatedExtendedCardNotConfiguredError", + "io.a2a.spec.ExtendedCardNotConfiguredError" -> new A2AClientException(errorMessage, new ExtendedCardNotConfiguredError(null, errorMessage, null)); case "io.a2a.spec.ContentTypeNotSupportedError" -> new A2AClientException(errorMessage, new ContentTypeNotSupportedError(null, null, errorMessage)); case "io.a2a.spec.InternalError" -> new A2AClientException(errorMessage, new InternalError(errorMessage)); case "io.a2a.spec.InvalidAgentResponseError" -> new A2AClientException(errorMessage, new InvalidAgentResponseError(null, null, errorMessage)); @@ -59,6 +62,8 @@ public static A2AClientException mapRestError(String className, String errorMess case "io.a2a.spec.PushNotificationNotSupportedError" -> new A2AClientException(errorMessage, new PushNotificationNotSupportedError()); case "io.a2a.spec.TaskNotCancelableError" -> new A2AClientException(errorMessage, new TaskNotCancelableError()); case "io.a2a.spec.UnsupportedOperationError" -> new A2AClientException(errorMessage, new UnsupportedOperationError()); + case "io.a2a.spec.ExtensionSupportRequiredError" -> new A2AClientException(errorMessage, new ExtensionSupportRequiredError(null, errorMessage, null)); + case "io.a2a.spec.VersionNotSupportedError" -> new A2AClientException(errorMessage, new VersionNotSupportedError(null, errorMessage, null)); default -> new A2AClientException(errorMessage); }; } diff --git a/client/transport/rest/src/test/java/io/a2a/client/transport/rest/RestTransportTest.java b/client/transport/rest/src/test/java/io/a2a/client/transport/rest/RestTransportTest.java index 954fafcae..eda760130 100644 --- a/client/transport/rest/src/test/java/io/a2a/client/transport/rest/RestTransportTest.java +++ b/client/transport/rest/src/test/java/io/a2a/client/transport/rest/RestTransportTest.java @@ -32,6 +32,7 @@ import java.util.logging.Logger; import io.a2a.client.transport.spi.interceptors.ClientCallContext; +import io.a2a.spec.A2AClientException; import io.a2a.spec.AgentCapabilities; import io.a2a.spec.AgentCard; import io.a2a.spec.AgentSkill; @@ -39,6 +40,8 @@ import io.a2a.spec.AuthenticationInfo; import io.a2a.spec.DeleteTaskPushNotificationConfigParams; import io.a2a.spec.EventKind; +import io.a2a.spec.ExtensionSupportRequiredError; +import io.a2a.spec.VersionNotSupportedError; import io.a2a.spec.FilePart; import io.a2a.spec.FileWithBytes; import io.a2a.spec.FileWithUri; @@ -407,7 +410,7 @@ public void testDeleteTaskPushNotificationConfigurations() throws Exception { @Test public void testResubscribe() throws Exception { log.info("Testing resubscribe"); - + this.server.when( request() .withMethod("POST") @@ -451,4 +454,164 @@ public void testResubscribe() throws Exception { assertTrue(part instanceof io.a2a.spec.TextPart); assertEquals("Why did the chicken cross the road? To get to the other side!", ((TextPart) part).text()); } + + /** + * Test that ExtensionSupportRequiredError is properly unmarshalled from REST error response. + */ + @Test + public void testExtensionSupportRequiredErrorUnmarshalling() throws Exception { + log.info("Testing ExtensionSupportRequiredError unmarshalling"); + + // Mock server returns HTTP 400 with ExtensionSupportRequiredError + String errorResponseBody = """ + { + "error": "io.a2a.spec.ExtensionSupportRequiredError", + "message": "Extension required: https://example.com/test-extension" + } + """; + + this.server.when( + request() + .withMethod("POST") + .withPath("/message:send") + ) + .respond( + response() + .withStatusCode(400) + .withHeader("Content-Type", "application/json") + .withBody(errorResponseBody) + ); + + RestTransport client = new RestTransport(CARD); + Message message = Message.builder() + .role(Message.Role.USER) + .parts(Collections.singletonList(new TextPart("test message"))) + .contextId("context-test") + .messageId("message-test") + .build(); + MessageSendParams params = new MessageSendParams(message, null, null, ""); + + // Should throw A2AClientException with ExtensionSupportRequiredError as cause + try { + client.sendMessage(params, null); + org.junit.jupiter.api.Assertions.fail("Expected A2AClientException to be thrown"); + } catch (A2AClientException e) { + // Verify the cause is ExtensionSupportRequiredError + assertInstanceOf(ExtensionSupportRequiredError.class, e.getCause()); + ExtensionSupportRequiredError extensionError = (ExtensionSupportRequiredError) e.getCause(); + assertTrue(extensionError.getMessage().contains("https://example.com/test-extension")); + } + } + + /** + * Test that VersionNotSupportedError is properly unmarshalled from REST error response. + */ + @Test + public void testVersionNotSupportedErrorUnmarshalling() throws Exception { + log.info("Testing VersionNotSupportedError unmarshalling"); + + // Mock server returns HTTP 501 with VersionNotSupportedError + String errorResponseBody = """ + { + "error": "io.a2a.spec.VersionNotSupportedError", + "message": "Protocol version 2.0 is not supported. This agent supports version 1.0" + } + """; + + this.server.when( + request() + .withMethod("POST") + .withPath("/message:send") + ) + .respond( + response() + .withStatusCode(501) + .withHeader("Content-Type", "application/json") + .withBody(errorResponseBody) + ); + + RestTransport client = new RestTransport(CARD); + Message message = Message.builder() + .role(Message.Role.USER) + .parts(Collections.singletonList(new TextPart("test message"))) + .contextId("context-test") + .messageId("message-test") + .build(); + MessageSendParams params = new MessageSendParams(message, null, null, ""); + + // Should throw A2AClientException with VersionNotSupportedError as cause + try { + client.sendMessage(params, null); + org.junit.jupiter.api.Assertions.fail("Expected A2AClientException to be thrown"); + } catch (A2AClientException e) { + // Verify the cause is VersionNotSupportedError + assertInstanceOf(VersionNotSupportedError.class, e.getCause()); + VersionNotSupportedError versionError = (VersionNotSupportedError) e.getCause(); + assertTrue(versionError.getMessage().contains("2.0")); + assertTrue(versionError.getMessage().contains("1.0")); + } + } + + /** + * Test that VersionNotSupportedError is properly handled in streaming responses. + */ + @Test + public void testVersionNotSupportedErrorUnmarshallingStreaming() throws Exception { + log.info("Testing VersionNotSupportedError unmarshalling in streaming"); + + // Mock server returns HTTP 200 with error event in SSE stream + String streamResponseBody = """ + data: {"kind":"error","error":"io.a2a.spec.VersionNotSupportedError","message":"Protocol version 2.0 is not supported. This agent supports version 1.0"} + + """; + + this.server.when( + request() + .withMethod("POST") + .withPath("/message:stream") + ) + .respond( + response() + .withStatusCode(200) + .withHeader("Content-Type", "text/event-stream") + .withBody(streamResponseBody) + ); + + RestTransport client = new RestTransport(CARD); + Message message = Message.builder() + .role(Message.Role.USER) + .parts(Collections.singletonList(new TextPart("test message"))) + .contextId("context-test") + .messageId("message-test") + .build(); + MessageSendConfiguration configuration = MessageSendConfiguration.builder() + .acceptedOutputModes(List.of("text")) + .blocking(false) + .build(); + MessageSendParams params = MessageSendParams.builder() + .message(message) + .configuration(configuration) + .build(); + + AtomicReference receivedError = new AtomicReference<>(); + CountDownLatch latch = new CountDownLatch(1); + Consumer eventHandler = event -> { + // Should not receive events, only error + }; + Consumer errorHandler = error -> { + receivedError.set(error); + latch.countDown(); + }; + client.sendMessageStreaming(params, eventHandler, errorHandler, null); + + boolean errorReceived = latch.await(10, TimeUnit.SECONDS); + assertTrue(errorReceived); + assertNotNull(receivedError.get()); + assertInstanceOf(A2AClientException.class, receivedError.get()); + A2AClientException clientException = (A2AClientException) receivedError.get(); + assertInstanceOf(VersionNotSupportedError.class, clientException.getCause()); + VersionNotSupportedError versionError = (VersionNotSupportedError) clientException.getCause(); + assertTrue(versionError.getMessage().contains("2.0")); + assertTrue(versionError.getMessage().contains("1.0")); + } } diff --git a/common/src/main/java/io/a2a/common/A2AHeaders.java b/common/src/main/java/io/a2a/common/A2AHeaders.java index 40bc2346c..f050333aa 100644 --- a/common/src/main/java/io/a2a/common/A2AHeaders.java +++ b/common/src/main/java/io/a2a/common/A2AHeaders.java @@ -5,6 +5,12 @@ */ public final class A2AHeaders { + /** + * HTTP header name for A2A protocol version. + * Used to communicate the protocol version that the client is using. + */ + public static final String X_A2A_VERSION = "X-A2A-Version"; + /** * HTTP header name for A2A extensions. * Used to communicate which extensions are requested by the client. diff --git a/jsonrpc-common/src/main/java/io/a2a/jsonrpc/common/wrappers/GetAuthenticatedExtendedCardRequest.java b/jsonrpc-common/src/main/java/io/a2a/jsonrpc/common/wrappers/GetAuthenticatedExtendedCardRequest.java index 5fb2f00bb..dec5ec257 100644 --- a/jsonrpc-common/src/main/java/io/a2a/jsonrpc/common/wrappers/GetAuthenticatedExtendedCardRequest.java +++ b/jsonrpc-common/src/main/java/io/a2a/jsonrpc/common/wrappers/GetAuthenticatedExtendedCardRequest.java @@ -5,7 +5,7 @@ import java.util.UUID; import io.a2a.spec.AgentCard; -import io.a2a.spec.AuthenticatedExtendedCardNotConfiguredError; +import io.a2a.spec.ExtendedCardNotConfiguredError; /** * JSON-RPC request to retrieve an agent's extended card with authenticated details. @@ -28,7 +28,7 @@ * * @see GetAuthenticatedExtendedCardResponse for the corresponding response * @see AgentCard for the card structure - * @see AuthenticatedExtendedCardNotConfiguredError for the error when unsupported + * @see ExtendedCardNotConfiguredError for the error when unsupported * @see A2A Protocol Specification */ public final class GetAuthenticatedExtendedCardRequest extends NonStreamingJSONRPCRequest { diff --git a/jsonrpc-common/src/main/java/io/a2a/jsonrpc/common/wrappers/GetAuthenticatedExtendedCardResponse.java b/jsonrpc-common/src/main/java/io/a2a/jsonrpc/common/wrappers/GetAuthenticatedExtendedCardResponse.java index 41e5c238b..63b4fd8eb 100644 --- a/jsonrpc-common/src/main/java/io/a2a/jsonrpc/common/wrappers/GetAuthenticatedExtendedCardResponse.java +++ b/jsonrpc-common/src/main/java/io/a2a/jsonrpc/common/wrappers/GetAuthenticatedExtendedCardResponse.java @@ -2,7 +2,7 @@ import io.a2a.spec.A2AError; import io.a2a.spec.AgentCard; -import io.a2a.spec.AuthenticatedExtendedCardNotConfiguredError; +import io.a2a.spec.ExtendedCardNotConfiguredError; /** * JSON-RPC response containing an agent's extended card with authenticated details. @@ -13,11 +13,11 @@ *

* If the agent doesn't support authenticated extended cards or authentication fails, * the error field will contain a {@link A2AError} such as - * {@link AuthenticatedExtendedCardNotConfiguredError}. + * {@link ExtendedCardNotConfiguredError}. * * @see GetAuthenticatedExtendedCardRequest for the corresponding request * @see AgentCard for the card structure - * @see AuthenticatedExtendedCardNotConfiguredError for the error when unsupported + * @see ExtendedCardNotConfiguredError for the error when unsupported * @see A2A Protocol Specification */ public final class GetAuthenticatedExtendedCardResponse extends A2AResponse { diff --git a/reference/grpc/src/main/java/io/a2a/server/grpc/quarkus/A2AExtensionsInterceptor.java b/reference/grpc/src/main/java/io/a2a/server/grpc/quarkus/A2AExtensionsInterceptor.java index a2c2b13c8..98e40585b 100644 --- a/reference/grpc/src/main/java/io/a2a/server/grpc/quarkus/A2AExtensionsInterceptor.java +++ b/reference/grpc/src/main/java/io/a2a/server/grpc/quarkus/A2AExtensionsInterceptor.java @@ -31,8 +31,13 @@ public ServerCall.Listener interceptCall( Metadata metadata, ServerCallHandler serverCallHandler) { + // Extract A2A protocol version header + Metadata.Key versionKey = + Metadata.Key.of(A2AHeaders.X_A2A_VERSION, Metadata.ASCII_STRING_MARSHALLER); + String version = metadata.get(versionKey); + // Extract A2A extensions header - Metadata.Key extensionsKey = + Metadata.Key extensionsKey = Metadata.Key.of(A2AHeaders.X_A2A_EXTENSIONS, Metadata.ASCII_STRING_MARSHALLER); String extensions = metadata.get(extensionsKey); @@ -45,6 +50,11 @@ public ServerCall.Listener interceptCall( // Store peer information for client connection details .withValue(GrpcContextKeys.PEER_INFO_KEY, getPeerInfo(serverCall)); + // Store A2A version if present + if (version != null) { + context = context.withValue(GrpcContextKeys.VERSION_HEADER_KEY, version); + } + // Store A2A extensions if present if (extensions != null) { context = context.withValue(GrpcContextKeys.EXTENSIONS_HEADER_KEY, extensions); diff --git a/reference/jsonrpc/src/main/java/io/a2a/server/apps/quarkus/A2AServerRoutes.java b/reference/jsonrpc/src/main/java/io/a2a/server/apps/quarkus/A2AServerRoutes.java index 699b003e0..1bf037e7f 100644 --- a/reference/jsonrpc/src/main/java/io/a2a/server/apps/quarkus/A2AServerRoutes.java +++ b/reference/jsonrpc/src/main/java/io/a2a/server/apps/quarkus/A2AServerRoutes.java @@ -241,11 +241,14 @@ public String getUsername() { headerNames.forEach(name -> headers.put(name, rc.request().getHeader(name))); state.put(HEADERS_KEY, headers); + // Extract requested protocol version from X-A2A-Version header + String requestedVersion = rc.request().getHeader(A2AHeaders.X_A2A_VERSION); + // Extract requested extensions from X-A2A-Extensions header List extensionHeaderValues = rc.request().headers().getAll(A2AHeaders.X_A2A_EXTENSIONS); Set requestedExtensions = A2AExtensions.getRequestedExtensions(extensionHeaderValues); - return new ServerCallContext(user, state, requestedExtensions); + return new ServerCallContext(user, state, requestedExtensions, requestedVersion); } else { CallContextFactory builder = callContextFactory.get(); return builder.build(rc); diff --git a/reference/rest/src/main/java/io/a2a/server/rest/quarkus/A2AServerRoutes.java b/reference/rest/src/main/java/io/a2a/server/rest/quarkus/A2AServerRoutes.java index d17815b8b..46d0d38e6 100644 --- a/reference/rest/src/main/java/io/a2a/server/rest/quarkus/A2AServerRoutes.java +++ b/reference/rest/src/main/java/io/a2a/server/rest/quarkus/A2AServerRoutes.java @@ -436,11 +436,14 @@ public String getUsername() { state.put(HEADERS_KEY, headers); state.put(METHOD_NAME_KEY, jsonRpcMethodName); + // Extract requested protocol version from X-A2A-Version header + String requestedVersion = rc.request().getHeader(A2AHeaders.X_A2A_VERSION); + // Extract requested extensions from X-A2A-Extensions header List extensionHeaderValues = rc.request().headers().getAll(A2AHeaders.X_A2A_EXTENSIONS); Set requestedExtensions = A2AExtensions.getRequestedExtensions(extensionHeaderValues); - return new ServerCallContext(user, state, requestedExtensions); + return new ServerCallContext(user, state, requestedExtensions, requestedVersion); } else { CallContextFactory builder = callContextFactory.get(); return builder.build(rc); diff --git a/server-common/src/main/java/io/a2a/server/ServerCallContext.java b/server-common/src/main/java/io/a2a/server/ServerCallContext.java index cef84700e..ba5c20b95 100644 --- a/server-common/src/main/java/io/a2a/server/ServerCallContext.java +++ b/server-common/src/main/java/io/a2a/server/ServerCallContext.java @@ -6,6 +6,7 @@ import java.util.concurrent.ConcurrentHashMap; import io.a2a.server.auth.User; +import org.jspecify.annotations.Nullable; public class ServerCallContext { // TODO Not totally sure yet about these field types @@ -14,12 +15,18 @@ public class ServerCallContext { private final User user; private final Set requestedExtensions; private final Set activatedExtensions; + private final @Nullable String requestedProtocolVersion; public ServerCallContext(User user, Map state, Set requestedExtensions) { + this(user, state, requestedExtensions, null); + } + + public ServerCallContext(User user, Map state, Set requestedExtensions, @Nullable String requestedProtocolVersion) { this.user = user; this.state = state; this.requestedExtensions = new HashSet<>(requestedExtensions); this.activatedExtensions = new HashSet<>(); // Always starts empty, populated later by application code + this.requestedProtocolVersion = requestedProtocolVersion; } public Map getState() { @@ -53,4 +60,8 @@ public boolean isExtensionActivated(String extensionUri) { public boolean isExtensionRequested(String extensionUri) { return requestedExtensions.contains(extensionUri); } + + public @Nullable String getRequestedProtocolVersion() { + return requestedProtocolVersion; + } } diff --git a/server-common/src/main/java/io/a2a/server/extensions/A2AExtensions.java b/server-common/src/main/java/io/a2a/server/extensions/A2AExtensions.java index a83128a1a..45e0e3ac2 100644 --- a/server-common/src/main/java/io/a2a/server/extensions/A2AExtensions.java +++ b/server-common/src/main/java/io/a2a/server/extensions/A2AExtensions.java @@ -4,8 +4,10 @@ import java.util.List; import java.util.Set; +import io.a2a.server.ServerCallContext; import io.a2a.spec.AgentCard; import io.a2a.spec.AgentExtension; +import io.a2a.spec.ExtensionSupportRequiredError; import org.jspecify.annotations.Nullable; public class A2AExtensions { @@ -43,4 +45,27 @@ public static Set getRequestedExtensions(List values) { } return null; } + + /** + * Validates that all required extensions declared in the AgentCard are requested by the client. + * + * @param agentCard the agent card containing extension declarations + * @param context the server call context containing requested extensions + * @throws ExtensionSupportRequiredError if a required extension is not requested + */ + public static void validateRequiredExtensions(AgentCard agentCard, ServerCallContext context) + throws ExtensionSupportRequiredError { + if (agentCard.capabilities() == null || agentCard.capabilities().extensions() == null) { + return; + } + + for (AgentExtension extension : agentCard.capabilities().extensions()) { + if (extension.required() && !context.isExtensionRequested(extension.uri())) { + throw new ExtensionSupportRequiredError( + null, + "Required extension '" + extension.uri() + "' was not requested by the client", + null); + } + } + } } diff --git a/server-common/src/main/java/io/a2a/server/requesthandlers/DefaultRequestHandler.java b/server-common/src/main/java/io/a2a/server/requesthandlers/DefaultRequestHandler.java index f3cb6f02e..b318032f0 100644 --- a/server-common/src/main/java/io/a2a/server/requesthandlers/DefaultRequestHandler.java +++ b/server-common/src/main/java/io/a2a/server/requesthandlers/DefaultRequestHandler.java @@ -56,6 +56,7 @@ import io.a2a.spec.Message; import io.a2a.spec.MessageSendParams; import io.a2a.spec.PushNotificationConfig; +import io.a2a.spec.PushNotificationNotSupportedError; import io.a2a.spec.StreamingEventKind; import io.a2a.spec.Task; import io.a2a.spec.TaskIdParams; diff --git a/server-common/src/main/java/io/a2a/server/version/A2AVersionValidator.java b/server-common/src/main/java/io/a2a/server/version/A2AVersionValidator.java new file mode 100644 index 000000000..83a83122f --- /dev/null +++ b/server-common/src/main/java/io/a2a/server/version/A2AVersionValidator.java @@ -0,0 +1,106 @@ +package io.a2a.server.version; + +import io.a2a.server.ServerCallContext; +import io.a2a.spec.AgentCard; +import io.a2a.spec.VersionNotSupportedError; + +/** + * Utility class for validating A2A protocol version compatibility between clients and agents. + * + *

Version validation follows semantic versioning rules: + *

    + *
  • Major versions must match exactly (1.x can only talk to 1.x)
  • + *
  • Minor versions are compatible (1.0 client can talk to 1.1 server and vice versa)
  • + *
+ * + *

If the client does not specify a version, the current protocol version + * ({@link AgentCard#CURRENT_PROTOCOL_VERSION}) is assumed as the default. + */ +public class A2AVersionValidator { + + /** + * Validates that the client's requested protocol version is compatible with the agent's + * supported version. + * + * @param agentCard the agent card containing the supported protocol version + * @param context the server call context containing the requested protocol version + * @throws VersionNotSupportedError if the versions are incompatible + */ + public static void validateProtocolVersion(AgentCard agentCard, ServerCallContext context) + throws VersionNotSupportedError { + String requestedVersion = context.getRequestedProtocolVersion(); + + // If client didn't specify a version, default to current version + if (requestedVersion == null || requestedVersion.trim().isEmpty()) { + requestedVersion = AgentCard.CURRENT_PROTOCOL_VERSION; + } + + String supportedVersion = agentCard.protocolVersion(); + + if (!isVersionCompatible(supportedVersion, requestedVersion)) { + throw new VersionNotSupportedError( + null, + "Protocol version '" + requestedVersion + "' is not supported. " + + "Supported version: " + supportedVersion, + null); + } + } + + /** + * Checks if the requested version is compatible with the supported version. + * + *

Compatibility rules: + *

    + *
  • Major versions must match exactly
  • + *
  • Minor versions are compatible (any x.Y works with x.Z)
  • + *
+ * + * @param supported the version supported by the agent (e.g., "1.0") + * @param requested the version requested by the client (e.g., "1.1") + * @return true if versions are compatible, false otherwise + */ + static boolean isVersionCompatible(String supported, String requested) { + try { + VersionParts supportedParts = parseVersion(supported); + VersionParts requestedParts = parseVersion(requested); + + // Major versions must match exactly + return supportedParts.major == requestedParts.major; + // Minor versions are compatible - any 1.x can talk to any 1.y + } catch (IllegalArgumentException e) { + // If we can't parse the version, consider it incompatible + return false; + } + } + + /** + * Parses a version string into major and minor components. + * + * @param version the version string (e.g., "1.0") + * @return the parsed version parts + * @throws IllegalArgumentException if the version format is invalid + */ + private static VersionParts parseVersion(String version) { + if (version == null || version.trim().isEmpty()) { + throw new IllegalArgumentException("Version cannot be null or empty"); + } + + String[] parts = version.split("\\."); + if (parts.length < 2) { + throw new IllegalArgumentException("Version must have at least major.minor format: " + version); + } + + try { + int major = Integer.parseInt(parts[0]); + int minor = Integer.parseInt(parts[1]); + return new VersionParts(major, minor); + } catch (NumberFormatException e) { + throw new IllegalArgumentException("Invalid version format: " + version, e); + } + } + + /** + * Simple record to hold version components. + */ + private record VersionParts(int major, int minor) {} +} diff --git a/server-common/src/test/java/io/a2a/server/version/A2AVersionValidatorTest.java b/server-common/src/test/java/io/a2a/server/version/A2AVersionValidatorTest.java new file mode 100644 index 000000000..528017731 --- /dev/null +++ b/server-common/src/test/java/io/a2a/server/version/A2AVersionValidatorTest.java @@ -0,0 +1,168 @@ +package io.a2a.server.version; + +import static org.junit.jupiter.api.Assertions.*; + +import java.util.Collections; +import java.util.HashSet; +import java.util.List; + +import io.a2a.server.ServerCallContext; +import io.a2a.server.auth.UnauthenticatedUser; +import io.a2a.spec.AgentCapabilities; +import io.a2a.spec.AgentCard; +import io.a2a.spec.AgentInterface; +import io.a2a.spec.VersionNotSupportedError; +import org.junit.jupiter.api.Test; + +public class A2AVersionValidatorTest { + + @Test + public void testIsVersionCompatible_SameMajorMinor() { + assertTrue(A2AVersionValidator.isVersionCompatible("1.0", "1.0")); + } + + @Test + public void testIsVersionCompatible_SameMajorDifferentMinor() { + // Major versions match, minor versions can differ + assertTrue(A2AVersionValidator.isVersionCompatible("1.0", "1.1")); + assertTrue(A2AVersionValidator.isVersionCompatible("1.1", "1.0")); + assertTrue(A2AVersionValidator.isVersionCompatible("1.5", "1.9")); + } + + @Test + public void testIsVersionCompatible_DifferentMajor() { + // Major versions must match exactly + assertFalse(A2AVersionValidator.isVersionCompatible("1.0", "2.0")); + assertFalse(A2AVersionValidator.isVersionCompatible("2.0", "1.0")); + assertFalse(A2AVersionValidator.isVersionCompatible("1.5", "2.5")); + } + + @Test + public void testIsVersionCompatible_InvalidFormat() { + // Invalid version formats should return false + assertFalse(A2AVersionValidator.isVersionCompatible("1.0", "invalid")); + assertFalse(A2AVersionValidator.isVersionCompatible("invalid", "1.0")); + assertFalse(A2AVersionValidator.isVersionCompatible("1", "1.0")); + assertFalse(A2AVersionValidator.isVersionCompatible("1.0", "")); + assertFalse(A2AVersionValidator.isVersionCompatible("", "1.0")); + assertFalse(A2AVersionValidator.isVersionCompatible("1.0", null)); + assertFalse(A2AVersionValidator.isVersionCompatible(null, "1.0")); + } + + @Test + public void testValidateProtocolVersion_NoVersionProvided_DefaultsTo1_0() { + // When no version is provided, should default to 1.0 and succeed + AgentCard agentCard = createAgentCard("1.0"); + ServerCallContext context = createContext(null); + + // Should not throw - defaults to 1.0 which matches + assertDoesNotThrow(() -> A2AVersionValidator.validateProtocolVersion(agentCard, context)); + } + + @Test + public void testValidateProtocolVersion_EmptyVersionProvided_DefaultsTo1_0() { + // When empty version is provided, should default to 1.0 and succeed + AgentCard agentCard = createAgentCard("1.0"); + ServerCallContext context = createContext(""); + + // Should not throw - defaults to 1.0 which matches + assertDoesNotThrow(() -> A2AVersionValidator.validateProtocolVersion(agentCard, context)); + } + + @Test + public void testValidateProtocolVersion_MatchingVersion() { + // When version matches exactly, should succeed + AgentCard agentCard = createAgentCard("1.0"); + ServerCallContext context = createContext("1.0"); + + // Should not throw - versions match + assertDoesNotThrow(() -> A2AVersionValidator.validateProtocolVersion(agentCard, context)); + } + + @Test + public void testValidateProtocolVersion_CompatibleMinorVersions() { + // When major version matches but minor differs, should succeed + AgentCard agentCard = createAgentCard("1.0"); + ServerCallContext context = createContext("1.1"); + + // Should not throw - same major version + assertDoesNotThrow(() -> A2AVersionValidator.validateProtocolVersion(agentCard, context)); + } + + @Test + public void testValidateProtocolVersion_CompatibleMinorVersions_Reverse() { + // When major version matches but minor differs (reverse), should succeed + AgentCard agentCard = createAgentCard("1.1"); + ServerCallContext context = createContext("1.0"); + + // Should not throw - same major version + assertDoesNotThrow(() -> A2AVersionValidator.validateProtocolVersion(agentCard, context)); + } + + @Test + public void testValidateProtocolVersion_IncompatibleMajorVersion() { + // When major version differs, should throw VersionNotSupportedError + AgentCard agentCard = createAgentCard("1.0"); + ServerCallContext context = createContext("2.0"); + + VersionNotSupportedError error = assertThrows(VersionNotSupportedError.class, + () -> A2AVersionValidator.validateProtocolVersion(agentCard, context)); + + assertTrue(error.getMessage().contains("2.0")); + assertTrue(error.getMessage().contains("1.0")); + assertTrue(error.getMessage().contains("not supported")); + } + + @Test + public void testValidateProtocolVersion_IncompatibleMajorVersion_Reverse() { + // When major version differs (reverse), should throw VersionNotSupportedError + AgentCard agentCard = createAgentCard("2.0"); + ServerCallContext context = createContext("1.0"); + + VersionNotSupportedError error = assertThrows(VersionNotSupportedError.class, + () -> A2AVersionValidator.validateProtocolVersion(agentCard, context)); + + assertTrue(error.getMessage().contains("1.0")); + assertTrue(error.getMessage().contains("2.0")); + assertTrue(error.getMessage().contains("not supported")); + } + + @Test + public void testValidateProtocolVersion_InvalidVersionFormat() { + // When invalid version is provided, should throw VersionNotSupportedError + AgentCard agentCard = createAgentCard("1.0"); + ServerCallContext context = createContext("invalid"); + + VersionNotSupportedError error = assertThrows(VersionNotSupportedError.class, + () -> A2AVersionValidator.validateProtocolVersion(agentCard, context)); + + assertTrue(error.getMessage().contains("invalid")); + assertTrue(error.getMessage().contains("not supported")); + } + + private AgentCard createAgentCard(String protocolVersion) { + return AgentCard.builder() + .name("test-card") + .description("Test card") + .supportedInterfaces(List.of(new AgentInterface("GRPC", "http://localhost:9999"))) + .version("1.0.0") + .capabilities(AgentCapabilities.builder() + .streaming(false) + .pushNotifications(false) + .build()) + .defaultInputModes(List.of("text")) + .defaultOutputModes(List.of("text")) + .skills(Collections.emptyList()) + .protocolVersion(protocolVersion) + .build(); + } + + private ServerCallContext createContext(String requestedProtocolVersion) { + return new ServerCallContext( + UnauthenticatedUser.INSTANCE, + Collections.emptyMap(), + new HashSet<>(), + requestedProtocolVersion + ); + } +} diff --git a/spec-grpc/src/main/java/io/a2a/grpc/utils/JSONRPCUtils.java b/spec-grpc/src/main/java/io/a2a/grpc/utils/JSONRPCUtils.java index 37b1e9f34..63167ed52 100644 --- a/spec-grpc/src/main/java/io/a2a/grpc/utils/JSONRPCUtils.java +++ b/spec-grpc/src/main/java/io/a2a/grpc/utils/JSONRPCUtils.java @@ -1,6 +1,8 @@ package io.a2a.grpc.utils; import static io.a2a.spec.A2AErrorCodes.CONTENT_TYPE_NOT_SUPPORTED_ERROR_CODE; +import static io.a2a.spec.A2AErrorCodes.EXTENDED_CARD_NOT_CONFIGURED_ERROR_CODE; +import static io.a2a.spec.A2AErrorCodes.EXTENSION_SUPPORT_REQUIRED_ERROR; import static io.a2a.spec.A2AErrorCodes.INTERNAL_ERROR_CODE; import static io.a2a.spec.A2AErrorCodes.INVALID_AGENT_RESPONSE_ERROR_CODE; import static io.a2a.spec.A2AErrorCodes.INVALID_PARAMS_ERROR_CODE; @@ -11,6 +13,7 @@ import static io.a2a.spec.A2AErrorCodes.TASK_NOT_CANCELABLE_ERROR_CODE; import static io.a2a.spec.A2AErrorCodes.TASK_NOT_FOUND_ERROR_CODE; import static io.a2a.spec.A2AErrorCodes.UNSUPPORTED_OPERATION_ERROR_CODE; +import static io.a2a.spec.A2AErrorCodes.VERSION_NOT_SUPPORTED_ERROR_CODE; import static io.a2a.spec.A2AMethods.CANCEL_TASK_METHOD; import static io.a2a.spec.A2AMethods.GET_EXTENDED_AGENT_CARD_METHOD; import static io.a2a.spec.A2AMethods.SEND_STREAMING_MESSAGE_METHOD; @@ -63,6 +66,8 @@ import io.a2a.jsonrpc.common.wrappers.SubscribeToTaskRequest; import io.a2a.spec.A2AError; import io.a2a.spec.ContentTypeNotSupportedError; +import io.a2a.spec.ExtendedCardNotConfiguredError; +import io.a2a.spec.ExtensionSupportRequiredError; import io.a2a.spec.InvalidAgentResponseError; import io.a2a.spec.InvalidParamsError; import io.a2a.spec.InvalidRequestError; @@ -72,6 +77,7 @@ import io.a2a.spec.TaskNotCancelableError; import io.a2a.spec.TaskNotFoundError; import io.a2a.spec.UnsupportedOperationError; +import io.a2a.spec.VersionNotSupportedError; import io.a2a.util.Utils; import org.jspecify.annotations.Nullable; @@ -387,6 +393,12 @@ private static A2AError processError(JsonObject error) { return new ContentTypeNotSupportedError(code, message, data); case INVALID_AGENT_RESPONSE_ERROR_CODE: return new InvalidAgentResponseError(code, message, data); + case EXTENDED_CARD_NOT_CONFIGURED_ERROR_CODE: + return new ExtendedCardNotConfiguredError(code, message, data); + case EXTENSION_SUPPORT_REQUIRED_ERROR: + return new ExtensionSupportRequiredError(code, message, data); + case VERSION_NOT_SUPPORTED_ERROR_CODE: + return new VersionNotSupportedError(code, message, data); case TASK_NOT_CANCELABLE_ERROR_CODE: return new TaskNotCancelableError(code, message, data); case TASK_NOT_FOUND_ERROR_CODE: diff --git a/spec/src/main/java/io/a2a/spec/A2AErrorCodes.java b/spec/src/main/java/io/a2a/spec/A2AErrorCodes.java index f8087bdaa..9c1b4c88a 100644 --- a/spec/src/main/java/io/a2a/spec/A2AErrorCodes.java +++ b/spec/src/main/java/io/a2a/spec/A2AErrorCodes.java @@ -23,8 +23,16 @@ public interface A2AErrorCodes { /** Error code indicating the agent returned an invalid response (-32006). */ int INVALID_AGENT_RESPONSE_ERROR_CODE = -32006; - /** Error code indicating authenticated extended card is not configured (-32007). */ - int AUTHENTICATED_EXTENDED_CARD_NOT_CONFIGURED_ERROR_CODE = -32007; + /** Error code indicating extended card is not configured (-32007). */ + int EXTENDED_CARD_NOT_CONFIGURED_ERROR_CODE = -32007; + + /** Error code indicating client requested use of an extension marked as required: true in the Agent Card + * but the client did not declare support for it in the request (-32008). */ + int EXTENSION_SUPPORT_REQUIRED_ERROR = -32008; + + /** Error code indicating the A2A protocol version specified in the request (via A2A-Version service parameter) + * is not supported by the agent (-32009). */ + int VERSION_NOT_SUPPORTED_ERROR_CODE = -32009; /** JSON-RPC error code for invalid request structure (-32600). */ int INVALID_REQUEST_ERROR_CODE = -32600; diff --git a/spec/src/main/java/io/a2a/spec/A2AProtocolError.java b/spec/src/main/java/io/a2a/spec/A2AProtocolError.java new file mode 100644 index 000000000..e8dfb3c47 --- /dev/null +++ b/spec/src/main/java/io/a2a/spec/A2AProtocolError.java @@ -0,0 +1,15 @@ +package io.a2a.spec; + +public class A2AProtocolError extends A2AError { + + private final String url; + + public A2AProtocolError(Integer code, String message, Object data, String url) { + super(code, message, data); + this.url = url; + } + + public String getUrl() { + return url; + } +} diff --git a/spec/src/main/java/io/a2a/spec/ContentTypeNotSupportedError.java b/spec/src/main/java/io/a2a/spec/ContentTypeNotSupportedError.java index a696a891a..812d290d2 100644 --- a/spec/src/main/java/io/a2a/spec/ContentTypeNotSupportedError.java +++ b/spec/src/main/java/io/a2a/spec/ContentTypeNotSupportedError.java @@ -36,7 +36,7 @@ * @see MessageSendConfiguration for client content type preferences * @see A2A Protocol Specification */ -public class ContentTypeNotSupportedError extends A2AError { +public class ContentTypeNotSupportedError extends A2AProtocolError { /** * Constructs a content type not supported error. @@ -48,6 +48,7 @@ public class ContentTypeNotSupportedError extends A2AError { public ContentTypeNotSupportedError(Integer code, String message, Object data) { super(defaultIfNull(code, CONTENT_TYPE_NOT_SUPPORTED_ERROR_CODE), defaultIfNull(message, "Incompatible content types"), - data); + data, + "https://a2a-protocol.org/errors/content-type-not-supported"); } } diff --git a/spec/src/main/java/io/a2a/spec/AuthenticatedExtendedCardNotConfiguredError.java b/spec/src/main/java/io/a2a/spec/ExtendedCardNotConfiguredError.java similarity index 78% rename from spec/src/main/java/io/a2a/spec/AuthenticatedExtendedCardNotConfiguredError.java rename to spec/src/main/java/io/a2a/spec/ExtendedCardNotConfiguredError.java index 52650dc1f..9ca08844c 100644 --- a/spec/src/main/java/io/a2a/spec/AuthenticatedExtendedCardNotConfiguredError.java +++ b/spec/src/main/java/io/a2a/spec/ExtendedCardNotConfiguredError.java @@ -1,6 +1,6 @@ package io.a2a.spec; -import static io.a2a.spec.A2AErrorCodes.AUTHENTICATED_EXTENDED_CARD_NOT_CONFIGURED_ERROR_CODE; +import static io.a2a.spec.A2AErrorCodes.EXTENDED_CARD_NOT_CONFIGURED_ERROR_CODE; import static io.a2a.util.Utils.defaultIfNull; @@ -27,7 +27,7 @@ * @see AgentCard for the base agent card structure * @see A2A Protocol Specification */ -public class AuthenticatedExtendedCardNotConfiguredError extends A2AError { +public class ExtendedCardNotConfiguredError extends A2AProtocolError { /** * Constructs an error for agents that don't support authenticated extended card retrieval. @@ -36,13 +36,14 @@ public class AuthenticatedExtendedCardNotConfiguredError extends A2AError { * @param message the error message * @param data additional error data */ - public AuthenticatedExtendedCardNotConfiguredError( + public ExtendedCardNotConfiguredError( Integer code, String message, Object data) { super( - defaultIfNull(code, AUTHENTICATED_EXTENDED_CARD_NOT_CONFIGURED_ERROR_CODE), + defaultIfNull(code, EXTENDED_CARD_NOT_CONFIGURED_ERROR_CODE), defaultIfNull(message, "Authenticated Extended Card not configured"), - data); + data, + "https://a2a-protocol.org/errors/extended-agent-card-not-configured"); } } diff --git a/spec/src/main/java/io/a2a/spec/ExtensionSupportRequiredError.java b/spec/src/main/java/io/a2a/spec/ExtensionSupportRequiredError.java new file mode 100644 index 000000000..f569708c6 --- /dev/null +++ b/spec/src/main/java/io/a2a/spec/ExtensionSupportRequiredError.java @@ -0,0 +1,50 @@ +package io.a2a.spec; + +import static io.a2a.spec.A2AErrorCodes.EXTENDED_CARD_NOT_CONFIGURED_ERROR_CODE; +import static io.a2a.spec.A2AErrorCodes.EXTENSION_SUPPORT_REQUIRED_ERROR; +import static io.a2a.util.Utils.defaultIfNull; + + +/** + * A2A Protocol error indicating that a client requested use of an extension marked as required + * but did not declare support for it. + *

+ * This error is returned when a client attempts to use a feature or capability that requires + * a specific extension, but the client has not declared support for that extension in the request. + * Extensions marked as {@code required: true} in the Agent Card must be explicitly supported + * by the client. + *

+ * Corresponds to A2A-specific error code {@code -32008}. + *

+ * Usage example: + *

{@code
+ * // In agent implementation
+ * if (requiredExtension && !clientSupportsExtension) {
+ *     throw new ExtensionSupportRequiredError(null,
+ *         "Extension 'custom-auth' is required but not supported by client", null);
+ * }
+ * }
+ * + * @see AgentCard for extension declarations + * @see A2A Protocol Specification + */ +public class ExtensionSupportRequiredError extends A2AProtocolError { + + /** + * Constructs an error when a client requests a required extension without declaring support. + * + * @param code the error code (defaults to -32008 if null) + * @param message the error message (defaults to standard message if null) + * @param data additional error data (optional) + */ + public ExtensionSupportRequiredError( + Integer code, + String message, + Object data) { + super( + defaultIfNull(code, EXTENSION_SUPPORT_REQUIRED_ERROR), + defaultIfNull(message, "Extension support required but not declared"), + data, + "https://a2a-protocol.org/errors/extension-support-required"); + } +} diff --git a/spec/src/main/java/io/a2a/spec/InvalidAgentResponseError.java b/spec/src/main/java/io/a2a/spec/InvalidAgentResponseError.java index 303a3f548..d86fb584e 100644 --- a/spec/src/main/java/io/a2a/spec/InvalidAgentResponseError.java +++ b/spec/src/main/java/io/a2a/spec/InvalidAgentResponseError.java @@ -35,7 +35,7 @@ * * @see A2A Protocol Specification */ -public class InvalidAgentResponseError extends A2AError { +public class InvalidAgentResponseError extends A2AProtocolError { /** * Constructs an invalid agent response error. @@ -48,6 +48,7 @@ public InvalidAgentResponseError(Integer code, String message, Object data) { super( defaultIfNull(code, INVALID_AGENT_RESPONSE_ERROR_CODE), defaultIfNull(message, "Invalid agent response"), - data); + data, + "https://a2a-protocol.org/errors/invalid-agent-response"); } } diff --git a/spec/src/main/java/io/a2a/spec/PushNotificationNotSupportedError.java b/spec/src/main/java/io/a2a/spec/PushNotificationNotSupportedError.java index 1a336cc00..f8f7747b8 100644 --- a/spec/src/main/java/io/a2a/spec/PushNotificationNotSupportedError.java +++ b/spec/src/main/java/io/a2a/spec/PushNotificationNotSupportedError.java @@ -26,7 +26,7 @@ * @see TaskPushNotificationConfig for push notification configuration * @see A2A Protocol Specification */ -public class PushNotificationNotSupportedError extends A2AError { +public class PushNotificationNotSupportedError extends A2AProtocolError { /** * Constructs error with default message. @@ -49,6 +49,7 @@ public PushNotificationNotSupportedError( super( defaultIfNull(code, PUSH_NOTIFICATION_NOT_SUPPORTED_ERROR_CODE), defaultIfNull(message, "Push Notification is not supported"), - data); + data, + "https://a2a-protocol.org/errors/push-notification-not-supported"); } } diff --git a/spec/src/main/java/io/a2a/spec/TaskNotCancelableError.java b/spec/src/main/java/io/a2a/spec/TaskNotCancelableError.java index 86e7887e3..86213eb37 100644 --- a/spec/src/main/java/io/a2a/spec/TaskNotCancelableError.java +++ b/spec/src/main/java/io/a2a/spec/TaskNotCancelableError.java @@ -29,7 +29,7 @@ * @see TaskStatus#state() for current task state * @see A2A Protocol Specification */ -public class TaskNotCancelableError extends A2AError { +public class TaskNotCancelableError extends A2AProtocolError { /** * Constructs error with default message. @@ -52,7 +52,8 @@ public TaskNotCancelableError( super( defaultIfNull(code, TASK_NOT_CANCELABLE_ERROR_CODE), defaultIfNull(message, "Task cannot be canceled"), - data); + data, + "https://a2a-protocol.org/errors/task-not-cancelable"); } /** diff --git a/spec/src/main/java/io/a2a/spec/TaskNotFoundError.java b/spec/src/main/java/io/a2a/spec/TaskNotFoundError.java index b1dd902d5..252286257 100644 --- a/spec/src/main/java/io/a2a/spec/TaskNotFoundError.java +++ b/spec/src/main/java/io/a2a/spec/TaskNotFoundError.java @@ -30,7 +30,7 @@ * @see Task for task object definition * @see A2A Protocol Specification */ -public class TaskNotFoundError extends A2AError { +public class TaskNotFoundError extends A2AProtocolError { /** * Constructs error with default message. @@ -53,6 +53,7 @@ public TaskNotFoundError( super( defaultIfNull(code, TASK_NOT_FOUND_ERROR_CODE), defaultIfNull(message, "Task not found"), - data); + data, + "https://a2a-protocol.org/errors/task-not-found"); } } diff --git a/spec/src/main/java/io/a2a/spec/UnsupportedOperationError.java b/spec/src/main/java/io/a2a/spec/UnsupportedOperationError.java index da2231e30..ac98efa3a 100644 --- a/spec/src/main/java/io/a2a/spec/UnsupportedOperationError.java +++ b/spec/src/main/java/io/a2a/spec/UnsupportedOperationError.java @@ -31,7 +31,7 @@ * @see MethodNotFoundError for unknown method errors * @see A2A Protocol Specification */ -public class UnsupportedOperationError extends A2AError { +public class UnsupportedOperationError extends A2AProtocolError { /** * Constructs error with all parameters. @@ -47,7 +47,8 @@ public UnsupportedOperationError( super( defaultIfNull(code, UNSUPPORTED_OPERATION_ERROR_CODE), defaultIfNull(message, "This operation is not supported"), - data); + data, + "https://a2a-protocol.org/errors/unsupported-operation"); } /** diff --git a/spec/src/main/java/io/a2a/spec/VersionNotSupportedError.java b/spec/src/main/java/io/a2a/spec/VersionNotSupportedError.java new file mode 100644 index 000000000..b6ff5c6d4 --- /dev/null +++ b/spec/src/main/java/io/a2a/spec/VersionNotSupportedError.java @@ -0,0 +1,49 @@ +package io.a2a.spec; + +import static io.a2a.spec.A2AErrorCodes.EXTENSION_SUPPORT_REQUIRED_ERROR; +import static io.a2a.spec.A2AErrorCodes.VERSION_NOT_SUPPORTED_ERROR_CODE; +import static io.a2a.util.Utils.defaultIfNull; + + +/** + * A2A Protocol error indicating that the A2A protocol version specified in the request + * is not supported by the agent. + *

+ * This error is returned when a client specifies a protocol version (via the A2A-Version + * service parameter) that the agent does not support. Agents should return this error + * when they cannot handle the requested protocol version. + *

+ * Corresponds to A2A-specific error code {@code -32009}. + *

+ * Usage example: + *

{@code
+ * // In agent implementation
+ * if (!isSupportedVersion(requestedVersion)) {
+ *     throw new VersionNotSupportedError(null,
+ *         "Protocol version " + requestedVersion + " is not supported", null);
+ * }
+ * }
+ * + * @see AgentCard#protocolVersion() for supported version declaration + * @see A2A Protocol Specification + */ +public class VersionNotSupportedError extends A2AProtocolError { + + /** + * Constructs an error when the requested protocol version is not supported. + * + * @param code the error code (defaults to -32009 if null) + * @param message the error message (defaults to standard message if null) + * @param data additional error data (optional) + */ + public VersionNotSupportedError( + Integer code, + String message, + Object data) { + super( + defaultIfNull(code, VERSION_NOT_SUPPORTED_ERROR_CODE), + defaultIfNull(message, "Protocol version not supported"), + data, + "https://a2a-protocol.org/errors/version-not-supported"); + } +} diff --git a/transport/grpc/src/main/java/io/a2a/transport/grpc/context/GrpcContextKeys.java b/transport/grpc/src/main/java/io/a2a/transport/grpc/context/GrpcContextKeys.java index 483daf7e8..a025236f0 100644 --- a/transport/grpc/src/main/java/io/a2a/transport/grpc/context/GrpcContextKeys.java +++ b/transport/grpc/src/main/java/io/a2a/transport/grpc/context/GrpcContextKeys.java @@ -11,11 +11,18 @@ */ public final class GrpcContextKeys { + /** + * Context key for storing the X-A2A-Version header value. + * Set by server interceptors and accessed by service handlers. + */ + public static final Context.Key VERSION_HEADER_KEY = + Context.key("x-a2a-version"); + /** * Context key for storing the X-A2A-Extensions header value. * Set by server interceptors and accessed by service handlers. */ - public static final Context.Key EXTENSIONS_HEADER_KEY = + public static final Context.Key EXTENSIONS_HEADER_KEY = Context.key("x-a2a-extensions"); /** diff --git a/transport/grpc/src/main/java/io/a2a/transport/grpc/handler/GrpcHandler.java b/transport/grpc/src/main/java/io/a2a/transport/grpc/handler/GrpcHandler.java index 261ee14a8..7a1e0a912 100644 --- a/transport/grpc/src/main/java/io/a2a/transport/grpc/handler/GrpcHandler.java +++ b/transport/grpc/src/main/java/io/a2a/transport/grpc/handler/GrpcHandler.java @@ -27,11 +27,14 @@ import io.a2a.server.auth.User; import io.a2a.server.extensions.A2AExtensions; import io.a2a.server.requesthandlers.RequestHandler; +import io.a2a.server.version.A2AVersionValidator; import io.a2a.spec.A2AError; import io.a2a.spec.AgentCard; import io.a2a.spec.ContentTypeNotSupportedError; import io.a2a.spec.DeleteTaskPushNotificationConfigParams; import io.a2a.spec.EventKind; +import io.a2a.spec.ExtendedCardNotConfiguredError; +import io.a2a.spec.ExtensionSupportRequiredError; import io.a2a.spec.GetTaskPushNotificationConfigParams; import io.a2a.spec.InternalError; import io.a2a.spec.InvalidAgentResponseError; @@ -51,6 +54,7 @@ import io.a2a.spec.TaskPushNotificationConfig; import io.a2a.spec.TaskQueryParams; import io.a2a.spec.UnsupportedOperationError; +import io.a2a.spec.VersionNotSupportedError; import io.a2a.transport.grpc.context.GrpcContextKeys; import io.grpc.Context; import io.grpc.Status; @@ -76,6 +80,8 @@ public void sendMessage(io.a2a.grpc.SendMessageRequest request, StreamObserver responseObserver) { try { ServerCallContext context = createCallContext(responseObserver); + A2AVersionValidator.validateProtocolVersion(getAgentCardInternal(), context); + A2AExtensions.validateRequiredExtensions(getAgentCardInternal(), context); MessageSendParams params = FromProto.messageSendParams(request); EventKind taskOrMessage = getRequestHandler().onMessageSend(params, context); io.a2a.grpc.SendMessageResponse response = ToProto.taskOrMessage(taskOrMessage); @@ -232,6 +238,8 @@ public void sendStreamingMessage(io.a2a.grpc.SendMessageRequest request, try { ServerCallContext context = createCallContext(responseObserver); + A2AVersionValidator.validateProtocolVersion(getAgentCardInternal(), context); + A2AExtensions.validateRequiredExtensions(getAgentCardInternal(), context); MessageSendParams params = FromProto.messageSendParams(request); Flow.Publisher publisher = getRequestHandler().onMessageSendStream(params, context); convertToStreamResponse(publisher, responseObserver); @@ -390,14 +398,17 @@ private ServerCallContext createCallContext(StreamObserver responseObserv LOGGER.fine(() -> "Error getting data from current context" + e); } + // Extract requested protocol version from gRPC context (set by interceptor) + String requestedVersion = getVersionFromContext(); + // Extract requested extensions from gRPC context (set by interceptor) Set requestedExtensions = new HashSet<>(); String extensionsHeader = getExtensionsFromContext(); if (extensionsHeader != null) { requestedExtensions = A2AExtensions.getRequestedExtensions(List.of(extensionsHeader)); } - - return new ServerCallContext(user, state, requestedExtensions); + + return new ServerCallContext(user, state, requestedExtensions, requestedVersion); } else { // TODO: CallContextFactory interface expects ServerCall + Metadata, but we only have StreamObserver // This is another manifestation of the architectural limitation mentioned above @@ -424,7 +435,7 @@ private void handleError(StreamObserver responseObserver, A2AError error) status = Status.NOT_FOUND; description = "TaskNotFoundError: " + error.getMessage(); } else if (error instanceof TaskNotCancelableError) { - status = Status.UNIMPLEMENTED; + status = Status.FAILED_PRECONDITION; description = "TaskNotCancelableError: " + error.getMessage(); } else if (error instanceof PushNotificationNotSupportedError) { status = Status.UNIMPLEMENTED; @@ -436,11 +447,20 @@ private void handleError(StreamObserver responseObserver, A2AError error) status = Status.INTERNAL; description = "JSONParseError: " + error.getMessage(); } else if (error instanceof ContentTypeNotSupportedError) { - status = Status.UNIMPLEMENTED; + status = Status.INVALID_ARGUMENT; description = "ContentTypeNotSupportedError: " + error.getMessage(); } else if (error instanceof InvalidAgentResponseError) { status = Status.INTERNAL; description = "InvalidAgentResponseError: " + error.getMessage(); + } else if (error instanceof ExtendedCardNotConfiguredError) { + status = Status.FAILED_PRECONDITION; + description = "ExtendedCardNotConfiguredError: " + error.getMessage(); + } else if (error instanceof ExtensionSupportRequiredError) { + status = Status.FAILED_PRECONDITION; + description = "ExtensionSupportRequiredError: " + error.getMessage(); + } else if (error instanceof VersionNotSupportedError) { + status = Status.UNIMPLEMENTED; + description = "VersionNotSupportedError: " + error.getMessage(); } else { status = Status.UNKNOWN; description = "Unknown error type: " + error.getMessage(); @@ -522,11 +542,27 @@ public static void setStreamingSubscribedRunnable(Runnable runnable) { protected abstract Executor getExecutor(); + /** + * Attempts to extract the X-A2A-Version header from the current gRPC context. + * This will only work if a server interceptor has been configured to capture + * the metadata and store it in the context. + * + * @return the version header value, or null if not available + */ + private String getVersionFromContext() { + try { + return GrpcContextKeys.VERSION_HEADER_KEY.get(); + } catch (Exception e) { + // Context not available or key not set + return null; + } + } + /** * Attempts to extract the X-A2A-Extensions header from the current gRPC context. * This will only work if a server interceptor has been configured to capture * the metadata and store it in the context. - * + * * @return the extensions header value, or null if not available */ private String getExtensionsFromContext() { diff --git a/transport/grpc/src/test/java/io/a2a/transport/grpc/handler/GrpcHandlerTest.java b/transport/grpc/src/test/java/io/a2a/transport/grpc/handler/GrpcHandlerTest.java index 0d8f6fb95..ad9b879f9 100644 --- a/transport/grpc/src/test/java/io/a2a/transport/grpc/handler/GrpcHandlerTest.java +++ b/transport/grpc/src/test/java/io/a2a/transport/grpc/handler/GrpcHandlerTest.java @@ -3,7 +3,11 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import java.util.ArrayList; +import java.util.Collections; +import java.util.HashSet; import java.util.List; +import java.util.Map; +import java.util.Set; import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; @@ -31,20 +35,28 @@ import io.a2a.grpc.TaskState; import io.a2a.grpc.TaskStatus; import io.a2a.server.ServerCallContext; +import io.a2a.server.auth.UnauthenticatedUser; import io.a2a.server.events.EventConsumer; import io.a2a.server.requesthandlers.AbstractA2ARequestHandlerTest; import io.a2a.server.requesthandlers.DefaultRequestHandler; import io.a2a.server.requesthandlers.RequestHandler; import io.a2a.server.tasks.TaskUpdater; +import io.a2a.spec.AgentCapabilities; import io.a2a.spec.AgentCard; +import io.a2a.spec.AgentExtension; +import io.a2a.spec.AgentInterface; import io.a2a.spec.Artifact; import io.a2a.spec.Event; +import io.a2a.spec.ExtensionSupportRequiredError; import io.a2a.spec.InternalError; +import io.a2a.spec.VersionNotSupportedError; import io.a2a.spec.MessageSendParams; import io.a2a.spec.TaskArtifactUpdateEvent; import io.a2a.spec.TaskStatusUpdateEvent; import io.a2a.spec.TextPart; import io.a2a.spec.UnsupportedOperationError; +import io.a2a.transport.grpc.context.GrpcContextKeys; +import io.grpc.Context; import io.grpc.Status; import io.grpc.StatusRuntimeException; import io.grpc.internal.testing.StreamRecorder; @@ -808,6 +820,333 @@ public void onCompleted() { Assertions.assertEquals(1, results.size(), "Should have received exactly one event"); } + @Test + public void testExtensionSupportRequiredErrorOnSendMessage() throws Exception { + // Create AgentCard with a required extension + AgentCard cardWithExtension = AgentCard.builder() + .name("test-card") + .description("Test card with required extension") + .supportedInterfaces(Collections.singletonList(new AgentInterface("GRPC", "http://localhost:9999"))) + .version("1.0.0") + .capabilities(AgentCapabilities.builder() + .streaming(true) + .pushNotifications(true) + .extensions(List.of( + AgentExtension.builder() + .uri("https://example.com/test-extension") + .required(true) + .build() + )) + .build()) + .defaultInputModes(List.of("text")) + .defaultOutputModes(List.of("text")) + .skills(List.of()) + .protocolVersion(AgentCard.CURRENT_PROTOCOL_VERSION) + .build(); + + GrpcHandler handler = new TestGrpcHandler(cardWithExtension, requestHandler, internalExecutor); + + SendMessageRequest request = SendMessageRequest.newBuilder() + .setRequest(GRPC_MESSAGE) + .build(); + StreamRecorder streamRecorder = StreamRecorder.create(); + handler.sendMessage(request, streamRecorder); + streamRecorder.awaitCompletion(5, TimeUnit.SECONDS); + + assertGrpcError(streamRecorder, Status.Code.FAILED_PRECONDITION); + } + + @Test + public void testExtensionSupportRequiredErrorOnSendStreamingMessage() throws Exception { + // Create AgentCard with a required extension + AgentCard cardWithExtension = AgentCard.builder() + .name("test-card") + .description("Test card with required extension") + .supportedInterfaces(Collections.singletonList(new AgentInterface("GRPC", "http://localhost:9999"))) + .version("1.0.0") + .capabilities(AgentCapabilities.builder() + .streaming(true) + .pushNotifications(true) + .extensions(List.of( + AgentExtension.builder() + .uri("https://example.com/streaming-extension") + .required(true) + .build() + )) + .build()) + .defaultInputModes(List.of("text")) + .defaultOutputModes(List.of("text")) + .skills(List.of()) + .protocolVersion(AgentCard.CURRENT_PROTOCOL_VERSION) + .build(); + + GrpcHandler handler = new TestGrpcHandler(cardWithExtension, requestHandler, internalExecutor); + + SendMessageRequest request = SendMessageRequest.newBuilder() + .setRequest(GRPC_MESSAGE) + .build(); + StreamRecorder streamRecorder = StreamRecorder.create(); + handler.sendStreamingMessage(request, streamRecorder); + streamRecorder.awaitCompletion(5, TimeUnit.SECONDS); + + assertGrpcError(streamRecorder, Status.Code.FAILED_PRECONDITION); + } + + @Test + public void testRequiredExtensionProvidedSuccess() throws Exception { + // Create AgentCard with a required extension + AgentCard cardWithExtension = AgentCard.builder() + .name("test-card") + .description("Test card with required extension") + .supportedInterfaces(Collections.singletonList(new AgentInterface("GRPC", "http://localhost:9999"))) + .version("1.0.0") + .capabilities(AgentCapabilities.builder() + .streaming(true) + .pushNotifications(true) + .extensions(List.of( + AgentExtension.builder() + .uri("https://example.com/required-extension") + .required(true) + .build() + )) + .build()) + .defaultInputModes(List.of("text")) + .defaultOutputModes(List.of("text")) + .skills(List.of()) + .protocolVersion(AgentCard.CURRENT_PROTOCOL_VERSION) + .build(); + + // Create a TestGrpcHandler that provides the required extension in the context + GrpcHandler handler = new TestGrpcHandler(cardWithExtension, requestHandler, internalExecutor) { + @Override + protected CallContextFactory getCallContextFactory() { + return new CallContextFactory() { + @Override + public ServerCallContext create(StreamObserver streamObserver) { + Set requestedExtensions = new HashSet<>(); + requestedExtensions.add("https://example.com/required-extension"); + return new ServerCallContext( + UnauthenticatedUser.INSTANCE, + Map.of("grpc_response_observer", streamObserver), + requestedExtensions + ); + } + }; + } + }; + + agentExecutorExecute = (context, eventQueue) -> { + eventQueue.enqueueEvent(context.getMessage()); + }; + + SendMessageRequest request = SendMessageRequest.newBuilder() + .setRequest(GRPC_MESSAGE) + .build(); + StreamRecorder streamRecorder = StreamRecorder.create(); + handler.sendMessage(request, streamRecorder); + streamRecorder.awaitCompletion(5, TimeUnit.SECONDS); + + // Should succeed without error + Assertions.assertNull(streamRecorder.getError()); + Assertions.assertFalse(streamRecorder.getValues().isEmpty()); + } + + @Test + public void testVersionNotSupportedErrorOnSendMessage() throws Exception { + // Create AgentCard with protocol version 1.0 + AgentCard agentCard = AgentCard.builder() + .name("test-card") + .description("Test card with version 1.0") + .supportedInterfaces(Collections.singletonList(new AgentInterface("GRPC", "http://localhost:9999"))) + .version("1.0.0") + .capabilities(AgentCapabilities.builder() + .streaming(true) + .pushNotifications(false) + .build()) + .defaultInputModes(List.of("text")) + .defaultOutputModes(List.of("text")) + .skills(List.of()) + .protocolVersion("1.0") + .build(); + + // Create handler that provides incompatible version 2.0 in the context + GrpcHandler handler = new TestGrpcHandler(agentCard, requestHandler, internalExecutor) { + @Override + protected CallContextFactory getCallContextFactory() { + return new CallContextFactory() { + @Override + public ServerCallContext create(StreamObserver streamObserver) { + return new ServerCallContext( + UnauthenticatedUser.INSTANCE, + Map.of("grpc_response_observer", streamObserver), + new HashSet<>(), + "2.0" // Incompatible version + ); + } + }; + } + }; + + SendMessageRequest request = SendMessageRequest.newBuilder() + .setRequest(GRPC_MESSAGE) + .build(); + StreamRecorder streamRecorder = StreamRecorder.create(); + handler.sendMessage(request, streamRecorder); + streamRecorder.awaitCompletion(5, TimeUnit.SECONDS); + + assertGrpcError(streamRecorder, Status.Code.UNIMPLEMENTED); + } + + @Test + public void testVersionNotSupportedErrorOnSendStreamingMessage() throws Exception { + // Create AgentCard with protocol version 1.0 + AgentCard agentCard = AgentCard.builder() + .name("test-card") + .description("Test card with version 1.0") + .supportedInterfaces(Collections.singletonList(new AgentInterface("GRPC", "http://localhost:9999"))) + .version("1.0.0") + .capabilities(AgentCapabilities.builder() + .streaming(true) + .pushNotifications(false) + .build()) + .defaultInputModes(List.of("text")) + .defaultOutputModes(List.of("text")) + .skills(List.of()) + .protocolVersion("1.0") + .build(); + + // Create handler that provides incompatible version 2.0 in the context + GrpcHandler handler = new TestGrpcHandler(agentCard, requestHandler, internalExecutor) { + @Override + protected CallContextFactory getCallContextFactory() { + return new CallContextFactory() { + @Override + public ServerCallContext create(StreamObserver streamObserver) { + return new ServerCallContext( + UnauthenticatedUser.INSTANCE, + Map.of("grpc_response_observer", streamObserver), + new HashSet<>(), + "2.0" // Incompatible version + ); + } + }; + } + }; + + SendMessageRequest request = SendMessageRequest.newBuilder() + .setRequest(GRPC_MESSAGE) + .build(); + StreamRecorder streamRecorder = StreamRecorder.create(); + handler.sendStreamingMessage(request, streamRecorder); + streamRecorder.awaitCompletion(5, TimeUnit.SECONDS); + + assertGrpcError(streamRecorder, Status.Code.UNIMPLEMENTED); + } + + @Test + public void testCompatibleVersionSuccess() throws Exception { + // Create AgentCard with protocol version 1.0 + AgentCard agentCard = AgentCard.builder() + .name("test-card") + .description("Test card with version 1.0") + .supportedInterfaces(Collections.singletonList(new AgentInterface("GRPC", "http://localhost:9999"))) + .version("1.0.0") + .capabilities(AgentCapabilities.builder() + .streaming(true) + .pushNotifications(false) + .build()) + .defaultInputModes(List.of("text")) + .defaultOutputModes(List.of("text")) + .skills(List.of()) + .protocolVersion("1.0") + .build(); + + // Create handler that provides compatible version 1.1 in the context + GrpcHandler handler = new TestGrpcHandler(agentCard, requestHandler, internalExecutor) { + @Override + protected CallContextFactory getCallContextFactory() { + return new CallContextFactory() { + @Override + public ServerCallContext create(StreamObserver streamObserver) { + return new ServerCallContext( + UnauthenticatedUser.INSTANCE, + Map.of("grpc_response_observer", streamObserver), + new HashSet<>(), + "1.1" // Compatible version (same major version) + ); + } + }; + } + }; + + agentExecutorExecute = (context, eventQueue) -> { + eventQueue.enqueueEvent(context.getMessage()); + }; + + SendMessageRequest request = SendMessageRequest.newBuilder() + .setRequest(GRPC_MESSAGE) + .build(); + StreamRecorder streamRecorder = StreamRecorder.create(); + handler.sendMessage(request, streamRecorder); + streamRecorder.awaitCompletion(5, TimeUnit.SECONDS); + + // Should succeed without error + Assertions.assertNull(streamRecorder.getError()); + Assertions.assertFalse(streamRecorder.getValues().isEmpty()); + } + + @Test + public void testNoVersionDefaultsToCurrentVersionSuccess() throws Exception { + // Create AgentCard with protocol version 1.0 (current version) + AgentCard agentCard = AgentCard.builder() + .name("test-card") + .description("Test card with version 1.0") + .supportedInterfaces(Collections.singletonList(new AgentInterface("GRPC", "http://localhost:9999"))) + .version("1.0.0") + .capabilities(AgentCapabilities.builder() + .streaming(true) + .pushNotifications(false) + .build()) + .defaultInputModes(List.of("text")) + .defaultOutputModes(List.of("text")) + .skills(List.of()) + .protocolVersion("1.0") + .build(); + + // Create handler that provides null version (should default to 1.0) + GrpcHandler handler = new TestGrpcHandler(agentCard, requestHandler, internalExecutor) { + @Override + protected CallContextFactory getCallContextFactory() { + return new CallContextFactory() { + @Override + public ServerCallContext create(StreamObserver streamObserver) { + return new ServerCallContext( + UnauthenticatedUser.INSTANCE, + Map.of("grpc_response_observer", streamObserver), + new HashSet<>(), + null // No version - should default to 1.0 + ); + } + }; + } + }; + + agentExecutorExecute = (context, eventQueue) -> { + eventQueue.enqueueEvent(context.getMessage()); + }; + + SendMessageRequest request = SendMessageRequest.newBuilder() + .setRequest(GRPC_MESSAGE) + .build(); + StreamRecorder streamRecorder = StreamRecorder.create(); + handler.sendMessage(request, streamRecorder); + streamRecorder.awaitCompletion(5, TimeUnit.SECONDS); + + // Should succeed without error (defaults to 1.0) + Assertions.assertNull(streamRecorder.getError()); + Assertions.assertFalse(streamRecorder.getValues().isEmpty()); + } + private StreamRecorder sendMessageRequest(GrpcHandler handler) throws Exception { SendMessageRequest request = SendMessageRequest.newBuilder() .setRequest(GRPC_MESSAGE) diff --git a/transport/jsonrpc/src/main/java/io/a2a/transport/jsonrpc/handler/JSONRPCHandler.java b/transport/jsonrpc/src/main/java/io/a2a/transport/jsonrpc/handler/JSONRPCHandler.java index 6e78f84aa..a00ad6b7e 100644 --- a/transport/jsonrpc/src/main/java/io/a2a/transport/jsonrpc/handler/JSONRPCHandler.java +++ b/transport/jsonrpc/src/main/java/io/a2a/transport/jsonrpc/handler/JSONRPCHandler.java @@ -36,11 +36,14 @@ import io.a2a.server.ExtendedAgentCard; import io.a2a.server.PublicAgentCard; import io.a2a.server.ServerCallContext; +import io.a2a.server.extensions.A2AExtensions; import io.a2a.server.requesthandlers.RequestHandler; import io.a2a.server.util.async.Internal; +import io.a2a.server.version.A2AVersionValidator; import io.a2a.spec.A2AError; import io.a2a.spec.AgentCard; -import io.a2a.spec.AuthenticatedExtendedCardNotConfiguredError; +import io.a2a.spec.ExtendedCardNotConfiguredError; +import io.a2a.spec.ExtensionSupportRequiredError; import io.a2a.spec.EventKind; import io.a2a.spec.InternalError; import io.a2a.spec.InvalidRequestError; @@ -50,6 +53,7 @@ import io.a2a.spec.Task; import io.a2a.spec.TaskNotFoundError; import io.a2a.spec.TaskPushNotificationConfig; +import io.a2a.spec.VersionNotSupportedError; import mutiny.zero.ZeroPublisher; import org.jspecify.annotations.Nullable; @@ -79,6 +83,8 @@ public JSONRPCHandler(@PublicAgentCard AgentCard agentCard, RequestHandler reque public SendMessageResponse onMessageSend(SendMessageRequest request, ServerCallContext context) { try { + A2AVersionValidator.validateProtocolVersion(agentCard, context); + A2AExtensions.validateRequiredExtensions(agentCard, context); EventKind taskOrMessage = requestHandler.onMessageSend(request.getParams(), context); return new SendMessageResponse(request.getId(), taskOrMessage); } catch (A2AError e) { @@ -99,6 +105,8 @@ public Flow.Publisher onMessageSendStream( } try { + A2AVersionValidator.validateProtocolVersion(agentCard, context); + A2AExtensions.validateRequiredExtensions(agentCard, context); Flow.Publisher publisher = requestHandler.onMessageSendStream(request.getParams(), context); // We can't use the convertingProcessor convenience method since that propagates any errors as an error handled @@ -241,7 +249,7 @@ public GetAuthenticatedExtendedCardResponse onGetAuthenticatedExtendedCardReques GetAuthenticatedExtendedCardRequest request, ServerCallContext context) { if (!agentCard.supportsExtendedAgentCard() || extendedAgentCard == null || !extendedAgentCard.isResolvable()) { return new GetAuthenticatedExtendedCardResponse(request.getId(), - new AuthenticatedExtendedCardNotConfiguredError(null, "Authenticated Extended Card not configured", null)); + new ExtendedCardNotConfiguredError(null, "Authenticated Extended Card not configured", null)); } try { return new GetAuthenticatedExtendedCardResponse(request.getId(), extendedAgentCard.get()); diff --git a/transport/jsonrpc/src/test/java/io/a2a/transport/jsonrpc/handler/JSONRPCHandlerTest.java b/transport/jsonrpc/src/test/java/io/a2a/transport/jsonrpc/handler/JSONRPCHandlerTest.java index 68fc677fe..c8e69f43f 100644 --- a/transport/jsonrpc/src/test/java/io/a2a/transport/jsonrpc/handler/JSONRPCHandlerTest.java +++ b/transport/jsonrpc/src/test/java/io/a2a/transport/jsonrpc/handler/JSONRPCHandlerTest.java @@ -9,6 +9,7 @@ import java.util.HashSet; import java.util.List; import java.util.Map; +import java.util.Set; import java.util.concurrent.CompletableFuture; import java.util.concurrent.CountDownLatch; import java.util.concurrent.Executors; @@ -43,9 +44,14 @@ import io.a2a.server.requesthandlers.DefaultRequestHandler; import io.a2a.server.tasks.ResultAggregator; import io.a2a.server.tasks.TaskUpdater; +import io.a2a.spec.AgentCapabilities; import io.a2a.spec.AgentCard; +import io.a2a.spec.AgentExtension; +import io.a2a.spec.AgentInterface; import io.a2a.spec.Artifact; -import io.a2a.spec.AuthenticatedExtendedCardNotConfiguredError; +import io.a2a.spec.ExtendedCardNotConfiguredError; +import io.a2a.spec.ExtensionSupportRequiredError; +import io.a2a.spec.VersionNotSupportedError; import io.a2a.spec.DeleteTaskPushNotificationConfigParams; import io.a2a.spec.Event; import io.a2a.spec.GetTaskPushNotificationConfigParams; @@ -1519,7 +1525,7 @@ public void testOnGetAuthenticatedExtendedAgentCard() throws Exception { GetAuthenticatedExtendedCardRequest request = new GetAuthenticatedExtendedCardRequest("1"); GetAuthenticatedExtendedCardResponse response = handler.onGetAuthenticatedExtendedCardRequest(request, callContext); assertEquals(request.getId(), response.getId()); - assertInstanceOf(AuthenticatedExtendedCardNotConfiguredError.class, response.getError()); + assertInstanceOf(ExtendedCardNotConfiguredError.class, response.getError()); assertNull(response.getResult()); } @@ -1600,4 +1606,361 @@ public void onComplete() { Assertions.assertTrue(eventReceived.get(), "Should have received streaming event"); Assertions.assertFalse(mainThreadBlocked.get(), "Main thread should not have been blocked"); } + + @Test + public void testExtensionSupportRequiredErrorOnMessageSend() { + // Create AgentCard with a required extension + AgentCard cardWithExtension = AgentCard.builder() + .name("test-card") + .description("Test card with required extension") + .supportedInterfaces(Collections.singletonList(new AgentInterface("JSONRPC", "http://localhost:9999"))) + .version("1.0.0") + .capabilities(AgentCapabilities.builder() + .streaming(true) + .pushNotifications(true) + .extensions(List.of( + AgentExtension.builder() + .uri("https://example.com/test-extension") + .required(true) + .build() + )) + .build()) + .defaultInputModes(List.of("text")) + .defaultOutputModes(List.of("text")) + .skills(List.of()) + .protocolVersion(AgentCard.CURRENT_PROTOCOL_VERSION) + .build(); + + JSONRPCHandler handler = new JSONRPCHandler(cardWithExtension, requestHandler, internalExecutor); + + // Use callContext which has empty requestedExtensions set + Message message = Message.builder(MESSAGE) + .taskId(MINIMAL_TASK.id()) + .contextId(MINIMAL_TASK.contextId()) + .build(); + SendMessageRequest request = new SendMessageRequest("1", new MessageSendParams(message, null, null)); + SendMessageResponse response = handler.onMessageSend(request, callContext); + + assertInstanceOf(ExtensionSupportRequiredError.class, response.getError()); + Assertions.assertTrue(response.getError().getMessage().contains("https://example.com/test-extension")); + assertNull(response.getResult()); + } + + @Test + public void testExtensionSupportRequiredErrorOnMessageSendStream() { + // Create AgentCard with a required extension + AgentCard cardWithExtension = AgentCard.builder() + .name("test-card") + .description("Test card with required extension") + .supportedInterfaces(Collections.singletonList(new AgentInterface("JSONRPC", "http://localhost:9999"))) + .version("1.0.0") + .capabilities(AgentCapabilities.builder() + .streaming(true) + .pushNotifications(true) + .extensions(List.of( + AgentExtension.builder() + .uri("https://example.com/streaming-extension") + .required(true) + .build() + )) + .build()) + .defaultInputModes(List.of("text")) + .defaultOutputModes(List.of("text")) + .skills(List.of()) + .protocolVersion(AgentCard.CURRENT_PROTOCOL_VERSION) + .build(); + + JSONRPCHandler handler = new JSONRPCHandler(cardWithExtension, requestHandler, internalExecutor); + + Message message = Message.builder(MESSAGE) + .taskId(MINIMAL_TASK.id()) + .contextId(MINIMAL_TASK.contextId()) + .build(); + SendStreamingMessageRequest request = new SendStreamingMessageRequest("1", new MessageSendParams(message, null, null)); + Flow.Publisher response = handler.onMessageSendStream(request, callContext); + + List results = new ArrayList<>(); + AtomicReference error = new AtomicReference<>(); + + response.subscribe(new Flow.Subscriber() { + private Flow.Subscription subscription; + + @Override + public void onSubscribe(Flow.Subscription subscription) { + this.subscription = subscription; + subscription.request(1); + } + + @Override + public void onNext(SendStreamingMessageResponse item) { + results.add(item); + subscription.request(1); + } + + @Override + public void onError(Throwable throwable) { + error.set(throwable); + subscription.cancel(); + } + + @Override + public void onComplete() { + subscription.cancel(); + } + }); + + assertEquals(1, results.size()); + assertInstanceOf(ExtensionSupportRequiredError.class, results.get(0).getError()); + Assertions.assertTrue(results.get(0).getError().getMessage().contains("https://example.com/streaming-extension")); + assertNull(results.get(0).getResult()); + } + + @Test + public void testRequiredExtensionProvidedSuccess() { + // Create AgentCard with a required extension + AgentCard cardWithExtension = AgentCard.builder() + .name("test-card") + .description("Test card with required extension") + .supportedInterfaces(Collections.singletonList(new AgentInterface("JSONRPC", "http://localhost:9999"))) + .version("1.0.0") + .capabilities(AgentCapabilities.builder() + .streaming(true) + .pushNotifications(true) + .extensions(List.of( + AgentExtension.builder() + .uri("https://example.com/required-extension") + .required(true) + .build() + )) + .build()) + .defaultInputModes(List.of("text")) + .defaultOutputModes(List.of("text")) + .skills(List.of()) + .protocolVersion(AgentCard.CURRENT_PROTOCOL_VERSION) + .build(); + + JSONRPCHandler handler = new JSONRPCHandler(cardWithExtension, requestHandler, internalExecutor); + + // Create context WITH the required extension + Set requestedExtensions = new HashSet<>(); + requestedExtensions.add("https://example.com/required-extension"); + ServerCallContext contextWithExtension = new ServerCallContext( + UnauthenticatedUser.INSTANCE, + Map.of("foo", "bar"), + requestedExtensions + ); + + agentExecutorExecute = (context, eventQueue) -> { + eventQueue.enqueueEvent(context.getMessage()); + }; + + Message message = Message.builder(MESSAGE) + .taskId(MINIMAL_TASK.id()) + .contextId(MINIMAL_TASK.contextId()) + .build(); + SendMessageRequest request = new SendMessageRequest("1", new MessageSendParams(message, null, null)); + SendMessageResponse response = handler.onMessageSend(request, contextWithExtension); + + // Should succeed without ExtensionSupportRequiredError + assertNull(response.getError()); + Assertions.assertSame(message, response.getResult()); + } + + @Test + public void testVersionNotSupportedErrorOnMessageSend() { + // Create AgentCard with protocol version 1.0 + AgentCard agentCard = AgentCard.builder() + .name("test-card") + .description("Test card with version 1.0") + .supportedInterfaces(Collections.singletonList(new AgentInterface("JSONRPC", "http://localhost:9999"))) + .version("1.0.0") + .capabilities(AgentCapabilities.builder() + .streaming(true) + .pushNotifications(false) + .build()) + .defaultInputModes(List.of("text")) + .defaultOutputModes(List.of("text")) + .skills(List.of()) + .protocolVersion("1.0") + .build(); + + JSONRPCHandler handler = new JSONRPCHandler(agentCard, requestHandler, internalExecutor); + + // Create context with incompatible version 2.0 + ServerCallContext contextWithVersion = new ServerCallContext( + UnauthenticatedUser.INSTANCE, + Map.of("foo", "bar"), + new HashSet<>(), + "2.0" // Incompatible version + ); + + Message message = Message.builder(MESSAGE) + .taskId(MINIMAL_TASK.id()) + .contextId(MINIMAL_TASK.contextId()) + .build(); + SendMessageRequest request = new SendMessageRequest("1", new MessageSendParams(message, null, null)); + SendMessageResponse response = handler.onMessageSend(request, contextWithVersion); + + assertInstanceOf(VersionNotSupportedError.class, response.getError()); + Assertions.assertTrue(response.getError().getMessage().contains("2.0")); + assertNull(response.getResult()); + } + + @Test + public void testVersionNotSupportedErrorOnMessageSendStream() throws Exception { + // Create AgentCard with protocol version 1.0 + AgentCard agentCard = AgentCard.builder() + .name("test-card") + .description("Test card with version 1.0") + .supportedInterfaces(Collections.singletonList(new AgentInterface("JSONRPC", "http://localhost:9999"))) + .version("1.0.0") + .capabilities(AgentCapabilities.builder() + .streaming(true) + .pushNotifications(false) + .build()) + .defaultInputModes(List.of("text")) + .defaultOutputModes(List.of("text")) + .skills(List.of()) + .protocolVersion("1.0") + .build(); + + JSONRPCHandler handler = new JSONRPCHandler(agentCard, requestHandler, internalExecutor); + + // Create context with incompatible version 2.0 + ServerCallContext contextWithVersion = new ServerCallContext( + UnauthenticatedUser.INSTANCE, + Map.of("foo", "bar"), + new HashSet<>(), + "2.0" // Incompatible version + ); + + Message message = Message.builder(MESSAGE) + .taskId(MINIMAL_TASK.id()) + .contextId(MINIMAL_TASK.contextId()) + .build(); + SendStreamingMessageRequest request = new SendStreamingMessageRequest("1", new MessageSendParams(message, null, null)); + Flow.Publisher response = handler.onMessageSendStream(request, contextWithVersion); + + List results = new ArrayList<>(); + AtomicReference error = new AtomicReference<>(); + CountDownLatch latch = new CountDownLatch(1); + + response.subscribe(new Flow.Subscriber() { + private Flow.Subscription subscription; + + @Override + public void onSubscribe(Flow.Subscription subscription) { + this.subscription = subscription; + subscription.request(1); + } + + @Override + public void onNext(SendStreamingMessageResponse item) { + results.add(item); + subscription.request(1); + latch.countDown(); + } + + @Override + public void onError(Throwable throwable) { + error.set(throwable); + latch.countDown(); + } + + @Override + public void onComplete() { + latch.countDown(); + } + }); + + // Wait for async processing + Assertions.assertTrue(latch.await(2, TimeUnit.SECONDS), "Expected to receive error event within timeout"); + + assertEquals(1, results.size()); + SendStreamingMessageResponse result = results.get(0); + assertInstanceOf(VersionNotSupportedError.class, result.getError()); + Assertions.assertTrue(result.getError().getMessage().contains("2.0")); + assertNull(result.getResult()); + } + + @Test + public void testCompatibleVersionSuccess() { + // Create AgentCard with protocol version 1.0 + AgentCard agentCard = AgentCard.builder() + .name("test-card") + .description("Test card with version 1.0") + .supportedInterfaces(Collections.singletonList(new AgentInterface("JSONRPC", "http://localhost:9999"))) + .version("1.0.0") + .capabilities(AgentCapabilities.builder() + .streaming(true) + .pushNotifications(false) + .build()) + .defaultInputModes(List.of("text")) + .defaultOutputModes(List.of("text")) + .skills(List.of()) + .protocolVersion("1.0") + .build(); + + JSONRPCHandler handler = new JSONRPCHandler(agentCard, requestHandler, internalExecutor); + + // Create context with compatible version 1.1 + ServerCallContext contextWithVersion = new ServerCallContext( + UnauthenticatedUser.INSTANCE, + Map.of("foo", "bar"), + new HashSet<>(), + "1.1" // Compatible version (same major version) + ); + + agentExecutorExecute = (context, eventQueue) -> { + eventQueue.enqueueEvent(context.getMessage()); + }; + + Message message = Message.builder(MESSAGE) + .taskId(MINIMAL_TASK.id()) + .contextId(MINIMAL_TASK.contextId()) + .build(); + SendMessageRequest request = new SendMessageRequest("1", new MessageSendParams(message, null, null)); + SendMessageResponse response = handler.onMessageSend(request, contextWithVersion); + + // Should succeed without error + assertNull(response.getError()); + Assertions.assertSame(message, response.getResult()); + } + + @Test + public void testNoVersionDefaultsToCurrentVersionSuccess() { + // Create AgentCard with protocol version 1.0 (current version) + AgentCard agentCard = AgentCard.builder() + .name("test-card") + .description("Test card with version 1.0") + .supportedInterfaces(Collections.singletonList(new AgentInterface("JSONRPC", "http://localhost:9999"))) + .version("1.0.0") + .capabilities(AgentCapabilities.builder() + .streaming(true) + .pushNotifications(false) + .build()) + .defaultInputModes(List.of("text")) + .defaultOutputModes(List.of("text")) + .skills(List.of()) + .protocolVersion("1.0") + .build(); + + JSONRPCHandler handler = new JSONRPCHandler(agentCard, requestHandler, internalExecutor); + + // Use default callContext (no version - should default to 1.0) + agentExecutorExecute = (context, eventQueue) -> { + eventQueue.enqueueEvent(context.getMessage()); + }; + + Message message = Message.builder(MESSAGE) + .taskId(MINIMAL_TASK.id()) + .contextId(MINIMAL_TASK.contextId()) + .build(); + SendMessageRequest request = new SendMessageRequest("1", new MessageSendParams(message, null, null)); + SendMessageResponse response = handler.onMessageSend(request, callContext); + + // Should succeed without error (defaults to 1.0) + assertNull(response.getError()); + Assertions.assertSame(message, response.getResult()); + } } diff --git a/transport/rest/src/main/java/io/a2a/transport/rest/handler/RestHandler.java b/transport/rest/src/main/java/io/a2a/transport/rest/handler/RestHandler.java index ab64cf167..f91246d0e 100644 --- a/transport/rest/src/main/java/io/a2a/transport/rest/handler/RestHandler.java +++ b/transport/rest/src/main/java/io/a2a/transport/rest/handler/RestHandler.java @@ -28,11 +28,13 @@ import io.a2a.server.ExtendedAgentCard; import io.a2a.server.PublicAgentCard; import io.a2a.server.ServerCallContext; +import io.a2a.server.extensions.A2AExtensions; import io.a2a.server.requesthandlers.RequestHandler; +import io.a2a.server.version.A2AVersionValidator; import io.a2a.server.util.async.Internal; import io.a2a.spec.A2AError; import io.a2a.spec.AgentCard; -import io.a2a.spec.AuthenticatedExtendedCardNotConfiguredError; +import io.a2a.spec.ExtendedCardNotConfiguredError; import io.a2a.spec.ContentTypeNotSupportedError; import io.a2a.spec.DeleteTaskPushNotificationConfigParams; import io.a2a.spec.EventKind; @@ -56,6 +58,8 @@ import io.a2a.spec.TaskQueryParams; import io.a2a.spec.TaskState; import io.a2a.spec.UnsupportedOperationError; +import io.a2a.spec.ExtensionSupportRequiredError; +import io.a2a.spec.VersionNotSupportedError; import mutiny.zero.ZeroPublisher; import org.jspecify.annotations.Nullable; @@ -94,6 +98,8 @@ public RestHandler(AgentCard agentCard, RequestHandler requestHandler, Executor public HTTPRestResponse sendMessage(String body, String tenant, ServerCallContext context) { try { + A2AVersionValidator.validateProtocolVersion(agentCard, context); + A2AExtensions.validateRequiredExtensions(agentCard, context); io.a2a.grpc.SendMessageRequest.Builder request = io.a2a.grpc.SendMessageRequest.newBuilder(); parseRequestBody(body, request); request.setTenant(tenant); @@ -111,6 +117,8 @@ public HTTPRestResponse sendStreamingMessage(String body, String tenant, ServerC if (!agentCard.capabilities().streaming()) { return createErrorResponse(new InvalidRequestError("Streaming is not supported by the agent")); } + A2AVersionValidator.validateProtocolVersion(agentCard, context); + A2AExtensions.validateRequiredExtensions(agentCard, context); io.a2a.grpc.SendMessageRequest.Builder request = io.a2a.grpc.SendMessageRequest.newBuilder(); parseRequestBody(body, request); request.setTenant(tenant); @@ -381,13 +389,15 @@ private int mapErrorToHttpStatus(A2AError error) { if (error instanceof InvalidParamsError) { return 422; } - if (error instanceof MethodNotFoundError || error instanceof TaskNotFoundError || error instanceof AuthenticatedExtendedCardNotConfiguredError) { + if (error instanceof MethodNotFoundError || error instanceof TaskNotFoundError) { return 404; } if (error instanceof TaskNotCancelableError) { return 409; } - if (error instanceof PushNotificationNotSupportedError || error instanceof UnsupportedOperationError) { + if (error instanceof PushNotificationNotSupportedError + || error instanceof UnsupportedOperationError + || error instanceof VersionNotSupportedError) { return 501; } if (error instanceof ContentTypeNotSupportedError) { @@ -396,6 +406,10 @@ private int mapErrorToHttpStatus(A2AError error) { if (error instanceof InvalidAgentResponseError) { return 502; } + if (error instanceof ExtendedCardNotConfiguredError + || error instanceof ExtensionSupportRequiredError) { + return 400; + } if (error instanceof InternalError) { return 500; } @@ -405,7 +419,7 @@ private int mapErrorToHttpStatus(A2AError error) { public HTTPRestResponse getExtendedAgentCard(String tenant) { try { if (!agentCard.supportsExtendedAgentCard() || extendedAgentCard == null || !extendedAgentCard.isResolvable()) { - throw new AuthenticatedExtendedCardNotConfiguredError(null, "Authenticated Extended Card not configured", null); + throw new ExtendedCardNotConfiguredError(null, "Authenticated Extended Card not configured", null); } return new HTTPRestResponse(200, "application/json", JsonUtil.toJson(extendedAgentCard.get())); } catch (A2AError e) { diff --git a/transport/rest/src/test/java/io/a2a/transport/rest/handler/RestHandlerTest.java b/transport/rest/src/test/java/io/a2a/transport/rest/handler/RestHandlerTest.java index 9dd4e9cdb..df2a5e7af 100644 --- a/transport/rest/src/test/java/io/a2a/transport/rest/handler/RestHandlerTest.java +++ b/transport/rest/src/test/java/io/a2a/transport/rest/handler/RestHandlerTest.java @@ -1,7 +1,10 @@ package io.a2a.transport.rest.handler; +import java.util.Collections; import java.util.HashSet; +import java.util.List; import java.util.Map; +import java.util.Set; import java.util.concurrent.CountDownLatch; import java.util.concurrent.Flow; import java.util.concurrent.TimeUnit; @@ -12,7 +15,12 @@ import io.a2a.server.auth.UnauthenticatedUser; import io.a2a.server.requesthandlers.AbstractA2ARequestHandlerTest; import io.a2a.server.tasks.TaskUpdater; +import io.a2a.spec.AgentCapabilities; import io.a2a.spec.AgentCard; +import io.a2a.spec.AgentExtension; +import io.a2a.spec.AgentInterface; +import io.a2a.spec.ExtensionSupportRequiredError; +import io.a2a.spec.VersionNotSupportedError; import io.a2a.spec.Task; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; @@ -403,4 +411,451 @@ public void onComplete() { // Verify we received the event Assertions.assertTrue(eventReceived.get(), "Should have received streaming event"); } + + @Test + public void testExtensionSupportRequiredErrorOnSendMessage() { + // Create AgentCard with a required extension + AgentCard cardWithExtension = AgentCard.builder() + .name("test-card") + .description("Test card with required extension") + .supportedInterfaces(Collections.singletonList(new AgentInterface("REST", "http://localhost:9999"))) + .version("1.0.0") + .capabilities(AgentCapabilities.builder() + .streaming(true) + .pushNotifications(true) + .extensions(List.of( + AgentExtension.builder() + .uri("https://example.com/test-extension") + .required(true) + .build() + )) + .build()) + .defaultInputModes(List.of("text")) + .defaultOutputModes(List.of("text")) + .skills(List.of()) + .protocolVersion(AgentCard.CURRENT_PROTOCOL_VERSION) + .build(); + + RestHandler handler = new RestHandler(cardWithExtension, requestHandler, internalExecutor); + + String requestBody = """ + { + "message": { + "messageId": "message-1234", + "contextId": "context-1234", + "role": "ROLE_USER", + "parts": [{ + "text": "tell me a joke" + }], + "metadata": {} + }, + "configuration": { + "blocking": true + } + }"""; + + RestHandler.HTTPRestResponse response = handler.sendMessage(requestBody, "", callContext); + + Assertions.assertEquals(400, response.getStatusCode()); + Assertions.assertEquals("application/json", response.getContentType()); + Assertions.assertTrue(response.getBody().contains("ExtensionSupportRequiredError")); + Assertions.assertTrue(response.getBody().contains("https://example.com/test-extension")); + } + + @Test + public void testExtensionSupportRequiredErrorOnSendStreamingMessage() { + // Create AgentCard with a required extension + AgentCard cardWithExtension = AgentCard.builder() + .name("test-card") + .description("Test card with required extension") + .supportedInterfaces(Collections.singletonList(new AgentInterface("REST", "http://localhost:9999"))) + .version("1.0.0") + .capabilities(AgentCapabilities.builder() + .streaming(true) + .pushNotifications(true) + .extensions(List.of( + AgentExtension.builder() + .uri("https://example.com/streaming-extension") + .required(true) + .build() + )) + .build()) + .defaultInputModes(List.of("text")) + .defaultOutputModes(List.of("text")) + .skills(List.of()) + .protocolVersion(AgentCard.CURRENT_PROTOCOL_VERSION) + .build(); + + RestHandler handler = new RestHandler(cardWithExtension, requestHandler, internalExecutor); + + String requestBody = """ + { + "message": { + "role": "ROLE_USER", + "parts": [{ + "text": "tell me some jokes" + }], + "messageId": "message-1234", + "contextId": "context-1234" + }, + "configuration": { + "acceptedOutputModes": ["text"] + } + }"""; + + RestHandler.HTTPRestResponse response = handler.sendStreamingMessage(requestBody, "", callContext); + + // Streaming responses embed errors in the stream with status 200 + Assertions.assertEquals(200, response.getStatusCode()); + Assertions.assertInstanceOf(RestHandler.HTTPRestStreamingResponse.class, response); + + // Subscribe to publisher and verify error in stream + RestHandler.HTTPRestStreamingResponse streamingResponse = (RestHandler.HTTPRestStreamingResponse) response; + Flow.Publisher publisher = streamingResponse.getPublisher(); + + AtomicBoolean errorFound = new AtomicBoolean(false); + CountDownLatch latch = new CountDownLatch(1); + + publisher.subscribe(new Flow.Subscriber() { + @Override + public void onSubscribe(Flow.Subscription subscription) { + subscription.request(1); + } + + @Override + public void onNext(String item) { + if (item.contains("ExtensionSupportRequiredError") && + item.contains("https://example.com/streaming-extension")) { + errorFound.set(true); + } + latch.countDown(); + } + + @Override + public void onError(Throwable throwable) { + latch.countDown(); + } + + @Override + public void onComplete() { + latch.countDown(); + } + }); + + try { + Assertions.assertTrue(latch.await(1, TimeUnit.SECONDS)); + Assertions.assertTrue(errorFound.get(), "Error should be found in streaming response"); + } catch (InterruptedException e) { + Assertions.fail("Test interrupted"); + } + } + + @Test + public void testRequiredExtensionProvidedSuccess() { + // Create AgentCard with a required extension + AgentCard cardWithExtension = AgentCard.builder() + .name("test-card") + .description("Test card with required extension") + .supportedInterfaces(Collections.singletonList(new AgentInterface("REST", "http://localhost:9999"))) + .version("1.0.0") + .capabilities(AgentCapabilities.builder() + .streaming(true) + .pushNotifications(true) + .extensions(List.of( + AgentExtension.builder() + .uri("https://example.com/required-extension") + .required(true) + .build() + )) + .build()) + .defaultInputModes(List.of("text")) + .defaultOutputModes(List.of("text")) + .skills(List.of()) + .protocolVersion(AgentCard.CURRENT_PROTOCOL_VERSION) + .build(); + + RestHandler handler = new RestHandler(cardWithExtension, requestHandler, internalExecutor); + + // Create context WITH the required extension + Set requestedExtensions = new HashSet<>(); + requestedExtensions.add("https://example.com/required-extension"); + ServerCallContext contextWithExtension = new ServerCallContext( + UnauthenticatedUser.INSTANCE, + Map.of("foo", "bar"), + requestedExtensions + ); + + agentExecutorExecute = (context, eventQueue) -> { + eventQueue.enqueueEvent(context.getMessage()); + }; + + String requestBody = """ + { + "message": { + "messageId": "message-1234", + "contextId": "context-1234", + "role": "ROLE_USER", + "parts": [{ + "text": "tell me a joke" + }], + "metadata": {} + }, + "configuration": { + "blocking": true + } + }"""; + + RestHandler.HTTPRestResponse response = handler.sendMessage(requestBody, "", contextWithExtension); + + // Should succeed without error + Assertions.assertEquals(200, response.getStatusCode()); + Assertions.assertEquals("application/json", response.getContentType()); + Assertions.assertNotNull(response.getBody()); + } + + @Test + public void testVersionNotSupportedErrorOnSendMessage() { + // Create AgentCard with protocol version 1.0 + AgentCard agentCard = AgentCard.builder() + .name("test-card") + .description("Test card with version 1.0") + .supportedInterfaces(Collections.singletonList(new AgentInterface("REST", "http://localhost:9999"))) + .version("1.0.0") + .capabilities(AgentCapabilities.builder() + .streaming(true) + .pushNotifications(false) + .build()) + .defaultInputModes(List.of("text")) + .defaultOutputModes(List.of("text")) + .skills(List.of()) + .protocolVersion("1.0") + .build(); + + RestHandler handler = new RestHandler(agentCard, requestHandler, internalExecutor); + + // Create context with incompatible version 2.0 + ServerCallContext contextWithVersion = new ServerCallContext( + UnauthenticatedUser.INSTANCE, + Map.of("foo", "bar"), + new HashSet<>(), + "2.0" // Incompatible version + ); + + String requestBody = """ + { + "message": { + "messageId": "message-1234", + "contextId": "context-1234", + "role": "ROLE_USER", + "parts": [{ + "text": "tell me a joke" + }], + "metadata": {} + }, + "configuration": { + "blocking": true + } + }"""; + + RestHandler.HTTPRestResponse response = handler.sendMessage(requestBody, "", contextWithVersion); + + Assertions.assertEquals(501, response.getStatusCode()); + Assertions.assertEquals("application/json", response.getContentType()); + Assertions.assertTrue(response.getBody().contains("VersionNotSupportedError")); + Assertions.assertTrue(response.getBody().contains("2.0")); + } + + @Test + public void testVersionNotSupportedErrorOnSendStreamingMessage() { + // Create AgentCard with protocol version 1.0 + AgentCard agentCard = AgentCard.builder() + .name("test-card") + .description("Test card with version 1.0") + .supportedInterfaces(Collections.singletonList(new AgentInterface("REST", "http://localhost:9999"))) + .version("1.0.0") + .capabilities(AgentCapabilities.builder() + .streaming(true) + .pushNotifications(false) + .build()) + .defaultInputModes(List.of("text")) + .defaultOutputModes(List.of("text")) + .skills(List.of()) + .protocolVersion("1.0") + .build(); + + RestHandler handler = new RestHandler(agentCard, requestHandler, internalExecutor); + + // Create context with incompatible version 2.0 + ServerCallContext contextWithVersion = new ServerCallContext( + UnauthenticatedUser.INSTANCE, + Map.of("foo", "bar"), + new HashSet<>(), + "2.0" // Incompatible version + ); + + String requestBody = """ + { + "message": { + "role": "ROLE_USER", + "parts": [{ + "text": "tell me some jokes" + }], + "messageId": "message-1234", + "contextId": "context-1234" + }, + "configuration": { + "acceptedOutputModes": ["text"] + } + }"""; + + RestHandler.HTTPRestResponse response = handler.sendStreamingMessage(requestBody, "", contextWithVersion); + + // Streaming responses embed errors in the stream with status 200 + Assertions.assertEquals(200, response.getStatusCode()); + Assertions.assertInstanceOf(RestHandler.HTTPRestStreamingResponse.class, response); + + // Subscribe to publisher and verify error in stream + RestHandler.HTTPRestStreamingResponse streamingResponse = (RestHandler.HTTPRestStreamingResponse) response; + Flow.Publisher publisher = streamingResponse.getPublisher(); + + AtomicBoolean errorFound = new AtomicBoolean(false); + CountDownLatch latch = new CountDownLatch(1); + + publisher.subscribe(new Flow.Subscriber<>() { + @Override + public void onSubscribe(Flow.Subscription subscription) { + subscription.request(Long.MAX_VALUE); + } + + @Override + public void onNext(String item) { + if (item.contains("VersionNotSupportedError") && + item.contains("2.0")) { + errorFound.set(true); + } + } + + @Override + public void onError(Throwable throwable) { + latch.countDown(); + } + + @Override + public void onComplete() { + latch.countDown(); + } + }); + + try { + Assertions.assertTrue(latch.await(1, TimeUnit.SECONDS)); + Assertions.assertTrue(errorFound.get(), "Error should be found in streaming response"); + } catch (InterruptedException e) { + Assertions.fail("Test interrupted"); + } + } + + @Test + public void testCompatibleVersionSuccess() { + // Create AgentCard with protocol version 1.0 + AgentCard agentCard = AgentCard.builder() + .name("test-card") + .description("Test card with version 1.0") + .supportedInterfaces(Collections.singletonList(new AgentInterface("REST", "http://localhost:9999"))) + .version("1.0.0") + .capabilities(AgentCapabilities.builder() + .streaming(true) + .pushNotifications(false) + .build()) + .defaultInputModes(List.of("text")) + .defaultOutputModes(List.of("text")) + .skills(List.of()) + .protocolVersion("1.0") + .build(); + + RestHandler handler = new RestHandler(agentCard, requestHandler, internalExecutor); + + // Create context with compatible version 1.1 + ServerCallContext contextWithVersion = new ServerCallContext( + UnauthenticatedUser.INSTANCE, + Map.of("foo", "bar"), + new HashSet<>(), + "1.1" // Compatible version (same major version) + ); + + agentExecutorExecute = (context, eventQueue) -> { + eventQueue.enqueueEvent(context.getMessage()); + }; + + String requestBody = """ + { + "message": { + "messageId": "message-1234", + "contextId": "context-1234", + "role": "ROLE_USER", + "parts": [{ + "text": "tell me a joke" + }], + "metadata": {} + }, + "configuration": { + "blocking": true + } + }"""; + + RestHandler.HTTPRestResponse response = handler.sendMessage(requestBody, "", contextWithVersion); + + // Should succeed without error + Assertions.assertEquals(200, response.getStatusCode()); + Assertions.assertEquals("application/json", response.getContentType()); + Assertions.assertNotNull(response.getBody()); + } + + @Test + public void testNoVersionDefaultsToCurrentVersionSuccess() { + // Create AgentCard with protocol version 1.0 (current version) + AgentCard agentCard = AgentCard.builder() + .name("test-card") + .description("Test card with version 1.0") + .supportedInterfaces(Collections.singletonList(new AgentInterface("REST", "http://localhost:9999"))) + .version("1.0.0") + .capabilities(AgentCapabilities.builder() + .streaming(true) + .pushNotifications(false) + .build()) + .defaultInputModes(List.of("text")) + .defaultOutputModes(List.of("text")) + .skills(List.of()) + .protocolVersion("1.0") + .build(); + + RestHandler handler = new RestHandler(agentCard, requestHandler, internalExecutor); + + // Use default callContext (no version - should default to 1.0) + agentExecutorExecute = (context, eventQueue) -> { + eventQueue.enqueueEvent(context.getMessage()); + }; + + String requestBody = """ + { + "message": { + "messageId": "message-1234", + "contextId": "context-1234", + "role": "ROLE_USER", + "parts": [{ + "text": "tell me a joke" + }], + "metadata": {} + }, + "configuration": { + "blocking": true + } + }"""; + + RestHandler.HTTPRestResponse response = handler.sendMessage(requestBody, "", callContext); + + // Should succeed without error (defaults to 1.0) + Assertions.assertEquals(200, response.getStatusCode()); + Assertions.assertEquals("application/json", response.getContentType()); + Assertions.assertNotNull(response.getBody()); + } } From deca3b09717a9720408bd98272f6cb3288655a15 Mon Sep 17 00:00:00 2001 From: Kabir Khan Date: Mon, 22 Dec 2025 20:32:14 +0000 Subject: [PATCH 2/2] Fix Gemini comments --- .../main/java/io/a2a/spec/ExtendedCardNotConfiguredError.java | 2 +- .../main/java/io/a2a/spec/ExtensionSupportRequiredError.java | 1 - spec/src/main/java/io/a2a/spec/VersionNotSupportedError.java | 1 - .../java/io/a2a/transport/jsonrpc/handler/JSONRPCHandler.java | 2 +- .../main/java/io/a2a/transport/rest/handler/RestHandler.java | 2 +- 5 files changed, 3 insertions(+), 5 deletions(-) diff --git a/spec/src/main/java/io/a2a/spec/ExtendedCardNotConfiguredError.java b/spec/src/main/java/io/a2a/spec/ExtendedCardNotConfiguredError.java index 9ca08844c..3403b3ffd 100644 --- a/spec/src/main/java/io/a2a/spec/ExtendedCardNotConfiguredError.java +++ b/spec/src/main/java/io/a2a/spec/ExtendedCardNotConfiguredError.java @@ -42,7 +42,7 @@ public ExtendedCardNotConfiguredError( Object data) { super( defaultIfNull(code, EXTENDED_CARD_NOT_CONFIGURED_ERROR_CODE), - defaultIfNull(message, "Authenticated Extended Card not configured"), + defaultIfNull(message, "Extended Card not configured"), data, "https://a2a-protocol.org/errors/extended-agent-card-not-configured"); } diff --git a/spec/src/main/java/io/a2a/spec/ExtensionSupportRequiredError.java b/spec/src/main/java/io/a2a/spec/ExtensionSupportRequiredError.java index f569708c6..ac0ea574d 100644 --- a/spec/src/main/java/io/a2a/spec/ExtensionSupportRequiredError.java +++ b/spec/src/main/java/io/a2a/spec/ExtensionSupportRequiredError.java @@ -1,6 +1,5 @@ package io.a2a.spec; -import static io.a2a.spec.A2AErrorCodes.EXTENDED_CARD_NOT_CONFIGURED_ERROR_CODE; import static io.a2a.spec.A2AErrorCodes.EXTENSION_SUPPORT_REQUIRED_ERROR; import static io.a2a.util.Utils.defaultIfNull; diff --git a/spec/src/main/java/io/a2a/spec/VersionNotSupportedError.java b/spec/src/main/java/io/a2a/spec/VersionNotSupportedError.java index b6ff5c6d4..50c8f3a45 100644 --- a/spec/src/main/java/io/a2a/spec/VersionNotSupportedError.java +++ b/spec/src/main/java/io/a2a/spec/VersionNotSupportedError.java @@ -1,6 +1,5 @@ package io.a2a.spec; -import static io.a2a.spec.A2AErrorCodes.EXTENSION_SUPPORT_REQUIRED_ERROR; import static io.a2a.spec.A2AErrorCodes.VERSION_NOT_SUPPORTED_ERROR_CODE; import static io.a2a.util.Utils.defaultIfNull; diff --git a/transport/jsonrpc/src/main/java/io/a2a/transport/jsonrpc/handler/JSONRPCHandler.java b/transport/jsonrpc/src/main/java/io/a2a/transport/jsonrpc/handler/JSONRPCHandler.java index a00ad6b7e..06de11dcd 100644 --- a/transport/jsonrpc/src/main/java/io/a2a/transport/jsonrpc/handler/JSONRPCHandler.java +++ b/transport/jsonrpc/src/main/java/io/a2a/transport/jsonrpc/handler/JSONRPCHandler.java @@ -249,7 +249,7 @@ public GetAuthenticatedExtendedCardResponse onGetAuthenticatedExtendedCardReques GetAuthenticatedExtendedCardRequest request, ServerCallContext context) { if (!agentCard.supportsExtendedAgentCard() || extendedAgentCard == null || !extendedAgentCard.isResolvable()) { return new GetAuthenticatedExtendedCardResponse(request.getId(), - new ExtendedCardNotConfiguredError(null, "Authenticated Extended Card not configured", null)); + new ExtendedCardNotConfiguredError(null, "Extended Card not configured", null)); } try { return new GetAuthenticatedExtendedCardResponse(request.getId(), extendedAgentCard.get()); diff --git a/transport/rest/src/main/java/io/a2a/transport/rest/handler/RestHandler.java b/transport/rest/src/main/java/io/a2a/transport/rest/handler/RestHandler.java index f91246d0e..8c4050fd9 100644 --- a/transport/rest/src/main/java/io/a2a/transport/rest/handler/RestHandler.java +++ b/transport/rest/src/main/java/io/a2a/transport/rest/handler/RestHandler.java @@ -419,7 +419,7 @@ private int mapErrorToHttpStatus(A2AError error) { public HTTPRestResponse getExtendedAgentCard(String tenant) { try { if (!agentCard.supportsExtendedAgentCard() || extendedAgentCard == null || !extendedAgentCard.isResolvable()) { - throw new ExtendedCardNotConfiguredError(null, "Authenticated Extended Card not configured", null); + throw new ExtendedCardNotConfiguredError(null, "Extended Card not configured", null); } return new HTTPRestResponse(200, "application/json", JsonUtil.toJson(extendedAgentCard.get())); } catch (A2AError e) {