diff --git a/acts/sut-behaviors.yaml b/acts/sut-behaviors.yaml new file mode 100644 index 000000000..3ab2dfb3f --- /dev/null +++ b/acts/sut-behaviors.yaml @@ -0,0 +1,107 @@ +# Which ACTS `tck-*` behaviours this SDK's ITK agent implements (ACTS §11.1). +# +# **This file is the list.** `ActsBehaviors.java` implements the behaviours but +# does not restate their names — it reads the prefix out of the message with a +# greedy regex — so there is nothing here to drift out of step with. The two +# are a claim and its implementation, and the runner checks one against the +# other by running the tests. +# +# A prefix listed here but not implemented makes the test FAIL, not skip: +# deliberately, so lagging support stays visible in the conformance report +# instead of quietly shrinking it. Adding a behaviour means adding a branch in +# `ActsBehaviors.dispatch` and an entry here. + +acts_version: "1.0" + +behaviors: + - prefix: "tck-complete-task" + description: "Complete the task with a text response message" + response_type: task + terminal_state: TASK_STATE_COMPLETED + + - prefix: "tck-input-required" + description: "Return the task in INPUT_REQUIRED" + response_type: task + terminal_state: TASK_STATE_INPUT_REQUIRED + + - prefix: "tck-auth-required" + description: "Return the task in AUTH_REQUIRED" + response_type: task + terminal_state: TASK_STATE_AUTH_REQUIRED + + - prefix: "tck-reject-task" + description: "Reject the task" + response_type: task + terminal_state: TASK_STATE_REJECTED + + - prefix: "tck-task-failure" + description: "Complete with FAILED and an error message" + response_type: task + terminal_state: TASK_STATE_FAILED + + - prefix: "tck-message-response" + description: "Return a direct Message, not a Task" + response_type: message + + - prefix: "tck-multi-turn" + description: "Stay in INPUT_REQUIRED until the user sends 'done'" + response_type: task + terminal_state: TASK_STATE_COMPLETED + + - prefix: "tck-cancel" + description: "Remain in WORKING until canceled" + response_type: task + terminal_state: TASK_STATE_CANCELED + + - prefix: "tck-long-running" + description: "Stay in WORKING briefly, then complete" + response_type: task + terminal_state: TASK_STATE_COMPLETED + delay_ms: 1000 + + - prefix: "tck-artifact-text" + description: "Complete with a text artifact" + response_type: task + terminal_state: TASK_STATE_COMPLETED + artifacts: + - text: "generated text content" + + - prefix: "tck-artifact-data" + description: "Complete with a structured data artifact" + response_type: task + terminal_state: TASK_STATE_COMPLETED + artifacts: + - data: {key: "value", count: 1} + + - prefix: "tck-artifact-file" + description: "Complete with a file artifact carrying inline bytes" + response_type: task + terminal_state: TASK_STATE_COMPLETED + artifacts: + - file: {name: "document.txt", mediaType: "text/plain"} + + - prefix: "tck-artifact-file-url" + description: "Complete with a file artifact carrying a URL" + response_type: task + terminal_state: TASK_STATE_COMPLETED + artifacts: + - fileUrl: + url: "https://example.com/document.txt" + name: "document.txt" + mediaType: "text/plain" + + - prefix: "tck-stream-basic" + description: "Stream working -> artifact -> completed" + response_type: task + terminal_state: TASK_STATE_COMPLETED + streaming: true + artifacts: + - text: "streamed content" + + - prefix: "tck-stream-chunked" + description: "Stream one artifact across several appended chunks" + response_type: task + terminal_state: TASK_STATE_COMPLETED + streaming: true + artifacts: + - text: "chunk one chunk two chunk three" diff --git a/itk/.gitignore b/itk/.gitignore index ce15aadf0..a228106ca 100644 --- a/itk/.gitignore +++ b/itk/.gitignore @@ -1,4 +1,7 @@ a2a-itk/ +a2a-itk logs/ raw_results.json itk_java.json +acts-report-*.json +acts_results_*.json diff --git a/itk/src/main/java/org/a2aproject/sdk/itk/ActsBehaviors.java b/itk/src/main/java/org/a2aproject/sdk/itk/ActsBehaviors.java new file mode 100644 index 000000000..c99a6523e --- /dev/null +++ b/itk/src/main/java/org/a2aproject/sdk/itk/ActsBehaviors.java @@ -0,0 +1,325 @@ +package org.a2aproject.sdk.itk; + +import java.nio.charset.StandardCharsets; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import org.a2aproject.sdk.server.agentexecution.RequestContext; +import org.a2aproject.sdk.server.tasks.AgentEmitter; +import org.a2aproject.sdk.spec.DataPart; +import org.a2aproject.sdk.spec.FilePart; +import org.a2aproject.sdk.spec.FileWithBytes; +import org.a2aproject.sdk.spec.FileWithUri; +import org.a2aproject.sdk.spec.Message; +import org.a2aproject.sdk.spec.Part; +import org.a2aproject.sdk.spec.Task; +import org.a2aproject.sdk.spec.TaskState; +import org.a2aproject.sdk.spec.TextPart; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * ACTS SUT behaviour contract (ACTS spec §11). + * + *

