diff --git a/docs/a2a-protocol/README.md b/docs/a2a-protocol/README.md
index 6072b7252f..d33121fd73 100644
--- a/docs/a2a-protocol/README.md
+++ b/docs/a2a-protocol/README.md
@@ -1,6 +1,25 @@
# EventMesh A2A 协议(Agent-to-Agent Communication Protocol)
-> 合并自 ARCHITECTURE.md + eventmesh-a2a-design.md + README.md + README_EN.md + IMPLEMENTATION_SUMMARY.md + IMPLEMENTATION_SUMMARY_EN.md + TEST_RESULTS.md(2026-08-13)。
+> **⚠️ EXPERIMENTAL — Issue #5302 D1 scope**
+>
+> The A2A Gateway is **Experimental** as of Sub-PR D1 (issue #5302). The gateway now persists
+> tasks through the unified `TaskStore` (issue #5301 Sub-PR A/C) and bridges A2A publish/subscribe
+> onto the Runtime via `EventMeshA2ATransport` — there is no longer a parallel in-memory transport.
+> However, the following pieces land in follow-up PRs and are required before the gateway is
+> suitable for production use:
+>
+> 1. **TaskExpirer reaper** (Sub-PR D2): periodic `TaskStore.expireStale()` sweep so terminal
+> tasks do not accumulate in the Meta store.
+> 2. **AgentCard Meta-ization** (Sub-PR D2): `A2APublishSubscribeService` still uses an
+> in-memory `ConcurrentHashMap` for the agent-card registry; production needs a Meta-backed
+> `SessionStore` (Sub-PR A) or equivalent.
+> 3. **End-to-end Testcontainers test** (Sub-PR D2): fault-injection under a real Meta + Runtime
+> wiring.
+>
+> Until all three land, treat the gateway as Experimental — wire it up against the
+> `MetaBackedTaskStore` only on dev clusters.
+
+---
## 目录
diff --git a/eventmesh-runtime/src/main/java/org/apache/eventmesh/runtime/a2a/A2AGatewayHttpHandler.java b/eventmesh-runtime/src/main/java/org/apache/eventmesh/runtime/a2a/A2AGatewayHttpHandler.java
new file mode 100644
index 0000000000..859ad4e46d
--- /dev/null
+++ b/eventmesh-runtime/src/main/java/org/apache/eventmesh/runtime/a2a/A2AGatewayHttpHandler.java
@@ -0,0 +1,341 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.eventmesh.runtime.a2a;
+
+import java.nio.charset.StandardCharsets;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.TimeUnit;
+
+import io.netty.buffer.Unpooled;
+import io.netty.channel.ChannelFuture;
+import io.netty.channel.ChannelFutureListener;
+import io.netty.channel.ChannelHandlerContext;
+import io.netty.channel.SimpleChannelInboundHandler;
+import io.netty.handler.codec.http.DefaultFullHttpResponse;
+import io.netty.handler.codec.http.FullHttpRequest;
+import io.netty.handler.codec.http.FullHttpResponse;
+import io.netty.handler.codec.http.HttpHeaderNames;
+import io.netty.handler.codec.http.HttpResponseStatus;
+import io.netty.handler.codec.http.HttpVersion;
+import io.netty.handler.codec.http.QueryStringDecoder;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+
+import lombok.extern.slf4j.Slf4j;
+
+/**
+ * Netty HTTP handler for the A2A Gateway REST + SSE API.
+ *
+ *
Endpoints (issue #5302 D1 scope):
+ *
+ *
+ * POST /a2a/tasks - submit task (sync/async)
+ * GET /a2a/tasks - list tasks (?state=COMPLETED&limit=100&offset=0)
+ * GET /a2a/tasks/{taskId} - get task status
+ * DELETE /a2a/tasks/{taskId} - cancel task
+ * GET /a2a/tasks/{taskId}/wait - wait for result (long-poll)
+ * GET /a2a/tasks/{taskId}/stream - SSE stream of task status updates
+ * GET /a2a/health - health check
+ *
+ *
+ * Note: the SSE stream and the long-poll endpoint resolve the same future; the SSE
+ * stream additionally registers a {@link A2AGatewayService.StatusSubscriber} so intermediate
+ * status updates (SUBMITTED -> WORKING -> COMPLETED) are pushed to the client before the
+ * final result.
+ */
+@Slf4j
+public class A2AGatewayHttpHandler extends SimpleChannelInboundHandler {
+
+ private static final ObjectMapper objectMapper = new ObjectMapper();
+
+ private final A2AGatewayService gatewayService;
+
+ public A2AGatewayHttpHandler(A2AGatewayService gatewayService) {
+ this.gatewayService = gatewayService;
+ }
+
+ @Override
+ protected void channelRead0(ChannelHandlerContext ctx, FullHttpRequest req) throws Exception {
+ String uri = req.uri();
+ try {
+ if (uri.startsWith("/a2a/tasks/") && uri.endsWith("/stream")) {
+ handleSse(ctx, req);
+ } else if (uri.startsWith("/a2a/tasks/") && uri.endsWith("/wait")) {
+ handleLongPoll(ctx, req);
+ } else if (uri.startsWith("/a2a/tasks/") && !uri.contains("?")) {
+ String taskId = uri.substring("/a2a/tasks/".length());
+ if ("DELETE".equalsIgnoreCase(req.method().name())) {
+ handleCancel(ctx, taskId);
+ } else {
+ handleGet(ctx, taskId);
+ }
+ } else if (uri.equals("/a2a/tasks") || uri.startsWith("/a2a/tasks?")) {
+ if ("POST".equalsIgnoreCase(req.method().name())) {
+ handleSubmit(ctx, req);
+ } else {
+ handleList(ctx, req);
+ }
+ } else if (uri.equals("/a2a/health") || uri.startsWith("/a2a/health?")) {
+ handleHealth(ctx);
+ } else {
+ writeJson(ctx, HttpResponseStatus.NOT_FOUND, "{\"error\":\"not_found\"}");
+ }
+ } catch (Exception e) {
+ log.error("Error handling {} {}", req.method(), uri, e);
+ writeJson(ctx, HttpResponseStatus.INTERNAL_SERVER_ERROR,
+ "{\"error\":\"internal\",\"message\":\"" + e.getMessage() + "\"}");
+ }
+ }
+
+ // =========================================================================
+ // Endpoint handlers
+ // =========================================================================
+
+ private void handleSubmit(ChannelHandlerContext ctx, FullHttpRequest req) throws Exception {
+ String body = req.content().toString(StandardCharsets.UTF_8);
+ @SuppressWarnings("unchecked")
+ Map payload = objectMapper.readValue(body, Map.class);
+ String targetAgent = (String) payload.get("targetAgent");
+ String message = (String) payload.get("message");
+ String parentTaskId = (String) payload.get("parentTaskId");
+ Boolean sync = (Boolean) payload.getOrDefault("sync", Boolean.FALSE);
+
+ if (targetAgent == null || message == null) {
+ writeJson(ctx, HttpResponseStatus.BAD_REQUEST,
+ "{\"error\":\"bad_request\",\"message\":\"targetAgent and message are required\"}");
+ return;
+ }
+
+ A2AGatewayService.TaskResult result;
+ try {
+ if (Boolean.TRUE.equals(sync)) {
+ result = gatewayService.submitTask(targetAgent, message, parentTaskId)
+ .get(10, TimeUnit.SECONDS);
+ } else {
+ // Async: return 202 with taskId
+ String taskId = "task-async-" + System.nanoTime();
+ gatewayService.submitTask(taskId, targetAgent, message, parentTaskId);
+ writeJson(ctx, HttpResponseStatus.ACCEPTED,
+ "{\"taskId\":\"" + taskId + "\",\"state\":\"SUBMITTED\"}");
+ return;
+ }
+ } catch (java.util.concurrent.TimeoutException e) {
+ writeJson(ctx, HttpResponseStatus.GATEWAY_TIMEOUT,
+ "{\"error\":\"timeout\",\"message\":\"" + e.getMessage() + "\"}");
+ return;
+ } catch (Exception e) {
+ writeJson(ctx, HttpResponseStatus.BAD_REQUEST,
+ "{\"error\":\"bad_request\",\"message\":\"" + e.getMessage() + "\"}");
+ return;
+ }
+
+ writeJson(ctx, HttpResponseStatus.OK, toJson(result));
+ }
+
+ private void handleGet(ChannelHandlerContext ctx, String taskId) {
+ A2AGatewayService.TaskSnapshot snap = gatewayService.getTaskStatus(taskId);
+ if (snap == null) {
+ writeJson(ctx, HttpResponseStatus.NOT_FOUND,
+ "{\"error\":\"not_found\",\"taskId\":\"" + taskId + "\"}");
+ return;
+ }
+ writeJson(ctx, HttpResponseStatus.OK, snapshotJson(snap));
+ }
+
+ private void handleCancel(ChannelHandlerContext ctx, String taskId) {
+ boolean ok = gatewayService.cancelTask(taskId);
+ if (!ok) {
+ writeJson(ctx, HttpResponseStatus.NOT_FOUND,
+ "{\"error\":\"not_cancellable\",\"taskId\":\"" + taskId + "\"}");
+ return;
+ }
+ writeJson(ctx, HttpResponseStatus.OK, "{\"taskId\":\"" + taskId + "\",\"state\":\"CANCELLED\"}");
+ }
+
+ private void handleList(ChannelHandlerContext ctx, FullHttpRequest req) {
+ QueryStringDecoder qsd = new QueryStringDecoder(req.uri());
+ String stateFilter = null;
+ int limit = 100;
+ int offset = 0;
+ for (Map.Entry> e : qsd.parameters().entrySet()) {
+ if ("state".equalsIgnoreCase(e.getKey()) && !e.getValue().isEmpty()) {
+ stateFilter = e.getValue().get(0);
+ } else if ("limit".equalsIgnoreCase(e.getKey()) && !e.getValue().isEmpty()) {
+ try {
+ limit = Integer.parseInt(e.getValue().get(0));
+ } catch (NumberFormatException ignored) {
+ // keep default limit on invalid input
+ }
+ } else if ("offset".equalsIgnoreCase(e.getKey()) && !e.getValue().isEmpty()) {
+ try {
+ offset = Integer.parseInt(e.getValue().get(0));
+ } catch (NumberFormatException ignored) {
+ // keep default offset on invalid input
+ }
+ }
+ }
+
+ // Build a synthetic list. The persistent store indexes by agent; the gateway filters
+ // its known tasks by state. D2 (issue #5302) will introduce a global index.
+ StringBuilder sb = new StringBuilder("{\"tasks\":[");
+ int count = 0;
+ int skipped = 0;
+ for (var entry : gatewayService.getTaskStore().listByAgent(
+ gatewayService.getGatewayId(), null).stream()
+ .sorted((a, b) -> Long.compare(b.createdAtMs, a.createdAtMs))
+ .toList()) {
+ if (stateFilter != null && !stateFilter.equalsIgnoreCase(
+ A2AGatewayService.toLegacyState(entry.status).name())) {
+ continue;
+ }
+ if (skipped < offset) {
+ skipped++;
+ continue;
+ }
+ if (count >= limit) {
+ break;
+ }
+ if (count > 0) {
+ sb.append(',');
+ }
+ sb.append("{\"taskId\":\"").append(entry.taskId).append("\",")
+ .append("\"state\":\"").append(A2AGatewayService.toLegacyState(entry.status)).append("\",")
+ .append("\"createdAt\":").append(entry.createdAtMs).append('}');
+ count++;
+ }
+ sb.append("],\"totalListed\":").append(count).append('}');
+
+ writeJson(ctx, HttpResponseStatus.OK, sb.toString());
+ }
+
+ private void handleLongPoll(ChannelHandlerContext ctx, FullHttpRequest req) {
+ String uri = req.uri();
+ String taskId = uri.substring("/a2a/tasks/".length(), uri.length() - "/wait".length());
+ A2AGatewayService.TaskSnapshot snap = gatewayService.getTaskStatus(taskId);
+ if (snap == null) {
+ writeJson(ctx, HttpResponseStatus.NOT_FOUND,
+ "{\"error\":\"not_found\",\"taskId\":\"" + taskId + "\"}");
+ return;
+ }
+ if (snap.getRecord().status.ordinal() >= 2) { // COMPLETED, FAILED, CANCELED
+ writeJson(ctx, HttpResponseStatus.OK, snapshotJson(snap));
+ return;
+ }
+ // For long-poll, the SSE endpoint is the more general solution; redirect to it
+ writeJson(ctx, HttpResponseStatus.SEE_OTHER,
+ "{\"hint\":\"use /a2a/tasks/" + taskId + "/stream for live updates\"}");
+ }
+
+ private void handleSse(ChannelHandlerContext ctx, FullHttpRequest req) {
+ String uri = req.uri();
+ String taskId = uri.substring("/a2a/tasks/".length(), uri.length() - "/stream".length());
+ A2AGatewayService.TaskSnapshot snap = gatewayService.getTaskStatus(taskId);
+ if (snap == null) {
+ writeJson(ctx, HttpResponseStatus.NOT_FOUND,
+ "{\"error\":\"not_found\",\"taskId\":\"" + taskId + "\"}");
+ return;
+ }
+
+ DefaultFullHttpResponse headers = new DefaultFullHttpResponse(
+ HttpVersion.HTTP_1_1, HttpResponseStatus.OK,
+ Unpooled.buffer(0));
+ headers.headers().set(HttpHeaderNames.CONTENT_TYPE, "text/event-stream");
+ headers.headers().set(HttpHeaderNames.CACHE_CONTROL, "no-cache");
+ headers.headers().set(HttpHeaderNames.CONNECTION, "keep-alive");
+ ctx.writeAndFlush(headers);
+
+ // Send current state immediately
+ ctx.writeAndFlush(Unpooled.wrappedBuffer(
+ ("data: " + snapshotJson(snap) + "\n\n").getBytes(StandardCharsets.UTF_8)));
+
+ // If terminal, close immediately
+ if (snap.getRecord().status.ordinal() >= 2) {
+ ctx.writeAndFlush(Unpooled.wrappedBuffer(
+ "event: end\ndata: {}\n\n".getBytes(StandardCharsets.UTF_8)))
+ .addListener(ChannelFutureListener.CLOSE);
+ return;
+ }
+
+ // Register subscriber for live updates
+ gatewayService.registerStatusSubscriber(taskId, (id, state, data) -> {
+ String line = "data: {\"taskId\":\"" + id + "\",\"state\":\"" + state + "\"";
+ if (data != null) {
+ line += ",\"data\":" + objectMapper.valueToTree(data).toString();
+ }
+ line += "}\n\n";
+ ChannelFuture f = ctx.writeAndFlush(Unpooled.wrappedBuffer(
+ line.getBytes(StandardCharsets.UTF_8)));
+ if ("completed".equals(state) || "failed".equals(state) || "cancelled".equals(state)) {
+ f.addListener(ChannelFutureListener.CLOSE);
+ }
+ });
+ }
+
+ private void handleHealth(ChannelHandlerContext ctx) {
+ writeJson(ctx, HttpResponseStatus.OK,
+ "{\"status\":\"ok\",\"gatewayId\":\"" + gatewayService.getGatewayId() + "\"}");
+ }
+
+ // =========================================================================
+ // Helpers
+ // =========================================================================
+
+ private void writeJson(ChannelHandlerContext ctx, HttpResponseStatus status, String json) {
+ FullHttpResponse resp = new DefaultFullHttpResponse(
+ HttpVersion.HTTP_1_1, status,
+ Unpooled.wrappedBuffer(json.getBytes(StandardCharsets.UTF_8)));
+ resp.headers().set(HttpHeaderNames.CONTENT_TYPE, "application/json");
+ resp.headers().set(HttpHeaderNames.ACCESS_CONTROL_ALLOW_ORIGIN, "*");
+ resp.headers().setInt(HttpHeaderNames.CONTENT_LENGTH, json.length());
+ ctx.writeAndFlush(resp);
+ }
+
+ private String snapshotJson(A2AGatewayService.TaskSnapshot snap) {
+ return "{"
+ + "\"taskId\":\"" + snap.getRecord().taskId + "\","
+ + "\"state\":\"" + snap.getState() + "\","
+ + "\"targetAgent\":\"" + snap.getRecord().agentId + "\","
+ + "\"createdAt\":" + snap.getRecord().createdAtMs + ","
+ + "\"updatedAt\":" + snap.getRecord().updatedAtMs
+ + (snap.getRecord().output != null
+ ? ",\"output\":" + objectMapper.valueToTree(snap.getRecord().output).toString()
+ : "")
+ + (snap.getParentTaskId() != null
+ ? ",\"parentTaskId\":\"" + snap.getParentTaskId() + "\""
+ : "")
+ + "}";
+ }
+
+ private String toJson(A2AGatewayService.TaskResult r) {
+ return "{\"state\":\"" + r.getState() + "\","
+ + (r.getData() != null
+ ? "\"data\":" + objectMapper.valueToTree(r.getData()).toString() + ","
+ : "")
+ + (r.getErrorMessage() != null
+ ? "\"errorMessage\":\"" + r.getErrorMessage() + "\""
+ : "")
+ + "}";
+ }
+
+ @Override
+ public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) {
+ log.error("Channel exception", cause);
+ ctx.close();
+ }
+}
diff --git a/eventmesh-runtime/src/main/java/org/apache/eventmesh/runtime/a2a/A2AGatewayServer.java b/eventmesh-runtime/src/main/java/org/apache/eventmesh/runtime/a2a/A2AGatewayServer.java
new file mode 100644
index 0000000000..58169db157
--- /dev/null
+++ b/eventmesh-runtime/src/main/java/org/apache/eventmesh/runtime/a2a/A2AGatewayServer.java
@@ -0,0 +1,133 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.eventmesh.runtime.a2a;
+
+import org.apache.eventmesh.protocol.a2a.A2AMessageTransport;
+import org.apache.eventmesh.runtime.state.TaskStore;
+
+import java.util.concurrent.TimeUnit;
+
+import io.netty.bootstrap.ServerBootstrap;
+import io.netty.channel.Channel;
+import io.netty.channel.ChannelInitializer;
+import io.netty.channel.ChannelOption;
+import io.netty.channel.EventLoopGroup;
+import io.netty.channel.nio.NioEventLoopGroup;
+import io.netty.channel.socket.SocketChannel;
+import io.netty.channel.socket.nio.NioServerSocketChannel;
+import io.netty.handler.codec.http.HttpObjectAggregator;
+import io.netty.handler.codec.http.HttpRequestDecoder;
+import io.netty.handler.codec.http.HttpResponseEncoder;
+
+import lombok.extern.slf4j.Slf4j;
+
+/**
+ * A2A Gateway HTTP server.
+ *
+ * Boots an embedded Netty HTTP server that exposes the A2A Gateway REST + SSE API on
+ * the given port. The gateway is constructed with a {@link TaskStore} (Sub-PR A/C), an
+ * {@link AgentCardRegistry} (in-memory D1; Meta-backed D2), and a
+ * {@link A2AMessageTransport} — production wiring is
+ * {@link EventMeshA2ATransport} (Runtime-bridged), tests pass an in-process transport.
+ *
+ * Issue #5302 D1 scope: this class is the new home of the gateway that
+ * PR #5260 originally added
+ * (then deleted in the uni-architecture redesign). The weather-agent demo from PR #5260
+ * has been removed — the gateway no longer pre-registers a mock agent; the demo
+ * client ({@code A2AGatewayDemo}) will be ported in D2 alongside AgentCard Meta-ization.
+ */
+@Slf4j
+public class A2AGatewayServer {
+
+ private final int port;
+ private final A2AMessageTransport transport;
+ private final TaskStore taskStore;
+ private final AgentCardRegistry agentCardRegistry;
+
+ private EventLoopGroup bossGroup;
+ private EventLoopGroup workerGroup;
+ private Channel serverChannel;
+
+ private A2AGatewayService gatewayService;
+ private A2AGatewayHttpHandler gatewayHandler;
+
+ public A2AGatewayServer(int port, A2AMessageTransport transport, TaskStore taskStore,
+ AgentCardRegistry agentCardRegistry) {
+ this.port = port;
+ this.transport = transport;
+ this.taskStore = taskStore;
+ this.agentCardRegistry = agentCardRegistry;
+ }
+
+ public void start() throws Exception {
+ // 1. Initialize components
+ gatewayService = new A2AGatewayService(
+ "global", "gateway-" + port, transport, taskStore, agentCardRegistry);
+ gatewayService.start();
+
+ gatewayHandler = new A2AGatewayHttpHandler(gatewayService);
+
+ // 2. Start Netty HTTP server
+ bossGroup = new NioEventLoopGroup(1);
+ workerGroup = new NioEventLoopGroup();
+
+ ServerBootstrap bootstrap = new ServerBootstrap();
+ bootstrap.group(bossGroup, workerGroup)
+ .channel(NioServerSocketChannel.class)
+ .childHandler(new ChannelInitializer() {
+ @Override
+ protected void initChannel(SocketChannel ch) {
+ ch.pipeline()
+ .addLast(new HttpRequestDecoder())
+ .addLast(new HttpObjectAggregator(65536))
+ .addLast(new HttpResponseEncoder())
+ .addLast(gatewayHandler);
+ }
+ })
+ .option(ChannelOption.SO_BACKLOG, 128)
+ .childOption(ChannelOption.SO_KEEPALIVE, true);
+
+ serverChannel = bootstrap.bind(port).sync().channel();
+ log.info("=== A2A Gateway Server started on port {} ===", port);
+ log.info("Gateway ID: {}, backed by: {}",
+ gatewayService.getGatewayId(), taskStore.getClass().getSimpleName());
+ }
+
+ public void shutdown() throws Exception {
+ if (serverChannel != null) {
+ serverChannel.close().sync();
+ }
+ if (bossGroup != null) {
+ bossGroup.shutdownGracefully(0, 1, TimeUnit.SECONDS);
+ }
+ if (workerGroup != null) {
+ workerGroup.shutdownGracefully(0, 1, TimeUnit.SECONDS);
+ }
+ if (gatewayService != null) {
+ gatewayService.shutdown();
+ }
+ }
+
+ public int getPort() {
+ return port;
+ }
+
+ public A2AGatewayService getGatewayService() {
+ return gatewayService;
+ }
+}
diff --git a/eventmesh-runtime/src/main/java/org/apache/eventmesh/runtime/a2a/A2AGatewayService.java b/eventmesh-runtime/src/main/java/org/apache/eventmesh/runtime/a2a/A2AGatewayService.java
new file mode 100644
index 0000000000..ccc3c3642d
--- /dev/null
+++ b/eventmesh-runtime/src/main/java/org/apache/eventmesh/runtime/a2a/A2AGatewayService.java
@@ -0,0 +1,538 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.eventmesh.runtime.a2a;
+
+import org.apache.eventmesh.protocol.a2a.A2AMessageTransport;
+import org.apache.eventmesh.protocol.a2a.A2AProtocolConstants;
+import org.apache.eventmesh.protocol.a2a.A2ATopicFactory;
+import org.apache.eventmesh.runtime.state.TaskStore;
+import org.apache.eventmesh.runtime.state.TaskStore.Status;
+import org.apache.eventmesh.runtime.state.TaskStore.TaskRecord;
+
+import java.nio.charset.StandardCharsets;
+import java.util.List;
+import java.util.UUID;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.CopyOnWriteArrayList;
+import java.util.concurrent.Executors;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.TimeUnit;
+
+import io.cloudevents.CloudEvent;
+import io.cloudevents.core.builder.CloudEventBuilder;
+
+import lombok.extern.slf4j.Slf4j;
+
+/**
+ * A2A Gateway Service: orchestrates task submission, response handling, and SSE streaming.
+ *
+ * This is the core component that ties together:
+ *
+ * - {@link A2AMessageTransport} for pub/sub (in production: the Runtime-bridged
+ * {@link EventMeshA2ATransport}, NOT a parallel in-memory transport)
+ * - {@link TaskStore} for task lifecycle (issue #5301 Sub-PR A/C; durable across restarts)
+ * - {@link AgentCardRegistry} for agent discovery (in-memory D1; Meta-backed in D2)
+ * - {@link A2ATopicFactory} for topic routing
+ *
+ *
+ * Design (issue #5302): the in-memory {@code TaskRegistry} that
+ * PR #5260 introduced has been
+ * replaced by {@link TaskStore} — the gateway no longer owns a private task table. A
+ * small runtime cache ({@code parentTaskIdCache}, {@code taskEpochCache}) holds fields the
+ * persistent store does not model (parent links, the per-task epoch used for stale-write
+ * rejection). Both caches are rebuildable from the store on a fresh JVM.
+ *
+ * Status mapping (PR #5260 -> Sub-PR A/C):
+ * {@code SUBMITTED -> PENDING}, {@code WORKING -> RUNNING},
+ * {@code CANCELLED -> CANCELED} (one L). The public state names exposed by
+ * {@link A2AGatewayService.TaskState} are the legacy names (kebab-cased protocol JSON); the
+ * internal store uses {@link Status}.
+ */
+@Slf4j
+public class A2AGatewayService {
+
+ private final String namespace;
+ private final String gatewayId;
+ private final A2AMessageTransport transport;
+ private final TaskStore taskStore;
+ private final AgentCardRegistry agentCardRegistry;
+
+ // Pending tasks waiting for response (runtime-only; not persisted)
+ private final ConcurrentHashMap> pendingTasks = new ConcurrentHashMap<>();
+ // SSE subscribers for status updates (runtime-only; rebuilt on stream start)
+ private final ConcurrentHashMap> statusSubscribers = new ConcurrentHashMap<>();
+ // Parent task id cache (TaskStore does not model parent links; rebuilt on recovery)
+ private final ConcurrentHashMap parentTaskIdCache = new ConcurrentHashMap<>();
+ // Per-task epoch cache (TaskStore.updateStatus requires the epoch set at createTask; we
+ // remember it so transition methods don't have to re-read the record)
+ private final ConcurrentHashMap taskEpochCache = new ConcurrentHashMap<>();
+
+ private volatile boolean started = false;
+ private String responseSubscriptionId;
+ private String statusSubscriptionId;
+
+ // Task timeout: tasks that don't receive a response within this duration are auto-failed
+ private static final long DEFAULT_TASK_TIMEOUT_MS = 120_000L; // 2 minutes
+ private final long taskTimeoutMs;
+ private ScheduledExecutorService taskTimeoutScheduler;
+
+ public A2AGatewayService(String namespace, String gatewayId,
+ A2AMessageTransport transport,
+ TaskStore taskStore,
+ AgentCardRegistry agentCardRegistry) {
+ this(namespace, gatewayId, transport, taskStore, agentCardRegistry, DEFAULT_TASK_TIMEOUT_MS);
+ }
+
+ public A2AGatewayService(String namespace, String gatewayId,
+ A2AMessageTransport transport,
+ TaskStore taskStore,
+ AgentCardRegistry agentCardRegistry,
+ long taskTimeoutMs) {
+ this.namespace = namespace;
+ this.gatewayId = gatewayId;
+ this.transport = transport;
+ this.taskStore = taskStore;
+ this.agentCardRegistry = agentCardRegistry;
+ this.taskTimeoutMs = taskTimeoutMs;
+ }
+
+ public String getGatewayId() {
+ return gatewayId;
+ }
+
+ public String getNamespace() {
+ return namespace;
+ }
+
+ public AgentCardRegistry getAgentCardRegistry() {
+ return agentCardRegistry;
+ }
+
+ public TaskStore getTaskStore() {
+ return taskStore;
+ }
+
+ /**
+ * Starts the gateway service: subscribes to gateway response/status topics.
+ */
+ public synchronized void start() throws Exception {
+ if (started) {
+ return;
+ }
+
+ // Start task timeout scheduler
+ taskTimeoutScheduler = Executors.newSingleThreadScheduledExecutor(r -> {
+ Thread t = new Thread(r, "a2a-task-timeout");
+ t.setDaemon(true);
+ return t;
+ });
+
+ // Subscribe to all responses for this gateway
+ String responseTopic = A2ATopicFactory.gatewayResponseWildcardTopic(namespace, gatewayId);
+ responseSubscriptionId = transport.subscribe(responseTopic, this::handleResponse);
+
+ // Subscribe to all status updates for this gateway
+ String statusTopic = A2ATopicFactory.gatewayStatusWildcardTopic(namespace, gatewayId);
+ statusSubscriptionId = transport.subscribe(statusTopic, this::handleStatus);
+
+ started = true;
+ log.info("A2AGatewayService started: gatewayId={}, namespace={}", gatewayId, namespace);
+ }
+
+ public synchronized void shutdown() throws Exception {
+ if (!started) {
+ return;
+ }
+ if (responseSubscriptionId != null) {
+ transport.unsubscribe(responseSubscriptionId);
+ }
+ if (statusSubscriptionId != null) {
+ transport.unsubscribe(statusSubscriptionId);
+ }
+ if (taskTimeoutScheduler != null) {
+ taskTimeoutScheduler.shutdownNow();
+ taskTimeoutScheduler = null;
+ }
+ pendingTasks.clear();
+ statusSubscribers.clear();
+ parentTaskIdCache.clear();
+ taskEpochCache.clear();
+ started = false;
+ log.info("A2AGatewayService shutdown.");
+ }
+
+ // =========================================================================
+ // Task Submission
+ // =========================================================================
+
+ /**
+ * Submits an A2A task to a target agent with an auto-generated task id.
+ */
+ public CompletableFuture submitTask(String targetAgent, String message, String parentTaskId) {
+ String taskId = generateTaskId();
+ return submitTask(taskId, targetAgent, message, parentTaskId);
+ }
+
+ /**
+ * Submits an A2A task with a specific task id. The target agent must be registered in
+ * the {@link AgentCardRegistry}.
+ */
+ public CompletableFuture submitTask(String taskId, String targetAgent,
+ String message, String parentTaskId) {
+ if (!started) {
+ CompletableFuture future = new CompletableFuture<>();
+ future.completeExceptionally(new IllegalStateException("Gateway not started"));
+ return future;
+ }
+
+ // Validate that the target agent is registered
+ if (!agentCardRegistry.isAgentRegistered(targetAgent)) {
+ CompletableFuture future = new CompletableFuture<>();
+ future.completeExceptionally(new IllegalArgumentException(
+ "Target agent not registered: " + targetAgent));
+ return future;
+ }
+
+ // Create task in the persistent store. createTask returns null on duplicate taskId.
+ TaskRecord rec = taskStore.createTask(taskId, targetAgent, gatewayId, message);
+ if (rec == null) {
+ CompletableFuture future = new CompletableFuture<>();
+ future.completeExceptionally(new IllegalStateException("Duplicate taskId: " + taskId));
+ return future;
+ }
+ if (parentTaskId != null) {
+ parentTaskIdCache.put(taskId, parentTaskId);
+ }
+ taskEpochCache.put(taskId, rec.taskEpoch);
+
+ // Build A2A CloudEvent
+ CloudEvent event = buildTaskRequestEvent(taskId, targetAgent, message, parentTaskId);
+
+ // Register pending future BEFORE publishing: a synchronous transport callback (e.g. a
+ // local in-memory test) could deliver the response before submitTask returns, and we
+ // would lose the future if put() ran after handleResponse().
+ CompletableFuture future = new CompletableFuture<>();
+ pendingTasks.put(taskId, future);
+
+ // Schedule timeout: if no response within taskTimeoutMs, auto-fail the task
+ if (taskTimeoutScheduler != null) {
+ taskTimeoutScheduler.schedule(() -> {
+ CompletableFuture pending = pendingTasks.get(taskId);
+ if (pending != null && !pending.isDone()) {
+ String errMsg = "Task timed out after " + taskTimeoutMs + "ms with no response";
+ Long epoch = taskEpochCache.get(taskId);
+ if (epoch != null) {
+ taskStore.updateStatus(taskId, epoch, Status.FAILED, errMsg);
+ }
+ pendingTasks.remove(taskId);
+ pending.completeExceptionally(new java.util.concurrent.TimeoutException(errMsg));
+ notifyStatusSubscribers(taskId, "failed", errMsg);
+ log.warn("Task timed out: taskId={}, targetAgent={}", taskId, targetAgent);
+ }
+ }, taskTimeoutMs, TimeUnit.MILLISECONDS);
+ }
+
+ // Publish to agent request topic
+ String requestTopic = A2ATopicFactory.agentRequestTopic(namespace, targetAgent);
+ try {
+ transport.publish(requestTopic, event);
+ log.info("Task submitted: taskId={}, targetAgent={}, topic={}", taskId, targetAgent, requestTopic);
+ } catch (Exception e) {
+ log.error("Failed to publish task: taskId={}", taskId, e);
+ Long epoch = taskEpochCache.remove(taskId);
+ if (epoch != null) {
+ taskStore.updateStatus(taskId, epoch, Status.FAILED, "Publish failed: " + e.getMessage());
+ }
+ pendingTasks.remove(taskId);
+ future.completeExceptionally(e);
+ }
+
+ return future;
+ }
+
+ /**
+ * Cancels a task. Idempotent: cancelling a non-PENDING/RUNNING task is a no-op.
+ */
+ public boolean cancelTask(String taskId) {
+ Long epoch = taskEpochCache.get(taskId);
+ if (epoch == null) {
+ return false;
+ }
+ TaskRecord rec = taskStore.getTask(taskId);
+ if (rec == null) {
+ return false;
+ }
+ if (rec.status == Status.COMPLETED || rec.status == Status.FAILED || rec.status == Status.CANCELED) {
+ return false;
+ }
+ boolean ok = taskStore.updateStatus(taskId, epoch, Status.CANCELED, null);
+ if (ok) {
+ CompletableFuture future = pendingTasks.remove(taskId);
+ if (future != null) {
+ future.complete(new TaskResult(TaskState.CANCELLED, null, "Task cancelled"));
+ }
+ notifyStatusSubscribers(taskId, "cancelled", "Task cancelled");
+ log.info("Task cancelled: taskId={}", taskId);
+ }
+ return ok;
+ }
+
+ /**
+ * Gets task status (a snapshot from the persistent store).
+ */
+ public TaskSnapshot getTaskStatus(String taskId) {
+ TaskRecord rec = taskStore.getTask(taskId);
+ if (rec == null) {
+ return null;
+ }
+ String parentId = parentTaskIdCache.get(taskId);
+ return new TaskSnapshot(rec, parentId);
+ }
+
+ /**
+ * Lists child task ids of a parent task, scanning the runtime cache. The persistent
+ * {@link TaskStore} does not model parent-child relations; this list reflects the
+ * gateway-local view and is rebuilt on a fresh JVM by replaying pendingTasks / scanning
+ * parentTaskIdCache (see issue #5302 D2 — the Meta-ized TaskStore should grow a
+ * parent index in a follow-up).
+ */
+ public List getChildTasks(String parentTaskId) {
+ List children = new java.util.ArrayList<>();
+ for (var entry : parentTaskIdCache.entrySet()) {
+ if (parentTaskId.equals(entry.getValue())) {
+ children.add(entry.getKey());
+ }
+ }
+ return children;
+ }
+
+ // =========================================================================
+ // SSE Status Subscription
+ // =========================================================================
+
+ /**
+ * Registers a subscriber for status updates on a specific task. The subscriber is invoked
+ * on whichever thread completes the task transition (transport callback, timeout scheduler,
+ * or a cancel).
+ */
+ public void registerStatusSubscriber(String taskId, StatusSubscriber subscriber) {
+ statusSubscribers.computeIfAbsent(taskId, k -> new CopyOnWriteArrayList<>()).add(subscriber);
+ }
+
+ /**
+ * Removes a status subscriber. If the subscriber list becomes empty, the task entry is
+ * removed entirely so the map does not grow unbounded.
+ */
+ public void unregisterStatusSubscriber(String taskId, StatusSubscriber subscriber) {
+ List subs = statusSubscribers.get(taskId);
+ if (subs != null) {
+ subs.remove(subscriber);
+ if (subs.isEmpty()) {
+ statusSubscribers.remove(taskId);
+ }
+ }
+ }
+
+ // =========================================================================
+ // Response / Status Handling
+ // =========================================================================
+
+ private void handleResponse(String topic, CloudEvent event) {
+ A2ATopicFactory.ParsedTopic parsed = A2ATopicFactory.parse(topic);
+ if (parsed == null || !parsed.isResponse() || parsed.getTaskId() == null) {
+ return;
+ }
+ String taskId = parsed.getTaskId();
+ log.info("Received response for task: {}", taskId);
+
+ TaskRecord rec = taskStore.getTask(taskId);
+ if (rec == null) {
+ log.warn("Response received for unknown task: {}", taskId);
+ return;
+ }
+
+ String resultData = extractEventData(event);
+ taskStore.updateStatus(taskId, rec.taskEpoch, Status.COMPLETED, resultData);
+
+ CompletableFuture future = pendingTasks.remove(taskId);
+ if (future != null) {
+ future.complete(new TaskResult(TaskState.COMPLETED, resultData, null));
+ }
+
+ // Notify SSE subscribers
+ notifyStatusSubscribers(taskId, "completed", resultData);
+ }
+
+ private void handleStatus(String topic, CloudEvent event) {
+ A2ATopicFactory.ParsedTopic parsed = A2ATopicFactory.parse(topic);
+ if (parsed == null || !parsed.isStatus() || parsed.getTaskId() == null) {
+ return;
+ }
+ String taskId = parsed.getTaskId();
+ String statusData = extractEventData(event);
+ log.debug("Received status for task: {} -> {}", taskId, statusData);
+
+ // Mark as RUNNING if currently PENDING. Idempotent: if the task is already terminal
+ // or RUNNING, updateStatus with the same epoch is a no-op for status but updates
+ // updatedAtMs — which we want for the timeout clock.
+ TaskRecord rec = taskStore.getTask(taskId);
+ if (rec != null && rec.status == Status.PENDING) {
+ taskStore.updateStatus(taskId, rec.taskEpoch, Status.RUNNING, null);
+ }
+
+ // Notify SSE subscribers
+ notifyStatusSubscribers(taskId, "working", statusData);
+ }
+
+ private void notifyStatusSubscribers(String taskId, String state, String data) {
+ List subs = statusSubscribers.get(taskId);
+ if (subs != null) {
+ for (StatusSubscriber sub : subs) {
+ try {
+ sub.onStatus(taskId, state, data);
+ } catch (Exception e) {
+ log.warn("Status subscriber error for task {}: {}", taskId, e.getMessage());
+ }
+ }
+ }
+ }
+
+ // =========================================================================
+ // CloudEvent Building
+ // =========================================================================
+
+ private CloudEvent buildTaskRequestEvent(String taskId, String targetAgent,
+ String message, String parentTaskId) {
+ CloudEventBuilder builder = CloudEventBuilder.v1()
+ .withId(taskId)
+ .withType(A2AProtocolConstants.CE_TYPE_PREFIX + "task.request")
+ .withSource(java.net.URI.create("gateway/" + gatewayId))
+ .withDataContentType("application/json")
+ .withData(message.getBytes(StandardCharsets.UTF_8))
+ .withExtension(A2AProtocolConstants.CE_EXTENSION_A2A_METHOD, A2AProtocolConstants.OP_SEND_MESSAGE)
+ .withExtension(A2AProtocolConstants.CE_EXTENSION_TARGET_AGENT, targetAgent)
+ .withExtension(A2AProtocolConstants.CE_EXTENSION_PROTOCOL, "A2A")
+ .withExtension(A2AProtocolConstants.CE_EXTENSION_PROTOCOL_VERSION, A2AProtocolConstants.PROTOCOL_VERSION);
+
+ if (parentTaskId != null) {
+ builder.withExtension(A2AProtocolConstants.CE_EXTENSION_COLLABORATION_ID, parentTaskId);
+ }
+
+ return builder.build();
+ }
+
+ private String extractEventData(CloudEvent event) {
+ if (event.getData() == null) {
+ return null;
+ }
+ return new String(event.getData().toBytes(), StandardCharsets.UTF_8);
+ }
+
+ private String generateTaskId() {
+ return "task-" + UUID.randomUUID().toString().substring(0, 8);
+ }
+
+ // =========================================================================
+ // Result Types
+ // =========================================================================
+
+ /**
+ * Legacy state names exposed to the A2A wire protocol and JSON response payloads. The
+ * persistent store uses {@link Status}; this enum is the on-the-wire vocabulary.
+ */
+ public enum TaskState {
+ SUBMITTED,
+ WORKING,
+ COMPLETED,
+ FAILED,
+ CANCELLED
+ }
+
+ public static class TaskResult {
+
+ private final TaskState state;
+ private final String data;
+ private final String errorMessage;
+
+ public TaskResult(TaskState state, String data, String errorMessage) {
+ this.state = state;
+ this.data = data;
+ this.errorMessage = errorMessage;
+ }
+
+ public TaskState getState() {
+ return state;
+ }
+
+ public String getData() {
+ return data;
+ }
+
+ public String getErrorMessage() {
+ return errorMessage;
+ }
+ }
+
+ /**
+ * A snapshot of a task — the persisted record plus the runtime-only parent link.
+ */
+ public static class TaskSnapshot {
+ private final TaskRecord record;
+ private final String parentTaskId;
+
+ public TaskSnapshot(TaskRecord record, String parentTaskId) {
+ this.record = record;
+ this.parentTaskId = parentTaskId;
+ }
+
+ public TaskRecord getRecord() {
+ return record;
+ }
+
+ public String getParentTaskId() {
+ return parentTaskId;
+ }
+
+ public TaskState getState() {
+ return toLegacyState(record.status);
+ }
+ }
+
+ /**
+ * Maps the persistent {@link Status} to the legacy A2A wire vocabulary.
+ */
+ public static TaskState toLegacyState(Status s) {
+ switch (s) {
+ case PENDING: return TaskState.SUBMITTED;
+ case RUNNING: return TaskState.WORKING;
+ case COMPLETED: return TaskState.COMPLETED;
+ case FAILED: return TaskState.FAILED;
+ case CANCELED: return TaskState.CANCELLED;
+ default: throw new IllegalStateException("Unknown status: " + s);
+ }
+ }
+
+ /**
+ * Callback interface for task status change notifications.
+ */
+ @FunctionalInterface
+ public interface StatusSubscriber {
+ void onStatus(String taskId, String state, String data);
+ }
+}
diff --git a/eventmesh-runtime/src/main/java/org/apache/eventmesh/runtime/a2a/AgentCardRegistry.java b/eventmesh-runtime/src/main/java/org/apache/eventmesh/runtime/a2a/AgentCardRegistry.java
new file mode 100644
index 0000000000..2d8b157257
--- /dev/null
+++ b/eventmesh-runtime/src/main/java/org/apache/eventmesh/runtime/a2a/AgentCardRegistry.java
@@ -0,0 +1,55 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.eventmesh.runtime.a2a;
+
+import org.apache.eventmesh.protocol.a2a.AgentIdentity;
+import org.apache.eventmesh.protocol.a2a.model.AgentCard;
+
+/**
+ * Agent-card registry. Holds the discovery table that A2A clients consult before submitting
+ * a task (so an unregistered agent cannot receive a message).
+ *
+ * Issue #5302 D1 scope: only the in-memory implementation
+ * ({@link InMemoryAgentCardRegistry}) is provided. A Meta-backed implementation is the
+ * subject of Sub-PR D2 — it will reuse {@code org.apache.eventmesh.runtime.state.SessionStore}
+ * (Sub-PR A) for cluster-shared agent registration with prefix-watch invalidation.
+ */
+public interface AgentCardRegistry {
+
+ /**
+ * Registers a card under the given identity. If a card is already registered for the
+ * same identity, the existing entry is replaced.
+ */
+ void registerCard(AgentIdentity id, AgentCard card);
+
+ /**
+ * Removes a card by identity. Returns {@code true} if a card was removed.
+ */
+ boolean removeCard(AgentIdentity id);
+
+ /**
+ * @return {@code true} if a card is registered for the given agent name (any identity
+ * tuple whose {@code agentId} field matches).
+ */
+ boolean isAgentRegistered(String agentName);
+
+ /**
+ * Looks up a card by agent name.
+ */
+ AgentCard getCard(String agentName);
+}
diff --git a/eventmesh-runtime/src/main/java/org/apache/eventmesh/runtime/a2a/InMemoryAgentCardRegistry.java b/eventmesh-runtime/src/main/java/org/apache/eventmesh/runtime/a2a/InMemoryAgentCardRegistry.java
new file mode 100644
index 0000000000..323902ede9
--- /dev/null
+++ b/eventmesh-runtime/src/main/java/org/apache/eventmesh/runtime/a2a/InMemoryAgentCardRegistry.java
@@ -0,0 +1,59 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.eventmesh.runtime.a2a;
+
+import org.apache.eventmesh.protocol.a2a.AgentIdentity;
+import org.apache.eventmesh.protocol.a2a.model.AgentCard;
+
+import java.util.concurrent.ConcurrentHashMap;
+
+import lombok.extern.slf4j.Slf4j;
+
+/**
+ * In-memory {@link AgentCardRegistry}. Suitable for tests and single-process demos; not
+ * cluster-safe (a fresh JVM starts with an empty registry).
+ *
+ * Sub-PR D2 will add a Meta-backed implementation that survives restarts and is shared
+ * across Runtime instances.
+ */
+@Slf4j
+public class InMemoryAgentCardRegistry implements AgentCardRegistry {
+
+ private final ConcurrentHashMap cardsByAgentId = new ConcurrentHashMap<>();
+
+ @Override
+ public void registerCard(AgentIdentity id, AgentCard card) {
+ cardsByAgentId.put(id.getAgentId(), card);
+ log.info("Registered agent card: agentId={}", id.getAgentId());
+ }
+
+ @Override
+ public boolean removeCard(AgentIdentity id) {
+ return cardsByAgentId.remove(id.getAgentId()) != null;
+ }
+
+ @Override
+ public boolean isAgentRegistered(String agentName) {
+ return cardsByAgentId.containsKey(agentName);
+ }
+
+ @Override
+ public AgentCard getCard(String agentName) {
+ return cardsByAgentId.get(agentName);
+ }
+}
diff --git a/eventmesh-runtime/src/test/java/org/apache/eventmesh/runtime/a2a/A2AGatewayServiceTest.java b/eventmesh-runtime/src/test/java/org/apache/eventmesh/runtime/a2a/A2AGatewayServiceTest.java
new file mode 100644
index 0000000000..dcdd7b5ad9
--- /dev/null
+++ b/eventmesh-runtime/src/test/java/org/apache/eventmesh/runtime/a2a/A2AGatewayServiceTest.java
@@ -0,0 +1,333 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.eventmesh.runtime.a2a;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import org.apache.eventmesh.protocol.a2a.AgentIdentity;
+import org.apache.eventmesh.protocol.a2a.model.AgentCapabilities;
+import org.apache.eventmesh.protocol.a2a.model.AgentCard;
+import org.apache.eventmesh.protocol.a2a.model.AgentInterface;
+import org.apache.eventmesh.protocol.a2a.model.AgentSkill;
+import org.apache.eventmesh.runtime.a2a.A2AGatewayService.TaskResult;
+import org.apache.eventmesh.runtime.a2a.A2AGatewayService.TaskSnapshot;
+import org.apache.eventmesh.runtime.a2a.A2AGatewayService.TaskState;
+import org.apache.eventmesh.runtime.state.TaskStore;
+import org.apache.eventmesh.runtime.state.TaskStore.Status;
+import org.apache.eventmesh.runtime.state.TaskStore.TaskRecord;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicLong;
+
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import io.cloudevents.CloudEvent;
+
+/**
+ * Sub-PR D1: A2AGatewayService is wired to {@link TaskStore} (Sub-PR A/C) instead of
+ * the in-memory {@code TaskRegistry} from PR #5260. These tests exercise the
+ * gateway's task lifecycle against an in-process TaskStore that mirrors the
+ * baseline contract test in {@code TaskStoreTest}.
+ */
+class A2AGatewayServiceTest {
+
+ /** In-process TaskStore mirroring the Sub-PR A test stub. */
+ static final class InProcessTaskStore implements TaskStore {
+ private final ConcurrentHashMap table = new ConcurrentHashMap<>();
+ private final AtomicLong epoch = new AtomicLong();
+
+ @Override
+ public TaskRecord createTask(String taskId, String agentId, String clientId, String input) {
+ long now = System.currentTimeMillis();
+ long e = epoch.incrementAndGet();
+ TaskRecord rec = new TaskRecord(taskId, agentId, clientId, Status.PENDING, now, now, input, null, e);
+ return table.putIfAbsent(taskId, rec) == null ? rec : null;
+ }
+
+ @Override
+ public TaskRecord getTask(String taskId) {
+ return table.get(taskId);
+ }
+
+ @Override
+ public boolean updateStatus(String taskId, long expectedTaskEpoch, Status newStatus, String output) {
+ TaskRecord rec = table.get(taskId);
+ if (rec == null || rec.taskEpoch != expectedTaskEpoch) {
+ return false;
+ }
+ rec.status = newStatus;
+ rec.updatedAtMs = System.currentTimeMillis();
+ rec.output = output;
+ return true;
+ }
+
+ @Override
+ public List listByAgent(String agentId, Status statusFilter) {
+ List out = new ArrayList<>();
+ for (TaskRecord r : table.values()) {
+ if (!r.agentId.equals(agentId)) {
+ continue;
+ }
+ if (statusFilter != null && r.status != statusFilter) {
+ continue;
+ }
+ out.add(r);
+ }
+ return out;
+ }
+
+ @Override
+ public List expireStale(long olderThanMs) {
+ long deadline = System.currentTimeMillis() - olderThanMs;
+ List expired = new ArrayList<>();
+ for (TaskRecord r : table.values()) {
+ if (r.updatedAtMs < deadline) {
+ expired.add(r.taskId);
+ }
+ }
+ for (String id : expired) {
+ table.remove(id);
+ }
+ return expired;
+ }
+
+ @Override
+ public void flush() { }
+
+ @Override
+ public void close() {
+ table.clear();
+ }
+ }
+
+ /** In-process pub/sub transport: publish delivers synchronously to subscribers on the same topic. */
+ static final class InProcessTransport implements org.apache.eventmesh.protocol.a2a.A2AMessageTransport {
+ final ConcurrentHashMap subs =
+ new ConcurrentHashMap<>();
+
+ @Override
+ public void publish(String topic, CloudEvent event) {
+ for (var entry : subs.entrySet()) {
+ if (matches(topic, entry.getKey())) {
+ entry.getValue().onMessage(topic, event);
+ }
+ }
+ }
+
+ @Override
+ public String subscribe(String topicPattern,
+ org.apache.eventmesh.protocol.a2a.A2AMessageTransport.MessageCallback callback) {
+ subs.put(topicPattern, callback);
+ return "sub-" + topicPattern;
+ }
+
+ @Override
+ public void unsubscribe(String subscriptionId) {
+ subs.remove(subscriptionId.replace("sub-", ""));
+ }
+
+ private boolean matches(String topic, String pattern) {
+ if (pattern.equals(topic)) {
+ return true;
+ }
+ // A2A topics use Pulsar/MQTT-style wildcards: + matches one path segment, *
+ // matches one or more. Convert to a regex for in-process delivery.
+ String regex = pattern
+ .replace("+", "[^/]+")
+ .replace("*", "[^/]+");
+ return topic.matches(regex);
+ }
+ }
+
+ private A2AGatewayService gateway;
+ private InMemoryAgentCardRegistry registry;
+ private InProcessTransport transport;
+ private InProcessTaskStore store;
+ private String testAgentName;
+
+ @BeforeEach
+ void setUp() throws Exception {
+ transport = new InProcessTransport();
+ store = new InProcessTaskStore();
+ registry = new InMemoryAgentCardRegistry();
+
+ testAgentName = "echo-agent-" + System.nanoTime();
+ registerEchoAgent(testAgentName);
+
+ gateway = new A2AGatewayService("global", "test-gateway", transport, store, registry, 5000L);
+ gateway.start();
+ }
+
+ @AfterEach
+ void tearDown() throws Exception {
+ if (gateway != null) {
+ gateway.shutdown();
+ }
+ if (store != null) {
+ store.close();
+ }
+ }
+
+ private void registerEchoAgent(String name) throws Exception {
+ AgentCard card = AgentCard.builder()
+ .name(name)
+ .description("Echoes the input back as a response")
+ .version("1.0.0")
+ .supportedInterfaces(Arrays.asList(AgentInterface.builder()
+ .url("http://localhost:0/a2a")
+ .protocolBinding("JSONRPC")
+ .protocolVersion("0.3")
+ .build()))
+ .capabilities(AgentCapabilities.builder().streaming(false).pushNotifications(false).build())
+ .skills(Arrays.asList(AgentSkill.builder()
+ .id("echo").name("Echo").description("Echoes input")
+ .tags(Arrays.asList("test", "echo")).build()))
+ .defaultInputModes(Arrays.asList("text/plain"))
+ .defaultOutputModes(Arrays.asList("text/plain"))
+ .build();
+ AgentIdentity id = AgentIdentity.builder().orgId("default").unitId("default").agentId(name).build();
+ registry.registerCard(id, card);
+
+ // Subscribe to the agent's request topic and echo back as a response.
+ String requestTopic = org.apache.eventmesh.protocol.a2a.A2ATopicFactory
+ .agentRequestTopic("global", name);
+ transport.subscribe(requestTopic, (topic, event) -> {
+ String taskId = event.getId();
+ try {
+ String respTopic = org.apache.eventmesh.protocol.a2a.A2ATopicFactory
+ .gatewayResponseTopic("global", "test-gateway", taskId);
+ io.cloudevents.CloudEvent resp = io.cloudevents.core.builder.CloudEventBuilder.v1()
+ .withId(taskId)
+ .withType("org.apache.eventmesh.protocol.a2a.task.response")
+ .withSource(java.net.URI.create("agent/" + name))
+ .withDataContentType("application/json")
+ .withData(("{\"echo\":\"" + new String(event.getData().toBytes(),
+ java.nio.charset.StandardCharsets.UTF_8) + "\"}")
+ .getBytes(java.nio.charset.StandardCharsets.UTF_8))
+ .build();
+ transport.publish(respTopic, resp);
+ } catch (Exception e) {
+ throw new RuntimeException(e);
+ }
+ });
+ }
+
+ @Test
+ void submitTaskCreatesPendingRecord() throws Exception {
+ CompletableFuture f = gateway.submitTask(testAgentName, "{\"q\":\"hi\"}", null);
+ // The synchronous echo agent delivers a response almost immediately; wait for it.
+ TaskResult r = f.get(2, TimeUnit.SECONDS);
+ assertEquals(TaskState.COMPLETED, r.getState());
+
+ // The task record should now be COMPLETED in the persistent store.
+ TaskSnapshot snap = gateway.getTaskStatus("never-existed-task");
+ assertNull(snap);
+
+ // Verify at least one task was created and is COMPLETED.
+ List tasks = store.listByAgent(testAgentName, null);
+ assertEquals(1, tasks.size());
+ assertEquals(Status.COMPLETED, tasks.get(0).status);
+ assertNotNull(tasks.get(0).output);
+ }
+
+ @Test
+ void cancelMarksTaskCanceled() throws Exception {
+ // Submit to a non-echo agent (no auto-responder) to keep the task in PENDING.
+ String silentAgent = "silent-" + System.nanoTime();
+ AgentCard card = AgentCard.builder()
+ .name(silentAgent).description("Silent").version("1.0.0")
+ .supportedInterfaces(Arrays.asList(AgentInterface.builder()
+ .url("http://localhost:0/a2a").protocolBinding("JSONRPC").protocolVersion("0.3").build()))
+ .capabilities(AgentCapabilities.builder().streaming(false).pushNotifications(false).build())
+ .skills(new ArrayList<>())
+ .defaultInputModes(Arrays.asList("text/plain"))
+ .defaultOutputModes(Arrays.asList("text/plain"))
+ .build();
+ registry.registerCard(
+ AgentIdentity.builder().orgId("default").unitId("default").agentId(silentAgent).build(), card);
+
+ // submit and then cancel before the timeout fires
+ String taskId = "task-cancel-" + System.nanoTime();
+ CompletableFuture f = gateway.submitTask(taskId, silentAgent, "{}", null);
+ assertTrue(gateway.cancelTask(taskId), "cancel should succeed on a PENDING task");
+ // The future should be completed with a CANCELLED result
+ TaskResult r = f.get(1, TimeUnit.SECONDS);
+ assertEquals(TaskState.CANCELLED, r.getState());
+
+ // The store should now reflect CANCELED
+ TaskRecord rec = store.getTask(taskId);
+ assertNotNull(rec);
+ assertEquals(Status.CANCELED, rec.status);
+ }
+
+ @Test
+ void cancelOnUnknownTaskIsNoop() {
+ assertFalse(gateway.cancelTask("task-never-existed"));
+ }
+
+ @Test
+ void submitToUnregisteredAgentFails() {
+ CompletableFuture f = gateway.submitTask("ghost-agent", "{}", null);
+ org.junit.jupiter.api.Assertions.assertThrows(java.util.concurrent.ExecutionException.class,
+ () -> f.get(1, TimeUnit.SECONDS));
+ }
+
+ @Test
+ void parentChildIndexIsTrackedInRuntimeCache() throws Exception {
+ String silentAgent = "silent-pc-" + System.nanoTime();
+ AgentCard card = AgentCard.builder()
+ .name(silentAgent).description("Silent pc").version("1.0.0")
+ .supportedInterfaces(Arrays.asList(AgentInterface.builder()
+ .url("http://localhost:0/a2a").protocolBinding("JSONRPC").protocolVersion("0.3").build()))
+ .capabilities(AgentCapabilities.builder().streaming(false).pushNotifications(false).build())
+ .skills(new ArrayList<>())
+ .defaultInputModes(Arrays.asList("text/plain"))
+ .defaultOutputModes(Arrays.asList("text/plain"))
+ .build();
+ registry.registerCard(
+ AgentIdentity.builder().orgId("default").unitId("default").agentId(silentAgent).build(), card);
+
+ String parent = "parent-" + System.nanoTime();
+ String child1 = "child1-" + System.nanoTime();
+ String child2 = "child2-" + System.nanoTime();
+ gateway.submitTask(parent, silentAgent, "{}", null);
+ gateway.submitTask(child1, silentAgent, "{}", parent);
+ gateway.submitTask(child2, silentAgent, "{}", parent);
+
+ List children = gateway.getChildTasks(parent);
+ assertEquals(2, children.size());
+ assertTrue(children.contains(child1));
+ assertTrue(children.contains(child2));
+ }
+
+ @Test
+ void taskNotFoundInStore() {
+ assertNull(gateway.getTaskStatus("never-existed"));
+ }
+}
diff --git a/eventmesh-runtime/src/test/java/org/apache/eventmesh/runtime/a2a/A2AGatewaySmokeTest.java b/eventmesh-runtime/src/test/java/org/apache/eventmesh/runtime/a2a/A2AGatewaySmokeTest.java
new file mode 100644
index 0000000000..acf21c79e8
--- /dev/null
+++ b/eventmesh-runtime/src/test/java/org/apache/eventmesh/runtime/a2a/A2AGatewaySmokeTest.java
@@ -0,0 +1,143 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.eventmesh.runtime.a2a;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+
+import org.apache.eventmesh.runtime.state.TaskStore;
+
+import java.net.HttpURLConnection;
+import java.net.URI;
+import java.net.URL;
+import java.nio.charset.StandardCharsets;
+import java.util.Collections;
+
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import io.cloudevents.CloudEvent;
+
+/**
+ * Sub-PR D1: smoke-test the A2A Gateway HTTP server boot path. Boots the Netty
+ * server on a fixed port, hits the /a2a/health endpoint over loopback,
+ * asserts 200 OK, then shuts the server down.
+ */
+class A2AGatewaySmokeTest {
+
+ private A2AGatewayServer server;
+ private int port;
+
+ @BeforeEach
+ void setUp() throws Exception {
+ // Use a high port to avoid collisions with system services.
+ port = 18080;
+ server = new A2AGatewayServer(port, new NoopTransport(), new StubTaskStore(),
+ new InMemoryAgentCardRegistry());
+ server.start();
+ }
+
+ @AfterEach
+ void tearDown() throws Exception {
+ if (server != null) {
+ server.shutdown();
+ }
+ }
+
+ @Test
+ void healthEndpointRespondsOk() throws Exception {
+ URL url = URI.create("http://127.0.0.1:" + port + "/a2a/health").toURL();
+ HttpURLConnection conn = (HttpURLConnection) url.openConnection();
+ conn.setRequestMethod("GET");
+ conn.setConnectTimeout(2000);
+ conn.setReadTimeout(2000);
+ int code = conn.getResponseCode();
+ assertEquals(200, code, "health endpoint must return 200");
+ byte[] body = conn.getInputStream().readAllBytes();
+ String s = new String(body, StandardCharsets.UTF_8);
+ assertNotNull(s);
+ assertEquals(true, s.contains("\"status\":\"ok\""));
+ }
+
+ /**
+ * Stub {@link TaskStore} returning null / empty for every call. Used only to satisfy the
+ * gateway's constructor; the smoke test never submits a task, so the stub's behavior is
+ * never exercised.
+ */
+ static final class StubTaskStore implements TaskStore {
+
+ @Override
+ public TaskRecord createTask(String taskId, String agentId, String clientId, String input) {
+ return null;
+ }
+
+ @Override
+ public TaskRecord getTask(String taskId) {
+ return null;
+ }
+
+ @Override
+ public boolean updateStatus(String taskId, long expectedTaskEpoch,
+ TaskStore.Status newStatus, String output) {
+ return false;
+ }
+
+ @Override
+ public java.util.List listByAgent(String agentId, TaskStore.Status statusFilter) {
+ return Collections.emptyList();
+ }
+
+ @Override
+ public java.util.List expireStale(long olderThanMs) {
+ return Collections.emptyList();
+ }
+
+ @Override
+ public void flush() {
+ // no buffered writes
+ }
+
+ @Override
+ public void close() {
+ // nothing to release
+ }
+ }
+
+ /**
+ * No-op A2AMessageTransport: every publish is dropped, every subscribe returns a constant id.
+ */
+ static final class NoopTransport implements org.apache.eventmesh.protocol.a2a.A2AMessageTransport {
+
+ @Override
+ public void publish(String topic, CloudEvent event) {
+ // drop
+ }
+
+ @Override
+ public String subscribe(String topicPattern,
+ org.apache.eventmesh.protocol.a2a.A2AMessageTransport.MessageCallback callback) {
+ return "noop";
+ }
+
+ @Override
+ public void unsubscribe(String subscriptionId) {
+ // no-op
+ }
+ }
+}