diff --git a/reference/grpc/src/test/java/io/a2a/server/grpc/quarkus/QuarkusA2AGrpcTest.java b/reference/grpc/src/test/java/io/a2a/server/grpc/quarkus/QuarkusA2AGrpcTest.java index 24320b94d..7423edda0 100644 --- a/reference/grpc/src/test/java/io/a2a/server/grpc/quarkus/QuarkusA2AGrpcTest.java +++ b/reference/grpc/src/test/java/io/a2a/server/grpc/quarkus/QuarkusA2AGrpcTest.java @@ -50,4 +50,10 @@ public static void closeChannel() { Thread.currentThread().interrupt(); } } + + @Override + public void testAgentCardHeaders() { + // Skip - gRPC doesn't use HTTP caching headers for Agent Card + // The A2A spec section 8.6 caching requirements apply only to HTTP endpoints + } } \ No newline at end of file diff --git a/reference/jsonrpc/src/main/java/io/a2a/server/apps/quarkus/A2AServerRoutes.java b/reference/jsonrpc/src/main/java/io/a2a/server/apps/quarkus/A2AServerRoutes.java index 6914dc5f9..63a88f355 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 @@ -173,6 +173,9 @@ public class A2AServerRoutes { @Inject JSONRPCHandler jsonRpcHandler; + @Inject + io.a2a.server.AgentCardCacheMetadata cacheMetadata; + // Hook so testing can wait until the MultiSseSupport is subscribed. // Without this we get intermittent failures private static volatile Runnable streamingMultiSseSupportSubscribedRunnable; @@ -322,6 +325,13 @@ public void invokeJSONRPCHandler(@Body String body, RoutingContext rc) { *

Returns the agent's capabilities and metadata in JSON format according to the * A2A protocol specification. This endpoint is publicly accessible (no authentication). * + *

Includes HTTP caching headers per A2A specification section 8.6: + *

+ * *

Request: *

{@code
      * GET /.well-known/agent-card.json
@@ -331,6 +341,9 @@ public void invokeJSONRPCHandler(@Body String body, RoutingContext rc) {
      * 
{@code
      * HTTP/1.1 200 OK
      * Content-Type: application/json
+     * Cache-Control: public, max-age=3600
+     * ETag: "a1b2c3d4..."
+     * Last-Modified: Mon, 17 Mar 2025 10:00:00 GMT
      *
      * {
      *   "name": "My Agent",
@@ -343,12 +356,15 @@ public void invokeJSONRPCHandler(@Body String body, RoutingContext rc) {
      * }
      * }
* + * @param rc the Vert.x routing context * @return the agent card as a JSON string * @throws JsonProcessingException if serialization fails * @see JSONRPCHandler#getAgentCard() */ @Route(path = "/.well-known/agent-card.json", methods = Route.HttpMethod.GET, produces = APPLICATION_JSON) - public String getAgentCard() throws JsonProcessingException { + public String getAgentCard(RoutingContext rc) throws JsonProcessingException { + // Add caching headers per A2A specification section 8.6 + cacheMetadata.getHttpHeadersMap().forEach((k, v) -> rc.response().putHeader(k, v)); return JsonUtil.toJson(jsonRpcHandler.getAgentCard()); } 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 19399c18a..7abbb934d 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 @@ -380,10 +380,14 @@ public void cancelTask(@Body String body, RoutingContext rc) { */ private void sendResponse(RoutingContext rc, @Nullable HTTPRestResponse response) { if (response != null) { - rc.response() + var httpResponse = rc.response() .setStatusCode(response.getStatusCode()) - .putHeader(CONTENT_TYPE, response.getContentType()) - .end(response.getBody()); + .putHeader(CONTENT_TYPE, response.getContentType()); + + // Add any additional headers from the response + response.getHeaders().forEach(httpResponse::putHeader); + + httpResponse.end(response.getBody()); } else { rc.response().end(); } diff --git a/server-common/src/main/java/io/a2a/server/AgentCardCacheMetadata.java b/server-common/src/main/java/io/a2a/server/AgentCardCacheMetadata.java new file mode 100644 index 000000000..573ac9384 --- /dev/null +++ b/server-common/src/main/java/io/a2a/server/AgentCardCacheMetadata.java @@ -0,0 +1,190 @@ +package io.a2a.server; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.time.Instant; +import java.time.ZoneOffset; +import java.time.format.DateTimeFormatter; +import java.util.HashMap; +import java.util.HexFormat; +import java.util.Map; +import java.util.function.Consumer; + +import jakarta.annotation.PostConstruct; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.inject.Instance; +import jakarta.inject.Inject; + +import org.jspecify.annotations.Nullable; + +import io.a2a.jsonrpc.common.json.JsonProcessingException; +import io.a2a.jsonrpc.common.json.JsonUtil; +import io.a2a.server.config.A2AConfigProvider; +import io.a2a.spec.AgentCard; + +/** + * Provides HTTP caching metadata for Agent Card responses. + * + *

This bean computes and caches HTTP caching headers (Cache-Control, ETag, Last-Modified) + * for the Agent Card endpoint as specified in the A2A protocol specification section 8.6. + * + *

The metadata is computed once at initialization: + *

+ * + *

Since the Agent Card is {@code @ApplicationScoped}, these values remain stable + * throughout the application lifecycle unless the application is restarted. + * + * @see A2A Specification - Agent Card Caching + */ +@ApplicationScoped +public class AgentCardCacheMetadata { + + private static final String CONFIG_KEY_MAX_AGE = "a2a.agent-card.cache.max-age"; + private static final String DEFAULT_MAX_AGE = "3600"; // 1 hour + private static final DateTimeFormatter RFC_1123_FORMATTER = DateTimeFormatter.RFC_1123_DATE_TIME; + + @Inject + @PublicAgentCard + Instance agentCardInstance; + + @Inject + Instance configInstance; + + private @Nullable AgentCard agentCard; + private @Nullable A2AConfigProvider config; + + @SuppressWarnings("NullAway") // Initialized in @PostConstruct when agentCard is available + private String etag; + @SuppressWarnings("NullAway") // Initialized in @PostConstruct when agentCard is available + private String lastModified; + @SuppressWarnings("NullAway") // Initialized in @PostConstruct when agentCard is available + private String cacheControl; + + /** + * Package-private no-arg constructor for CDI. + */ + AgentCardCacheMetadata() { + // For CDI + } + + /** + * Public constructor for testing purposes. + * + * @param agentCard the agent card + * @param config the configuration provider + */ + public AgentCardCacheMetadata(AgentCard agentCard, A2AConfigProvider config) { + this.agentCard = agentCard; + this.config = config; + init(); + } + + @PostConstruct + @SuppressWarnings("NullAway") // agentCard and config are guaranteed non-null in both paths + void init() { + // Handle two initialization paths: + // 1. CDI injection: get beans from Instance if available + // 2. Direct constructor: agentCard and config already set + + if (agentCard == null && agentCardInstance != null) { + // CDI path - only initialize if AgentCard bean is available + if (agentCardInstance.isUnsatisfied() || configInstance.isUnsatisfied()) { + return; + } + this.agentCard = agentCardInstance.get(); + this.config = configInstance.get(); + } + + // At this point, agentCard and config should be set (either via CDI or constructor) + if (agentCard == null || config == null) { + return; + } + + // Calculate ETag from the serialized JSON representation + this.etag = calculateETag(agentCard); + + // Set Last-Modified to the initialization time + this.lastModified = RFC_1123_FORMATTER.format(Instant.now().atZone(ZoneOffset.UTC)); + + // Configure Cache-Control with max-age directive + String maxAge = config.getOptionalValue(CONFIG_KEY_MAX_AGE).orElse(DEFAULT_MAX_AGE); + this.cacheControl = "public, max-age=" + maxAge; + } + + /** + * Returns the ETag header value for the Agent Card. + * + *

The ETag is an MD5 hash of the serialized Agent Card JSON, quoted per HTTP specification. + * + * @return the ETag header value (e.g., {@code "a1b2c3d4..."}) + */ + public String getETag() { + return etag; + } + + /** + * Returns the Last-Modified header value for the Agent Card. + * + *

The timestamp represents when the bean was initialized, in RFC 1123 format. + * + * @return the Last-Modified header value (e.g., {@code "Mon, 17 Mar 2025 10:00:00 GMT"}) + */ + public String getLastModified() { + return lastModified; + } + + /** + * Returns the Cache-Control header value for the Agent Card. + * + *

The value includes {@code public} and a {@code max-age} directive configured + * via {@code a2a.agent-card.cache.max-age} (default: 3600 seconds). + * + * @return the Cache-Control header value (e.g., {@code "public, max-age=3600"}) + */ + public String getCacheControl() { + return cacheControl; + } + + /** + * Calculates an MD5 hash of the Agent Card JSON for use as an ETag. + * + * @param card the agent card to hash + * @return the hex-encoded MD5 hash, quoted per HTTP specification + */ + private String calculateETag(AgentCard card) { + try { + String json = JsonUtil.toJson(card); + MessageDigest md = MessageDigest.getInstance("MD5"); + byte[] hash = md.digest(json.getBytes(StandardCharsets.UTF_8)); + return "\"" + HexFormat.of().formatHex(hash) + "\""; + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException("MD5 algorithm not available", e); + } catch (JsonProcessingException e) { + throw new IllegalStateException("Failed to serialize Agent Card for ETag calculation", e); + } + } + + /** + * Populates a map with header names and header values stored in this instance. + * + * @return a map of the headers + */ + public Map getHttpHeadersMap() { + Map headers = new HashMap<>(); + if (cacheControl != null) { + headers.put("Cache-Control", cacheControl); + } + if (lastModified != null) { + headers.put("Last-Modified", lastModified); + } + if (etag != null) { + headers.put("ETag", etag); + } + return headers; + } +} diff --git a/tests/server-common/src/test/java/io/a2a/server/apps/common/AbstractA2AServerTest.java b/tests/server-common/src/test/java/io/a2a/server/apps/common/AbstractA2AServerTest.java index f7cdacc61..90f226790 100644 --- a/tests/server-common/src/test/java/io/a2a/server/apps/common/AbstractA2AServerTest.java +++ b/tests/server-common/src/test/java/io/a2a/server/apps/common/AbstractA2AServerTest.java @@ -636,6 +636,48 @@ public void testGetExtendedAgentCard() throws A2AClientException { assertTrue(agentCard.skills().isEmpty()); } + /** + * Tests that the Agent Card endpoint returns HTTP caching headers. + * + *

Per A2A specification section 8.6, Agent Card HTTP endpoints SHOULD include: + *

+ * + * @throws Exception if HTTP request fails + */ + @Test + public void testAgentCardHeaders() throws Exception { + HttpClient client = HttpClient.newHttpClient(); + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create("http://localhost:" + serverPort + "/.well-known/agent-card.json")) + .GET() + .build(); + HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString()); + + assertEquals(200, response.statusCode()); + + // Verify Cache-Control header with max-age directive (CARD-CACHE-001) + Optional cacheControl = response.headers().firstValue("Cache-Control"); + assertTrue(cacheControl.isPresent(), "Cache-Control header should be present"); + assertTrue(cacheControl.get().contains("max-age"), + "Cache-Control should contain max-age directive, got: " + cacheControl.get()); + + // Verify ETag header (CARD-CACHE-002) + Optional etag = response.headers().firstValue("ETag"); + assertTrue(etag.isPresent(), "ETag header should be present"); + assertTrue(etag.get().startsWith("\"") && etag.get().endsWith("\""), + "ETag should be quoted per HTTP specification, got: " + etag.get()); + + // Verify Last-Modified header in RFC 1123 format (CARD-CACHE-003) + Optional lastModified = response.headers().firstValue("Last-Modified"); + assertTrue(lastModified.isPresent(), "Last-Modified header should be present"); + assertTrue(lastModified.get().contains("GMT"), + "Last-Modified should be in RFC 1123 format (containing GMT), got: " + lastModified.get()); + } + @Test public void testSendMessageStreamNewMessageSuccess() throws Exception { testSendStreamingMessage(false); 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 8491d9216..b7a7f692f 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 @@ -29,6 +29,7 @@ import io.a2a.jsonrpc.common.json.JsonProcessingException; import io.a2a.jsonrpc.common.json.JsonUtil; import io.a2a.jsonrpc.common.wrappers.ListTasksResult; +import io.a2a.server.AgentCardCacheMetadata; import io.a2a.server.AgentCardValidator; import io.a2a.server.ExtendedAgentCard; import io.a2a.server.PublicAgentCard; @@ -127,6 +128,7 @@ public class RestHandler { // final, is not proxyable in all runtimes private AgentCard agentCard; private @Nullable Instance extendedAgentCard; + private AgentCardCacheMetadata cacheMetadata; private RequestHandler requestHandler; private Executor executor; @@ -146,14 +148,16 @@ protected RestHandler() { * * @param agentCard the public agent card containing agent capabilities * @param extendedAgentCard optional extended agent card instance + * @param cacheMetadata the agent card caching metadata * @param requestHandler the handler for processing A2A requests * @param executor the executor for asynchronous operations */ @Inject public RestHandler(@PublicAgentCard AgentCard agentCard, @ExtendedAgentCard Instance extendedAgentCard, - RequestHandler requestHandler, @Internal Executor executor) { + AgentCardCacheMetadata cacheMetadata, RequestHandler requestHandler, @Internal Executor executor) { this.agentCard = agentCard; this.extendedAgentCard = extendedAgentCard; + this.cacheMetadata = cacheMetadata; this.requestHandler = requestHandler; this.executor = executor; @@ -165,11 +169,14 @@ public RestHandler(@PublicAgentCard AgentCard agentCard, @ExtendedAgentCard Inst * Creates a REST handler with basic dependencies. * * @param agentCard the agent card containing agent capabilities + * @param cacheMetadata the agent card caching metadata * @param requestHandler the handler for processing A2A requests * @param executor the executor for asynchronous operations */ - public RestHandler(AgentCard agentCard, RequestHandler requestHandler, Executor executor) { + public RestHandler(AgentCard agentCard, AgentCardCacheMetadata cacheMetadata, + RequestHandler requestHandler, Executor executor) { this.agentCard = agentCard; + this.cacheMetadata = cacheMetadata; this.requestHandler = requestHandler; this.executor = executor; } @@ -929,7 +936,8 @@ public HTTPRestResponse getExtendedAgentCard(ServerCallContext context, String t */ public HTTPRestResponse getAgentCard() { try { - return new HTTPRestResponse(200, APPLICATION_JSON, JsonUtil.toJson(agentCard)); + return new HTTPRestResponse(200, APPLICATION_JSON, JsonUtil.toJson(agentCard), + cacheMetadata.getHttpHeadersMap()); } catch (Throwable t) { return createErrorResponse(500, new InternalError(t.getMessage())); } @@ -943,6 +951,7 @@ public static class HTTPRestResponse { private final int statusCode; private final String contentType; private final String body; + private final Map headers; /** * Creates an HTTP REST response. @@ -952,9 +961,22 @@ public static class HTTPRestResponse { * @param body the response body */ public HTTPRestResponse(int statusCode, String contentType, String body) { + this(statusCode, contentType, body, Map.of()); + } + + /** + * Creates an HTTP REST response with custom headers. + * + * @param statusCode the HTTP status code + * @param contentType the content type of the response + * @param body the response body + * @param headers additional HTTP headers + */ + public HTTPRestResponse(int statusCode, String contentType, String body, Map headers) { this.statusCode = statusCode; this.contentType = contentType; this.body = body; + this.headers = Map.copyOf(headers); } /** @@ -984,9 +1006,18 @@ public String getBody() { return body; } + /** + * Returns additional HTTP headers. + * + * @return the headers map + */ + public Map getHeaders() { + return headers; + } + @Override public String toString() { - return "HTTPRestResponse{" + "statusCode=" + statusCode + ", contentType=" + contentType + ", body=" + body + '}'; + return "HTTPRestResponse{" + "statusCode=" + statusCode + ", contentType=" + contentType + ", body=" + body + ", headers=" + headers + '}'; } } 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 738b027e7..f6085bb46 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 @@ -14,8 +14,10 @@ import com.google.gson.JsonObject; import com.google.gson.JsonParser; import com.google.protobuf.InvalidProtocolBufferException; +import io.a2a.server.AgentCardCacheMetadata; import io.a2a.server.ServerCallContext; import io.a2a.server.auth.UnauthenticatedUser; +import io.a2a.server.config.DefaultValuesConfigProvider; import io.a2a.server.requesthandlers.AbstractA2ARequestHandlerTest; import io.a2a.spec.AgentCapabilities; import io.a2a.spec.AgentCard; @@ -31,9 +33,17 @@ public class RestHandlerTest extends AbstractA2ARequestHandlerTest { private final ServerCallContext callContext = new ServerCallContext(UnauthenticatedUser.INSTANCE, Map.of("foo", "bar"), new HashSet<>()); + private static AgentCardCacheMetadata createCacheMetadata() { + return createCacheMetadata(CARD); + } + + private static AgentCardCacheMetadata createCacheMetadata(AgentCard card) { + return new AgentCardCacheMetadata(card, new DefaultValuesConfigProvider()); + } + @Test public void testGetTaskSuccess() { - RestHandler handler = new RestHandler(CARD, requestHandler, internalExecutor); + RestHandler handler = new RestHandler(CARD, createCacheMetadata(), requestHandler, internalExecutor); taskStore.save(MINIMAL_TASK, false); RestHandler.HTTPRestResponse response = handler.getTask(callContext, "", MINIMAL_TASK.id(), 0); @@ -51,7 +61,7 @@ public void testGetTaskSuccess() { @Test public void testGetTaskNotFound() { - RestHandler handler = new RestHandler(CARD, requestHandler, internalExecutor); + RestHandler handler = new RestHandler(CARD, createCacheMetadata(), requestHandler, internalExecutor); RestHandler.HTTPRestResponse response = handler.getTask(callContext, "", "nonexistent", 0); @@ -61,7 +71,7 @@ public void testGetTaskNotFound() { @Test public void testGetTaskNegativeHistoryLengthReturns422() { - RestHandler handler = new RestHandler(CARD, requestHandler, internalExecutor); + RestHandler handler = new RestHandler(CARD, createCacheMetadata(), requestHandler, internalExecutor); RestHandler.HTTPRestResponse response = handler.getTask(callContext, "", MINIMAL_TASK.id(), -1); @@ -71,7 +81,7 @@ public void testGetTaskNegativeHistoryLengthReturns422() { @Test public void testListTasksStatusWireString() { - RestHandler handler = new RestHandler(CARD, requestHandler, internalExecutor); + RestHandler handler = new RestHandler(CARD, createCacheMetadata(), requestHandler, internalExecutor); taskStore.save(MINIMAL_TASK, false); RestHandler.HTTPRestResponse response = handler.listTasks(callContext, "", null, "TASK_STATE_SUBMITTED", null, null, @@ -84,7 +94,7 @@ public void testListTasksStatusWireString() { @Test public void testListTasksInvalidStatus() { - RestHandler handler = new RestHandler(CARD, requestHandler, internalExecutor); + RestHandler handler = new RestHandler(CARD, createCacheMetadata(), requestHandler, internalExecutor); RestHandler.HTTPRestResponse response = handler.listTasks(callContext, "", null, "not-a-status", null, null, null, null, null); @@ -95,7 +105,7 @@ public void testListTasksInvalidStatus() { @Test public void testSendMessage() throws InvalidProtocolBufferException { - RestHandler handler = new RestHandler(CARD, requestHandler, internalExecutor); + RestHandler handler = new RestHandler(CARD, createCacheMetadata(), requestHandler, internalExecutor); agentExecutorExecute = (context, agentEmitter) -> { agentEmitter.sendMessage(context.getMessage()); }; @@ -126,7 +136,7 @@ public void testSendMessage() throws InvalidProtocolBufferException { @Test public void testSendMessageInvalidBody() { - RestHandler handler = new RestHandler(CARD, requestHandler, internalExecutor); + RestHandler handler = new RestHandler(CARD, createCacheMetadata(), requestHandler, internalExecutor); String invalidBody = "invalid json"; RestHandler.HTTPRestResponse response = handler.sendMessage(callContext, "", invalidBody); @@ -137,7 +147,7 @@ public void testSendMessageInvalidBody() { @Test public void testSendMessageWrongValueBody() { - RestHandler handler = new RestHandler(CARD, requestHandler, internalExecutor); + RestHandler handler = new RestHandler(CARD, createCacheMetadata(), requestHandler, internalExecutor); String requestBody = """ { "message": @@ -165,7 +175,7 @@ public void testSendMessageWrongValueBody() { @Test public void testSendMessageEmptyBody() { - RestHandler handler = new RestHandler(CARD, requestHandler, internalExecutor); + RestHandler handler = new RestHandler(CARD, createCacheMetadata(), requestHandler, internalExecutor); RestHandler.HTTPRestResponse response = handler.sendMessage(callContext, "", ""); @@ -175,7 +185,7 @@ public void testSendMessageEmptyBody() { @Test public void testCancelTaskSuccess() { - RestHandler handler = new RestHandler(CARD, requestHandler, internalExecutor); + RestHandler handler = new RestHandler(CARD, createCacheMetadata(), requestHandler, internalExecutor); taskStore.save(MINIMAL_TASK, false); agentExecutorCancel = (context, agentEmitter) -> { @@ -196,7 +206,7 @@ public void testCancelTaskSuccess() { @Test public void testCancelTaskNotFound() { - RestHandler handler = new RestHandler(CARD, requestHandler, internalExecutor); + RestHandler handler = new RestHandler(CARD, createCacheMetadata(), requestHandler, internalExecutor); String requestBody = "{\"id\":\"nonexistent\"}"; RestHandler.HTTPRestResponse response = handler.cancelTask(callContext, "", requestBody, "nonexistent"); @@ -207,7 +217,7 @@ public void testCancelTaskNotFound() { @Test public void testCancelTaskWithMetadata() { - RestHandler handler = new RestHandler(CARD, requestHandler, internalExecutor); + RestHandler handler = new RestHandler(CARD, createCacheMetadata(), requestHandler, internalExecutor); taskStore.save(MINIMAL_TASK, false); agentExecutorCancel = (context, agentEmitter) -> { @@ -238,7 +248,7 @@ public void testCancelTaskWithMetadata() { @Test public void testCancelTaskWithEmptyMetadata() { - RestHandler handler = new RestHandler(CARD, requestHandler, internalExecutor); + RestHandler handler = new RestHandler(CARD, createCacheMetadata(), requestHandler, internalExecutor); taskStore.save(MINIMAL_TASK, false); agentExecutorCancel = (context, agentEmitter) -> { @@ -262,7 +272,7 @@ public void testCancelTaskWithEmptyMetadata() { @Test public void testCancelTaskWithNoMetadata() { - RestHandler handler = new RestHandler(CARD, requestHandler, internalExecutor); + RestHandler handler = new RestHandler(CARD, createCacheMetadata(), requestHandler, internalExecutor); taskStore.save(MINIMAL_TASK, false); agentExecutorCancel = (context, agentEmitter) -> { @@ -282,7 +292,7 @@ public void testCancelTaskWithNoMetadata() { @Test public void testCancelTaskWithNullBody() { - RestHandler handler = new RestHandler(CARD, requestHandler, internalExecutor); + RestHandler handler = new RestHandler(CARD, createCacheMetadata(), requestHandler, internalExecutor); taskStore.save(MINIMAL_TASK, false); agentExecutorCancel = (context, agentEmitter) -> { @@ -300,7 +310,7 @@ public void testCancelTaskWithNullBody() { @Test public void testSendStreamingMessageSuccess() { - RestHandler handler = new RestHandler(CARD, requestHandler, internalExecutor); + RestHandler handler = new RestHandler(CARD, createCacheMetadata(), requestHandler, internalExecutor); agentExecutorExecute = (context, agentEmitter) -> { agentEmitter.sendMessage(context.getMessage()); }; @@ -332,7 +342,7 @@ public void testSendStreamingMessageSuccess() { @Test public void testSendStreamingMessageNotSupported() { AgentCard card = createAgentCard(false, true); - RestHandler handler = new RestHandler(card, requestHandler, internalExecutor); + RestHandler handler = new RestHandler(card, createCacheMetadata(card), requestHandler, internalExecutor); String requestBody = """ { @@ -353,7 +363,7 @@ public void testSendStreamingMessageNotSupported() { @Test public void testPushNotificationConfigSuccess() { - RestHandler handler = new RestHandler(CARD, requestHandler, internalExecutor); + RestHandler handler = new RestHandler(CARD, createCacheMetadata(), requestHandler, internalExecutor); taskStore.save(MINIMAL_TASK, false); String requestBody = """ @@ -376,7 +386,7 @@ public void testPushNotificationConfigSuccess() { @Test public void testPushNotificationConfigNotSupported() { AgentCard card = createAgentCard(true, false); - RestHandler handler = new RestHandler(card, requestHandler, internalExecutor); + RestHandler handler = new RestHandler(card, createCacheMetadata(card), requestHandler, internalExecutor); String requestBody = """ { @@ -395,7 +405,7 @@ public void testPushNotificationConfigNotSupported() { @Test public void testGetPushNotificationConfig() { - RestHandler handler = new RestHandler(CARD, requestHandler, internalExecutor); + RestHandler handler = new RestHandler(CARD, createCacheMetadata(), requestHandler, internalExecutor); taskStore.save(MINIMAL_TASK, false); // First, create a push notification config @@ -419,7 +429,7 @@ public void testGetPushNotificationConfig() { @Test public void testDeletePushNotificationConfig() { - RestHandler handler = new RestHandler(CARD, requestHandler, internalExecutor); + RestHandler handler = new RestHandler(CARD, createCacheMetadata(), requestHandler, internalExecutor); taskStore.save(MINIMAL_TASK, false); RestHandler.HTTPRestResponse response = handler.deleteTaskPushNotificationConfiguration(callContext, "", MINIMAL_TASK.id(), "default-config-id"); Assertions.assertEquals(204, response.getStatusCode()); @@ -427,7 +437,7 @@ public void testDeletePushNotificationConfig() { @Test public void testListPushNotificationConfigs() { - RestHandler handler = new RestHandler(CARD, requestHandler, internalExecutor); + RestHandler handler = new RestHandler(CARD, createCacheMetadata(), requestHandler, internalExecutor); taskStore.save(MINIMAL_TASK, false); RestHandler.HTTPRestResponse response = handler.listTaskPushNotificationConfigurations(callContext, "", MINIMAL_TASK.id(), 0, ""); @@ -439,7 +449,7 @@ public void testListPushNotificationConfigs() { @Test public void testHttpStatusCodeMapping() { - RestHandler handler = new RestHandler(CARD, requestHandler, internalExecutor); + RestHandler handler = new RestHandler(CARD, createCacheMetadata(), requestHandler, internalExecutor); // Test 400 for invalid request RestHandler.HTTPRestResponse response = handler.sendMessage(callContext, "", ""); @@ -452,7 +462,7 @@ public void testHttpStatusCodeMapping() { @Test public void testStreamingDoesNotBlockMainThread() throws Exception { - RestHandler handler = new RestHandler(CARD, requestHandler, internalExecutor); + RestHandler handler = new RestHandler(CARD, createCacheMetadata(), requestHandler, internalExecutor); // Track if the main thread gets blocked during streaming AtomicBoolean eventReceived = new AtomicBoolean(false); @@ -556,7 +566,7 @@ public void testExtensionSupportRequiredErrorOnSendMessage() { .skills(List.of()) .build(); - RestHandler handler = new RestHandler(cardWithExtension, requestHandler, internalExecutor); + RestHandler handler = new RestHandler(cardWithExtension, createCacheMetadata(cardWithExtension), requestHandler, internalExecutor); String requestBody = """ { @@ -604,7 +614,7 @@ public void testExtensionSupportRequiredErrorOnSendStreamingMessage() { .skills(List.of()) .build(); - RestHandler handler = new RestHandler(cardWithExtension, requestHandler, internalExecutor); + RestHandler handler = new RestHandler(cardWithExtension, createCacheMetadata(cardWithExtension), requestHandler, internalExecutor); String requestBody = """ { @@ -693,7 +703,7 @@ public void testRequiredExtensionProvidedSuccess() { .skills(List.of()) .build(); - RestHandler handler = new RestHandler(cardWithExtension, requestHandler, internalExecutor); + RestHandler handler = new RestHandler(cardWithExtension, createCacheMetadata(cardWithExtension), requestHandler, internalExecutor); // Create context WITH the required extension Set requestedExtensions = new HashSet<>(); @@ -749,7 +759,7 @@ public void testVersionNotSupportedErrorOnSendMessage() { .skills(List.of()) .build(); - RestHandler handler = new RestHandler(agentCard, requestHandler, internalExecutor); + RestHandler handler = new RestHandler(agentCard, createCacheMetadata(agentCard), requestHandler, internalExecutor); // Create context with incompatible version 2.0 ServerCallContext contextWithVersion = new ServerCallContext( @@ -799,7 +809,7 @@ public void testVersionNotSupportedErrorOnSendStreamingMessage() { .skills(List.of()) .build(); - RestHandler handler = new RestHandler(agentCard, requestHandler, internalExecutor); + RestHandler handler = new RestHandler(agentCard, createCacheMetadata(agentCard), requestHandler, internalExecutor); // Create context with incompatible version 2.0 ServerCallContext contextWithVersion = new ServerCallContext( @@ -889,7 +899,7 @@ public void testCompatibleVersionSuccess() { .skills(List.of()) .build(); - RestHandler handler = new RestHandler(agentCard, requestHandler, internalExecutor); + RestHandler handler = new RestHandler(agentCard, createCacheMetadata(agentCard), requestHandler, internalExecutor); // Create context with compatible version 1.1 ServerCallContext contextWithVersion = new ServerCallContext( @@ -944,7 +954,7 @@ public void testNoVersionDefaultsToCurrentVersionSuccess() { .skills(List.of()) .build(); - RestHandler handler = new RestHandler(agentCard, requestHandler, internalExecutor); + RestHandler handler = new RestHandler(agentCard, createCacheMetadata(agentCard), requestHandler, internalExecutor); // Use default callContext (no version - should default to 1.0) agentExecutorExecute = (context, agentEmitter) -> { @@ -977,7 +987,7 @@ public void testNoVersionDefaultsToCurrentVersionSuccess() { @Test public void testListTasksNegativeTimestampReturns422() { - RestHandler handler = new RestHandler(CARD, requestHandler, internalExecutor); + RestHandler handler = new RestHandler(CARD, createCacheMetadata(), requestHandler, internalExecutor); // Negative timestamp should return 422 (Invalid params) RestHandler.HTTPRestResponse response = handler.listTasks(callContext, "", null, null, null, null, @@ -989,7 +999,7 @@ public void testListTasksNegativeTimestampReturns422() { @Test public void testListTasksUnixMillisecondsTimestamp() { - RestHandler handler = new RestHandler(CARD, requestHandler, internalExecutor); + RestHandler handler = new RestHandler(CARD, createCacheMetadata(), requestHandler, internalExecutor); // Unix milliseconds timestamp are no longer accepted RestHandler.HTTPRestResponse response = handler.listTasks(callContext, "", null, null, null, null, null, "1234567", null); @@ -998,7 +1008,7 @@ public void testListTasksUnixMillisecondsTimestamp() { @Test public void testListTasksProtobufEnumStatus() { - RestHandler handler = new RestHandler(CARD, requestHandler, internalExecutor); + RestHandler handler = new RestHandler(CARD, createCacheMetadata(), requestHandler, internalExecutor); taskStore.save(MINIMAL_TASK, false); // Protobuf enum format (TASK_STATE_SUBMITTED) should be accepted @@ -1012,7 +1022,7 @@ public void testListTasksProtobufEnumStatus() { @Test public void testListTasksEnumConstantStatus() { - RestHandler handler = new RestHandler(CARD, requestHandler, internalExecutor); + RestHandler handler = new RestHandler(CARD, createCacheMetadata(), requestHandler, internalExecutor); taskStore.save(MINIMAL_TASK, false); // Enum constant format (TASK_STATE_SUBMITTED) should be accepted @@ -1026,7 +1036,7 @@ public void testListTasksEnumConstantStatus() { @Test public void testListTasksEmptyResultIncludesAllFields() { - RestHandler handler = new RestHandler(CARD, requestHandler, internalExecutor); + RestHandler handler = new RestHandler(CARD, createCacheMetadata(), requestHandler, internalExecutor); // Query for a context that doesn't exist - should return empty result with all fields RestHandler.HTTPRestResponse response = handler.listTasks(callContext, "", "nonexistent-context-id", null, null, null,