ACTS tests are declarative — they say what to send and what to expect — so the agent under + * test has to produce a deterministic reply for each case. §11 does that with a message-prefix + * convention rather than a side-channel API: the text of the first user message names the + * behaviour. {@code ItkAgentExecutor} routes here when it sees a {@code tck-} prefix and otherwise + * runs the ITK instruction path, so one agent serves both suites. + * + *

{@code acts/sut-behaviors.yaml} is what this SDK claims; this class is what it does. Nothing + * here restates the list of names — the prefix is read out of the message, and one that reaches + * {@link #dispatch} without a branch fails the task rather than completing it, so a gap shows up + * in the conformance report instead of passing quietly. + */ +final class ActsBehaviors { + + private static final Logger LOGGER = LoggerFactory.getLogger(ActsBehaviors.class); + + /** + * Greedy to the word boundary, which gives longest-match for free: {@code tck-artifact-file-url} + * beats {@code tck-artifact-file} with no ordered table. + */ + private static final Pattern NAME = Pattern.compile("^(tck-[a-z0-9]+(?:-[a-z0-9]+)*)"); + + /** + * Behaviours whose whole contract is the state they leave the task in. Named for + * {@code sut-behaviors.yaml}'s {@code terminal_state} key, though two of them are interrupted + * states rather than terminal ones. + */ + private static final Map TERMINAL_STATES = Map.of( + "tck-complete-task", TaskState.TASK_STATE_COMPLETED, + "tck-task-failure", TaskState.TASK_STATE_FAILED, + "tck-reject-task", TaskState.TASK_STATE_REJECTED, + "tck-input-required", TaskState.TASK_STATE_INPUT_REQUIRED, + "tck-auth-required", TaskState.TASK_STATE_AUTH_REQUIRED); + + private static final String MULTI_TURN_DONE = "done"; + + /** + * Short enough not to dominate a run, long enough that a test polling for a non-terminal state + * sees one: the corpus polls every 2s, 15 times. + */ + private static final long LONG_RUNNING_DELAY_MS = 1000; + + /** Safety bound on a parked {@code tck-cancel} task, so agent-pool threads are never leaked. */ + private static final long CANCEL_PARK_TIMEOUT_MINUTES = 5; + + /** + * Wakes a parked {@code tck-cancel} execution. + * + *

The SDK delivers no cancellation signal into a running {@code execute()} — the + * {@code CompletableFuture.cancel(true)} it issues never interrupts — and {@code cancel()} runs + * on the request thread with its own {@code RequestContext} and {@code AgentEmitter}. A latch + * shared by task id is the only channel between the two. + */ + private final ConcurrentMap parked = new ConcurrentHashMap<>(); + + /** + * The behaviour each live task was opened with. + * + *

A continuation names the behaviour only in its first message, so the other SDKs' agents + * recover it from the task's history. a2a-java stores no history — a task created from an + * agent event gets {@code initialMessage = null}, so the user's message never lands in it — + * which leaves the executor no record of its own contract. This note is that record. It hides + * nothing: CORE-HIST-005 and CORE-HIST-006 assert on the history itself and still fail. + */ + private final ConcurrentMap opened = new ConcurrentHashMap<>(); + + private final ActsClientParse clientParse = new ActsClientParse(); + + /** + * Whether the card should advertise a diminished capability set. + * + *

Four ACTS tests assert that an agent without a capability answers + * {@code UnsupportedOperationError}, so their preconditions require the card not to advertise + * it and they can never run against a fully capable agent. The runner starts a second SUT with + * this variable set to reach them. The card alone is enough here: every a2a-java transport + * handler gates streaming, push-notification and extended-card operations on the resolved + * card's capabilities. + */ + static boolean reducedCapabilities() { + String value = System.getenv("ITK_ACTS_REDUCED_CAPABILITIES"); + return value != null && !value.isEmpty(); + } + + /** + * Resolves the behaviour from the incoming message, falling back to the task it belongs to. + * + *

A multi-turn test opens with the prefix and then sends plain "here is more input" and + * "done", so a continuation has to recover the contract from where it was declared. History is + * the right place to look and is tried first; {@link #opened} covers the case where this SDK + * kept none. + * + *

The name is an asserted behaviour, not necessarily an implemented one: an unknown + * {@code tck-} still routes to ACTS and is reported as unimplemented, which beats handing a + * message plainly meant for ACTS to the traversal decoder. + */ + String behaviorFor(RequestContext context) { + String named = behaviorIn(firstText(context.getMessage())); + if (named != null) { + opened.put(context.getTaskId(), named); + return named; + } + Task task = context.getTask(); + if (task == null) { + return null; + } + if (task.history() != null) { + for (Message historical : task.history()) { + String found = behaviorIn(firstText(historical)); + if (found != null) { + return found; + } + } + } + return opened.get(context.getTaskId()); + } + + private static String behaviorIn(String text) { + if (text == null) { + return null; + } + Matcher matcher = NAME.matcher(text.strip()); + return matcher.find() ? matcher.group(1) : null; + } + + private static String firstText(Message message) { + if (message == null || message.parts() == null) { + return null; + } + for (Part part : message.parts()) { + if (part instanceof TextPart textPart && !textPart.text().isEmpty()) { + return textPart.text(); + } + } + return null; + } + + void run(RequestContext context, AgentEmitter emitter, String behavior) { + LOGGER.info("Serving ACTS behaviour {} for task {}", behavior, emitter.getTaskId()); + + // This one must open no task at all: A2A lets an agent answer with a bare Message, and a + // server that created a task first would turn the reply into a task update, which is what + // CORE-SEND-003 checks. + if ("tck-message-response".equals(behavior)) { + emitter.sendMessage("tck message response"); + return; + } + + // Persists WORKING before anything blocks, so a concurrent cancel_task finds a non-final + // task in the store and a blocking send_message is released by the first event. + emitter.startWork(); + dispatch(context, emitter, behavior); + } + + private void dispatch(RequestContext context, AgentEmitter emitter, String behavior) { + if (ActsClientParse.BEHAVIOR.equals(behavior)) { + clientParse.run(context, emitter); + return; + } + + if (behavior.startsWith("tck-artifact-")) { + List> parts = artifactParts(behavior); + if (parts == null) { + unimplemented(emitter, behavior); + return; + } + emitter.addArtifact(parts, null, behavior, null); + complete(emitter, TaskState.TASK_STATE_COMPLETED, behavior + " ok"); + return; + } + + switch (behavior) { + case "tck-multi-turn" -> multiTurn(context, emitter); + case "tck-cancel" -> waitForCancel(emitter); + case "tck-long-running" -> longRunning(emitter); + case "tck-stream-basic", "tck-stream-chunked" -> stream(emitter, behavior); + default -> { + TaskState state = TERMINAL_STATES.get(behavior); + if (state == null) { + unimplemented(emitter, behavior); + } else { + complete(emitter, state, behavior + " ok"); + } + } + } + } + + /** Releases a task parked by {@code tck-cancel}; a no-op for every other behaviour. */ + void released(String taskId) { + opened.remove(taskId); + CountDownLatch latch = parked.get(taskId); + if (latch != null) { + latch.countDown(); + } + } + + /** Fails the task rather than completing it: a silent success would report conformance the agent never demonstrated. */ + private void unimplemented(AgentEmitter emitter, String behavior) { + complete(emitter, TaskState.TASK_STATE_FAILED, "unimplemented ACTS behaviour \"" + behavior + "\""); + } + + private void complete(AgentEmitter emitter, TaskState state, String text) { + if (state.isFinal()) { + opened.remove(emitter.getTaskId()); + } + emitter.updateStatus(state, emitter.newAgentMessage(List.of(new TextPart(text)), null)); + } + + private void multiTurn(RequestContext context, AgentEmitter emitter) { + String said = context.getUserInput(" ").strip().toLowerCase(); + if (said.startsWith(MULTI_TURN_DONE)) { + complete(emitter, TaskState.TASK_STATE_COMPLETED, "multi-turn complete"); + return; + } + complete(emitter, TaskState.TASK_STATE_INPUT_REQUIRED, "more input please"); + } + + /** + * Holds the task in WORKING until {@code cancel_task} arrives. + * + *

Emits nothing while parked. {@code doCancelTask} breaks on the first event it sees on the + * tapped queue and rejects anything that is not a CANCELED task, so a heartbeat here would make + * the cancel RPC fail with {@code TaskNotCancelableError}. Returning emits nothing either — the + * cancel side has already published CANCELED on its own emitter. + */ + private void waitForCancel(AgentEmitter emitter) { + String taskId = emitter.getTaskId(); + CountDownLatch latch = parked.computeIfAbsent(taskId, id -> new CountDownLatch(1)); + try { + if (!latch.await(CANCEL_PARK_TIMEOUT_MINUTES, TimeUnit.MINUTES)) { + complete(emitter, TaskState.TASK_STATE_FAILED, "tck-cancel was never canceled"); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } finally { + parked.remove(taskId, latch); + } + } + + private void longRunning(AgentEmitter emitter) { + try { + Thread.sleep(LONG_RUNNING_DELAY_MS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return; + } + + // CORE-EXEC-001 polls to completion and then asserts the finished task carries at least one + // artifact, so the work has to leave one behind even though §11.2 describes this behaviour + // only as delayed completion. + emitter.addArtifact(List.of(new TextPart("long running result")), null, "long-running", null); + complete(emitter, TaskState.TASK_STATE_COMPLETED, "long running work finished"); + } + + private List> artifactParts(String behavior) { + return switch (behavior) { + case "tck-artifact-text" -> List.of(new TextPart("generated text content")); + case "tck-artifact-data" -> List.of(new DataPart(dataArtifact())); + case "tck-artifact-file" -> List.of(new FilePart(new FileWithBytes( + "text/plain", "document.txt", "file bytes".getBytes(StandardCharsets.UTF_8)))); + case "tck-artifact-file-url" -> List.of(new FilePart(new FileWithUri( + "text/plain", "document.txt", "https://example.com/document.txt"))); + default -> null; + }; + } + + private Map dataArtifact() { + Map data = new LinkedHashMap<>(); + data.put("key", "value"); + data.put("count", 1); + return data; + } + + /** + * Emits working -> artifact(s) -> completed as separate events, so each becomes its own SSE + * frame; a single combined update would satisfy {@code min_count} only by accident. + */ + private void stream(AgentEmitter emitter, String behavior) { + emitter.startWork(emitter.newAgentMessage(List.of(new TextPart("streaming started")), null)); + + if ("tck-stream-chunked".equals(behavior)) { + List chunks = List.of("chunk one ", "chunk two ", "chunk three"); + String artifactId = UUID.randomUUID().toString(); + for (int i = 0; i < chunks.size(); i++) { + // The first chunk goes out with append=false; an update naming an artifact the task + // has not seen yet has nothing to append to. + emitter.addArtifact(List.of(new TextPart(chunks.get(i))), artifactId, "chunked", null, + i > 0, i == chunks.size() - 1); + } + } else { + emitter.addArtifact(List.of(new TextPart("streamed content")), null, "streamed", null, false, true); + } + + complete(emitter, TaskState.TASK_STATE_COMPLETED, behavior + " ok"); + } +} diff --git a/itk/src/main/java/org/a2aproject/sdk/itk/ActsClientParse.java b/itk/src/main/java/org/a2aproject/sdk/itk/ActsClientParse.java new file mode 100644 index 000000000..317651957 --- /dev/null +++ b/itk/src/main/java/org/a2aproject/sdk/itk/ActsClientParse.java @@ -0,0 +1,445 @@ +package org.a2aproject.sdk.itk; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Consumer; + +import com.google.gson.Gson; +import com.google.protobuf.MessageOrBuilder; +import com.google.protobuf.util.JsonFormat; + +import org.a2aproject.sdk.A2A; +import org.a2aproject.sdk.client.Client; +import org.a2aproject.sdk.client.ClientEvent; +import org.a2aproject.sdk.client.MessageEvent; +import org.a2aproject.sdk.client.TaskEvent; +import org.a2aproject.sdk.client.TaskUpdateEvent; +import org.a2aproject.sdk.client.config.ClientConfig; +import org.a2aproject.sdk.client.http.A2AHttpClient; +import org.a2aproject.sdk.client.http.A2AHttpResponse; +import org.a2aproject.sdk.client.http.ServerSentEvent; +import org.a2aproject.sdk.client.transport.jsonrpc.JSONRPCTransport; +import org.a2aproject.sdk.client.transport.jsonrpc.JSONRPCTransportConfigBuilder; +import org.a2aproject.sdk.grpc.utils.ProtoJsonUtils; +import org.a2aproject.sdk.grpc.utils.ProtoUtils; +import org.a2aproject.sdk.server.agentexecution.RequestContext; +import org.a2aproject.sdk.server.tasks.AgentEmitter; +import org.a2aproject.sdk.spec.A2AError; +import org.a2aproject.sdk.spec.AgentCapabilities; +import org.a2aproject.sdk.spec.AgentCard; +import org.a2aproject.sdk.spec.AgentInterface; +import org.a2aproject.sdk.spec.DataPart; +import org.a2aproject.sdk.spec.EventKind; +import org.a2aproject.sdk.spec.Message; +import org.a2aproject.sdk.spec.Part; +import org.a2aproject.sdk.spec.Task; +import org.a2aproject.sdk.spec.TaskQueryParams; +import org.a2aproject.sdk.spec.TaskState; +import org.a2aproject.sdk.spec.TextPart; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * The {@code tck-client-parse} behaviour: ACTS §10 client tests. + * + *

Every other ACTS step drives the SUT as a server — send bytes, assert on what comes back. A + * client test inverts that: it supplies a canonical wire payload and asks whether this SDK's + * client parses it correctly, which no A2A operation can ask of a server. §10 defines the + * file format and says nothing about the mechanism, so without a convention like this one the + * runner cannot reach the client at all and skips the CLIENT-* tests. + * + *

The runner sends an ordinary {@code send_message} naming this behaviour with + * {@code {operation, wire_payload}} in a data part; the agent builds a real client whose HTTP + * transport returns that payload verbatim, performs the operation, and hands back whatever its own + * client produced. + * + *

A stubbed {@link A2AHttpClient} rather than a bare deserializer: decoding the payload straight + * into spec types would be a fraction of the code and would prove much less, skipping the JSON-RPC + * envelope, the error mapping and the response plumbing that are most of what a client test is + * about. CLIENT-PARSE-004 makes that concrete — it feeds an error envelope and expects + * {@code {error: {code, message}}}. + */ +final class ActsClientParse { + + private static final Logger LOGGER = LoggerFactory.getLogger(ActsClientParse.class); + + static final String BEHAVIOR = "tck-client-parse"; + + /** Nothing dials this — the stub answers before a socket is opened — but the client needs a syntactically valid base. */ + private static final String BASE_URL = "http://acts-client-parse.invalid"; + + private static final Gson GSON = new Gson(); + + /** + * The printer the SDK itself uses on the wire (see {@code JSONRPCUtils}), minus the whitespace + * option, which only affects readability. {@code alwaysPrintFieldsWithNoPresence} matters: + * without it a capability the client correctly parsed as {@code false} vanishes from the + * rendering, and CLIENT-CAP-001 asserts on exactly that. + */ + private static final JsonFormat.Printer PRINTER = JsonFormat.printer().alwaysPrintFieldsWithNoPresence(); + + void run(RequestContext context, AgentEmitter emitter) { + Map request = requestIn(context.getMessage()); + if (request == null) { + emitter.updateStatus(TaskState.TASK_STATE_FAILED, emitter.newAgentMessage( + List.of(new TextPart(BEHAVIOR + " needs {operation, wire_payload}")), null)); + return; + } + + String operation = String.valueOf(request.get("operation")); + String payload = GSON.toJson(withIntegralNumbers(request.get("wire_payload"))); + LOGGER.info("Parsing an ACTS {} payload through this SDK's own client", operation); + + Map parsed = parse(operation, payload); + emitter.addArtifact(List.of(new DataPart(parsed)), null, BEHAVIOR, null); + emitter.updateStatus(TaskState.TASK_STATE_COMPLETED, + emitter.newAgentMessage(List.of(new TextPart(operation + " parsed")), null)); + } + + @SuppressWarnings("unchecked") + private Map requestIn(Message message) { + if (message == null || message.parts() == null) { + return null; + } + for (Part part : message.parts()) { + if (part instanceof DataPart dataPart && dataPart.data() instanceof Map data + && data.get("operation") instanceof String) { + return (Map) data; + } + } + return null; + } + + /** + * Restores integers the transport turned into doubles. + * + *

A {@code DataPart} rides the wire as a {@code google.protobuf.Value}, whose only numeric + * kind is {@code double}, so the corpus's {@code code: -32001} reaches us as {@code -32001.0} + * and Gson would re-emit it that way, leaving the client to coerce a fractional literal into an + * {@code int32} field. The corpus wrote an integer; this puts one back. + */ + private Object withIntegralNumbers(Object value) { + if (value instanceof Double number && !number.isInfinite() && number == Math.rint(number)) { + return number.longValue(); + } + if (value instanceof Map map) { + Map copy = new LinkedHashMap<>(); + map.forEach((key, entry) -> copy.put(String.valueOf(key), withIntegralNumbers(entry))); + return copy; + } + if (value instanceof List list) { + List copy = new ArrayList<>(list.size()); + list.forEach(entry -> copy.add(withIntegralNumbers(entry))); + return copy; + } + return value; + } + + private Map parse(String operation, String payload) { + try { + return switch (operation) { + // Both card operations land here when the payload is a bare card, which is how the + // corpus writes them — correctly, since a card is fetched over plain HTTP on every + // binding, so there is no envelope to unwrap. + case "get_agent_card" -> wire(ProtoUtils.ToProto.agentCard(parseCard(payload))); + case "get_extended_agent_card" -> wire(ProtoUtils.ToProto.agentCard(parseExtendedCard(payload))); + case "send_message" -> wire(ProtoUtils.ToProto.taskOrMessage(parseSendMessage(payload))); + case "get_task" -> wire(ProtoUtils.ToProto.task(parseGetTask(payload))); + default -> error("unsupported client operation \"" + operation + "\"", null); + }; + } catch (Exception e) { + return raised(e, payload); + } + } + + private AgentCard parseCard(String payload) throws Exception { + return A2A.getAgentCard(new FixedHttpClient(payload), BASE_URL); + } + + private AgentCard parseExtendedCard(String payload) throws Exception { + // The corpus writes this as a bare card, matching the wire: its own payload names REST, + // where the extended card is a plain GET. Accept an envelope too, since JSON-RPC wraps it. + if (!isEnveloped(payload)) { + return parseCard(payload); + } + try (Client client = client(payload, new AtomicReference<>())) { + return client.getExtendedAgentCard(); + } + } + + private EventKind parseSendMessage(String payload) throws Exception { + AtomicReference captured = new AtomicReference<>(); + try (Client client = client(payload, captured)) { + client.sendMessage(A2A.toUserMessage("acts")); + } + ClientEvent event = captured.get(); + if (event instanceof MessageEvent messageEvent) { + return messageEvent.getMessage(); + } + if (event instanceof TaskEvent taskEvent) { + return taskEvent.getTask(); + } + if (event instanceof TaskUpdateEvent updateEvent) { + return updateEvent.getTask(); + } + throw new IllegalStateException("the client produced no event"); + } + + private Task parseGetTask(String payload) throws Exception { + try (Client client = client(payload, new AtomicReference<>())) { + return client.getTask(new TaskQueryParams("acts")); + } + } + + /** + * A real client bound to the stub transport. + * + *

Streaming is off in both the config and the synthetic card, which puts {@code sendMessage} + * on the unary path — there the consumer fires synchronously, before the call returns. + */ + private Client client(String payload, AtomicReference captured) throws Exception { + return Client.builder(syntheticCard()) + .clientConfig(new ClientConfig.Builder().setStreaming(false).build()) + .withTransport(JSONRPCTransport.class, + new JSONRPCTransportConfigBuilder().httpClient(new FixedHttpClient(payload))) + .addConsumer((event, card) -> captured.set(event)) + .build(); + } + + private AgentCard syntheticCard() { + return AgentCard.builder() + .name("ACTS client parse") + .description("Drives this SDK's client against a canned wire payload.") + .version("1.0.0") + .capabilities(AgentCapabilities.builder().build()) + .defaultInputModes(List.of("text")) + .defaultOutputModes(List.of("text")) + .skills(List.of()) + .supportedInterfaces(List.of(new AgentInterface("JSONRPC", BASE_URL))) + .build(); + } + + private boolean isEnveloped(String payload) { + Map body = envelopeOf(payload); + return body != null + && (body.containsKey("jsonrpc") || body.containsKey("result") || body.containsKey("error")); + } + + @SuppressWarnings("unchecked") + private Map envelopeOf(String payload) { + try { + return GSON.fromJson(payload, Map.class); + } catch (RuntimeException e) { + return null; + } + } + + /** + * Renders what the client produced as A2A wire JSON. + * + *

{@code send_message} keeps its {@code SendMessageResponse} envelope because §4.2 makes the + * {@code task}/{@code message} discriminator part of that operation's assertion root; + * {@code get_task} and the card operations are asserted on their own fields. + */ + @SuppressWarnings("unchecked") + private Map wire(MessageOrBuilder proto) throws Exception { + return GSON.fromJson(ProtoJsonUtils.toJson(PRINTER, proto), Map.class); + } + + /** + * Renders a client-raised failure the way {@code expect_parsed} addresses it. + * + *

The envelope's own error wins when the payload carried one: the assertion is about the + * client having surfaced that error, and inventing a code here would pass the test + * without the client having done anything. + */ + private Map raised(Exception e, String payload) { + Map envelope = envelopeOf(payload); + if (envelope != null && envelope.get("error") instanceof Map wire) { + Map out = new LinkedHashMap<>(); + out.put("error", wire); + out.put("raised", String.valueOf(e.getMessage())); + return out; + } + Integer code = e.getCause() instanceof A2AError a2aError ? a2aError.getCode() : null; + return error(String.valueOf(e.getMessage()), code); + } + + private Map error(String message, Integer code) { + Map detail = new LinkedHashMap<>(); + if (code != null) { + detail.put("code", code); + } + detail.put("message", message); + return Map.of("error", detail); + } + + /** + * Answers every request with the payload under test. + * + *

The status is always 200: the JSON-RPC transport checks the HTTP status before it looks at + * the body, so an error envelope served at 4xx is discarded as an unexpected HTTP status and + * never parsed. + */ + private final class FixedHttpClient implements A2AHttpClient { + + private final String payload; + + private FixedHttpClient(String payload) { + this.payload = payload; + } + + @Override + public GetBuilder createGet() { + return new FixedGetBuilder(); + } + + @Override + public PostBuilder createPost() { + return new FixedPostBuilder(); + } + + @Override + public DeleteBuilder createDelete() { + return new FixedDeleteBuilder(); + } + + /** + * Rewrites the response's JSON-RPC id to the request's, which is what a real server does. + * + *

The corpus's canned payloads carry a fixed id that cannot match one the client invented + * at call time, so a client validating the correlation rejects the payload before parsing + * any of it — leaving the test measuring correlation rather than parsing. + */ + private String echoingId(String sent) { + if (!isEnveloped(payload)) { + return payload; + } + Map envelope = envelopeOf(payload); + Map request = envelopeOf(sent); + if (envelope == null || request == null || !request.containsKey("id")) { + return payload; + } + envelope.put("id", request.get("id")); + return GSON.toJson(envelope); + } + + private A2AHttpResponse respond(String body) { + return new A2AHttpResponse() { + @Override + public int status() { + return 200; + } + + @Override + public boolean success() { + return true; + } + + @Override + public String body() { + return body; + } + }; + } + + /** No §10 payload is a stream; a client that asks for one has taken a path the test did not intend. */ + private CompletableFuture unsupportedStream(Consumer errorConsumer) { + IllegalStateException failure = new IllegalStateException("ACTS client parse serves no stream"); + errorConsumer.accept(failure); + return CompletableFuture.failedFuture(failure); + } + + private final class FixedGetBuilder implements GetBuilder { + @Override + public GetBuilder url(String s) { + return this; + } + + @Override + public GetBuilder addHeaders(Map headers) { + return this; + } + + @Override + public GetBuilder addHeader(String name, String value) { + return this; + } + + @Override + public A2AHttpResponse get() { + return respond(payload); + } + + @Override + public CompletableFuture getAsyncSSE(Consumer messageConsumer, + Consumer errorConsumer, + Runnable completeRunnable) { + return unsupportedStream(errorConsumer); + } + } + + private final class FixedPostBuilder implements PostBuilder { + private String sent = ""; + + @Override + public PostBuilder body(String body) { + this.sent = body; + return this; + } + + @Override + public PostBuilder url(String s) { + return this; + } + + @Override + public PostBuilder addHeaders(Map headers) { + return this; + } + + @Override + public PostBuilder addHeader(String name, String value) { + return this; + } + + @Override + public A2AHttpResponse post() { + return respond(echoingId(sent)); + } + + @Override + public CompletableFuture postAsyncSSE(Consumer messageConsumer, + Consumer errorConsumer, + Runnable completeRunnable) { + return unsupportedStream(errorConsumer); + } + } + + private final class FixedDeleteBuilder implements DeleteBuilder { + @Override + public DeleteBuilder url(String s) { + return this; + } + + @Override + public DeleteBuilder addHeaders(Map headers) { + return this; + } + + @Override + public DeleteBuilder addHeader(String name, String value) { + return this; + } + + @Override + public A2AHttpResponse delete() { + return respond(payload); + } + } + } +} diff --git a/itk/src/main/java/org/a2aproject/sdk/itk/AgentCardProducer.java b/itk/src/main/java/org/a2aproject/sdk/itk/AgentCardProducer.java index c4a87a53b..e417f562a 100644 --- a/itk/src/main/java/org/a2aproject/sdk/itk/AgentCardProducer.java +++ b/itk/src/main/java/org/a2aproject/sdk/itk/AgentCardProducer.java @@ -7,6 +7,7 @@ import org.a2aproject.sdk.compat03.spec.AgentCapabilities_v0_3; import org.a2aproject.sdk.compat03.spec.AgentCard_v0_3; +import org.a2aproject.sdk.server.ExtendedAgentCard; import org.a2aproject.sdk.server.PublicAgentCard; import org.a2aproject.sdk.spec.AgentCapabilities; import org.a2aproject.sdk.spec.AgentCard; @@ -26,8 +27,26 @@ public class AgentCardProducer { @ConfigProperty(name = "quarkus.grpc.server.port", defaultValue = "11002") int grpcPort; + /** + * The capabilities this agent advertises — everything, unless the ACTS runner asked for less. + * See {@link ActsBehaviors#reducedCapabilities()} for why a diminished card is needed at all. + */ + private AgentCapabilities capabilities() { + if (ActsBehaviors.reducedCapabilities()) { + return AgentCapabilities.builder().build(); + } + return AgentCapabilities.builder() + .streaming(true) + .pushNotifications(true) + .extendedAgentCard(true) + .build(); + } + + // Both qualifiers on one card: ACTS exercises the extended-card endpoint for its capability + // gating and its access controls, not for content that differs from the public card. @Produces @PublicAgentCard + @ExtendedAgentCard public AgentCard agentCard() { String url = "http://127.0.0.1:" + httpPort; List interfaces = List.of( @@ -40,10 +59,7 @@ public AgentCard agentCard() { .description("Java agent using A2A SDK (current source).") .version("1.0.0") .supportedInterfaces(interfaces) - .capabilities(AgentCapabilities.builder() - .streaming(true) - .pushNotifications(true) - .build()) + .capabilities(capabilities()) .defaultInputModes(List.of("text")) .defaultOutputModes(List.of("text")) .skills(List.of(AgentSkill.builder() diff --git a/itk/src/main/java/org/a2aproject/sdk/itk/AgentExecutorProducer.java b/itk/src/main/java/org/a2aproject/sdk/itk/AgentExecutorProducer.java index f937e7fae..695281ae3 100644 --- a/itk/src/main/java/org/a2aproject/sdk/itk/AgentExecutorProducer.java +++ b/itk/src/main/java/org/a2aproject/sdk/itk/AgentExecutorProducer.java @@ -82,10 +82,21 @@ static class ItkAgentExecutor implements AgentExecutor { private static final long HOLD_INTERVAL_MS = 2000; private static final long TASK_TIMEOUT_SECONDS = 60; + private final ActsBehaviors acts = new ActsBehaviors(); + @Override public void execute(RequestContext context, AgentEmitter emitter) throws A2AError { LOGGER.info("Executing task {}", emitter.getTaskId()); + // Dual-mode (ACTS §11): a "tck-" prefix in the first user message names a conformance + // behaviour, anything else is an ITK traversal instruction. The branch is taken before + // startWork() because tck-message-response must open no task at all. + String behavior = acts.behaviorFor(context); + if (behavior != null) { + acts.run(context, emitter, behavior); + return; + } + emitter.startWork(); Instruction instruction = extractInstruction(context.getMessage()); @@ -149,6 +160,9 @@ public void execute(RequestContext context, AgentEmitter emitter) throws A2AErro public void cancel(RequestContext context, AgentEmitter emitter) throws A2AError { LOGGER.info("Cancel requested for task {}", emitter.getTaskId()); emitter.cancel(); + // The SDK delivers no cancellation signal into a running execute(), so a tck-cancel + // task parked in WORKING has to be released from here. + acts.released(emitter.getTaskId()); } private Instruction extractInstruction(Message message) {