diff --git a/.gitignore b/.gitignore
index a20568a..02608ab 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,5 +1,6 @@
### Maven/Gradle Builds ###
target/
+.m2repo/
!.mvn/wrapper/maven-wrapper.jar
!**/src/main/**/target/
!**/src/test/**/target/
@@ -46,6 +47,12 @@ build.log
shell.log
derby.log
+### Environment Files ###
+.env
+.env.*
+**/.env
+**/.env.*
+
### Compiled Files ###
*.class
@@ -57,6 +64,7 @@ derby.log
*.zip
*.tar.gz
*.rar
+node_modules/
### Claude Code ###
.claude/
@@ -68,5 +76,7 @@ hs_err_pid*
replay_pid*
### Planning and Internal Documentation ###
-plans/
+plans/*
+!plans/STREAMABLE-HTTP-TRANSPORT.md
+!plans/STREAMABLE-HTTP-AGENT-TRANSPORT.md
learnings/
diff --git a/README.md b/README.md
index 486ee6a..0d75e92 100644
--- a/README.md
+++ b/README.md
@@ -52,6 +52,15 @@ For WebSocket server support (agents accepting WebSocket connections):
```
+For Streamable HTTP server support (agents accepting remote HTTP/SSE connections):
+```xml
+
+ com.agentclientprotocol
+ acp-streamable-http-jetty
+ 0.11.0
+
+```
+
---
## Getting Started
@@ -368,7 +377,8 @@ agent.start().block(); // Starts WebSocket server on port 8080
| Artifact | Description |
|----------|-------------|
-| [`acp-core`](https://central.sonatype.com/artifact/com.agentclientprotocol/acp-core) | Client and Agent SDKs, stdio and WebSocket client transports |
+| [`acp-core`](https://central.sonatype.com/artifact/com.agentclientprotocol/acp-core) | Client and Agent SDKs, stdio, WebSocket, and Streamable HTTP client transports |
+| `acp-streamable-http-jetty` | Jetty-backed Streamable HTTP agent transport for listener-backed remote agents |
| [`acp-annotations`](https://central.sonatype.com/artifact/com.agentclientprotocol/acp-annotations) | `@AcpAgent`, `@Prompt`, and other annotations |
| [`acp-agent-support`](https://central.sonatype.com/artifact/com.agentclientprotocol/acp-agent-support) | Annotation-based agent runtime |
| [`acp-test`](https://central.sonatype.com/artifact/com.agentclientprotocol/acp-test) | In-memory transport and mock utilities for testing |
@@ -380,6 +390,7 @@ agent.start().block(); // Starts WebSocket server on port 8080
|-----------|--------|-------|--------|
| Stdio | `StdioAcpClientTransport` | `StdioAcpAgentTransport` | acp-core |
| WebSocket | `WebSocketAcpClientTransport` | `WebSocketAcpAgentTransport` | acp-core / acp-websocket-jetty |
+| Streamable HTTP | `StreamableHttpAcpClientTransport` | `StreamableHttpAcpAgentTransport` | acp-core / acp-streamable-http-jetty |
---
diff --git a/acp-core/src/main/java/com/agentclientprotocol/sdk/agent/AcpAgentFactory.java b/acp-core/src/main/java/com/agentclientprotocol/sdk/agent/AcpAgentFactory.java
new file mode 100644
index 0000000..ec05b06
--- /dev/null
+++ b/acp-core/src/main/java/com/agentclientprotocol/sdk/agent/AcpAgentFactory.java
@@ -0,0 +1,60 @@
+/*
+ * Copyright 2025-2026 the original author or authors.
+ */
+
+package com.agentclientprotocol.sdk.agent;
+
+import java.util.function.Function;
+
+import com.agentclientprotocol.sdk.spec.AcpAgentTransport;
+import com.agentclientprotocol.sdk.util.Assert;
+
+/**
+ * Factory for creating one ACP agent runtime for one agent-side transport.
+ *
+ *
+ * Listener-backed transports such as remote HTTP transports accept multiple client
+ * connections over their lifetime. Each accepted connection needs its own
+ * connection-bound agent runtime while reusing the same agent definition. This factory
+ * is the explicit public seam for that relationship.
+ *
+ *
+ * @author Kaiser Dandangi
+ */
+@FunctionalInterface
+public interface AcpAgentFactory {
+
+ /**
+ * Creates a new asynchronous agent runtime for the supplied transport.
+ * @param transport per-connection transport
+ * @return a fresh asynchronous agent runtime
+ */
+ AcpAsyncAgent create(AcpAgentTransport transport);
+
+ /**
+ * Creates a factory from an asynchronous agent builder function.
+ * @param factory function that creates a fresh asynchronous agent per transport
+ * @return an agent factory
+ */
+ static AcpAgentFactory async(Function factory) {
+ Assert.notNull(factory, "The async factory can not be null");
+ return factory::apply;
+ }
+
+ /**
+ * Creates a factory from a synchronous agent builder function.
+ *
+ *
+ * Synchronous agents are wrappers around asynchronous agents in this SDK, so the
+ * transport seam remains asynchronous underneath while callers may still author
+ * agents with the blocking API.
+ *
+ * @param factory function that creates a fresh synchronous agent per transport
+ * @return an agent factory
+ */
+ static AcpAgentFactory sync(Function factory) {
+ Assert.notNull(factory, "The sync factory can not be null");
+ return transport -> factory.apply(transport).async();
+ }
+
+}
diff --git a/acp-core/src/main/java/com/agentclientprotocol/sdk/agent/transport/RemoteAcpConnection.java b/acp-core/src/main/java/com/agentclientprotocol/sdk/agent/transport/RemoteAcpConnection.java
new file mode 100644
index 0000000..79f9cac
--- /dev/null
+++ b/acp-core/src/main/java/com/agentclientprotocol/sdk/agent/transport/RemoteAcpConnection.java
@@ -0,0 +1,254 @@
+/*
+ * Copyright 2025-2026 the original author or authors.
+ */
+
+package com.agentclientprotocol.sdk.agent.transport;
+
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.function.Consumer;
+import java.util.function.Function;
+
+import com.agentclientprotocol.sdk.agent.AcpAgentFactory;
+import com.agentclientprotocol.sdk.agent.AcpAsyncAgent;
+import com.agentclientprotocol.sdk.error.AcpConnectionException;
+import com.agentclientprotocol.sdk.json.AcpJsonMapper;
+import com.agentclientprotocol.sdk.json.TypeRef;
+import com.agentclientprotocol.sdk.spec.AcpAgentTransport;
+import com.agentclientprotocol.sdk.spec.AcpSchema;
+import com.agentclientprotocol.sdk.spec.AcpSchema.JSONRPCMessage;
+import com.agentclientprotocol.sdk.util.Assert;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import reactor.core.publisher.Mono;
+import reactor.core.publisher.Sinks;
+
+/**
+ * Shared per-connection core for listener-backed remote ACP agent transports.
+ *
+ *
+ * Remote transports such as Streamable HTTP and WebSocket have different wire-level
+ * framing, but they both need the same agent-side shape once a remote ACP connection
+ * exists: one connection-bound {@link AcpAgentTransport}, one fresh agent runtime from
+ * {@link AcpAgentFactory}, inbound JSON-RPC delivery to the agent, and outbound JSON-RPC
+ * delivery back to the wire adapter.
+ *
+ *
+ *
+ * This class intentionally does not know about HTTP headers, SSE streams, WebSocket
+ * sessions, or route maps. Those remain transport-adapter concerns.
+ *
+ *
+ * @author Kaiser Dandangi
+ */
+public final class RemoteAcpConnection {
+
+ private static final Logger logger = LoggerFactory.getLogger(RemoteAcpConnection.class);
+
+ private final String id;
+
+ private final AcpJsonMapper jsonMapper;
+
+ private final ConnectionTransport transport;
+
+ private final AtomicBoolean started = new AtomicBoolean(false);
+
+ private final AtomicBoolean closing = new AtomicBoolean(false);
+
+ private volatile AcpAsyncAgent agent;
+
+ /**
+ * Creates a new remote ACP connection core.
+ * @param id stable transport connection id
+ * @param jsonMapper JSON mapper used by the connection transport
+ * @param outboundConsumer callback that receives agent-originated outbound messages
+ */
+ public RemoteAcpConnection(String id, AcpJsonMapper jsonMapper, Consumer outboundConsumer) {
+ Assert.hasText(id, "The id can not be empty");
+ Assert.notNull(jsonMapper, "The jsonMapper can not be null");
+ Assert.notNull(outboundConsumer, "The outboundConsumer can not be null");
+ this.id = id;
+ this.jsonMapper = jsonMapper;
+ this.transport = new ConnectionTransport(outboundConsumer);
+ }
+
+ /**
+ * Returns the transport-level connection id.
+ * @return connection id
+ */
+ public String id() {
+ return id;
+ }
+
+ /**
+ * Starts a fresh agent runtime for this connection.
+ * @param agentFactory factory used to create the connection-bound agent runtime
+ * @return mono that completes when the agent runtime is started
+ */
+ public Mono start(AcpAgentFactory agentFactory) {
+ Assert.notNull(agentFactory, "The agentFactory can not be null");
+ if (!started.compareAndSet(false, true)) {
+ return Mono.error(new IllegalStateException("Already started"));
+ }
+ return Mono.defer(() -> {
+ this.agent = agentFactory.create(transport);
+ return this.agent.start();
+ }).doOnError(this::signalException);
+ }
+
+ /**
+ * Accepts one client-originated JSON-RPC message for delivery to the connection's
+ * agent runtime.
+ * @param message inbound message
+ */
+ public void acceptInbound(JSONRPCMessage message) {
+ transport.acceptInbound(message);
+ }
+
+ /**
+ * Reports a transport adapter exception to the agent transport exception handler.
+ * @param error exception to report
+ */
+ public void signalException(Throwable error) {
+ transport.signalException(error);
+ }
+
+ /**
+ * Closes the connection and its agent runtime gracefully.
+ * @return mono that completes when close work has been requested
+ */
+ public Mono closeGracefully() {
+ return Mono.defer(() -> {
+ if (!closing.compareAndSet(false, true)) {
+ return Mono.empty();
+ }
+ AcpAsyncAgent currentAgent = this.agent;
+ if (currentAgent != null) {
+ return currentAgent.closeGracefully()
+ .onErrorResume(error -> {
+ signalException(error);
+ return Mono.empty();
+ })
+ .then(transport.closeGracefully());
+ }
+ return transport.closeGracefully();
+ });
+ }
+
+ /**
+ * Closes the connection and its agent runtime immediately.
+ */
+ public void close() {
+ if (!closing.compareAndSet(false, true)) {
+ return;
+ }
+ AcpAsyncAgent currentAgent = this.agent;
+ if (currentAgent != null) {
+ currentAgent.close();
+ }
+ transport.close();
+ }
+
+ private final class ConnectionTransport implements AcpAgentTransport {
+
+ private final Consumer outboundConsumer;
+
+ private final Sinks.Many inboundSink = Sinks.many().unicast().onBackpressureBuffer();
+
+ /*
+ * Streamable HTTP can deliver multiple POST requests for one ACP connection on
+ * different server threads. Reactor unicast sinks require serialized producers,
+ * so all transport-adapter ingress is funneled through this monitor before
+ * emission.
+ */
+ private final Object inboundEmitMonitor = new Object();
+
+ private final Sinks.One terminationSink = Sinks.one();
+
+ private final AtomicBoolean transportStarted = new AtomicBoolean(false);
+
+ private final AtomicBoolean transportClosing = new AtomicBoolean(false);
+
+ private volatile Consumer exceptionHandler = t -> logger.error("Remote ACP transport error", t);
+
+ ConnectionTransport(Consumer outboundConsumer) {
+ this.outboundConsumer = outboundConsumer;
+ }
+
+ @Override
+ public Mono start(Function, Mono> handler) {
+ Assert.notNull(handler, "The handler can not be null");
+ if (!transportStarted.compareAndSet(false, true)) {
+ return Mono.error(new IllegalStateException("Already started"));
+ }
+ inboundSink.asFlux()
+ .flatMap(message -> Mono.just(message).transform(handler))
+ .doOnNext(response -> {
+ if (response != null) {
+ outboundConsumer.accept(response);
+ }
+ })
+ .doOnError(this::signalException)
+ .doFinally(signal -> terminationSink.tryEmitValue(null))
+ .subscribe();
+ return Mono.empty();
+ }
+
+ void acceptInbound(JSONRPCMessage message) {
+ Assert.notNull(message, "The message can not be null");
+ if (transportClosing.get()) {
+ throw new AcpConnectionException("Remote ACP connection is closing");
+ }
+ synchronized (inboundEmitMonitor) {
+ Sinks.EmitResult result = inboundSink.tryEmitNext(message);
+ if (result.isFailure()) {
+ throw new AcpConnectionException("Failed to enqueue inbound message: " + result);
+ }
+ }
+ }
+
+ void signalException(Throwable error) {
+ exceptionHandler.accept(error);
+ }
+
+ @Override
+ public Mono sendMessage(JSONRPCMessage message) {
+ return Mono.fromRunnable(() -> {
+ if (transportClosing.get()) {
+ throw new AcpConnectionException("Remote ACP connection is closing");
+ }
+ outboundConsumer.accept(message);
+ });
+ }
+
+ @Override
+ public T unmarshalFrom(Object data, TypeRef typeRef) {
+ return jsonMapper.convertValue(data, typeRef);
+ }
+
+ @Override
+ public Mono closeGracefully() {
+ return Mono.fromRunnable(this::close);
+ }
+
+ @Override
+ public void close() {
+ if (transportClosing.compareAndSet(false, true)) {
+ inboundSink.tryEmitComplete();
+ terminationSink.tryEmitValue(null);
+ }
+ }
+
+ @Override
+ public void setExceptionHandler(Consumer handler) {
+ Assert.notNull(handler, "The handler can not be null");
+ this.exceptionHandler = handler;
+ }
+
+ @Override
+ public Mono awaitTermination() {
+ return terminationSink.asMono();
+ }
+
+ }
+
+}
diff --git a/acp-core/src/main/java/com/agentclientprotocol/sdk/client/transport/StreamableHttpAcpClientTransport.java b/acp-core/src/main/java/com/agentclientprotocol/sdk/client/transport/StreamableHttpAcpClientTransport.java
new file mode 100644
index 0000000..8f5a9db
--- /dev/null
+++ b/acp-core/src/main/java/com/agentclientprotocol/sdk/client/transport/StreamableHttpAcpClientTransport.java
@@ -0,0 +1,747 @@
+/*
+ * Copyright 2025-2025 the original author or authors.
+ */
+
+package com.agentclientprotocol.sdk.client.transport;
+
+import java.io.BufferedReader;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.net.CookieManager;
+import java.net.URI;
+import java.net.http.HttpClient;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
+import java.nio.charset.StandardCharsets;
+import java.time.Duration;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.function.Consumer;
+import java.util.function.Function;
+
+import com.agentclientprotocol.sdk.error.AcpConnectionException;
+import com.agentclientprotocol.sdk.json.AcpJsonMapper;
+import com.agentclientprotocol.sdk.json.TypeRef;
+import com.agentclientprotocol.sdk.spec.AcpClientTransport;
+import com.agentclientprotocol.sdk.spec.AcpSchema;
+import com.agentclientprotocol.sdk.spec.AcpSchema.JSONRPCMessage;
+import com.agentclientprotocol.sdk.util.Assert;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import reactor.core.publisher.Mono;
+import reactor.core.publisher.Sinks;
+
+/**
+ * Client-side ACP transport for the Streamable HTTP profile.
+ *
+ *
+ * Streamable HTTP maps ACP's logical duplex conversation onto HTTP POST requests plus
+ * long-lived Server-Sent Event (SSE) streams. The transport keeps all HTTP-specific
+ * routing state internal so the higher-level ACP session can continue to operate only on
+ * JSON-RPC messages.
+ *
+ *
+ * @author Kaiser Dandangi
+ */
+public class StreamableHttpAcpClientTransport implements AcpClientTransport {
+
+ private static final Logger logger = LoggerFactory.getLogger(StreamableHttpAcpClientTransport.class);
+
+ /** Default ACP path used by the remote transport RFD. */
+ public static final String DEFAULT_ACP_PATH = "/acp";
+
+ private static final String HEADER_CONNECTION_ID = "Acp-Connection-Id";
+
+ private static final String HEADER_SESSION_ID = "Acp-Session-Id";
+
+ private static final String CONTENT_TYPE_JSON = "application/json";
+
+ private static final String CONTENT_TYPE_EVENT_STREAM = "text/event-stream";
+
+ /**
+ * Controls how unknown outbound request / notification methods are classified.
+ */
+ public enum RoutingMode {
+
+ /**
+ * Prefer explicit ACP routing, but fall back to session-id shape inference for
+ * unknown methods so clients can remain forward-compatible with extensions.
+ */
+ COMPATIBLE,
+
+ /**
+ * Require every outbound request / notification method to have an explicit routing
+ * rule.
+ */
+ STRICT
+
+ }
+
+ private enum ScopeKind {
+
+ BOOTSTRAP,
+
+ CONNECTION,
+
+ SESSION
+
+ }
+
+ private enum RequestKind {
+
+ INITIALIZE,
+
+ SESSION_NEW,
+
+ SESSION_LOAD,
+
+ GENERIC
+
+ }
+
+ private record RouteScope(ScopeKind kind, String sessionId) {
+
+ static RouteScope bootstrap() {
+ return new RouteScope(ScopeKind.BOOTSTRAP, null);
+ }
+
+ static RouteScope connection() {
+ return new RouteScope(ScopeKind.CONNECTION, null);
+ }
+
+ static RouteScope session(String sessionId) {
+ return new RouteScope(ScopeKind.SESSION, sessionId);
+ }
+
+ boolean isSession() {
+ return kind == ScopeKind.SESSION;
+ }
+
+ }
+
+ private record OutboundRequestRoute(RequestKind kind, RouteScope requestScope, RouteScope responseScope) {
+ }
+
+ private record HttpClientBundle(HttpClient httpClient, ExecutorService ownedExecutor) {
+ }
+
+ private final URI endpointUri;
+
+ private final AcpJsonMapper jsonMapper;
+
+ private final HttpClient httpClient;
+
+ private final ExecutorService ownedHttpExecutor;
+
+ private final ExecutorService httpSignalExecutor;
+
+ private final ExecutorService sseExecutor;
+
+ private final Sinks.Many inboundSink;
+
+ /*
+ * A streamable HTTP client may have one connection SSE reader and multiple session
+ * SSE readers active at the same time. Reactor unicast sinks require serialized
+ * producers, so every SSE reader emits through this monitor.
+ */
+ private final Object inboundEmitMonitor = new Object();
+
+ private final AtomicBoolean connected = new AtomicBoolean(false);
+
+ private final AtomicBoolean initialized = new AtomicBoolean(false);
+
+ private final AtomicBoolean closing = new AtomicBoolean(false);
+
+ // Client-originated request id -> where the eventual SSE response is expected.
+ private final Map outboundRequestRoutes = new ConcurrentHashMap<>();
+
+ // Agent-originated request id -> HTTP scope required for the later client POST response.
+ private final Map inboundRequestRoutes = new ConcurrentHashMap<>();
+
+ private final Map sessionStreams = new ConcurrentHashMap<>();
+
+ // Session id -> shared open operation so callers reuse one GET while the stream lives.
+ private final Map> sessionStreamOpenOperations = new ConcurrentHashMap<>();
+
+ private volatile SseStream connectionStream;
+
+ private volatile String connectionId;
+
+ private volatile RoutingMode routingMode = RoutingMode.COMPATIBLE;
+
+ private volatile Consumer exceptionHandler = t -> logger.error("Transport error", t);
+
+ /**
+ * Creates a new Streamable HTTP client transport using a default JDK {@link HttpClient}
+ * configured with an internal {@link CookieManager}.
+ * @param endpointUri the remote ACP endpoint URI
+ * @param jsonMapper JSON mapper used for message serialization
+ */
+ public StreamableHttpAcpClientTransport(URI endpointUri, AcpJsonMapper jsonMapper) {
+ this(endpointUri, jsonMapper, createDefaultHttpClient());
+ }
+
+ /**
+ * Creates a new Streamable HTTP client transport using a caller-provided
+ * {@link HttpClient}. This allows advanced callers to customize cookies, TLS,
+ * executors, or proxy behavior.
+ * @param endpointUri the remote ACP endpoint URI
+ * @param jsonMapper JSON mapper used for message serialization
+ * @param httpClient HTTP client to use for requests
+ */
+ public StreamableHttpAcpClientTransport(URI endpointUri, AcpJsonMapper jsonMapper, HttpClient httpClient) {
+ this(endpointUri, jsonMapper, new HttpClientBundle(httpClient, null));
+ }
+
+ private StreamableHttpAcpClientTransport(URI endpointUri, AcpJsonMapper jsonMapper, HttpClientBundle bundle) {
+ Assert.notNull(endpointUri, "The endpointUri can not be null");
+ Assert.notNull(jsonMapper, "The JsonMapper can not be null");
+ Assert.notNull(bundle, "The HttpClient bundle can not be null");
+ Assert.notNull(bundle.httpClient(), "The HttpClient can not be null");
+ Assert.isTrue("http".equalsIgnoreCase(endpointUri.getScheme())
+ || "https".equalsIgnoreCase(endpointUri.getScheme()),
+ "The endpointUri must use http or https");
+
+ this.endpointUri = endpointUri;
+ this.jsonMapper = jsonMapper;
+ this.httpClient = bundle.httpClient();
+ this.ownedHttpExecutor = bundle.ownedExecutor();
+ this.httpSignalExecutor = Executors.newCachedThreadPool(r -> {
+ Thread t = new Thread(r, "acp-streamable-http-signal");
+ t.setDaemon(true);
+ return t;
+ });
+ this.sseExecutor = Executors.newCachedThreadPool(r -> {
+ Thread t = new Thread(r, "acp-streamable-http-sse");
+ t.setDaemon(true);
+ return t;
+ });
+ this.inboundSink = Sinks.many().unicast().onBackpressureBuffer();
+ }
+
+ private static HttpClientBundle createDefaultHttpClient() {
+ ExecutorService executor = Executors.newCachedThreadPool(r -> {
+ Thread t = new Thread(r, "acp-streamable-http-client");
+ t.setDaemon(true);
+ return t;
+ });
+ HttpClient client = HttpClient.newBuilder()
+ .version(HttpClient.Version.HTTP_2)
+ .cookieHandler(new CookieManager())
+ .executor(executor)
+ .build();
+ return new HttpClientBundle(client, executor);
+ }
+
+ /**
+ * Sets the routing mode for outbound request / notification classification.
+ * @param routingMode routing mode to apply
+ * @return this transport
+ */
+ public StreamableHttpAcpClientTransport routingMode(RoutingMode routingMode) {
+ Assert.notNull(routingMode, "The routingMode can not be null");
+ this.routingMode = routingMode;
+ return this;
+ }
+
+ @Override
+ public Mono connect(Function, Mono> handler) {
+ Assert.notNull(handler, "The handler can not be null");
+ if (!connected.compareAndSet(false, true)) {
+ return Mono.error(new IllegalStateException("Already connected"));
+ }
+
+ handleIncomingMessages(handler);
+ return Mono.empty();
+ }
+
+ private void handleIncomingMessages(Function, Mono> handler) {
+ this.inboundSink.asFlux()
+ .flatMap(message -> Mono.just(message).transform(handler))
+ .doOnNext(this::forwardHandlerEmissionForCompatibility)
+ .subscribe();
+ }
+
+ private void forwardHandlerEmissionForCompatibility(JSONRPCMessage emittedMessage) {
+ /*
+ * Compatibility note:
+ * WebSocketAcpClientTransport currently forwards any message emitted by the
+ * registered client handler back onto the transport. AcpClientSession also sends
+ * client responses explicitly via sendMessage(...), so the client-side contract is
+ * still ambiguous. Preserve parity for now and keep this path isolated so it can be
+ * removed cheaply if the client transport contract is later made receive-only.
+ */
+ if (emittedMessage != null && !closing.get()) {
+ routeAndPost(emittedMessage).subscribe(v -> {
+ }, exceptionHandler);
+ }
+ }
+
+ @Override
+ public Mono sendMessage(JSONRPCMessage message) {
+ Assert.notNull(message, "The message can not be null");
+ if (closing.get()) {
+ return Mono.error(new AcpConnectionException("Transport is closing"));
+ }
+
+ if (message instanceof AcpSchema.JSONRPCRequest request
+ && AcpSchema.METHOD_INITIALIZE.equals(request.method())) {
+ return initialize(request);
+ }
+
+ return routeAndPost(message);
+ }
+
+ private Mono initialize(AcpSchema.JSONRPCRequest request) {
+ if (!initialized.compareAndSet(false, true)) {
+ return Mono.error(new IllegalStateException("Transport is already initialized"));
+ }
+
+ HttpRequest httpRequest;
+ try {
+ httpRequest = jsonPostBuilder(RouteScope.bootstrap())
+ .POST(HttpRequest.BodyPublishers.ofString(jsonMapper.writeValueAsString(request), StandardCharsets.UTF_8))
+ .build();
+ }
+ catch (IOException e) {
+ initialized.set(false);
+ return Mono.error(new AcpConnectionException("Failed to serialize initialize request", e));
+ }
+
+ return sendAsync(httpRequest, HttpResponse.BodyHandlers.ofString())
+ .flatMap(response -> {
+ if (response.statusCode() != 200) {
+ return Mono.error(new AcpConnectionException(
+ "Expected 200 for initialize, got " + response.statusCode()));
+ }
+ String contentType = response.headers().firstValue("Content-Type").orElse("");
+ if (!contentType.toLowerCase().contains(CONTENT_TYPE_JSON)) {
+ return Mono.error(new AcpConnectionException(
+ "Expected " + CONTENT_TYPE_JSON + " initialize response, got " + contentType));
+ }
+ this.connectionId = response.headers()
+ .firstValue(HEADER_CONNECTION_ID)
+ .orElseThrow(() -> new AcpConnectionException(
+ "Initialize response missing " + HEADER_CONNECTION_ID));
+ JSONRPCMessage responseMessage;
+ try {
+ responseMessage = AcpSchema.deserializeJsonRpcMessage(jsonMapper, response.body());
+ }
+ catch (Exception e) {
+ return Mono.error(new AcpConnectionException("Failed to deserialize initialize response", e));
+ }
+ return openConnectionStream().then(emitInbound(responseMessage));
+ })
+ .doOnError(error -> {
+ initialized.set(false);
+ exceptionHandler.accept(error);
+ });
+ }
+
+ private Mono routeAndPost(JSONRPCMessage message) {
+ return Mono.defer(() -> {
+ ResolvedOutboundRoute resolved = resolveOutboundRoute(message);
+ Mono preparation = prepareRoute(resolved);
+ return preparation.then(postAccepted(message, resolved.scope()))
+ .doOnSuccess(ignored -> {
+ if (message instanceof AcpSchema.JSONRPCResponse response) {
+ inboundRequestRoutes.remove(response.id());
+ }
+ })
+ .doOnError(error -> {
+ if (message instanceof AcpSchema.JSONRPCRequest request) {
+ outboundRequestRoutes.remove(request.id());
+ }
+ });
+ });
+ }
+
+ private Mono prepareRoute(ResolvedOutboundRoute resolved) {
+ if (resolved.message() instanceof AcpSchema.JSONRPCRequest request
+ && AcpSchema.METHOD_SESSION_LOAD.equals(request.method())) {
+ return openSessionStream(resolved.scope().sessionId());
+ }
+ if (resolved.scope().isSession() && !sessionStreams.containsKey(resolved.scope().sessionId())) {
+ return Mono.error(new AcpConnectionException(
+ "No open session stream for session " + resolved.scope().sessionId()));
+ }
+ return Mono.empty();
+ }
+
+ private Mono postAccepted(JSONRPCMessage message, RouteScope scope) {
+ HttpRequest request;
+ try {
+ request = jsonPostBuilder(scope)
+ .POST(HttpRequest.BodyPublishers.ofString(jsonMapper.writeValueAsString(message), StandardCharsets.UTF_8))
+ .build();
+ }
+ catch (IOException e) {
+ return Mono.error(new AcpConnectionException("Failed to serialize outbound message", e));
+ }
+
+ return sendAsync(request, HttpResponse.BodyHandlers.discarding())
+ .flatMap(response -> {
+ if (response.statusCode() != 202) {
+ return Mono.error(new AcpConnectionException(
+ "Expected 202 for POST, got " + response.statusCode()));
+ }
+ return Mono.empty();
+ });
+ }
+
+ private HttpRequest.Builder jsonPostBuilder(RouteScope scope) {
+ HttpRequest.Builder builder = HttpRequest.newBuilder(endpointUri)
+ .header("Content-Type", CONTENT_TYPE_JSON)
+ .header("Accept", CONTENT_TYPE_JSON);
+ addScopeHeaders(builder, scope);
+ return builder;
+ }
+
+ private Mono openConnectionStream() {
+ return openSseStream(RouteScope.connection()).doOnSuccess(stream -> this.connectionStream = stream).then();
+ }
+
+ private Mono openSessionStream(String sessionId) {
+ return sessionStreamOpenOperations.computeIfAbsent(sessionId, this::createSessionStreamOpenMono);
+ }
+
+ private Mono createSessionStreamOpenMono(String sessionId) {
+ return openSseStream(RouteScope.session(sessionId))
+ .doOnSuccess(stream -> sessionStreams.putIfAbsent(sessionId, stream))
+ .then()
+ .doOnError(error -> sessionStreamOpenOperations.remove(sessionId))
+ .cache();
+ }
+
+ private Mono openSseStream(RouteScope scope) {
+ HttpRequest.Builder builder = HttpRequest.newBuilder(endpointUri).GET().header("Accept", CONTENT_TYPE_EVENT_STREAM);
+ addScopeHeaders(builder, scope);
+ HttpRequest request = builder.build();
+
+ return sendAsync(request, HttpResponse.BodyHandlers.ofInputStream())
+ .flatMap(response -> {
+ if (response.statusCode() != 200) {
+ return Mono.error(new AcpConnectionException(
+ "Expected 200 when opening SSE stream, got " + response.statusCode()));
+ }
+ String contentType = response.headers().firstValue("Content-Type").orElse("");
+ if (!contentType.toLowerCase().contains(CONTENT_TYPE_EVENT_STREAM)) {
+ return Mono.error(new AcpConnectionException(
+ "Expected " + CONTENT_TYPE_EVENT_STREAM + " response, got " + contentType));
+ }
+ SseStream stream = new SseStream(scope, response.body());
+ stream.start();
+ return Mono.just(stream);
+ });
+ }
+
+ private void addScopeHeaders(HttpRequest.Builder builder, RouteScope scope) {
+ if (scope.kind() != ScopeKind.BOOTSTRAP) {
+ String currentConnectionId = requireConnectionId();
+ builder.header(HEADER_CONNECTION_ID, currentConnectionId);
+ }
+ if (scope.isSession()) {
+ builder.header(HEADER_SESSION_ID, scope.sessionId());
+ }
+ }
+
+ private String requireConnectionId() {
+ String currentConnectionId = this.connectionId;
+ if (currentConnectionId == null || currentConnectionId.isBlank()) {
+ throw new AcpConnectionException("Missing " + HEADER_CONNECTION_ID);
+ }
+ return currentConnectionId;
+ }
+
+ private ResolvedOutboundRoute resolveOutboundRoute(JSONRPCMessage message) {
+ if (message instanceof AcpSchema.JSONRPCResponse response) {
+ RouteScope scope = inboundRequestRoutes.get(response.id());
+ if (scope == null) {
+ throw new AcpConnectionException("Cannot route outbound response with unknown id " + response.id());
+ }
+ return new ResolvedOutboundRoute(message, scope, null);
+ }
+
+ if (message instanceof AcpSchema.JSONRPCRequest request) {
+ ResolvedOutboundRoute resolved = resolveRequestOrNotificationRoute(message, request.method(), request.params());
+ if (resolved.requestRoute() != null && request.id() != null) {
+ outboundRequestRoutes.put(request.id(), resolved.requestRoute());
+ }
+ return resolved;
+ }
+
+ if (message instanceof AcpSchema.JSONRPCNotification notification) {
+ return resolveRequestOrNotificationRoute(message, notification.method(), notification.params());
+ }
+
+ throw new AcpConnectionException("Unsupported outbound JSON-RPC message type: " + message);
+ }
+
+ private ResolvedOutboundRoute resolveRequestOrNotificationRoute(JSONRPCMessage message, String method, Object params) {
+ RouteScope requestScope;
+ RequestKind requestKind = RequestKind.GENERIC;
+ RouteScope responseScope;
+
+ switch (method) {
+ case AcpSchema.METHOD_INITIALIZE:
+ requestScope = RouteScope.bootstrap();
+ requestKind = RequestKind.INITIALIZE;
+ responseScope = RouteScope.bootstrap();
+ break;
+ case AcpSchema.METHOD_AUTHENTICATE:
+ case AcpSchema.METHOD_SESSION_NEW:
+ requestScope = RouteScope.connection();
+ requestKind = AcpSchema.METHOD_SESSION_NEW.equals(method) ? RequestKind.SESSION_NEW : RequestKind.GENERIC;
+ responseScope = RouteScope.connection();
+ break;
+ case AcpSchema.METHOD_SESSION_LOAD:
+ requestScope = RouteScope.session(requireSessionId(params, method));
+ requestKind = RequestKind.SESSION_LOAD;
+ responseScope = RouteScope.connection();
+ break;
+ case AcpSchema.METHOD_SESSION_PROMPT:
+ case AcpSchema.METHOD_SESSION_SET_MODE:
+ case AcpSchema.METHOD_SESSION_SET_MODEL:
+ case AcpSchema.METHOD_SESSION_CANCEL:
+ requestScope = RouteScope.session(requireSessionId(params, method));
+ responseScope = requestScope;
+ break;
+ default:
+ Optional sessionId = extractSessionId(params);
+ if (routingMode == RoutingMode.STRICT) {
+ throw new AcpConnectionException("No explicit routing rule for outbound method " + method);
+ }
+ if (sessionId.isPresent()) {
+ logger.warn("Falling back to inferred session routing for unknown method '{}'", method);
+ requestScope = RouteScope.session(sessionId.get());
+ }
+ else {
+ logger.warn("Falling back to inferred connection routing for unknown method '{}'", method);
+ requestScope = RouteScope.connection();
+ }
+ responseScope = requestScope;
+ }
+
+ OutboundRequestRoute requestRoute = null;
+ if (message instanceof AcpSchema.JSONRPCRequest) {
+ requestRoute = new OutboundRequestRoute(requestKind, requestScope, responseScope);
+ }
+ return new ResolvedOutboundRoute(message, requestScope, requestRoute);
+ }
+
+ private Optional extractSessionId(Object params) {
+ if (params == null) {
+ return Optional.empty();
+ }
+ Map, ?> paramsMap = jsonMapper.convertValue(params, Map.class);
+ Object sessionId = paramsMap.get("sessionId");
+ return sessionId == null ? Optional.empty() : Optional.of(sessionId.toString());
+ }
+
+ private String requireSessionId(Object params, String method) {
+ return extractSessionId(params)
+ .filter(sessionId -> !sessionId.isBlank())
+ .orElseThrow(() -> new AcpConnectionException("Missing sessionId for outbound method " + method));
+ }
+
+ private Mono processInbound(RouteScope actualScope, JSONRPCMessage message) {
+ if (message instanceof AcpSchema.JSONRPCResponse response) {
+ OutboundRequestRoute expectedRoute = outboundRequestRoutes.get(response.id());
+ if (expectedRoute != null && !Objects.equals(expectedRoute.responseScope(), actualScope)) {
+ return Mono.error(new AcpConnectionException("Response id " + response.id() + " arrived on "
+ + actualScope + " but expected " + expectedRoute.responseScope()));
+ }
+ if (expectedRoute != null && expectedRoute.kind() == RequestKind.SESSION_NEW) {
+ AcpSchema.NewSessionResponse sessionResponse = jsonMapper.convertValue(response.result(),
+ new TypeRef() {
+ });
+ String sessionId = sessionResponse.sessionId();
+ if (sessionId == null || sessionId.isBlank()) {
+ return Mono.error(new AcpConnectionException("session/new response missing sessionId"));
+ }
+ return openSessionStream(sessionId)
+ .then(Mono.fromRunnable(() -> outboundRequestRoutes.remove(response.id())))
+ .then(emitInbound(message));
+ }
+ if (expectedRoute != null) {
+ outboundRequestRoutes.remove(response.id());
+ }
+ return emitInbound(message);
+ }
+
+ if (message instanceof AcpSchema.JSONRPCRequest request) {
+ if (request.id() != null) {
+ inboundRequestRoutes.put(request.id(), actualScope);
+ }
+ return emitInbound(message);
+ }
+
+ return emitInbound(message);
+ }
+
+ private Mono emitInbound(JSONRPCMessage message) {
+ return Mono.fromRunnable(() -> {
+ synchronized (inboundEmitMonitor) {
+ Sinks.EmitResult result = inboundSink.tryEmitNext(message);
+ if (result.isFailure()) {
+ throw new AcpConnectionException("Failed to enqueue inbound message: " + result);
+ }
+ }
+ });
+ }
+
+ @Override
+ public Mono closeGracefully() {
+ return Mono.defer(() -> {
+ closing.set(true);
+ Optional.ofNullable(connectionStream).ifPresent(SseStream::close);
+ sessionStreams.values().forEach(SseStream::close);
+
+ Mono deleteRequest = Mono.empty();
+ if (connectionId != null) {
+ HttpRequest request = HttpRequest.newBuilder(endpointUri)
+ .DELETE()
+ .header(HEADER_CONNECTION_ID, connectionId)
+ .build();
+ deleteRequest = sendAsync(request, HttpResponse.BodyHandlers.discarding())
+ .flatMap(response -> {
+ if (response.statusCode() != 202) {
+ return Mono.error(new AcpConnectionException(
+ "Expected 202 for DELETE, got " + response.statusCode()));
+ }
+ return Mono.empty();
+ });
+ }
+
+ return deleteRequest.doFinally(signal -> clearState());
+ });
+ }
+
+ private void clearState() {
+ connectionStream = null;
+ sessionStreams.clear();
+ sessionStreamOpenOperations.clear();
+ inboundRequestRoutes.clear();
+ outboundRequestRoutes.clear();
+ connectionId = null;
+ inboundSink.tryEmitComplete();
+ sseExecutor.shutdownNow();
+ httpSignalExecutor.shutdownNow();
+ if (ownedHttpExecutor != null) {
+ ownedHttpExecutor.shutdownNow();
+ }
+ }
+
+ @Override
+ public void setExceptionHandler(Consumer handler) {
+ this.exceptionHandler = handler;
+ }
+
+ @Override
+ public T unmarshalFrom(Object data, TypeRef typeRef) {
+ return jsonMapper.convertValue(data, typeRef);
+ }
+
+ private record ResolvedOutboundRoute(JSONRPCMessage message, RouteScope scope, OutboundRequestRoute requestRoute) {
+ }
+
+ private Mono> sendAsync(HttpRequest request, HttpResponse.BodyHandler bodyHandler) {
+ return Mono.create(sink -> httpClient.sendAsync(request, bodyHandler).whenCompleteAsync((response, error) -> {
+ if (error != null) {
+ sink.error(error);
+ }
+ else {
+ sink.success(response);
+ }
+ }, httpSignalExecutor));
+ }
+
+ private class SseStream {
+
+ private final RouteScope scope;
+
+ private final InputStream body;
+
+ private final AtomicBoolean closed = new AtomicBoolean(false);
+
+ private Future> readerTask;
+
+ SseStream(RouteScope scope, InputStream body) {
+ this.scope = scope;
+ this.body = body;
+ }
+
+ void start() {
+ this.readerTask = sseExecutor.submit(this::readLoop);
+ }
+
+ void close() {
+ if (closed.compareAndSet(false, true)) {
+ try {
+ body.close();
+ }
+ catch (IOException ignored) {
+ }
+ if (readerTask != null) {
+ readerTask.cancel(true);
+ }
+ }
+ }
+
+ private void readLoop() {
+ try (BufferedReader reader = new BufferedReader(new InputStreamReader(body, StandardCharsets.UTF_8))) {
+ StringBuilder dataBuffer = new StringBuilder();
+ String line;
+ while (!closed.get() && (line = reader.readLine()) != null) {
+ if (line.isEmpty()) {
+ dispatchEvent(dataBuffer);
+ dataBuffer.setLength(0);
+ continue;
+ }
+ if (line.startsWith(":")) {
+ continue;
+ }
+ if (line.startsWith("data:")) {
+ if (!dataBuffer.isEmpty()) {
+ dataBuffer.append('\n');
+ }
+ dataBuffer.append(line.substring(5).stripLeading());
+ }
+ }
+ dispatchEvent(dataBuffer);
+ if (!closed.get() && !closing.get()) {
+ throw new AcpConnectionException("SSE stream closed unexpectedly: " + scope);
+ }
+ }
+ catch (Exception e) {
+ if (!closed.get() && !closing.get()) {
+ exceptionHandler.accept(e);
+ }
+ }
+ }
+
+ private void dispatchEvent(StringBuilder dataBuffer) {
+ if (dataBuffer.isEmpty()) {
+ return;
+ }
+ try {
+ JSONRPCMessage message = AcpSchema.deserializeJsonRpcMessage(jsonMapper, dataBuffer.toString());
+ processInbound(scope, message).block(Duration.ofSeconds(30));
+ }
+ catch (Exception e) {
+ if (!closed.get() && !closing.get()) {
+ exceptionHandler.accept(e);
+ }
+ }
+ }
+
+ }
+
+}
diff --git a/acp-core/src/main/java/com/agentclientprotocol/sdk/client/transport/WebSocketAcpClientTransport.java b/acp-core/src/main/java/com/agentclientprotocol/sdk/client/transport/WebSocketAcpClientTransport.java
index c5f6281..b0f8c41 100644
--- a/acp-core/src/main/java/com/agentclientprotocol/sdk/client/transport/WebSocketAcpClientTransport.java
+++ b/acp-core/src/main/java/com/agentclientprotocol/sdk/client/transport/WebSocketAcpClientTransport.java
@@ -166,6 +166,13 @@ private void handleIncomingMessages(Function, Mono Mono.just(message).transform(handler))
.doOnNext(response -> {
+ /*
+ * Compatibility note:
+ * AcpClientSession currently sends client responses explicitly through
+ * sendMessage(...), but this transport has also historically forwarded any
+ * message emitted by the registered handler. Keep the behavior for parity
+ * until the client-side transport contract is clarified.
+ */
if (response != null) {
this.outboundSink.tryEmitNext(response);
}
diff --git a/acp-core/src/main/java/com/agentclientprotocol/sdk/spec/AcpAgentSession.java b/acp-core/src/main/java/com/agentclientprotocol/sdk/spec/AcpAgentSession.java
index a0168fd..d27a96a 100644
--- a/acp-core/src/main/java/com/agentclientprotocol/sdk/spec/AcpAgentSession.java
+++ b/acp-core/src/main/java/com/agentclientprotocol/sdk/spec/AcpAgentSession.java
@@ -6,11 +6,11 @@
import java.time.Duration;
import java.util.Map;
+import java.util.Set;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.Executors;
import java.util.concurrent.atomic.AtomicLong;
-import java.util.concurrent.atomic.AtomicReference;
import reactor.core.scheduler.Scheduler;
import reactor.core.scheduler.Schedulers;
@@ -78,10 +78,17 @@ public class AcpAgentSession implements AcpSession {
private final AtomicLong requestCounter = new AtomicLong(0);
/**
- * Active prompt tracking for single-turn enforcement.
- * Only ONE prompt can be active at a time per ACP session.
+ * Active prompt tracking for single-turn enforcement, keyed by logical ACP
+ * sessionId.
+ *
+ *
+ * Kotlin SDK precedent: its Agent.SessionWrapper owns a single active prompt guard
+ * per logical session wrapper. This Java session can multiplex multiple logical ACP
+ * sessionIds over one transport connection, so the same single-turn rule needs to
+ * be applied per sessionId instead of once for the whole connection.
+ *
*/
- private final AtomicReference activePrompt = new AtomicReference<>(null);
+ private final ConcurrentHashMap activePrompts = new ConcurrentHashMap<>();
/**
* Represents an active prompt session for single-turn enforcement.
@@ -235,12 +242,12 @@ private Mono handleIncomingRequest(AcpSchema.JSONRPCR
String sessionId = extractSessionId(request.params());
ActivePrompt newPrompt = new ActivePrompt(sessionId, request.id());
- // Try to set as active prompt - fails if another prompt is active
- if (!activePrompt.compareAndSet(null, newPrompt)) {
- ActivePrompt current = activePrompt.get();
- logger.warn("Rejected concurrent prompt request. Active prompt: sessionId={}, requestId={}",
- current != null ? current.sessionId() : "unknown",
- current != null ? current.requestId() : "unknown");
+ // Try to set as active prompt - fails if this logical session already has
+ // a prompt active.
+ ActivePrompt current = activePrompts.putIfAbsent(sessionId, newPrompt);
+ if (current != null) {
+ logger.warn("Rejected concurrent prompt request for sessionId={}. Active requestId={}", sessionId,
+ current.requestId());
return Mono.just(new AcpSchema.JSONRPCResponse(AcpSchema.JSONRPC_VERSION, request.id(), null,
new AcpSchema.JSONRPCError(-32000, "There is already an active prompt execution", null)));
}
@@ -249,8 +256,8 @@ private Mono handleIncomingRequest(AcpSchema.JSONRPCR
return handler.handle(request.params())
.map(result -> new AcpSchema.JSONRPCResponse(AcpSchema.JSONRPC_VERSION, request.id(), result, null))
.doFinally(signal -> {
- activePrompt.compareAndSet(newPrompt, null);
- logger.debug("Prompt completed with signal: {}", signal);
+ activePrompts.remove(sessionId, newPrompt);
+ logger.debug("Prompt completed for sessionId={} with signal: {}", sessionId, signal);
});
}
@@ -262,8 +269,13 @@ private Mono handleIncomingRequest(AcpSchema.JSONRPCR
/**
* Extracts the sessionId from request parameters.
*/
- @SuppressWarnings("unchecked")
private String extractSessionId(Object params) {
+ if (params instanceof AcpSchema.PromptRequest promptRequest) {
+ return promptRequest.sessionId() != null ? promptRequest.sessionId() : "unknown";
+ }
+ if (params instanceof AcpSchema.CancelNotification cancelNotification) {
+ return cancelNotification.sessionId() != null ? cancelNotification.sessionId() : "unknown";
+ }
if (params instanceof Map, ?> map) {
Object sessionId = map.get("sessionId");
return sessionId != null ? sessionId.toString() : "unknown";
@@ -289,9 +301,8 @@ private Mono handleIncomingNotification(AcpSchema.JSONRPCNotification noti
// Handle cancel notification specially
if (AcpSchema.METHOD_SESSION_CANCEL.equals(notification.method())) {
String sessionId = extractSessionId(notification.params());
- ActivePrompt current = activePrompt.get();
- if (current != null && sessionId.equals(current.sessionId())) {
- activePrompt.compareAndSet(current, null);
+ ActivePrompt current = activePrompts.remove(sessionId);
+ if (current != null) {
logger.debug("Cancelled active prompt for session: {}", sessionId);
}
}
@@ -372,16 +383,39 @@ public Mono sendNotification(String method, Object params) {
* @return true if a prompt is currently active
*/
public boolean hasActivePrompt() {
- return activePrompt.get() != null;
+ return !activePrompts.isEmpty();
+ }
+
+ /**
+ * Checks if there is an active prompt being processed for the specified logical
+ * ACP session.
+ * @param sessionId the logical ACP session ID
+ * @return true if a prompt is currently active for the session
+ */
+ public boolean hasActivePrompt(String sessionId) {
+ Assert.hasText(sessionId, "The sessionId can not be empty");
+ return activePrompts.containsKey(sessionId);
}
/**
- * Gets the session ID of the active prompt, if any.
- * @return the session ID or null if no prompt is active
+ * Gets one active prompt session ID, if any.
+ *
+ *
+ * This is a legacy aggregate view. When multiple logical ACP sessions are active on
+ * the same transport connection, the returned session ID is arbitrary.
+ *
+ * @return one active session ID or null if no prompt is active
*/
public String getActivePromptSessionId() {
- ActivePrompt current = activePrompt.get();
- return current != null ? current.sessionId() : null;
+ return activePrompts.keySet().stream().findFirst().orElse(null);
+ }
+
+ /**
+ * Gets the logical ACP session IDs that currently have active prompts.
+ * @return an immutable snapshot of active prompt session IDs
+ */
+ public Set getActivePromptSessionIds() {
+ return Set.copyOf(activePrompts.keySet());
}
/**
@@ -391,7 +425,7 @@ public String getActivePromptSessionId() {
@Override
public Mono closeGracefully() {
return Mono.fromRunnable(() -> {
- activePrompt.set(null);
+ activePrompts.clear();
dismissPendingResponses();
timeoutScheduler.dispose();
}).then(this.transport.closeGracefully());
@@ -402,7 +436,7 @@ public Mono closeGracefully() {
*/
@Override
public void close() {
- activePrompt.set(null);
+ activePrompts.clear();
dismissPendingResponses();
timeoutScheduler.dispose();
transport.close();
diff --git a/acp-core/src/main/java/com/agentclientprotocol/sdk/spec/AcpClientSession.java b/acp-core/src/main/java/com/agentclientprotocol/sdk/spec/AcpClientSession.java
index 8dd0f5e..ca14392 100644
--- a/acp-core/src/main/java/com/agentclientprotocol/sdk/spec/AcpClientSession.java
+++ b/acp-core/src/main/java/com/agentclientprotocol/sdk/spec/AcpClientSession.java
@@ -148,7 +148,26 @@ public AcpClientSession(Duration requestTimeout, AcpClientTransport transport,
return t;
}), "acp-timeout-" + sessionPrefix);
- this.transport.connect(mono -> mono.doOnNext(this::handle)).transform(connectHook).subscribe();
+ this.transport.setExceptionHandler(this::handleTransportException);
+
+ /*
+ * Client transports currently retain a compatibility path that may forward any
+ * message emitted by this handler back onto the wire. The session handles outbound
+ * replies explicitly via transport.sendMessage(...), so the default session handler
+ * should consume inbound messages without re-emitting them. The transport-level
+ * handler type is Function, Mono>, so returning
+ * Mono.empty() is intentional here: the signature permits an emitted message, but
+ * the default client session has no message to return through that path.
+ */
+ this.transport.connect(mono -> mono.doOnNext(this::handle).then(Mono.empty())).transform(connectHook).subscribe();
+ }
+
+ private void handleTransportException(Throwable error) {
+ this.pendingResponses.forEach((id, sink) -> {
+ logger.warn("Terminating exchange for request {} after transport error", id, error);
+ sink.error(error);
+ });
+ this.pendingResponses.clear();
}
private void dismissPendingResponses() {
diff --git a/acp-core/src/test/java/com/agentclientprotocol/sdk/agent/AcpAgentFactoryTest.java b/acp-core/src/test/java/com/agentclientprotocol/sdk/agent/AcpAgentFactoryTest.java
new file mode 100644
index 0000000..fb91fc2
--- /dev/null
+++ b/acp-core/src/test/java/com/agentclientprotocol/sdk/agent/AcpAgentFactoryTest.java
@@ -0,0 +1,41 @@
+/*
+ * Copyright 2025-2026 the original author or authors.
+ */
+
+package com.agentclientprotocol.sdk.agent;
+
+import com.agentclientprotocol.sdk.spec.AcpSchema;
+import com.agentclientprotocol.sdk.test.InMemoryTransportPair;
+import org.junit.jupiter.api.Test;
+import reactor.core.publisher.Mono;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class AcpAgentFactoryTest {
+
+ @Test
+ void asyncFactoryReturnsFreshAgentRuntime() {
+ AcpAgentFactory factory = AcpAgentFactory.async(transport -> AcpAgent.async(transport)
+ .initializeHandler(request -> Mono.just(AcpSchema.InitializeResponse.ok()))
+ .newSessionHandler(request -> Mono.just(new AcpSchema.NewSessionResponse("session", null, null)))
+ .build());
+
+ AcpAsyncAgent first = factory.create(InMemoryTransportPair.create().agentTransport());
+ AcpAsyncAgent second = factory.create(InMemoryTransportPair.create().agentTransport());
+
+ assertThat(first).isNotSameAs(second);
+ }
+
+ @Test
+ void syncFactoryAdaptsToAsyncRuntime() {
+ AcpAgentFactory factory = AcpAgentFactory.sync(transport -> AcpAgent.sync(transport)
+ .initializeHandler(request -> AcpSchema.InitializeResponse.ok())
+ .newSessionHandler(request -> new AcpSchema.NewSessionResponse("session", null, null))
+ .build());
+
+ AcpAsyncAgent agent = factory.create(InMemoryTransportPair.create().agentTransport());
+
+ assertThat(agent).isNotNull();
+ }
+
+}
diff --git a/acp-core/src/test/java/com/agentclientprotocol/sdk/client/transport/StreamableHttpAcpClientTransportIntegrationTest.java b/acp-core/src/test/java/com/agentclientprotocol/sdk/client/transport/StreamableHttpAcpClientTransportIntegrationTest.java
new file mode 100644
index 0000000..1cf2e51
--- /dev/null
+++ b/acp-core/src/test/java/com/agentclientprotocol/sdk/client/transport/StreamableHttpAcpClientTransportIntegrationTest.java
@@ -0,0 +1,530 @@
+/*
+ * Copyright 2025-2025 the original author or authors.
+ */
+
+package com.agentclientprotocol.sdk.client.transport;
+
+import java.io.IOException;
+import java.io.OutputStream;
+import java.net.InetSocketAddress;
+import java.net.ServerSocket;
+import java.net.URI;
+import java.net.http.HttpClient;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
+import java.nio.charset.StandardCharsets;
+import java.time.Duration;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.BlockingQueue;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.CopyOnWriteArrayList;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.LinkedBlockingQueue;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import com.agentclientprotocol.sdk.AcpTestFixtures;
+import com.agentclientprotocol.sdk.client.AcpAsyncClient;
+import com.agentclientprotocol.sdk.client.AcpClient;
+import com.agentclientprotocol.sdk.error.AcpConnectionException;
+import com.agentclientprotocol.sdk.json.AcpJsonMapper;
+import com.agentclientprotocol.sdk.spec.AcpSchema;
+import com.sun.net.httpserver.HttpExchange;
+import com.sun.net.httpserver.HttpServer;
+import org.junit.jupiter.api.Test;
+import reactor.core.publisher.Mono;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/**
+ * End-to-end tests against an in-process Java Streamable HTTP fixture server.
+ */
+class StreamableHttpAcpClientTransportIntegrationTest {
+
+ private static final Duration TIMEOUT = Duration.ofSeconds(5);
+
+ private static final String CONNECTION_ID = "conn-test";
+
+ private static final String CONNECTION_STREAM = "connection";
+
+ private static final String CONTENT_TYPE_JSON = "application/json";
+
+ private static final String CONTENT_TYPE_EVENT_STREAM = "text/event-stream";
+
+ private static final AcpJsonMapper JSON_MAPPER = AcpJsonMapper.createDefault();
+
+ @Test
+ void happyPathUsesConnectionAndSessionStreams() throws Exception {
+ try (FixtureServer fixture = FixtureServer.start()) {
+ CopyOnWriteArrayList updates = new CopyOnWriteArrayList<>();
+ AcpAsyncClient client = newClient(fixture.endpoint())
+ .sessionUpdateConsumer(notification -> {
+ updates.add(notification);
+ return Mono.empty();
+ })
+ .build();
+
+ client.initialize().block(TIMEOUT);
+ AcpSchema.NewSessionResponse session = client
+ .newSession(AcpTestFixtures.createNewSessionRequest("/workspace"))
+ .block(TIMEOUT);
+ AcpSchema.PromptResponse prompt = client
+ .prompt(AcpTestFixtures.createPromptRequest(session.sessionId(), "hello"))
+ .block(TIMEOUT);
+
+ assertThat(session.sessionId()).isEqualTo("sess-1");
+ assertThat(prompt.stopReason()).isEqualTo(AcpSchema.StopReason.END_TURN);
+ assertThat(updates).hasSize(1);
+ assertThat(fixture.connectionStreamOpened()).isTrue();
+ assertThat(fixture.sessionStreamOpened("sess-1")).isTrue();
+
+ client.closeGracefully().block(TIMEOUT);
+ assertThat(fixture.deleteReceived()).isTrue();
+ }
+ }
+
+ @Test
+ void permissionRequestRoundTripsOnSessionStream() throws Exception {
+ try (FixtureServer fixture = FixtureServer.start()) {
+ AtomicInteger permissionRequests = new AtomicInteger();
+ AcpAsyncClient client = newClient(fixture.endpoint())
+ .requestPermissionHandler(request -> {
+ permissionRequests.incrementAndGet();
+ return Mono.just(new AcpSchema.RequestPermissionResponse(
+ new AcpSchema.PermissionSelected("allow")));
+ })
+ .build();
+
+ client.initialize().block(TIMEOUT);
+ AcpSchema.NewSessionResponse session = client
+ .newSession(AcpTestFixtures.createNewSessionRequest("/workspace"))
+ .block(TIMEOUT);
+ AcpSchema.PromptResponse prompt = client
+ .prompt(AcpTestFixtures.createPromptRequest(session.sessionId(), "needs permission"))
+ .block(TIMEOUT);
+
+ assertThat(prompt.stopReason()).isEqualTo(AcpSchema.StopReason.END_TURN);
+ assertThat(permissionRequests).hasValue(1);
+ assertThat(fixture.permissionResponseReceived()).isTrue();
+
+ client.closeGracefully().block(TIMEOUT);
+ }
+ }
+
+ @Test
+ void loadSessionOpensSessionStreamBeforePosting() throws Exception {
+ try (FixtureServer fixture = FixtureServer.start()) {
+ AcpAsyncClient client = newClient(fixture.endpoint()).build();
+
+ client.initialize().block(TIMEOUT);
+ AcpSchema.LoadSessionResponse response = client
+ .loadSession(new AcpSchema.LoadSessionRequest("sess-load", "/workspace", List.of()))
+ .block(TIMEOUT);
+
+ assertThat(response).isNotNull();
+ assertThat(fixture.sessionLoadStreamWasOpenBeforePost()).isTrue();
+
+ client.closeGracefully().block(TIMEOUT);
+ }
+ }
+
+ @Test
+ void supportsTwoConcurrentLogicalSessions() throws Exception {
+ try (FixtureServer fixture = FixtureServer.start()) {
+ AcpAsyncClient client = newClient(fixture.endpoint()).build();
+
+ client.initialize().block(TIMEOUT);
+ AcpSchema.NewSessionResponse first = client
+ .newSession(AcpTestFixtures.createNewSessionRequest("/workspace/one"))
+ .block(TIMEOUT);
+ AcpSchema.NewSessionResponse second = client
+ .newSession(AcpTestFixtures.createNewSessionRequest("/workspace/two"))
+ .block(TIMEOUT);
+ AcpSchema.PromptResponse firstPrompt = client
+ .prompt(AcpTestFixtures.createPromptRequest(first.sessionId(), "one"))
+ .block(TIMEOUT);
+ AcpSchema.PromptResponse secondPrompt = client
+ .prompt(AcpTestFixtures.createPromptRequest(second.sessionId(), "two"))
+ .block(TIMEOUT);
+
+ assertThat(first.sessionId()).isEqualTo("sess-1");
+ assertThat(second.sessionId()).isEqualTo("sess-2");
+ assertThat(firstPrompt.stopReason()).isEqualTo(AcpSchema.StopReason.END_TURN);
+ assertThat(secondPrompt.stopReason()).isEqualTo(AcpSchema.StopReason.END_TURN);
+ assertThat(fixture.sessionStreamOpened("sess-1")).isTrue();
+ assertThat(fixture.sessionStreamOpened("sess-2")).isTrue();
+
+ client.closeGracefully().block(TIMEOUT);
+ }
+ }
+
+ @Test
+ void wrongStreamResponseFailsPendingExchange() throws Exception {
+ try (FixtureServer fixture = FixtureServer.start()) {
+ fixture.routePromptResponsesOnConnectionStream();
+ AcpAsyncClient client = newClient(fixture.endpoint()).build();
+
+ client.initialize().block(TIMEOUT);
+ AcpSchema.NewSessionResponse session = client
+ .newSession(AcpTestFixtures.createNewSessionRequest("/workspace"))
+ .block(TIMEOUT);
+
+ assertThatThrownBy(() -> client
+ .prompt(AcpTestFixtures.createPromptRequest(session.sessionId(), "wrong stream"))
+ .block(TIMEOUT))
+ .isInstanceOf(AcpConnectionException.class)
+ .hasMessageContaining("arrived on RouteScope");
+
+ client.closeGracefully().block(TIMEOUT);
+ }
+ }
+
+ @Test
+ void initializeRequiresConnectionIdHeader() throws Exception {
+ try (FixtureServer fixture = FixtureServer.start()) {
+ fixture.omitConnectionIdOnInitialize();
+ AcpAsyncClient client = newClient(fixture.endpoint()).build();
+
+ assertThatThrownBy(() -> client.initialize().block(TIMEOUT))
+ .isInstanceOf(AcpConnectionException.class)
+ .hasMessageContaining("Initialize response missing Acp-Connection-Id");
+
+ client.closeGracefully().onErrorResume(ignored -> Mono.empty()).block(TIMEOUT);
+ }
+ }
+
+ private AcpClient.AsyncSpec newClient(URI endpoint) {
+ StreamableHttpAcpClientTransport transport = new StreamableHttpAcpClientTransport(endpoint,
+ AcpJsonMapper.createDefault());
+ return AcpClient.async(transport).requestTimeout(TIMEOUT);
+ }
+
+ private static final class FixtureServer implements AutoCloseable {
+
+ private final HttpServer server;
+
+ private final ExecutorService executor;
+
+ private final Map streams = new ConcurrentHashMap<>();
+
+ private final AtomicInteger sessionCounter = new AtomicInteger();
+
+ private final AtomicBoolean deleteReceived = new AtomicBoolean(false);
+
+ private final AtomicBoolean omitConnectionIdOnInitialize = new AtomicBoolean(false);
+
+ private final AtomicBoolean routePromptResponsesOnConnectionStream = new AtomicBoolean(false);
+
+ private final AtomicBoolean permissionResponseReceived = new AtomicBoolean(false);
+
+ private final AtomicBoolean sessionLoadStreamWasOpenBeforePost = new AtomicBoolean(false);
+
+ private final CompletableFuture permissionResponse = new CompletableFuture<>();
+
+ private FixtureServer(HttpServer server, ExecutorService executor) {
+ this.server = server;
+ this.executor = executor;
+ }
+
+ static FixtureServer start() throws Exception {
+ HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", freePort()), 0);
+ ExecutorService executor = Executors.newCachedThreadPool();
+ FixtureServer fixture = new FixtureServer(server, executor);
+ server.createContext("/acp", fixture::handle);
+ server.setExecutor(executor);
+ server.start();
+ return fixture;
+ }
+
+ URI endpoint() {
+ return URI.create("http://127.0.0.1:" + server.getAddress().getPort() + "/acp");
+ }
+
+ boolean connectionStreamOpened() {
+ return streams.containsKey(CONNECTION_STREAM);
+ }
+
+ boolean sessionStreamOpened(String sessionId) {
+ return streams.containsKey(sessionKey(sessionId));
+ }
+
+ boolean deleteReceived() {
+ return deleteReceived.get();
+ }
+
+ boolean permissionResponseReceived() {
+ return permissionResponseReceived.get();
+ }
+
+ boolean sessionLoadStreamWasOpenBeforePost() {
+ return sessionLoadStreamWasOpenBeforePost.get();
+ }
+
+ void omitConnectionIdOnInitialize() {
+ omitConnectionIdOnInitialize.set(true);
+ }
+
+ void routePromptResponsesOnConnectionStream() {
+ routePromptResponsesOnConnectionStream.set(true);
+ }
+
+ private void handle(HttpExchange exchange) throws IOException {
+ switch (exchange.getRequestMethod()) {
+ case "POST" -> handlePost(exchange);
+ case "GET" -> handleGet(exchange);
+ case "DELETE" -> handleDelete(exchange);
+ default -> writeText(exchange, 405, "method not allowed");
+ }
+ }
+
+ private void handlePost(HttpExchange exchange) throws IOException {
+ String body = new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8);
+ AcpSchema.JSONRPCMessage message;
+ try {
+ message = AcpSchema.deserializeJsonRpcMessage(JSON_MAPPER, body);
+ }
+ catch (Exception e) {
+ writeText(exchange, 400, "invalid json-rpc");
+ return;
+ }
+
+ if (message instanceof AcpSchema.JSONRPCRequest request
+ && AcpSchema.METHOD_INITIALIZE.equals(request.method())) {
+ if (!omitConnectionIdOnInitialize.get()) {
+ exchange.getResponseHeaders().add("Acp-Connection-Id", CONNECTION_ID);
+ }
+ writeJson(exchange, 200, response(request.id(), AcpSchema.InitializeResponse.ok()));
+ return;
+ }
+
+ String connectionId = exchange.getRequestHeaders().getFirst("Acp-Connection-Id");
+ if (!CONNECTION_ID.equals(connectionId)) {
+ writeText(exchange, 400, "Acp-Connection-Id header required");
+ return;
+ }
+
+ String sessionId = exchange.getRequestHeaders().getFirst("Acp-Session-Id");
+ exchange.sendResponseHeaders(202, -1);
+ exchange.close();
+ handleAcceptedMessage(message, sessionId);
+ }
+
+ private void handleAcceptedMessage(AcpSchema.JSONRPCMessage message, String sessionHeader) {
+ if (message instanceof AcpSchema.JSONRPCResponse response) {
+ permissionResponseReceived.set(true);
+ permissionResponse.complete(response);
+ return;
+ }
+ if (!(message instanceof AcpSchema.JSONRPCRequest request)) {
+ return;
+ }
+
+ switch (request.method()) {
+ case AcpSchema.METHOD_SESSION_NEW -> {
+ String sessionId = "sess-" + sessionCounter.incrementAndGet();
+ send(CONNECTION_STREAM, response(request.id(),
+ new AcpSchema.NewSessionResponse(sessionId, null, null)));
+ }
+ case AcpSchema.METHOD_SESSION_LOAD -> {
+ String sessionId = sessionId(request.params());
+ sessionLoadStreamWasOpenBeforePost.set(sessionStreamOpened(sessionId));
+ send(CONNECTION_STREAM, response(request.id(), new AcpSchema.LoadSessionResponse(null, null)));
+ }
+ case AcpSchema.METHOD_SESSION_PROMPT -> handlePrompt(request, sessionHeader);
+ default -> send(CONNECTION_STREAM, response(request.id(), Map.of()));
+ }
+ }
+
+ private void handlePrompt(AcpSchema.JSONRPCRequest request, String sessionHeader) {
+ String sessionId = sessionId(request.params());
+ if (routePromptResponsesOnConnectionStream.get()) {
+ send(CONNECTION_STREAM, response(request.id(), AcpSchema.PromptResponse.endTurn()));
+ return;
+ }
+
+ if (requestText(request.params()).contains("permission")) {
+ String permissionId = "permission-1";
+ send(sessionKey(sessionId), new AcpSchema.JSONRPCRequest(AcpSchema.JSONRPC_VERSION, permissionId,
+ AcpSchema.METHOD_SESSION_REQUEST_PERMISSION, new AcpSchema.RequestPermissionRequest(sessionId,
+ new AcpSchema.ToolCallUpdate("tool-1", "Edit file", AcpSchema.ToolKind.EDIT,
+ AcpSchema.ToolCallStatus.PENDING, null, null, null, null),
+ List.of(new AcpSchema.PermissionOption("allow", "Allow", AcpSchema.PermissionOptionKind.ALLOW_ONCE)))));
+ try {
+ permissionResponse.get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
+ }
+ catch (Exception e) {
+ throw new AssertionError("Timed out waiting for permission response", e);
+ }
+ }
+ else {
+ send(sessionKey(sessionId), new AcpSchema.JSONRPCNotification(AcpSchema.METHOD_SESSION_UPDATE,
+ new AcpSchema.SessionNotification(sessionId,
+ new AcpSchema.AgentMessageChunk("agent_message_chunk", new AcpSchema.TextContent("hello")))));
+ }
+ send(sessionKey(sessionId), response(request.id(), AcpSchema.PromptResponse.endTurn()));
+ }
+
+ private void handleGet(HttpExchange exchange) throws IOException {
+ if (!accepts(exchange, CONTENT_TYPE_EVENT_STREAM)) {
+ writeText(exchange, 406, "client must accept text/event-stream");
+ return;
+ }
+ if (!CONNECTION_ID.equals(exchange.getRequestHeaders().getFirst("Acp-Connection-Id"))) {
+ writeText(exchange, 400, "Acp-Connection-Id header required");
+ return;
+ }
+
+ String sessionId = exchange.getRequestHeaders().getFirst("Acp-Session-Id");
+ String key = sessionId == null ? CONNECTION_STREAM : sessionKey(sessionId);
+ exchange.getResponseHeaders().add("Content-Type", CONTENT_TYPE_EVENT_STREAM);
+ exchange.sendResponseHeaders(200, 0);
+ SseStream stream = new SseStream(exchange.getResponseBody());
+ streams.put(key, stream);
+ stream.run();
+ }
+
+ private void handleDelete(HttpExchange exchange) throws IOException {
+ deleteReceived.set(true);
+ streams.values().forEach(SseStream::close);
+ writeText(exchange, 202, "");
+ }
+
+ private void send(String key, AcpSchema.JSONRPCMessage message) {
+ try {
+ SseStream stream = awaitStream(key);
+ stream.send(JSON_MAPPER.writeValueAsString(message));
+ }
+ catch (Exception e) {
+ throw new AssertionError("Failed to send SSE message on " + key, e);
+ }
+ }
+
+ private SseStream awaitStream(String key) throws InterruptedException {
+ long deadline = System.nanoTime() + TIMEOUT.toNanos();
+ while (System.nanoTime() < deadline) {
+ SseStream stream = streams.get(key);
+ if (stream != null) {
+ return stream;
+ }
+ Thread.sleep(10);
+ }
+ throw new AssertionError("Timed out waiting for SSE stream " + key);
+ }
+
+ private static AcpSchema.JSONRPCResponse response(Object id, Object result) {
+ return new AcpSchema.JSONRPCResponse(AcpSchema.JSONRPC_VERSION, id, result, null);
+ }
+
+ private static boolean accepts(HttpExchange exchange, String expected) {
+ return exchange.getRequestHeaders()
+ .getOrDefault("Accept", List.of())
+ .stream()
+ .map(String::toLowerCase)
+ .anyMatch(value -> value.contains(expected));
+ }
+
+ private static String sessionId(Object params) {
+ Object sessionId = JSON_MAPPER.convertValue(params, Map.class).get("sessionId");
+ return sessionId == null ? null : sessionId.toString();
+ }
+
+ private static String requestText(Object params) {
+ Map, ?> map = JSON_MAPPER.convertValue(params, Map.class);
+ Object prompt = map.get("prompt");
+ return prompt == null ? "" : prompt.toString();
+ }
+
+ private static String sessionKey(String sessionId) {
+ return "session:" + sessionId;
+ }
+
+ private static void writeJson(HttpExchange exchange, int status, Object body) throws IOException {
+ byte[] bytes = JSON_MAPPER.writeValueAsString(body).getBytes(StandardCharsets.UTF_8);
+ exchange.getResponseHeaders().add("Content-Type", CONTENT_TYPE_JSON);
+ exchange.sendResponseHeaders(status, bytes.length);
+ exchange.getResponseBody().write(bytes);
+ exchange.close();
+ }
+
+ private static void writeText(HttpExchange exchange, int status, String body) throws IOException {
+ byte[] bytes = body.getBytes(StandardCharsets.UTF_8);
+ exchange.sendResponseHeaders(status, bytes.length);
+ exchange.getResponseBody().write(bytes);
+ exchange.close();
+ }
+
+ private static int freePort() throws IOException {
+ try (ServerSocket socket = new ServerSocket(0)) {
+ return socket.getLocalPort();
+ }
+ }
+
+ @Override
+ public void close() {
+ streams.values().forEach(SseStream::close);
+ server.stop(0);
+ executor.shutdownNow();
+ }
+
+ }
+
+ private static final class SseStream {
+
+ private static final String CLOSE = "__close__";
+
+ private final OutputStream outputStream;
+
+ private final BlockingQueue queue = new LinkedBlockingQueue<>();
+
+ private final AtomicBoolean closed = new AtomicBoolean(false);
+
+ private SseStream(OutputStream outputStream) {
+ this.outputStream = outputStream;
+ }
+
+ void send(String json) {
+ queue.add(json);
+ }
+
+ void close() {
+ if (closed.compareAndSet(false, true)) {
+ queue.offer(CLOSE);
+ try {
+ outputStream.close();
+ }
+ catch (IOException ignored) {
+ }
+ }
+ }
+
+ void run() {
+ try {
+ while (true) {
+ String json = queue.take();
+ if (CLOSE.equals(json)) {
+ return;
+ }
+ byte[] bytes = ("data: " + json + "\n\n").getBytes(StandardCharsets.UTF_8);
+ outputStream.write(bytes);
+ outputStream.flush();
+ }
+ }
+ catch (Exception ignored) {
+ }
+ finally {
+ try {
+ outputStream.close();
+ }
+ catch (IOException ignored) {
+ }
+ }
+ }
+
+ }
+
+}
diff --git a/acp-core/src/test/java/com/agentclientprotocol/sdk/client/transport/StreamableHttpAcpClientTransportTest.java b/acp-core/src/test/java/com/agentclientprotocol/sdk/client/transport/StreamableHttpAcpClientTransportTest.java
new file mode 100644
index 0000000..5f9bec3
--- /dev/null
+++ b/acp-core/src/test/java/com/agentclientprotocol/sdk/client/transport/StreamableHttpAcpClientTransportTest.java
@@ -0,0 +1,179 @@
+/*
+ * Copyright 2025-2025 the original author or authors.
+ */
+
+package com.agentclientprotocol.sdk.client.transport;
+
+import java.io.ByteArrayInputStream;
+import java.io.InputStream;
+import java.net.URI;
+import java.net.http.HttpClient;
+import java.net.http.HttpHeaders;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.stream.Collectors;
+
+import com.agentclientprotocol.sdk.AcpTestFixtures;
+import com.agentclientprotocol.sdk.json.AcpJsonMapper;
+import com.agentclientprotocol.sdk.spec.AcpSchema;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import reactor.core.publisher.Mono;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+/**
+ * Unit tests for {@link StreamableHttpAcpClientTransport}.
+ */
+class StreamableHttpAcpClientTransportTest {
+
+ private AcpJsonMapper jsonMapper;
+
+ @BeforeEach
+ void setUp() {
+ jsonMapper = AcpJsonMapper.createDefault();
+ }
+
+ @Test
+ void constructorValidatesEndpointUri() {
+ assertThatThrownBy(() -> new StreamableHttpAcpClientTransport(null, jsonMapper))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("endpointUri");
+ }
+
+ @Test
+ void constructorValidatesJsonMapper() {
+ assertThatThrownBy(
+ () -> new StreamableHttpAcpClientTransport(URI.create("https://localhost:8443/acp"), null))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("JsonMapper");
+ }
+
+ @Test
+ void constructorRejectsNonHttpSchemes() {
+ assertThatThrownBy(() -> new StreamableHttpAcpClientTransport(URI.create("ws://localhost:8080/acp"), jsonMapper))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("http or https");
+ }
+
+ @Test
+ void constructorAcceptsCustomHttpClient() {
+ HttpClient httpClient = mock(HttpClient.class);
+
+ StreamableHttpAcpClientTransport transport = new StreamableHttpAcpClientTransport(
+ URI.create("https://localhost:8443/acp"), jsonMapper, httpClient);
+
+ assertThat(transport).isNotNull();
+ }
+
+ @Test
+ void routingModeIsConfigurable() {
+ StreamableHttpAcpClientTransport transport = new StreamableHttpAcpClientTransport(
+ URI.create("https://localhost:8443/acp"), jsonMapper)
+ .routingMode(StreamableHttpAcpClientTransport.RoutingMode.STRICT);
+
+ assertThat(transport).isNotNull();
+ }
+
+ @Test
+ void defaultAcpPathIsCorrect() {
+ assertThat(StreamableHttpAcpClientTransport.DEFAULT_ACP_PATH).isEqualTo("/acp");
+ }
+
+ @Test
+ void strictRoutingRejectsUnknownOutboundMethods() {
+ StreamableHttpAcpClientTransport transport = new StreamableHttpAcpClientTransport(
+ URI.create("https://localhost:8443/acp"), jsonMapper)
+ .routingMode(StreamableHttpAcpClientTransport.RoutingMode.STRICT);
+
+ transport.connect(message -> Mono.empty()).block();
+
+ assertThatThrownBy(() -> transport
+ .sendMessage(new AcpSchema.JSONRPCNotification(AcpSchema.JSONRPC_VERSION, "extension/custom",
+ Map.of("sessionId", "session-1")))
+ .block())
+ .hasMessageContaining("No explicit routing rule for outbound method extension/custom");
+ }
+
+ @Test
+ void concurrentSessionLoadsReuseInFlightSessionStreamOpen() throws Exception {
+ HttpClient httpClient = mock(HttpClient.class);
+ AtomicInteger sessionGetCount = new AtomicInteger();
+ CountDownLatch sessionGetStarted = new CountDownLatch(1);
+ CompletableFuture> sessionStreamResponse = new CompletableFuture<>();
+
+ when(httpClient.sendAsync(any(), any())).thenAnswer(invocation -> {
+ HttpRequest request = invocation.getArgument(0);
+ if ("POST".equals(request.method())
+ && request.headers().firstValue("Acp-Connection-Id").isEmpty()) {
+ String initializeResponse = jsonMapper.writeValueAsString(AcpTestFixtures
+ .createJsonRpcResponse("init-1", AcpTestFixtures.createInitializeResponse()));
+ return CompletableFuture.completedFuture(response(200,
+ Map.of("Content-Type", "application/json", "Acp-Connection-Id", "conn-1"),
+ initializeResponse));
+ }
+ if ("GET".equals(request.method())
+ && request.headers().firstValue("Acp-Session-Id").isEmpty()) {
+ return CompletableFuture.completedFuture(
+ response(200, Map.of("Content-Type", "text/event-stream"), emptyBody()));
+ }
+ if ("GET".equals(request.method())) {
+ sessionGetCount.incrementAndGet();
+ sessionGetStarted.countDown();
+ return sessionStreamResponse;
+ }
+ if ("POST".equals(request.method())) {
+ return CompletableFuture.completedFuture(response(202, Map.of(), null));
+ }
+ return CompletableFuture.completedFuture(response(202, Map.of(), null));
+ });
+
+ StreamableHttpAcpClientTransport transport = new StreamableHttpAcpClientTransport(
+ URI.create("https://localhost:8443/acp"), jsonMapper, httpClient);
+ transport.setExceptionHandler(error -> {
+ });
+ transport.connect(message -> Mono.empty()).block();
+ transport.sendMessage(AcpTestFixtures.createJsonRpcRequest(AcpSchema.METHOD_INITIALIZE, "init-1",
+ AcpTestFixtures.createInitializeRequest()))
+ .block();
+
+ CompletableFuture loads = Mono.when(
+ transport.sendMessage(AcpTestFixtures.createJsonRpcRequest(AcpSchema.METHOD_SESSION_LOAD, "load-1",
+ new AcpSchema.LoadSessionRequest("sess-1", "/workspace", List.of()))),
+ transport.sendMessage(AcpTestFixtures.createJsonRpcRequest(AcpSchema.METHOD_SESSION_LOAD, "load-2",
+ new AcpSchema.LoadSessionRequest("sess-1", "/workspace", List.of()))))
+ .toFuture();
+
+ assertThat(sessionGetStarted.await(1, TimeUnit.SECONDS)).isTrue();
+ assertThat(sessionGetCount).hasValue(1);
+
+ sessionStreamResponse.complete(response(200, Map.of("Content-Type", "text/event-stream"), emptyBody()));
+ loads.get(1, TimeUnit.SECONDS);
+ }
+
+ private InputStream emptyBody() {
+ return new ByteArrayInputStream(new byte[0]);
+ }
+
+ private HttpResponse response(int statusCode, Map headers, T body) {
+ HttpResponse response = mock(HttpResponse.class);
+ when(response.statusCode()).thenReturn(statusCode);
+ when(response.headers()).thenReturn(HttpHeaders.of(headers.entrySet()
+ .stream()
+ .collect(Collectors.toMap(Map.Entry::getKey, entry -> List.of(entry.getValue()))),
+ (name, value) -> true));
+ when(response.body()).thenReturn(body);
+ return response;
+ }
+
+}
diff --git a/acp-core/src/test/java/com/agentclientprotocol/sdk/spec/AcpAgentSessionTest.java b/acp-core/src/test/java/com/agentclientprotocol/sdk/spec/AcpAgentSessionTest.java
index 4ce255d..4d46cf5 100644
--- a/acp-core/src/test/java/com/agentclientprotocol/sdk/spec/AcpAgentSessionTest.java
+++ b/acp-core/src/test/java/com/agentclientprotocol/sdk/spec/AcpAgentSessionTest.java
@@ -5,16 +5,18 @@
package com.agentclientprotocol.sdk.spec;
import java.time.Duration;
-import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicReference;
import com.agentclientprotocol.sdk.test.InMemoryTransportPair;
import org.junit.jupiter.api.Test;
import reactor.core.publisher.Mono;
+import reactor.core.publisher.Sinks;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertThrows;
@@ -26,6 +28,18 @@ class AcpAgentSessionTest {
private static final Duration TIMEOUT = Duration.ofSeconds(5);
+ private static final Duration PROMPT_RESPONSE_DELAY = Duration.ofMillis(250);
+
+ private static final long AGENT_TRANSPORT_SUBSCRIPTION_DELAY_MILLIS = 100;
+
+ private static final long CLIENT_TRANSPORT_SUBSCRIPTION_DELAY_MILLIS = 50;
+
+ private static final int ACTIVE_PROMPT_ERROR_CODE = -32000;
+
+ private static final String SESSION_1 = "session-1";
+
+ private static final String SESSION_2 = "session-2";
+
@Test
void constructorValidatesArguments() {
var transportPair = InMemoryTransportPair.create();
@@ -59,8 +73,7 @@ void handlesIncomingRequest() throws Exception {
new AcpAgentSession(TIMEOUT, transportPair.agentTransport(), requestHandlers, Map.of());
- // Allow transport to start
- Thread.sleep(100);
+ allowAgentTransportSubscription();
// Send a request from the client side
CountDownLatch latch = new CountDownLatch(1);
@@ -74,7 +87,7 @@ void handlesIncomingRequest() throws Exception {
latch.countDown();
}).then(Mono.empty())).subscribe();
- Thread.sleep(50);
+ allowClientTransportSubscription();
transportPair.clientTransport().sendMessage(request).block(TIMEOUT);
assertThat(latch.await(5, TimeUnit.SECONDS)).isTrue();
@@ -96,7 +109,7 @@ void handlesMethodNotFound() throws Exception {
// Create session with no handlers
new AcpAgentSession(TIMEOUT, transportPair.agentTransport(), Map.of(), Map.of());
- Thread.sleep(100);
+ allowAgentTransportSubscription();
// Send a request for unknown method
CountDownLatch latch = new CountDownLatch(1);
@@ -110,7 +123,7 @@ void handlesMethodNotFound() throws Exception {
latch.countDown();
}).then(Mono.empty())).subscribe();
- Thread.sleep(50);
+ allowClientTransportSubscription();
transportPair.clientTransport().sendMessage(request).block(TIMEOUT);
assertThat(latch.await(5, TimeUnit.SECONDS)).isTrue();
@@ -140,14 +153,14 @@ void handlesNotification() throws Exception {
new AcpAgentSession(TIMEOUT, transportPair.agentTransport(), Map.of(), notificationHandlers);
- Thread.sleep(100);
+ allowAgentTransportSubscription();
// Send a notification from client
AcpSchema.JSONRPCNotification notification = new AcpSchema.JSONRPCNotification(AcpSchema.JSONRPC_VERSION,
- AcpSchema.METHOD_SESSION_CANCEL, new AcpSchema.CancelNotification("session-1"));
+ AcpSchema.METHOD_SESSION_CANCEL, new AcpSchema.CancelNotification(SESSION_1));
transportPair.clientTransport().connect(mono -> mono.then(Mono.empty())).subscribe();
- Thread.sleep(50);
+ allowClientTransportSubscription();
transportPair.clientTransport().sendMessage(notification).block(TIMEOUT);
assertThat(notificationLatch.await(5, TimeUnit.SECONDS)).isTrue();
@@ -159,70 +172,115 @@ void handlesNotification() throws Exception {
}
@Test
- void singleTurnEnforcementRejectsConcurrentPrompts() throws Exception {
+ void singleTurnEnforcementRejectsConcurrentPromptsForSameSession() throws Exception {
var transportPair = InMemoryTransportPair.create();
try {
- // Create a handler that uses a Mono.delay to simulate async processing
- AtomicReference promptCanProceedRef = new AtomicReference<>(new CountDownLatch(1));
+ CountDownLatch handlerStarted = new CountDownLatch(1);
+ AtomicInteger handlerInvocations = new AtomicInteger();
Map> requestHandlers = Map.of(AcpSchema.METHOD_SESSION_PROMPT,
params -> Mono.defer(() -> {
- // First call gets blocked, second call should be rejected before getting here
- return Mono.delay(Duration.ofMillis(100))
+ handlerInvocations.incrementAndGet();
+ handlerStarted.countDown();
+ return Mono.delay(PROMPT_RESPONSE_DELAY)
.map(ignored -> new AcpSchema.PromptResponse(AcpSchema.StopReason.END_TURN));
}));
AcpAgentSession session = new AcpAgentSession(TIMEOUT, transportPair.agentTransport(), requestHandlers,
Map.of());
- Thread.sleep(100);
-
- // Manually set active prompt to simulate an in-progress prompt
- // We use reflection to access the activePrompt field for testing
- java.lang.reflect.Field activePromptField = AcpAgentSession.class.getDeclaredField("activePrompt");
- activePromptField.setAccessible(true);
- @SuppressWarnings("unchecked")
- AtomicReference activePromptRef = (AtomicReference) activePromptField.get(session);
-
- // Create an ActivePrompt instance using reflection
- Class> activePromptClass = Class.forName(
- "com.agentclientprotocol.sdk.spec.AcpAgentSession$ActivePrompt");
- java.lang.reflect.Constructor> constructor = activePromptClass.getDeclaredConstructor(String.class,
- Object.class);
- constructor.setAccessible(true);
- Object activePrompt = constructor.newInstance("session-1", "existing-request-id");
- activePromptRef.set(activePrompt);
-
- // Verify active prompt is set
- assertThat(session.hasActivePrompt()).isTrue();
+ allowAgentTransportSubscription();
- // Set up client to receive response
- CountDownLatch responseLatch = new CountDownLatch(1);
- AtomicReference response = new AtomicReference<>();
+ CountDownLatch responseLatch = new CountDownLatch(2);
+ List responses = new CopyOnWriteArrayList<>();
transportPair.clientTransport().connect(mono -> mono.doOnNext(msg -> {
- response.set((AcpSchema.JSONRPCResponse) msg);
+ if (msg instanceof AcpSchema.JSONRPCResponse response) {
+ responses.add(response);
+ }
responseLatch.countDown();
}).then(Mono.empty())).subscribe();
- Thread.sleep(50);
+ allowClientTransportSubscription();
- // Send prompt request while another is "active"
- Map params = new HashMap<>();
- params.put("sessionId", "session-1");
- params.put("prompt", List.of(new AcpSchema.TextContent("Hello")));
- AcpSchema.JSONRPCRequest request = new AcpSchema.JSONRPCRequest(AcpSchema.JSONRPC_VERSION, "1",
- AcpSchema.METHOD_SESSION_PROMPT, params);
- transportPair.clientTransport().sendMessage(request).block(TIMEOUT);
+ transportPair.clientTransport().sendMessage(promptRequest("1", SESSION_1, "first")).block(TIMEOUT);
+ assertThat(handlerStarted.await(5, TimeUnit.SECONDS)).isTrue();
+ assertThat(session.hasActivePrompt(SESSION_1)).isTrue();
+
+ transportPair.clientTransport().sendMessage(promptRequest("2", SESSION_1, "second")).block(TIMEOUT);
+
+ assertThat(responseLatch.await(5, TimeUnit.SECONDS)).isTrue();
+
+ AcpSchema.JSONRPCResponse rejectedResponse = responseById(responses, "2");
+ assertThat(rejectedResponse.error()).isNotNull();
+ assertThat(rejectedResponse.error().code()).isEqualTo(ACTIVE_PROMPT_ERROR_CODE);
+ assertThat(rejectedResponse.error().message()).contains("already an active prompt");
+ assertThat(handlerInvocations.get()).isEqualTo(1);
+ assertThat(session.hasActivePrompt()).isFalse();
+ }
+ finally {
+ transportPair.closeGracefully().block(TIMEOUT);
+ }
+ }
+
+ @Test
+ void singleTurnEnforcementAllowsConcurrentPromptsForDifferentSessions() throws Exception {
+ var transportPair = InMemoryTransportPair.create();
+ try {
+ CountDownLatch handlersStarted = new CountDownLatch(2);
+ AtomicInteger handlerInvocations = new AtomicInteger();
+ Sinks.One session1Release = Sinks.one();
+ Sinks.One session2Release = Sinks.one();
+
+ Map> requestHandlers = Map.of(AcpSchema.METHOD_SESSION_PROMPT,
+ params -> Mono.defer(() -> {
+ handlerInvocations.incrementAndGet();
+ handlersStarted.countDown();
+ String sessionId = sessionId(params);
+ Sinks.One release = SESSION_1.equals(sessionId) ? session1Release : session2Release;
+ return release.asMono()
+ .thenReturn(new AcpSchema.PromptResponse(AcpSchema.StopReason.END_TURN));
+ }));
+
+ AcpAgentSession session = new AcpAgentSession(TIMEOUT, transportPair.agentTransport(), requestHandlers,
+ Map.of());
+
+ allowAgentTransportSubscription();
+
+ CountDownLatch responseLatch = new CountDownLatch(2);
+ List responses = new CopyOnWriteArrayList<>();
+
+ transportPair.clientTransport().connect(mono -> mono.doOnNext(msg -> {
+ if (msg instanceof AcpSchema.JSONRPCResponse response) {
+ responses.add(response);
+ }
+ responseLatch.countDown();
+ }).then(Mono.empty())).subscribe();
+
+ allowClientTransportSubscription();
+
+ transportPair.clientTransport().sendMessage(promptRequest("1", SESSION_1, "first")).block(TIMEOUT);
+ transportPair.clientTransport().sendMessage(promptRequest("2", SESSION_2, "second")).block(TIMEOUT);
+
+ assertThat(handlersStarted.await(5, TimeUnit.SECONDS)).isTrue();
+ assertThat(session.hasActivePrompt(SESSION_1)).isTrue();
+ assertThat(session.hasActivePrompt(SESSION_2)).isTrue();
+ assertThat(session.getActivePromptSessionIds()).containsExactlyInAnyOrder(SESSION_1, SESSION_2);
+
+ // Release the responses one at a time. The in-memory test transport uses a
+ // unicast sink, so simultaneous emissions from concurrent prompt handlers can
+ // fail with FAIL_NON_SERIALIZED and obscure the behavior under test.
+ session1Release.tryEmitValue(null);
+ awaitResponse(responses, "1");
+ session2Release.tryEmitValue(null);
- // Wait for response
assertThat(responseLatch.await(5, TimeUnit.SECONDS)).isTrue();
- // Should be rejected with error
- assertThat(response.get()).isNotNull();
- assertThat(response.get().error()).isNotNull();
- assertThat(response.get().error().code()).isEqualTo(-32000);
- assertThat(response.get().error().message()).contains("already an active prompt");
+ assertThat(responseById(responses, "1").error()).isNull();
+ assertThat(responseById(responses, "2").error()).isNull();
+ assertThat(handlerInvocations.get()).isEqualTo(2);
+ assertThat(session.hasActivePrompt()).isFalse();
+ assertThat(session.getActivePromptSessionIds()).isEmpty();
}
finally {
transportPair.closeGracefully().block(TIMEOUT);
@@ -233,42 +291,43 @@ void singleTurnEnforcementRejectsConcurrentPrompts() throws Exception {
void hasActivePromptReturnsCorrectState() throws Exception {
var transportPair = InMemoryTransportPair.create();
try {
+ CountDownLatch handlerStarted = new CountDownLatch(1);
+
Map> requestHandlers = Map.of(AcpSchema.METHOD_SESSION_PROMPT,
- params -> Mono.just(new AcpSchema.PromptResponse(AcpSchema.StopReason.END_TURN)));
+ params -> Mono.defer(() -> {
+ handlerStarted.countDown();
+ return Mono.delay(PROMPT_RESPONSE_DELAY)
+ .map(ignored -> new AcpSchema.PromptResponse(AcpSchema.StopReason.END_TURN));
+ }));
AcpAgentSession session = new AcpAgentSession(TIMEOUT, transportPair.agentTransport(), requestHandlers,
Map.of());
- Thread.sleep(100);
+ allowAgentTransportSubscription();
- // Initially no active prompt
assertThat(session.hasActivePrompt()).isFalse();
+ assertThat(session.hasActivePrompt(SESSION_1)).isFalse();
assertThat(session.getActivePromptSessionId()).isNull();
+ assertThat(session.getActivePromptSessionIds()).isEmpty();
+
+ CountDownLatch responseLatch = new CountDownLatch(1);
+ transportPair.clientTransport().connect(mono -> mono.doOnNext(msg -> responseLatch.countDown())
+ .then(Mono.empty())).subscribe();
- // Manually set active prompt using reflection to test the getter methods
- java.lang.reflect.Field activePromptField = AcpAgentSession.class.getDeclaredField("activePrompt");
- activePromptField.setAccessible(true);
- @SuppressWarnings("unchecked")
- AtomicReference activePromptRef = (AtomicReference) activePromptField.get(session);
-
- // Create an ActivePrompt instance using reflection
- Class> activePromptClass = Class.forName(
- "com.agentclientprotocol.sdk.spec.AcpAgentSession$ActivePrompt");
- java.lang.reflect.Constructor> constructor = activePromptClass.getDeclaredConstructor(String.class,
- Object.class);
- constructor.setAccessible(true);
- Object activePrompt = constructor.newInstance("session-1", "request-1");
- activePromptRef.set(activePrompt);
-
- // Now there should be an active prompt
+ allowClientTransportSubscription();
+ transportPair.clientTransport().sendMessage(promptRequest("1", SESSION_1, "hello")).block(TIMEOUT);
+
+ assertThat(handlerStarted.await(5, TimeUnit.SECONDS)).isTrue();
assertThat(session.hasActivePrompt()).isTrue();
- assertThat(session.getActivePromptSessionId()).isEqualTo("session-1");
+ assertThat(session.hasActivePrompt(SESSION_1)).isTrue();
+ assertThat(session.getActivePromptSessionIds()).containsExactly(SESSION_1);
+ assertThat(session.getActivePromptSessionId()).isEqualTo(SESSION_1);
- // Clear active prompt
- activePromptRef.set(null);
+ assertThat(responseLatch.await(5, TimeUnit.SECONDS)).isTrue();
- // Active prompt should be cleared
assertThat(session.hasActivePrompt()).isFalse();
+ assertThat(session.hasActivePrompt(SESSION_1)).isFalse();
+ assertThat(session.getActivePromptSessionIds()).isEmpty();
assertThat(session.getActivePromptSessionId()).isNull();
}
finally {
@@ -282,7 +341,7 @@ void closeGracefullyCompletes() throws Exception {
AcpAgentSession session = new AcpAgentSession(TIMEOUT, transportPair.agentTransport(), Map.of(), Map.of());
- Thread.sleep(100);
+ allowAgentTransportSubscription();
// Should complete without error
session.closeGracefully().block(TIMEOUT);
@@ -299,7 +358,7 @@ void handlerErrorReturnsJsonRpcError() throws Exception {
new AcpAgentSession(TIMEOUT, transportPair.agentTransport(), requestHandlers, Map.of());
- Thread.sleep(100);
+ allowAgentTransportSubscription();
CountDownLatch latch = new CountDownLatch(1);
AtomicReference response = new AtomicReference<>();
@@ -312,7 +371,7 @@ void handlerErrorReturnsJsonRpcError() throws Exception {
latch.countDown();
}).then(Mono.empty())).subscribe();
- Thread.sleep(50);
+ allowClientTransportSubscription();
transportPair.clientTransport().sendMessage(request).block(TIMEOUT);
assertThat(latch.await(5, TimeUnit.SECONDS)).isTrue();
@@ -327,4 +386,43 @@ void handlerErrorReturnsJsonRpcError() throws Exception {
}
}
+ private static AcpSchema.JSONRPCRequest promptRequest(String id, String sessionId, String text) {
+ return new AcpSchema.JSONRPCRequest(AcpSchema.JSONRPC_VERSION, id, AcpSchema.METHOD_SESSION_PROMPT,
+ new AcpSchema.PromptRequest(sessionId, List.of(new AcpSchema.TextContent(text))));
+ }
+
+ private static AcpSchema.JSONRPCResponse responseById(List responses, Object id) {
+ return responses.stream().filter(response -> id.equals(response.id())).findFirst().orElseThrow();
+ }
+
+ private static void awaitResponse(List responses, Object id) throws InterruptedException {
+ long deadline = System.nanoTime() + TIMEOUT.toNanos();
+ while (System.nanoTime() < deadline) {
+ if (responses.stream().anyMatch(response -> id.equals(response.id()))) {
+ return;
+ }
+ Thread.sleep(10);
+ }
+ }
+
+ private static String sessionId(Object params) {
+ if (params instanceof AcpSchema.PromptRequest promptRequest) {
+ return promptRequest.sessionId();
+ }
+ throw new IllegalArgumentException("Expected PromptRequest params but received " + params);
+ }
+
+ private static void allowAgentTransportSubscription() throws InterruptedException {
+ // AcpAgentSession subscribes to the in-memory transport in its constructor.
+ // subscribe() is asynchronous, so give the unicast sink subscriber a short
+ // window to attach before the test sends client messages.
+ Thread.sleep(AGENT_TRANSPORT_SUBSCRIPTION_DELAY_MILLIS);
+ }
+
+ private static void allowClientTransportSubscription() throws InterruptedException {
+ // clientTransport.connect(...).subscribe() also attaches asynchronously. Without
+ // this small wait, an immediate agent response can race the test subscriber.
+ Thread.sleep(CLIENT_TRANSPORT_SUBSCRIPTION_DELAY_MILLIS);
+ }
+
}
diff --git a/acp-streamable-http-jetty/pom.xml b/acp-streamable-http-jetty/pom.xml
new file mode 100644
index 0000000..c31405a
--- /dev/null
+++ b/acp-streamable-http-jetty/pom.xml
@@ -0,0 +1,68 @@
+
+
+ 4.0.0
+
+
+ com.agentclientprotocol
+ acp-java-sdk
+ 0.12.0-SNAPSHOT
+
+
+ acp-streamable-http-jetty
+ jar
+
+ ACP Streamable HTTP Jetty
+ Streamable HTTP agent transport using Jetty for listener-backed remote agents
+
+
+
+ com.agentclientprotocol
+ acp-core
+
+
+
+ org.eclipse.jetty
+ jetty-server
+
+
+ org.eclipse.jetty.ee10
+ jetty-ee10-servlet
+
+
+ org.eclipse.jetty.http2
+ jetty-http2-server
+
+
+ org.eclipse.jetty.websocket
+ jetty-websocket-jetty-server
+
+
+ org.eclipse.jetty.websocket
+ jetty-websocket-jetty-api
+
+
+
+ org.junit.jupiter
+ junit-jupiter
+ test
+
+
+ org.assertj
+ assertj-core
+ test
+
+
+ ch.qos.logback
+ logback-classic
+ test
+
+
+ io.projectreactor
+ reactor-test
+ test
+
+
+
+
diff --git a/acp-streamable-http-jetty/src/main/java/com/agentclientprotocol/sdk/agent/transport/StreamableHttpAcpAgentTransport.java b/acp-streamable-http-jetty/src/main/java/com/agentclientprotocol/sdk/agent/transport/StreamableHttpAcpAgentTransport.java
new file mode 100644
index 0000000..6b40605
--- /dev/null
+++ b/acp-streamable-http-jetty/src/main/java/com/agentclientprotocol/sdk/agent/transport/StreamableHttpAcpAgentTransport.java
@@ -0,0 +1,1144 @@
+/*
+ * Copyright 2025-2026 the original author or authors.
+ */
+
+package com.agentclientprotocol.sdk.agent.transport;
+
+import java.io.IOException;
+import java.io.PrintWriter;
+import java.nio.channels.ClosedChannelException;
+import java.nio.charset.StandardCharsets;
+import java.time.Duration;
+import java.util.ArrayDeque;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.UUID;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ConcurrentMap;
+import java.util.concurrent.CopyOnWriteArrayList;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+import com.agentclientprotocol.sdk.agent.AcpAgentFactory;
+import com.agentclientprotocol.sdk.error.AcpConnectionException;
+import com.agentclientprotocol.sdk.json.AcpJsonMapper;
+import com.agentclientprotocol.sdk.json.TypeRef;
+import com.agentclientprotocol.sdk.spec.AcpSchema;
+import com.agentclientprotocol.sdk.spec.AcpSchema.JSONRPCMessage;
+import com.agentclientprotocol.sdk.util.Assert;
+import jakarta.servlet.AsyncContext;
+import jakarta.servlet.ServletException;
+import jakarta.servlet.http.HttpServlet;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import org.eclipse.jetty.ee10.servlet.ServletContextHandler;
+import org.eclipse.jetty.ee10.servlet.ServletHolder;
+import org.eclipse.jetty.http2.server.HTTP2CServerConnectionFactory;
+import org.eclipse.jetty.server.HttpConfiguration;
+import org.eclipse.jetty.server.HttpConnectionFactory;
+import org.eclipse.jetty.server.Server;
+import org.eclipse.jetty.server.ServerConnector;
+import org.eclipse.jetty.websocket.api.Callback;
+import org.eclipse.jetty.websocket.api.Session;
+import org.eclipse.jetty.websocket.api.StatusCode;
+import org.eclipse.jetty.websocket.api.annotations.OnWebSocketClose;
+import org.eclipse.jetty.websocket.api.annotations.OnWebSocketError;
+import org.eclipse.jetty.websocket.api.annotations.OnWebSocketMessage;
+import org.eclipse.jetty.websocket.api.annotations.OnWebSocketOpen;
+import org.eclipse.jetty.websocket.api.annotations.WebSocket;
+import org.eclipse.jetty.websocket.server.WebSocketUpgradeHandler;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import reactor.core.publisher.Mono;
+import reactor.core.publisher.Sinks;
+
+/**
+ * Listener-backed ACP Streamable HTTP transport for agents.
+ *
+ *
+ * This transport hosts the ACP Streamable HTTP endpoint on Jetty, including POST/SSE
+ * request handling and WebSocket upgrades on the same path. It creates one fresh agent
+ * runtime per remote ACP connection through {@link AcpAgentFactory}. The accepted
+ * connection then owns its own per-connection {@link RemoteAcpConnection}, while the
+ * listener remains responsible only for wire-level concerns such as headers, SSE
+ * streams, WebSocket frames, and request routing.
+ *
+ *
+ *
+ * WebSocket support is intentionally hosted here instead of as a separate public
+ * listener so one {@code /acp} endpoint can behave like the RFD and the Rust
+ * {@code AcpHttpServer}: HTTP requests fall through to the servlet, while valid
+ * WebSocket upgrade requests are accepted by Jetty's {@link WebSocketUpgradeHandler}.
+ *
+ *
+ * @author Kaiser Dandangi
+ */
+public class StreamableHttpAcpAgentTransport {
+
+ private static final Logger logger = LoggerFactory.getLogger(StreamableHttpAcpAgentTransport.class);
+
+ public static final String DEFAULT_ACP_PATH = "/acp";
+
+ private static final String HEADER_CONNECTION_ID = "Acp-Connection-Id";
+
+ private static final String HEADER_SESSION_ID = "Acp-Session-Id";
+
+ private static final String CONTENT_TYPE_JSON = "application/json";
+
+ private static final String CONTENT_TYPE_EVENT_STREAM = "text/event-stream";
+
+ private static final int MAX_REPLAY_EVENTS = 1024;
+
+ private static final Duration INITIALIZE_TIMEOUT = Duration.ofSeconds(30);
+
+ /**
+ * Controls whether unknown message methods may fall back to shape-based routing.
+ */
+ public enum RoutingMode {
+
+ /**
+ * Prefer explicit ACP routing and fall back to session-id shape inference for
+ * extension methods. Also permits provisional session streams before
+ * {@code session/load} so the currently ambiguous resume flow can work.
+ */
+ COMPATIBLE,
+
+ /**
+ * Require explicit routing rules and reject unknown session streams.
+ */
+ STRICT
+
+ }
+
+ private enum ScopeKind {
+
+ CONNECTION,
+
+ SESSION
+
+ }
+
+ private enum RequestKind {
+
+ INITIALIZE,
+
+ SESSION_NEW,
+
+ SESSION_LOAD,
+
+ GENERIC
+
+ }
+
+ private enum SessionState {
+
+ PENDING_LOAD,
+
+ KNOWN
+
+ }
+
+ private record RouteScope(ScopeKind kind, String sessionId) {
+
+ static RouteScope connection() {
+ return new RouteScope(ScopeKind.CONNECTION, null);
+ }
+
+ static RouteScope session(String sessionId) {
+ return new RouteScope(ScopeKind.SESSION, sessionId);
+ }
+
+ boolean isSession() {
+ return kind == ScopeKind.SESSION;
+ }
+
+ }
+
+ private record ClientRequestRoute(RequestKind kind, RouteScope requestScope, RouteScope responseScope) {
+ }
+
+ private record ResolvedInboundRoute(JSONRPCMessage message, RouteScope requestScope,
+ ClientRequestRoute requestRoute) {
+ }
+
+ private final int configuredPort;
+
+ private final String path;
+
+ private final AcpJsonMapper jsonMapper;
+
+ private final AcpAgentFactory agentFactory;
+
+ private final ConcurrentMap connections = new ConcurrentHashMap<>();
+
+ private final ConcurrentMap webSocketConnections = new ConcurrentHashMap<>();
+
+ private final AtomicBoolean started = new AtomicBoolean(false);
+
+ private final AtomicBoolean closing = new AtomicBoolean(false);
+
+ private final Sinks.One terminationSink = Sinks.one();
+
+ private volatile RoutingMode routingMode = RoutingMode.COMPATIBLE;
+
+ private volatile Server server;
+
+ private volatile ServerConnector connector;
+
+ /**
+ * Creates a new Streamable HTTP listener on the default ACP path.
+ * @param port port to listen on
+ * @param jsonMapper JSON mapper used for serialization
+ * @param agentFactory factory used to create one agent runtime per connection
+ */
+ public StreamableHttpAcpAgentTransport(int port, AcpJsonMapper jsonMapper, AcpAgentFactory agentFactory) {
+ this(port, DEFAULT_ACP_PATH, jsonMapper, agentFactory);
+ }
+
+ /**
+ * Creates a new Streamable HTTP listener.
+ * @param port port to listen on
+ * @param path endpoint path
+ * @param jsonMapper JSON mapper used for serialization
+ * @param agentFactory factory used to create one agent runtime per connection
+ */
+ public StreamableHttpAcpAgentTransport(int port, String path, AcpJsonMapper jsonMapper,
+ AcpAgentFactory agentFactory) {
+ Assert.isTrue(port > 0, "Port must be positive");
+ Assert.hasText(path, "Path must not be empty");
+ Assert.notNull(jsonMapper, "The JsonMapper can not be null");
+ Assert.notNull(agentFactory, "The agentFactory can not be null");
+ this.configuredPort = port;
+ this.path = path;
+ this.jsonMapper = jsonMapper;
+ this.agentFactory = agentFactory;
+ }
+
+ /**
+ * Sets the routing mode used by the listener.
+ * @param routingMode routing mode to use
+ * @return this transport
+ */
+ public StreamableHttpAcpAgentTransport routingMode(RoutingMode routingMode) {
+ Assert.notNull(routingMode, "The routingMode can not be null");
+ this.routingMode = routingMode;
+ return this;
+ }
+
+ /**
+ * Starts the embedded Jetty server.
+ * @return a mono that completes when the listener is ready
+ */
+ public Mono start() {
+ if (!started.compareAndSet(false, true)) {
+ return Mono.error(new IllegalStateException("Already started"));
+ }
+
+ return Mono.fromCallable(() -> {
+ Server jettyServer = new Server();
+ HttpConfiguration httpConfig = new HttpConfiguration();
+ ServerConnector jettyConnector = new ServerConnector(jettyServer,
+ new HttpConnectionFactory(httpConfig), new HTTP2CServerConnectionFactory(httpConfig));
+ jettyConnector.setPort(configuredPort);
+ jettyServer.addConnector(jettyConnector);
+
+ ServletContextHandler context = new ServletContextHandler();
+ context.setContextPath("/");
+ context.addServlet(new ServletHolder(new AcpServlet()), path);
+
+ WebSocketUpgradeHandler webSocketHandler = WebSocketUpgradeHandler.from(jettyServer, context, container -> {
+ container.setIdleTimeout(Duration.ofMinutes(30));
+ container.addMapping(path, (request, response, callback) -> {
+ WebSocketConnectionState connection = createWebSocketConnection();
+ try {
+ connection.start();
+ webSocketConnections.put(connection.id(), connection);
+ response.getHeaders().put(HEADER_CONNECTION_ID, connection.id());
+ return new AcpWebSocketEndpoint(connection);
+ }
+ catch (Exception e) {
+ connection.close();
+ callback.failed(e);
+ return null;
+ }
+ });
+ });
+ context.insertHandler(webSocketHandler);
+ jettyServer.setHandler(context);
+
+ jettyServer.start();
+ this.server = jettyServer;
+ this.connector = jettyConnector;
+ logger.info("Streamable HTTP agent listener started on port {} at path {}", getPort(), path);
+ return null;
+ }).then();
+ }
+
+ /**
+ * Returns the bound port.
+ * @return listener port
+ */
+ public int getPort() {
+ ServerConnector currentConnector = this.connector;
+ return currentConnector != null ? currentConnector.getLocalPort() : configuredPort;
+ }
+
+ /**
+ * Closes all active connections and stops the listener.
+ * @return a mono that completes when shutdown finishes
+ */
+ public Mono closeGracefully() {
+ return Mono.fromRunnable(() -> {
+ if (!closing.compareAndSet(false, true)) {
+ return;
+ }
+ connections.values().forEach(ConnectionState::close);
+ connections.clear();
+ webSocketConnections.values().forEach(WebSocketConnectionState::close);
+ webSocketConnections.clear();
+ Server currentServer = this.server;
+ if (currentServer != null) {
+ try {
+ currentServer.stop();
+ }
+ catch (Exception e) {
+ throw new AcpConnectionException("Failed to stop Streamable HTTP listener", e);
+ }
+ }
+ terminationSink.tryEmitValue(null);
+ });
+ }
+
+ /**
+ * Returns a mono that completes once the listener terminates.
+ * @return termination mono
+ */
+ public Mono awaitTermination() {
+ return terminationSink.asMono();
+ }
+
+ int activeConnectionCount() {
+ return connections.size() + webSocketConnections.size();
+ }
+
+ private ConnectionState createConnection() {
+ String connectionId = UUID.randomUUID().toString();
+ ConnectionState connection = new ConnectionState(connectionId);
+ connection.start();
+ return connection;
+ }
+
+ private WebSocketConnectionState createWebSocketConnection() {
+ String connectionId = UUID.randomUUID().toString();
+ return new WebSocketConnectionState(connectionId);
+ }
+
+ private boolean isInitializeRequest(JSONRPCMessage message) {
+ return message instanceof AcpSchema.JSONRPCRequest request
+ && AcpSchema.METHOD_INITIALIZE.equals(request.method()) && request.id() != null;
+ }
+
+ private final class AcpServlet extends HttpServlet {
+
+ @Override
+ protected void doPost(HttpServletRequest request, HttpServletResponse response)
+ throws ServletException, IOException {
+ if (!hasContentType(request, CONTENT_TYPE_JSON)) {
+ writeText(response, HttpServletResponse.SC_UNSUPPORTED_MEDIA_TYPE,
+ "Content-Type must be application/json");
+ return;
+ }
+
+ String body = new String(request.getInputStream().readAllBytes(), StandardCharsets.UTF_8);
+ if (body.stripLeading().startsWith("[")) {
+ writeText(response, HttpServletResponse.SC_NOT_IMPLEMENTED, "JSON-RPC batches are not supported");
+ return;
+ }
+
+ JSONRPCMessage message;
+ try {
+ message = AcpSchema.deserializeJsonRpcMessage(jsonMapper, body);
+ }
+ catch (Exception e) {
+ writeText(response, HttpServletResponse.SC_BAD_REQUEST, "Invalid JSON-RPC");
+ return;
+ }
+
+ if (isInitialize(message)) {
+ handleInitialize(request, response, (AcpSchema.JSONRPCRequest) message);
+ return;
+ }
+
+ String connectionId = header(request, HEADER_CONNECTION_ID).orElse(null);
+ if (connectionId == null) {
+ writeText(response, HttpServletResponse.SC_BAD_REQUEST, HEADER_CONNECTION_ID + " header required");
+ return;
+ }
+ ConnectionState connection = connections.get(connectionId);
+ if (connection == null) {
+ response.setStatus(HttpServletResponse.SC_NOT_FOUND);
+ return;
+ }
+
+ try {
+ connection.acceptClientPost(message, header(request, HEADER_SESSION_ID).orElse(null));
+ response.setStatus(HttpServletResponse.SC_ACCEPTED);
+ }
+ catch (UnknownSessionException e) {
+ writeText(response, HttpServletResponse.SC_NOT_FOUND, e.getMessage());
+ }
+ catch (AcpConnectionException | IllegalArgumentException e) {
+ writeText(response, HttpServletResponse.SC_BAD_REQUEST, e.getMessage());
+ }
+ }
+
+ @Override
+ protected void doGet(HttpServletRequest request, HttpServletResponse response)
+ throws ServletException, IOException {
+ if (!accepts(request, CONTENT_TYPE_EVENT_STREAM)) {
+ writeText(response, HttpServletResponse.SC_NOT_ACCEPTABLE, "client must accept text/event-stream");
+ return;
+ }
+
+ String connectionId = header(request, HEADER_CONNECTION_ID).orElse(null);
+ if (connectionId == null) {
+ writeText(response, HttpServletResponse.SC_BAD_REQUEST, HEADER_CONNECTION_ID + " header required");
+ return;
+ }
+ ConnectionState connection = connections.get(connectionId);
+ if (connection == null) {
+ response.setStatus(HttpServletResponse.SC_NOT_FOUND);
+ return;
+ }
+
+ try {
+ connection.openStream(request, response, header(request, HEADER_SESSION_ID).orElse(null));
+ }
+ catch (UnknownSessionException e) {
+ writeText(response, HttpServletResponse.SC_NOT_FOUND, e.getMessage());
+ }
+ }
+
+ @Override
+ protected void doDelete(HttpServletRequest request, HttpServletResponse response)
+ throws ServletException, IOException {
+ String connectionId = header(request, HEADER_CONNECTION_ID).orElse(null);
+ if (connectionId == null) {
+ writeText(response, HttpServletResponse.SC_BAD_REQUEST, HEADER_CONNECTION_ID + " header required");
+ return;
+ }
+ ConnectionState connection = connections.remove(connectionId);
+ if (connection == null) {
+ response.setStatus(HttpServletResponse.SC_NOT_FOUND);
+ return;
+ }
+ connection.close();
+ response.setStatus(HttpServletResponse.SC_ACCEPTED);
+ }
+
+ private void handleInitialize(HttpServletRequest request, HttpServletResponse response,
+ AcpSchema.JSONRPCRequest initializeRequest) throws IOException {
+ if (header(request, HEADER_CONNECTION_ID).isPresent()) {
+ writeText(response, HttpServletResponse.SC_BAD_REQUEST,
+ "initialize must not include " + HEADER_CONNECTION_ID);
+ return;
+ }
+
+ ConnectionState connection = createConnection();
+ try {
+ JSONRPCMessage initializeResponse = connection.initialize(initializeRequest)
+ .block(INITIALIZE_TIMEOUT);
+ if (!(initializeResponse instanceof AcpSchema.JSONRPCResponse)) {
+ throw new AcpConnectionException("initialize did not produce a JSON-RPC response");
+ }
+ connections.put(connection.id(), connection);
+ response.setStatus(HttpServletResponse.SC_OK);
+ response.setContentType(CONTENT_TYPE_JSON);
+ response.setHeader(HEADER_CONNECTION_ID, connection.id());
+ response.getWriter().write(jsonMapper.writeValueAsString(initializeResponse));
+ }
+ catch (Exception e) {
+ connection.close();
+ writeText(response, HttpServletResponse.SC_INTERNAL_SERVER_ERROR, "initialize failed");
+ }
+ }
+
+ }
+
+ private boolean isInitialize(JSONRPCMessage message) {
+ return message instanceof AcpSchema.JSONRPCRequest request
+ && AcpSchema.METHOD_INITIALIZE.equals(request.method());
+ }
+
+ private boolean hasContentType(HttpServletRequest request, String expected) {
+ return Optional.ofNullable(request.getContentType())
+ .map(String::toLowerCase)
+ .filter(contentType -> contentType.contains(expected))
+ .isPresent();
+ }
+
+ private boolean accepts(HttpServletRequest request, String expected) {
+ return Optional.ofNullable(request.getHeader("Accept"))
+ .map(String::toLowerCase)
+ .filter(accept -> accept.contains(expected))
+ .isPresent();
+ }
+
+ private Optional header(HttpServletRequest request, String name) {
+ return Optional.ofNullable(request.getHeader(name)).filter(value -> !value.isBlank());
+ }
+
+ private void writeText(HttpServletResponse response, int status, String body) throws IOException {
+ response.setStatus(status);
+ response.setContentType("text/plain");
+ response.getWriter().write(body);
+ }
+
+ private final class ConnectionState {
+
+ private final String id;
+
+ private final RemoteAcpConnection connection;
+
+ private final OutboundStream connectionStream = new OutboundStream();
+
+ private final ConcurrentMap sessionStreams = new ConcurrentHashMap<>();
+
+ private final ConcurrentMap sessions = new ConcurrentHashMap<>();
+
+ // Client-originated request id -> route expected for the later agent response.
+ private final ConcurrentMap clientRequestRoutes = new ConcurrentHashMap<>();
+
+ // Agent-originated request id -> route required for the later client response.
+ private final ConcurrentMap agentRequestRoutes = new ConcurrentHashMap<>();
+
+ private final Sinks.One initializeResponse = Sinks.one();
+
+ private final AtomicBoolean initialized = new AtomicBoolean(false);
+
+ private volatile Object initializeRequestId;
+
+ ConnectionState(String id) {
+ this.id = id;
+ this.connection = new RemoteAcpConnection(id, jsonMapper, this::routeAgentMessage);
+ }
+
+ String id() {
+ return id;
+ }
+
+ void start() {
+ this.connection.start(agentFactory).block(INITIALIZE_TIMEOUT);
+ }
+
+ Mono initialize(AcpSchema.JSONRPCRequest request) {
+ this.initializeRequestId = request.id();
+ connection.acceptInbound(request);
+ return initializeResponse.asMono().doOnSuccess(ignored -> initialized.set(true));
+ }
+
+ void acceptClientPost(JSONRPCMessage message, String sessionHeader) {
+ if (message instanceof AcpSchema.JSONRPCResponse response) {
+ validateClientResponseScope(response, sessionHeader);
+ connection.acceptInbound(message);
+ return;
+ }
+
+ ResolvedInboundRoute resolved = resolveInboundRoute(message, sessionHeader);
+ if (resolved.requestScope().isSession()) {
+ prepareSessionForInbound(resolved.requestScope().sessionId(), resolved.requestRoute());
+ }
+ if (message instanceof AcpSchema.JSONRPCRequest request && request.id() != null
+ && resolved.requestRoute() != null) {
+ clientRequestRoutes.put(request.id(), resolved.requestRoute());
+ }
+ connection.acceptInbound(message);
+ }
+
+ void openStream(HttpServletRequest request, HttpServletResponse response, String sessionId)
+ throws IOException {
+ RouteScope scope = sessionId == null ? RouteScope.connection() : RouteScope.session(sessionId);
+ OutboundStream stream;
+ if (scope.isSession()) {
+ stream = openSessionStream(scope.sessionId());
+ }
+ else {
+ stream = connectionStream;
+ }
+
+ response.setStatus(HttpServletResponse.SC_OK);
+ response.setContentType(CONTENT_TYPE_EVENT_STREAM);
+ response.setHeader("Cache-Control", "no-cache");
+ response.setHeader(HEADER_CONNECTION_ID, id);
+ if (scope.isSession()) {
+ response.setHeader(HEADER_SESSION_ID, scope.sessionId());
+ }
+ AsyncContext asyncContext = request.startAsync();
+ asyncContext.setTimeout(0);
+ stream.subscribe(asyncContext, response);
+ }
+
+ void close() {
+ connectionStream.close();
+ sessionStreams.values().forEach(OutboundStream::close);
+ connection.closeGracefully().subscribe();
+ }
+
+ private void routeAgentMessage(JSONRPCMessage message) {
+ try {
+ if (message instanceof AcpSchema.JSONRPCResponse response
+ && Objects.equals(response.id(), initializeRequestId) && !initialized.get()) {
+ initializeResponse.tryEmitValue(message);
+ return;
+ }
+
+ RouteScope scope = resolveAgentOutboundScope(message);
+ String payload = jsonMapper.writeValueAsString(message);
+ if (scope.isSession()) {
+ sessionStream(scope.sessionId()).push(payload);
+ }
+ else {
+ connectionStream.push(payload);
+ }
+ }
+ catch (Exception e) {
+ connection.signalException(e);
+ }
+ }
+
+ private RouteScope resolveAgentOutboundScope(JSONRPCMessage message) {
+ if (message instanceof AcpSchema.JSONRPCResponse response) {
+ ClientRequestRoute route = clientRequestRoutes.remove(response.id());
+ if (route == null) {
+ logger.warn("Agent emitted response for unknown client request id {}; routing to connection stream",
+ response.id());
+ return RouteScope.connection();
+ }
+ if (route.kind() == RequestKind.SESSION_NEW && response.error() == null) {
+ String sessionId = extractSessionIdFromNewSessionResponse(response);
+ markSessionKnown(sessionId);
+ }
+ if (route.kind() == RequestKind.SESSION_LOAD && response.error() == null) {
+ markSessionKnown(route.requestScope().sessionId());
+ }
+ return route.responseScope();
+ }
+
+ String method;
+ Object params;
+ Object id = null;
+ if (message instanceof AcpSchema.JSONRPCRequest request) {
+ method = request.method();
+ params = request.params();
+ id = request.id();
+ }
+ else if (message instanceof AcpSchema.JSONRPCNotification notification) {
+ method = notification.method();
+ params = notification.params();
+ }
+ else {
+ throw new AcpConnectionException("Unsupported outbound JSON-RPC message type: " + message);
+ }
+
+ RouteScope scope = resolveAgentRequestOrNotificationScope(method, params);
+ if (id != null) {
+ agentRequestRoutes.put(id, scope);
+ }
+ return scope;
+ }
+
+ private RouteScope resolveAgentRequestOrNotificationScope(String method, Object params) {
+ switch (method) {
+ case AcpSchema.METHOD_SESSION_REQUEST_PERMISSION:
+ case AcpSchema.METHOD_SESSION_UPDATE:
+ case AcpSchema.METHOD_FS_READ_TEXT_FILE:
+ case AcpSchema.METHOD_FS_WRITE_TEXT_FILE:
+ case AcpSchema.METHOD_TERMINAL_CREATE:
+ case AcpSchema.METHOD_TERMINAL_OUTPUT:
+ case AcpSchema.METHOD_TERMINAL_RELEASE:
+ case AcpSchema.METHOD_TERMINAL_WAIT_FOR_EXIT:
+ case AcpSchema.METHOD_TERMINAL_KILL:
+ return RouteScope.session(requireSessionId(params, method));
+ default:
+ Optional sessionId = extractSessionId(params);
+ if (routingMode == RoutingMode.STRICT) {
+ throw new AcpConnectionException("No explicit routing rule for outbound method " + method);
+ }
+ return sessionId.map(RouteScope::session).orElseGet(RouteScope::connection);
+ }
+ }
+
+ private ResolvedInboundRoute resolveInboundRoute(JSONRPCMessage message, String sessionHeader) {
+ String method;
+ Object params;
+ if (message instanceof AcpSchema.JSONRPCRequest request) {
+ method = request.method();
+ params = request.params();
+ }
+ else if (message instanceof AcpSchema.JSONRPCNotification notification) {
+ method = notification.method();
+ params = notification.params();
+ }
+ else {
+ throw new AcpConnectionException("Unsupported inbound JSON-RPC message type: " + message);
+ }
+
+ RouteScope requestScope;
+ RequestKind kind = RequestKind.GENERIC;
+ RouteScope responseScope;
+
+ switch (method) {
+ case AcpSchema.METHOD_AUTHENTICATE:
+ case AcpSchema.METHOD_SESSION_NEW:
+ requestScope = RouteScope.connection();
+ kind = AcpSchema.METHOD_SESSION_NEW.equals(method) ? RequestKind.SESSION_NEW : RequestKind.GENERIC;
+ responseScope = RouteScope.connection();
+ break;
+ case AcpSchema.METHOD_SESSION_LOAD:
+ requestScope = requireSessionScope(method, params, sessionHeader);
+ kind = RequestKind.SESSION_LOAD;
+ responseScope = RouteScope.connection();
+ break;
+ case AcpSchema.METHOD_SESSION_PROMPT:
+ case AcpSchema.METHOD_SESSION_SET_MODE:
+ case AcpSchema.METHOD_SESSION_SET_MODEL:
+ case AcpSchema.METHOD_SESSION_CANCEL:
+ requestScope = requireSessionScope(method, params, sessionHeader);
+ responseScope = requestScope;
+ break;
+ default:
+ Optional sessionId = extractSessionId(params);
+ if (routingMode == RoutingMode.STRICT) {
+ throw new AcpConnectionException("No explicit routing rule for inbound method " + method);
+ }
+ if (sessionId.isPresent()) {
+ requestScope = requireSessionScope(method, params, sessionHeader);
+ }
+ else {
+ requestScope = RouteScope.connection();
+ }
+ responseScope = requestScope;
+ }
+
+ ClientRequestRoute requestRoute = message instanceof AcpSchema.JSONRPCRequest
+ ? new ClientRequestRoute(kind, requestScope, responseScope) : null;
+ return new ResolvedInboundRoute(message, requestScope, requestRoute);
+ }
+
+ private RouteScope requireSessionScope(String method, Object params, String sessionHeader) {
+ String sessionId = requireSessionId(params, method);
+ if (sessionHeader == null) {
+ throw new AcpConnectionException(HEADER_SESSION_ID + " header required for " + method);
+ }
+ if (!sessionId.equals(sessionHeader)) {
+ throw new AcpConnectionException("Header " + HEADER_SESSION_ID + " does not match params.sessionId");
+ }
+ return RouteScope.session(sessionId);
+ }
+
+ private void prepareSessionForInbound(String sessionId, ClientRequestRoute route) {
+ SessionState current = sessions.get(sessionId);
+ if (route != null && route.kind() == RequestKind.SESSION_LOAD) {
+ if (current == null) {
+ if (routingMode == RoutingMode.STRICT) {
+ throw new UnknownSessionException("Unknown session " + sessionId);
+ }
+ sessions.putIfAbsent(sessionId, SessionState.PENDING_LOAD);
+ sessionStream(sessionId);
+ }
+ return;
+ }
+ if (current != SessionState.KNOWN) {
+ throw new UnknownSessionException("Unknown session " + sessionId);
+ }
+ }
+
+ private void validateClientResponseScope(AcpSchema.JSONRPCResponse response, String sessionHeader) {
+ RouteScope expected = agentRequestRoutes.get(response.id());
+ if (expected == null) {
+ logger.warn("Client posted response for unknown agent request id {}", response.id());
+ return;
+ }
+ RouteScope actual = sessionHeader == null ? RouteScope.connection() : RouteScope.session(sessionHeader);
+ if (!Objects.equals(expected, actual)) {
+ throw new AcpConnectionException(
+ "Response id " + response.id() + " arrived on " + actual + " but expected " + expected);
+ }
+ agentRequestRoutes.remove(response.id(), expected);
+ }
+
+ private OutboundStream openSessionStream(String sessionId) {
+ SessionState current = sessions.get(sessionId);
+ if (current == null) {
+ if (routingMode == RoutingMode.STRICT) {
+ throw new UnknownSessionException("Unknown session " + sessionId);
+ }
+ /*
+ * RFD gap:
+ * The current text says unknown session-scoped GET requests return 404,
+ * but its resume flow also asks clients to open a session stream before
+ * sending session/load. Compatible mode keeps a provisional stream so
+ * practical resume can work while strict mode preserves the literal rule.
+ */
+ sessions.putIfAbsent(sessionId, SessionState.PENDING_LOAD);
+ }
+ return sessionStream(sessionId);
+ }
+
+ private OutboundStream sessionStream(String sessionId) {
+ return sessionStreams.computeIfAbsent(sessionId, ignored -> new OutboundStream());
+ }
+
+ private void markSessionKnown(String sessionId) {
+ sessions.put(sessionId, SessionState.KNOWN);
+ sessionStream(sessionId);
+ }
+
+ private String extractSessionIdFromNewSessionResponse(AcpSchema.JSONRPCResponse response) {
+ AcpSchema.NewSessionResponse sessionResponse = jsonMapper.convertValue(response.result(),
+ new TypeRef() {
+ });
+ if (sessionResponse.sessionId() == null || sessionResponse.sessionId().isBlank()) {
+ throw new AcpConnectionException("session/new response missing sessionId");
+ }
+ return sessionResponse.sessionId();
+ }
+
+ }
+
+ private Optional extractSessionId(Object params) {
+ if (params == null) {
+ return Optional.empty();
+ }
+ Map, ?> paramsMap = jsonMapper.convertValue(params, Map.class);
+ Object sessionId = paramsMap.get("sessionId");
+ return sessionId == null ? Optional.empty() : Optional.of(sessionId.toString());
+ }
+
+ private String requireSessionId(Object params, String method) {
+ return extractSessionId(params)
+ .filter(sessionId -> !sessionId.isBlank())
+ .orElseThrow(() -> new AcpConnectionException("Missing sessionId for method " + method));
+ }
+
+ private final class OutboundStream {
+
+ private final ArrayDeque replay = new ArrayDeque<>();
+
+ private final List subscribers = new CopyOnWriteArrayList<>();
+
+ private final AtomicBoolean closed = new AtomicBoolean(false);
+
+ private boolean replayOpen = true;
+
+ synchronized void push(String payload) {
+ if (closed.get()) {
+ return;
+ }
+ if (replayOpen) {
+ if (replay.size() == MAX_REPLAY_EVENTS) {
+ replay.removeFirst();
+ }
+ replay.addLast(payload);
+ return;
+ }
+ subscribers.forEach(subscriber -> subscriber.send(payload));
+ }
+
+ synchronized void subscribe(AsyncContext asyncContext, HttpServletResponse response) throws IOException {
+ if (closed.get()) {
+ throw new IOException("SSE stream is closed");
+ }
+ SseSubscriber subscriber = new SseSubscriber(this, asyncContext, response);
+ subscribers.add(subscriber);
+ if (replayOpen) {
+ for (String payload : new ArrayList<>(replay)) {
+ subscriber.send(payload);
+ }
+ replay.clear();
+ replayOpen = false;
+ }
+ subscriber.flush();
+ }
+
+ void remove(SseSubscriber subscriber) {
+ subscribers.remove(subscriber);
+ }
+
+ void close() {
+ if (closed.compareAndSet(false, true)) {
+ subscribers.forEach(SseSubscriber::close);
+ subscribers.clear();
+ synchronized (this) {
+ replay.clear();
+ }
+ }
+ }
+
+ }
+
+ private final class SseSubscriber {
+
+ private final OutboundStream parent;
+
+ private final AsyncContext asyncContext;
+
+ private final PrintWriter writer;
+
+ private final AtomicBoolean closed = new AtomicBoolean(false);
+
+ SseSubscriber(OutboundStream parent, AsyncContext asyncContext, HttpServletResponse response) throws IOException {
+ this.parent = parent;
+ this.asyncContext = asyncContext;
+ this.writer = response.getWriter();
+ }
+
+ synchronized void send(String payload) {
+ if (closed.get()) {
+ return;
+ }
+ writer.write("data: ");
+ writer.write(payload);
+ writer.write("\n\n");
+ writer.flush();
+ if (writer.checkError()) {
+ close();
+ }
+ }
+
+ synchronized void flush() {
+ writer.flush();
+ }
+
+ void close() {
+ if (closed.compareAndSet(false, true)) {
+ parent.remove(this);
+ try {
+ asyncContext.complete();
+ }
+ catch (IllegalStateException ignored) {
+ }
+ }
+ }
+
+ }
+
+ private final class WebSocketConnectionState {
+
+ private final String id;
+
+ private final RemoteAcpConnection remoteConnection;
+
+ private final AtomicBoolean initialized = new AtomicBoolean(false);
+
+ private final AtomicBoolean closed = new AtomicBoolean(false);
+
+ private final SerializedWebSocketSender outboundSender = new SerializedWebSocketSender();
+
+ private volatile Session session;
+
+ WebSocketConnectionState(String id) {
+ this.id = id;
+ this.remoteConnection = new RemoteAcpConnection(id, jsonMapper, this::sendToClient);
+ }
+
+ String id() {
+ return id;
+ }
+
+ void start() {
+ this.remoteConnection.start(agentFactory).block(INITIALIZE_TIMEOUT);
+ }
+
+ void open(Session session) {
+ this.session = session;
+ }
+
+ void acceptFromClient(JSONRPCMessage message) {
+ if (!initialized.get()) {
+ // The WebSocket branch of the streamable endpoint has no POST
+ // initialize response that can create the connection first, so the first
+ // client-originated JSON-RPC message on the socket must be initialize.
+ if (!isInitializeRequest(message)) {
+ close(StatusCode.PROTOCOL, "first ACP WebSocket message must be initialize");
+ return;
+ }
+ initialized.set(true);
+ }
+ remoteConnection.acceptInbound(message);
+ }
+
+ void sendToClient(JSONRPCMessage message) {
+ try {
+ String payload = jsonMapper.writeValueAsString(message);
+ logger.debug("Sending streamable ACP WebSocket message: {}", payload);
+ outboundSender.send(payload);
+ }
+ catch (Exception e) {
+ remoteConnection.signalException(e);
+ close(StatusCode.SERVER_ERROR, "failed to send ACP message");
+ }
+ }
+
+ void close() {
+ close(StatusCode.NORMAL, "server closing");
+ }
+
+ void close(int statusCode, String reason) {
+ if (!closed.compareAndSet(false, true)) {
+ return;
+ }
+ outboundSender.close();
+ webSocketConnections.remove(id, this);
+ Session currentSession = this.session;
+ if (currentSession != null && currentSession.isOpen()) {
+ currentSession.close(statusCode, reason, Callback.NOOP);
+ }
+ remoteConnection.closeGracefully().subscribe();
+ }
+
+ private final class SerializedWebSocketSender {
+
+ private final Object lock = new Object();
+
+ private final ArrayDeque queue = new ArrayDeque<>();
+
+ private boolean sendInProgress = false;
+
+ void send(String payload) {
+ boolean shouldDrain;
+ synchronized (lock) {
+ if (closed.get()) {
+ throw new AcpConnectionException("Streamable ACP WebSocket connection is closed");
+ }
+ queue.addLast(payload);
+ shouldDrain = !sendInProgress;
+ if (shouldDrain) {
+ sendInProgress = true;
+ }
+ }
+ if (shouldDrain) {
+ drain();
+ }
+ }
+
+ /*
+ * Jetty WebSocket sessions do not allow overlapping writes. Agent messages can
+ * be produced by concurrent prompt handlers, so this per-connection queue sends
+ * exactly one frame at a time and advances only after Jetty completes the
+ * callback for the previous frame.
+ */
+ private void drain() {
+ String payload;
+ Session currentSession;
+ synchronized (lock) {
+ if (closed.get()) {
+ clear();
+ return;
+ }
+ payload = queue.pollFirst();
+ if (payload == null) {
+ sendInProgress = false;
+ return;
+ }
+ currentSession = session;
+ }
+
+ if (currentSession == null || !currentSession.isOpen()) {
+ fail(new AcpConnectionException("Streamable ACP WebSocket connection is closed"));
+ return;
+ }
+
+ try {
+ currentSession.sendText(payload, Callback.from(this::drain, this::fail));
+ }
+ catch (Exception e) {
+ fail(e);
+ }
+ }
+
+ private void fail(Throwable error) {
+ if (!closed.get()) {
+ remoteConnection.signalException(error);
+ WebSocketConnectionState.this.close(StatusCode.SERVER_ERROR, "failed to send ACP message");
+ }
+ }
+
+ void close() {
+ clear();
+ }
+
+ private void clear() {
+ synchronized (lock) {
+ queue.clear();
+ sendInProgress = false;
+ }
+ }
+
+ }
+
+ }
+
+ /**
+ * Jetty WebSocket endpoint for one WebSocket-upgraded ACP connection.
+ */
+ @WebSocket
+ public class AcpWebSocketEndpoint {
+
+ private final WebSocketConnectionState connection;
+
+ AcpWebSocketEndpoint(WebSocketConnectionState connection) {
+ this.connection = connection;
+ }
+
+ @OnWebSocketOpen
+ public void onOpen(Session session) {
+ logger.info("Streamable ACP WebSocket client connected from {}", session.getRemoteSocketAddress());
+ connection.open(session);
+ }
+
+ @OnWebSocketMessage
+ public void onMessage(Session session, String message) {
+ logger.debug("Received streamable ACP WebSocket message: {}", message);
+
+ try {
+ JSONRPCMessage jsonRpcMessage = AcpSchema.deserializeJsonRpcMessage(jsonMapper, message);
+ connection.acceptFromClient(jsonRpcMessage);
+ }
+ catch (Exception e) {
+ logger.warn("Closing streamable ACP WebSocket connection after invalid JSON-RPC frame", e);
+ connection.close(StatusCode.PROTOCOL, "invalid JSON-RPC frame");
+ }
+ }
+
+ @OnWebSocketClose
+ public void onClose(Session session, int statusCode, String reason) {
+ logger.info("Streamable ACP WebSocket client disconnected: {} - {}", statusCode, reason);
+ connection.close(statusCode, reason);
+ }
+
+ @OnWebSocketError
+ public void onError(Session session, Throwable error) {
+ if (error instanceof ClosedChannelException) {
+ logger.debug("Streamable ACP WebSocket channel closed");
+ connection.close(StatusCode.NORMAL, "WebSocket channel closed");
+ return;
+ }
+ logger.error("Streamable ACP WebSocket error", error);
+ connection.remoteConnection.signalException(error);
+ connection.close(StatusCode.SERVER_ERROR, "WebSocket error");
+ }
+
+ }
+
+ private static final class UnknownSessionException extends RuntimeException {
+
+ UnknownSessionException(String message) {
+ super(message);
+ }
+
+ }
+
+}
diff --git a/acp-streamable-http-jetty/src/test/java/com/agentclientprotocol/sdk/agent/transport/StreamableHttpAcpAgentTransportIntegrationTest.java b/acp-streamable-http-jetty/src/test/java/com/agentclientprotocol/sdk/agent/transport/StreamableHttpAcpAgentTransportIntegrationTest.java
new file mode 100644
index 0000000..c42d2fa
--- /dev/null
+++ b/acp-streamable-http-jetty/src/test/java/com/agentclientprotocol/sdk/agent/transport/StreamableHttpAcpAgentTransportIntegrationTest.java
@@ -0,0 +1,383 @@
+/*
+ * Copyright 2025-2026 the original author or authors.
+ */
+
+package com.agentclientprotocol.sdk.agent.transport;
+
+import java.io.BufferedReader;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.net.ServerSocket;
+import java.net.URI;
+import java.net.http.HttpClient;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
+import java.nio.charset.StandardCharsets;
+import java.time.Duration;
+import java.util.List;
+import java.util.concurrent.BlockingQueue;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.LinkedBlockingQueue;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import com.agentclientprotocol.sdk.agent.AcpAgent;
+import com.agentclientprotocol.sdk.agent.AcpAgentFactory;
+import com.agentclientprotocol.sdk.client.AcpAsyncClient;
+import com.agentclientprotocol.sdk.client.AcpClient;
+import com.agentclientprotocol.sdk.client.transport.StreamableHttpAcpClientTransport;
+import com.agentclientprotocol.sdk.json.AcpJsonMapper;
+import com.agentclientprotocol.sdk.json.TypeRef;
+import com.agentclientprotocol.sdk.spec.AcpSchema;
+import org.junit.jupiter.api.Test;
+import reactor.core.publisher.Mono;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * End-to-end tests against the Java Streamable HTTP agent transport.
+ */
+class StreamableHttpAcpAgentTransportIntegrationTest {
+
+ private static final Duration TIMEOUT = Duration.ofSeconds(5);
+
+ private static final AcpJsonMapper JSON_MAPPER = AcpJsonMapper.createDefault();
+
+ @Test
+ void javaClientCanTalkToRunningJavaServer() throws Exception {
+ try (FixtureServer server = FixtureServer.start(StreamableHttpAcpAgentTransport.RoutingMode.COMPATIBLE)) {
+ AcpAsyncClient client = AcpClient
+ .async(new StreamableHttpAcpClientTransport(server.endpoint(), AcpJsonMapper.createDefault()))
+ .requestTimeout(TIMEOUT)
+ .build();
+
+ client.initialize().block(TIMEOUT);
+ AcpSchema.NewSessionResponse session = client
+ .newSession(new AcpSchema.NewSessionRequest("/workspace", List.of(), null))
+ .block(TIMEOUT);
+ AcpSchema.PromptResponse prompt = client
+ .prompt(new AcpSchema.PromptRequest(session.sessionId(),
+ List.of(new AcpSchema.TextContent("hello")), null))
+ .block(TIMEOUT);
+
+ assertThat(session.sessionId()).isEqualTo("sess-1");
+ assertThat(prompt.stopReason()).isEqualTo(AcpSchema.StopReason.END_TURN);
+
+ client.closeGracefully().block(TIMEOUT);
+ }
+ }
+
+ @Test
+ void permissionRequestRoundTripsOverSessionStream() throws Exception {
+ try (FixtureServer server = FixtureServer.start(StreamableHttpAcpAgentTransport.RoutingMode.COMPATIBLE)) {
+ AtomicInteger permissionRequests = new AtomicInteger();
+ AcpAsyncClient client = AcpClient
+ .async(new StreamableHttpAcpClientTransport(server.endpoint(), AcpJsonMapper.createDefault()))
+ .requestPermissionHandler(request -> {
+ permissionRequests.incrementAndGet();
+ return Mono.just(new AcpSchema.RequestPermissionResponse(
+ new AcpSchema.PermissionSelected("allow")));
+ })
+ .requestTimeout(TIMEOUT)
+ .build();
+
+ client.initialize().block(TIMEOUT);
+ AcpSchema.NewSessionResponse session = client
+ .newSession(new AcpSchema.NewSessionRequest("/workspace", List.of(), null))
+ .block(TIMEOUT);
+ AcpSchema.PromptResponse prompt = client
+ .prompt(new AcpSchema.PromptRequest(session.sessionId(),
+ List.of(new AcpSchema.TextContent("permission please")), null))
+ .block(TIMEOUT);
+
+ assertThat(prompt.stopReason()).isEqualTo(AcpSchema.StopReason.END_TURN);
+ assertThat(permissionRequests).hasValue(1);
+
+ client.closeGracefully().block(TIMEOUT);
+ }
+ }
+
+ @Test
+ void compatibleModeAllowsSessionLoadPreopen() throws Exception {
+ try (FixtureServer server = FixtureServer.start(StreamableHttpAcpAgentTransport.RoutingMode.COMPATIBLE)) {
+ AcpAsyncClient client = AcpClient
+ .async(new StreamableHttpAcpClientTransport(server.endpoint(), AcpJsonMapper.createDefault()))
+ .requestTimeout(TIMEOUT)
+ .build();
+
+ client.initialize().block(TIMEOUT);
+ AcpSchema.LoadSessionResponse response = client
+ .loadSession(new AcpSchema.LoadSessionRequest("sess-load", "/workspace", List.of()))
+ .block(TIMEOUT);
+
+ assertThat(response).isNotNull();
+
+ client.closeGracefully().block(TIMEOUT);
+ }
+ }
+
+ @Test
+ void supportsTwoLogicalSessions() throws Exception {
+ try (FixtureServer server = FixtureServer.start(StreamableHttpAcpAgentTransport.RoutingMode.COMPATIBLE)) {
+ AcpAsyncClient client = AcpClient
+ .async(new StreamableHttpAcpClientTransport(server.endpoint(), AcpJsonMapper.createDefault()))
+ .requestTimeout(TIMEOUT)
+ .build();
+
+ client.initialize().block(TIMEOUT);
+ AcpSchema.NewSessionResponse first = client
+ .newSession(new AcpSchema.NewSessionRequest("/workspace/one", List.of(), null))
+ .block(TIMEOUT);
+ AcpSchema.NewSessionResponse second = client
+ .newSession(new AcpSchema.NewSessionRequest("/workspace/two", List.of(), null))
+ .block(TIMEOUT);
+ AcpSchema.PromptResponse firstPrompt = client
+ .prompt(new AcpSchema.PromptRequest(first.sessionId(), List.of(new AcpSchema.TextContent("one")), null))
+ .block(TIMEOUT);
+ AcpSchema.PromptResponse secondPrompt = client
+ .prompt(new AcpSchema.PromptRequest(second.sessionId(), List.of(new AcpSchema.TextContent("two")), null))
+ .block(TIMEOUT);
+
+ assertThat(first.sessionId()).isEqualTo("sess-1");
+ assertThat(second.sessionId()).isEqualTo("sess-2");
+ assertThat(firstPrompt.stopReason()).isEqualTo(AcpSchema.StopReason.END_TURN);
+ assertThat(secondPrompt.stopReason()).isEqualTo(AcpSchema.StopReason.END_TURN);
+
+ client.closeGracefully().block(TIMEOUT);
+ }
+ }
+
+ @Test
+ void wrongStreamClientResponseIsRejected() throws Exception {
+ try (FixtureServer server = FixtureServer.start(StreamableHttpAcpAgentTransport.RoutingMode.COMPATIBLE)) {
+ HttpClient rawClient = HttpClient.newHttpClient();
+ HttpResponse initialize = rawClient.send(HttpRequest.newBuilder(server.endpoint())
+ .header("Content-Type", "application/json")
+ .header("Accept", "application/json")
+ .POST(HttpRequest.BodyPublishers.ofString("""
+ {"jsonrpc":"2.0","id":"init-1","method":"initialize","params":{"protocolVersion":1,"clientCapabilities":{}}}
+ """))
+ .build(), HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
+ String connectionId = initialize.headers().firstValue("Acp-Connection-Id").orElseThrow();
+ try (SseReader connectionStream = SseReader.open(rawClient, server.endpoint(), connectionId, null)) {
+ postJson(rawClient, server.endpoint(), connectionId, null,
+ """
+ {"jsonrpc":"2.0","id":"new-1","method":"session/new","params":{"cwd":"/workspace","mcpServers":[]}}
+ """);
+ AcpSchema.JSONRPCResponse newSessionResponse = connectionStream.nextResponse();
+ AcpSchema.NewSessionResponse session = JSON_MAPPER.convertValue(newSessionResponse.result(),
+ new TypeRef() {
+ });
+
+ try (SseReader sessionStream = SseReader.open(rawClient, server.endpoint(), connectionId,
+ session.sessionId())) {
+ postJson(rawClient, server.endpoint(), connectionId, session.sessionId(),
+ """
+ {"jsonrpc":"2.0","id":"prompt-1","method":"session/prompt","params":{"sessionId":"%s","prompt":[{"type":"text","text":"permission please"}]}}
+ """.formatted(session.sessionId()));
+ AcpSchema.JSONRPCRequest permissionRequest = sessionStream.nextRequest();
+ HttpResponse wrongStreamResponse = postJson(rawClient, server.endpoint(), connectionId, null,
+ """
+ {"jsonrpc":"2.0","id":"%s","result":{"outcome":{"outcome":"selected","optionId":"allow"}}}
+ """.formatted(permissionRequest.id()));
+
+ assertThat(wrongStreamResponse.statusCode()).isEqualTo(400);
+ assertThat(wrongStreamResponse.body()).contains("expected RouteScope");
+ }
+ }
+ }
+ }
+
+ @Test
+ void validationFailuresUseHttpStatusCodes() throws Exception {
+ try (FixtureServer server = FixtureServer.start(StreamableHttpAcpAgentTransport.RoutingMode.COMPATIBLE)) {
+ HttpClient rawClient = HttpClient.newHttpClient();
+ HttpResponse unsupportedContentType = rawClient.send(HttpRequest.newBuilder(server.endpoint())
+ .header("Content-Type", "text/plain")
+ .POST(HttpRequest.BodyPublishers.ofString("{}"))
+ .build(), HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
+ HttpResponse batch = rawClient.send(HttpRequest.newBuilder(server.endpoint())
+ .header("Content-Type", "application/json")
+ .POST(HttpRequest.BodyPublishers.ofString("[]"))
+ .build(), HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
+ HttpResponse wrongAccept = rawClient.send(HttpRequest.newBuilder(server.endpoint())
+ .header("Accept", "application/json")
+ .GET()
+ .build(), HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
+ HttpResponse missingConnection = rawClient.send(HttpRequest.newBuilder(server.endpoint())
+ .header("Accept", "text/event-stream")
+ .GET()
+ .build(), HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
+
+ assertThat(unsupportedContentType.statusCode()).isEqualTo(415);
+ assertThat(batch.statusCode()).isEqualTo(501);
+ assertThat(wrongAccept.statusCode()).isEqualTo(406);
+ assertThat(missingConnection.statusCode()).isEqualTo(400);
+ }
+ }
+
+ @Test
+ void strictModeRejectsUnknownSessionStream() throws Exception {
+ try (FixtureServer server = FixtureServer.start(StreamableHttpAcpAgentTransport.RoutingMode.STRICT)) {
+ HttpResponse response = HttpClient.newHttpClient()
+ .send(HttpRequest.newBuilder(server.endpoint())
+ .header("Content-Type", "application/json")
+ .header("Accept", "application/json")
+ .POST(HttpRequest.BodyPublishers.ofString("""
+ {"jsonrpc":"2.0","id":"init-1","method":"initialize","params":{"protocolVersion":1,"clientCapabilities":{}}}
+ """))
+ .build(), HttpResponse.BodyHandlers.discarding());
+ String connectionId = response.headers().firstValue("Acp-Connection-Id").orElseThrow();
+ HttpResponse unknownSession = HttpClient.newHttpClient()
+ .send(HttpRequest.newBuilder(server.endpoint())
+ .header("Accept", "text/event-stream")
+ .header("Acp-Connection-Id", connectionId)
+ .header("Acp-Session-Id", "unknown")
+ .GET()
+ .build(), HttpResponse.BodyHandlers.discarding());
+
+ assertThat(response.statusCode()).isEqualTo(200);
+ assertThat(unknownSession.statusCode()).isEqualTo(404);
+ }
+ }
+
+ private static HttpResponse postJson(HttpClient client, URI endpoint, String connectionId, String sessionId,
+ String body) throws Exception {
+ HttpRequest.Builder builder = HttpRequest.newBuilder(endpoint)
+ .header("Content-Type", "application/json")
+ .header("Accept", "application/json")
+ .header("Acp-Connection-Id", connectionId)
+ .POST(HttpRequest.BodyPublishers.ofString(body));
+ if (sessionId != null) {
+ builder.header("Acp-Session-Id", sessionId);
+ }
+ return client.send(builder.build(), HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
+ }
+
+ private static final class FixtureServer implements AutoCloseable {
+
+ private final StreamableHttpAcpAgentTransport transport;
+
+ private FixtureServer(StreamableHttpAcpAgentTransport transport) {
+ this.transport = transport;
+ }
+
+ static FixtureServer start(StreamableHttpAcpAgentTransport.RoutingMode routingMode) throws Exception {
+ AtomicInteger sessionCounter = new AtomicInteger();
+ AcpAgentFactory agentFactory = AcpAgentFactory.async(transport -> AcpAgent.async(transport)
+ .initializeHandler(request -> Mono.just(new AcpSchema.InitializeResponse(
+ AcpSchema.LATEST_PROTOCOL_VERSION, new AcpSchema.AgentCapabilities(true, null, null), null)))
+ .newSessionHandler(request -> Mono.just(new AcpSchema.NewSessionResponse(
+ "sess-" + sessionCounter.incrementAndGet(), null, null)))
+ .loadSessionHandler(request -> Mono.just(new AcpSchema.LoadSessionResponse(null, null)))
+ .promptHandler((request, context) -> {
+ Mono work = request.text().contains("permission")
+ ? context.askPermission("fixture permission").then()
+ : Mono.empty();
+ return work.then(context.sendMessage("hello"))
+ .thenReturn(AcpSchema.PromptResponse.endTurn());
+ })
+ .build());
+ StreamableHttpAcpAgentTransport transport = new StreamableHttpAcpAgentTransport(
+ freePort(), AcpJsonMapper.createDefault(), agentFactory).routingMode(routingMode);
+ transport.start().block(TIMEOUT);
+ return new FixtureServer(transport);
+ }
+
+ URI endpoint() {
+ return URI.create("http://127.0.0.1:" + transport.getPort() + "/acp");
+ }
+
+ @Override
+ public void close() {
+ transport.closeGracefully().block(TIMEOUT);
+ }
+
+ private static int freePort() throws IOException {
+ try (ServerSocket socket = new ServerSocket(0)) {
+ return socket.getLocalPort();
+ }
+ }
+
+ }
+
+ private static final class SseReader implements AutoCloseable {
+
+ private final BlockingQueue messages = new LinkedBlockingQueue<>();
+
+ private final InputStream body;
+
+ private final ExecutorService executor;
+
+ private SseReader(InputStream body) {
+ this.body = body;
+ this.executor = Executors.newSingleThreadExecutor(r -> {
+ Thread thread = new Thread(r, "streamable-http-test-sse-reader");
+ thread.setDaemon(true);
+ return thread;
+ });
+ this.executor.submit(this::readLoop);
+ }
+
+ static SseReader open(HttpClient client, URI endpoint, String connectionId, String sessionId) throws Exception {
+ HttpRequest.Builder builder = HttpRequest.newBuilder(endpoint)
+ .header("Accept", "text/event-stream")
+ .header("Acp-Connection-Id", connectionId)
+ .GET();
+ if (sessionId != null) {
+ builder.header("Acp-Session-Id", sessionId);
+ }
+ HttpResponse response = client.send(builder.build(), HttpResponse.BodyHandlers.ofInputStream());
+ assertThat(response.statusCode()).isEqualTo(200);
+ return new SseReader(response.body());
+ }
+
+ AcpSchema.JSONRPCResponse nextResponse() throws Exception {
+ return (AcpSchema.JSONRPCResponse) nextMessage();
+ }
+
+ AcpSchema.JSONRPCRequest nextRequest() throws Exception {
+ return (AcpSchema.JSONRPCRequest) nextMessage();
+ }
+
+ private AcpSchema.JSONRPCMessage nextMessage() throws Exception {
+ AcpSchema.JSONRPCMessage message = messages.poll(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
+ assertThat(message).isNotNull();
+ return message;
+ }
+
+ private void readLoop() {
+ try (BufferedReader reader = new BufferedReader(new InputStreamReader(body, StandardCharsets.UTF_8))) {
+ StringBuilder data = new StringBuilder();
+ String line;
+ while ((line = reader.readLine()) != null) {
+ if (line.isEmpty()) {
+ dispatch(data);
+ data.setLength(0);
+ }
+ else if (line.startsWith("data:")) {
+ data.append(line.substring(5).stripLeading());
+ }
+ }
+ }
+ catch (Exception ignored) {
+ }
+ }
+
+ private void dispatch(StringBuilder data) throws IOException {
+ if (!data.isEmpty()) {
+ messages.add(AcpSchema.deserializeJsonRpcMessage(JSON_MAPPER, data.toString()));
+ }
+ }
+
+ @Override
+ public void close() throws IOException {
+ body.close();
+ executor.shutdownNow();
+ }
+
+ }
+
+}
diff --git a/acp-streamable-http-jetty/src/test/java/com/agentclientprotocol/sdk/agent/transport/StreamableHttpAcpAgentTransportWebSocketIntegrationTest.java b/acp-streamable-http-jetty/src/test/java/com/agentclientprotocol/sdk/agent/transport/StreamableHttpAcpAgentTransportWebSocketIntegrationTest.java
new file mode 100644
index 0000000..fc5ac86
--- /dev/null
+++ b/acp-streamable-http-jetty/src/test/java/com/agentclientprotocol/sdk/agent/transport/StreamableHttpAcpAgentTransportWebSocketIntegrationTest.java
@@ -0,0 +1,398 @@
+/*
+ * Copyright 2025-2026 the original author or authors.
+ */
+
+package com.agentclientprotocol.sdk.agent.transport;
+
+import java.io.BufferedReader;
+import java.io.IOException;
+import java.io.InputStreamReader;
+import java.io.OutputStreamWriter;
+import java.io.PrintWriter;
+import java.net.ServerSocket;
+import java.net.Socket;
+import java.net.URI;
+import java.net.http.HttpClient;
+import java.net.http.WebSocket;
+import java.nio.charset.StandardCharsets;
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.Base64;
+import java.util.List;
+import java.util.Locale;
+import java.util.UUID;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.CompletionStage;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.atomic.AtomicReference;
+
+import com.agentclientprotocol.sdk.agent.AcpAgent;
+import com.agentclientprotocol.sdk.agent.AcpAgentFactory;
+import com.agentclientprotocol.sdk.client.AcpAsyncClient;
+import com.agentclientprotocol.sdk.client.AcpClient;
+import com.agentclientprotocol.sdk.client.transport.WebSocketAcpClientTransport;
+import com.agentclientprotocol.sdk.json.AcpJsonMapper;
+import com.agentclientprotocol.sdk.spec.AcpSchema;
+import org.eclipse.jetty.websocket.api.StatusCode;
+import org.junit.jupiter.api.Test;
+import reactor.core.publisher.Flux;
+import reactor.core.publisher.Mono;
+import reactor.core.scheduler.Schedulers;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/**
+ * End-to-end tests for the WebSocket upgrade path on the Streamable HTTP transport.
+ */
+class StreamableHttpAcpAgentTransportWebSocketIntegrationTest {
+
+ private static final Duration TIMEOUT = Duration.ofSeconds(5);
+
+ @Test
+ void constructorValidatesRequiredArguments() {
+ AcpJsonMapper jsonMapper = AcpJsonMapper.createDefault();
+ AcpAgentFactory agentFactory = simpleAgentFactory();
+
+ assertThatThrownBy(() -> new StreamableHttpAcpAgentTransport(0, jsonMapper, agentFactory))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("Port");
+ assertThatThrownBy(() -> new StreamableHttpAcpAgentTransport(8080, "", jsonMapper, agentFactory))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("Path");
+ assertThatThrownBy(() -> new StreamableHttpAcpAgentTransport(8080, null, agentFactory))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("JsonMapper");
+ assertThatThrownBy(() -> new StreamableHttpAcpAgentTransport(8080, jsonMapper, null))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("agentFactory");
+ }
+
+ @Test
+ void handshakeReturnsConnectionIdHeader() throws Exception {
+ try (FixtureServer server = FixtureServer.start(simpleAgentFactory());
+ Socket socket = new Socket("127.0.0.1", server.port())) {
+ socket.setSoTimeout((int) TIMEOUT.toMillis());
+
+ String key = Base64.getEncoder()
+ .encodeToString(UUID.randomUUID().toString().substring(0, 16).getBytes(StandardCharsets.UTF_8));
+ PrintWriter writer = new PrintWriter(new OutputStreamWriter(socket.getOutputStream(), StandardCharsets.UTF_8));
+ writer.print("GET /acp HTTP/1.1\r\n");
+ writer.print("Host: 127.0.0.1:" + server.port() + "\r\n");
+ writer.print("Upgrade: websocket\r\n");
+ writer.print("Connection: Upgrade\r\n");
+ writer.print("Sec-WebSocket-Key: " + key + "\r\n");
+ writer.print("Sec-WebSocket-Version: 13\r\n");
+ writer.print("\r\n");
+ writer.flush();
+
+ List responseLines = readHttpHeaders(socket);
+
+ assertThat(responseLines.get(0)).contains("101");
+ assertThat(responseLines.stream()
+ .map(line -> line.toLowerCase(Locale.ROOT))
+ .anyMatch(line -> line.startsWith("acp-connection-id:"))).isTrue();
+ }
+ }
+
+ @Test
+ void javaClientCanTalkToStreamableWebSocketUpgrade() throws Exception {
+ AtomicReference receivedUpdate = new AtomicReference<>();
+
+ try (FixtureServer server = FixtureServer.start(simpleAgentFactory())) {
+ AcpAsyncClient client = AcpClient
+ .async(new WebSocketAcpClientTransport(server.endpoint(), AcpJsonMapper.createDefault()))
+ .sessionUpdateConsumer(update -> {
+ receivedUpdate.set(update);
+ return Mono.empty();
+ })
+ .requestTimeout(TIMEOUT)
+ .build();
+ try {
+ client.initialize(new AcpSchema.InitializeRequest(
+ AcpSchema.LATEST_PROTOCOL_VERSION, new AcpSchema.ClientCapabilities()))
+ .block(TIMEOUT);
+ AcpSchema.NewSessionResponse session = client
+ .newSession(new AcpSchema.NewSessionRequest("/workspace", List.of()))
+ .block(TIMEOUT);
+ AcpSchema.PromptResponse prompt = client
+ .prompt(new AcpSchema.PromptRequest(session.sessionId(),
+ List.of(new AcpSchema.TextContent("hello over ws"))))
+ .block(TIMEOUT);
+
+ assertThat(session.sessionId()).startsWith("sess-");
+ assertThat(prompt.stopReason()).isEqualTo(AcpSchema.StopReason.END_TURN);
+ assertThat(receivedUpdate.get()).isNotNull();
+ }
+ finally {
+ client.closeGracefully().block(TIMEOUT);
+ }
+ }
+ }
+
+ @Test
+ void permissionRequestRoundTripsOverStreamableWebSocketUpgrade() throws Exception {
+ AtomicInteger permissionRequests = new AtomicInteger();
+ AcpAgentFactory agentFactory = AcpAgentFactory.async(transport -> AcpAgent.async(transport)
+ .initializeHandler(request -> Mono.just(new AcpSchema.InitializeResponse(
+ AcpSchema.LATEST_PROTOCOL_VERSION, new AcpSchema.AgentCapabilities(true, null, null), List.of())))
+ .newSessionHandler(request -> Mono.just(new AcpSchema.NewSessionResponse("permission-session", null, null)))
+ .promptHandler((request, context) -> context.askPermission("streamable websocket edit")
+ .map(allowed -> {
+ assertThat(allowed).isTrue();
+ return AcpSchema.PromptResponse.endTurn();
+ }))
+ .build());
+
+ try (FixtureServer server = FixtureServer.start(agentFactory)) {
+ AcpAsyncClient client = AcpClient
+ .async(new WebSocketAcpClientTransport(server.endpoint(), AcpJsonMapper.createDefault()))
+ .requestPermissionHandler(request -> {
+ permissionRequests.incrementAndGet();
+ return Mono.just(new AcpSchema.RequestPermissionResponse(
+ new AcpSchema.PermissionSelected("allow")));
+ })
+ .requestTimeout(TIMEOUT)
+ .build();
+ try {
+ client.initialize(new AcpSchema.InitializeRequest(
+ AcpSchema.LATEST_PROTOCOL_VERSION, new AcpSchema.ClientCapabilities()))
+ .block(TIMEOUT);
+ client.newSession(new AcpSchema.NewSessionRequest("/workspace", List.of())).block(TIMEOUT);
+ AcpSchema.PromptResponse prompt = client
+ .prompt(new AcpSchema.PromptRequest("permission-session",
+ List.of(new AcpSchema.TextContent("please ask permission"))))
+ .block(TIMEOUT);
+
+ assertThat(prompt.stopReason()).isEqualTo(AcpSchema.StopReason.END_TURN);
+ assertThat(permissionRequests.get()).isEqualTo(1);
+ }
+ finally {
+ client.closeGracefully().block(TIMEOUT);
+ }
+ }
+ }
+
+ @Test
+ void supportsMultipleConcurrentWebSocketClients() throws Exception {
+ try (FixtureServer server = FixtureServer.start(simpleAgentFactory())) {
+ AcpAsyncClient firstClient = AcpClient
+ .async(new WebSocketAcpClientTransport(server.endpoint(), AcpJsonMapper.createDefault()))
+ .requestTimeout(TIMEOUT)
+ .build();
+ AcpAsyncClient secondClient = AcpClient
+ .async(new WebSocketAcpClientTransport(server.endpoint(), AcpJsonMapper.createDefault()))
+ .requestTimeout(TIMEOUT)
+ .build();
+ try {
+ firstClient.initialize(new AcpSchema.InitializeRequest(
+ AcpSchema.LATEST_PROTOCOL_VERSION, new AcpSchema.ClientCapabilities()))
+ .block(TIMEOUT);
+ secondClient.initialize(new AcpSchema.InitializeRequest(
+ AcpSchema.LATEST_PROTOCOL_VERSION, new AcpSchema.ClientCapabilities()))
+ .block(TIMEOUT);
+
+ AcpSchema.NewSessionResponse firstSession = firstClient
+ .newSession(new AcpSchema.NewSessionRequest("/workspace/one", List.of()))
+ .block(TIMEOUT);
+ AcpSchema.NewSessionResponse secondSession = secondClient
+ .newSession(new AcpSchema.NewSessionRequest("/workspace/two", List.of()))
+ .block(TIMEOUT);
+
+ assertThat(firstSession.sessionId()).isNotEqualTo(secondSession.sessionId());
+ assertThat(server.transport().activeConnectionCount()).isEqualTo(2);
+ }
+ finally {
+ firstClient.closeGracefully().block(TIMEOUT);
+ secondClient.closeGracefully().block(TIMEOUT);
+ }
+ }
+ }
+
+ @Test
+ void serializesConcurrentAgentMessagesOnOneWebSocketConnection() throws Exception {
+ AtomicInteger sessionCounter = new AtomicInteger();
+ AtomicInteger receivedUpdates = new AtomicInteger();
+ AcpAgentFactory agentFactory = AcpAgentFactory.async(transport -> AcpAgent.async(transport)
+ .initializeHandler(request -> Mono.just(new AcpSchema.InitializeResponse(
+ AcpSchema.LATEST_PROTOCOL_VERSION, new AcpSchema.AgentCapabilities(true, null, null), List.of())))
+ .newSessionHandler(request -> Mono.just(new AcpSchema.NewSessionResponse(
+ "sess-" + sessionCounter.incrementAndGet(), null, null)))
+ .promptHandler((request, context) -> Mono.delay(Duration.ofMillis(25))
+ .thenMany(Flux.range(0, 20)
+ .flatMap(i -> context.sendMessage(request.sessionId() + ": update-" + i)
+ .subscribeOn(Schedulers.parallel()), 8))
+ .then(Mono.just(AcpSchema.PromptResponse.endTurn())))
+ .build());
+
+ try (FixtureServer server = FixtureServer.start(agentFactory)) {
+ AcpAsyncClient client = AcpClient
+ .async(new WebSocketAcpClientTransport(server.endpoint(), AcpJsonMapper.createDefault()))
+ .sessionUpdateConsumer(update -> {
+ receivedUpdates.incrementAndGet();
+ return Mono.empty();
+ })
+ .requestTimeout(TIMEOUT)
+ .build();
+ try {
+ client.initialize(new AcpSchema.InitializeRequest(
+ AcpSchema.LATEST_PROTOCOL_VERSION, new AcpSchema.ClientCapabilities()))
+ .block(TIMEOUT);
+ AcpSchema.NewSessionResponse firstSession = client
+ .newSession(new AcpSchema.NewSessionRequest("/workspace/one", List.of()))
+ .block(TIMEOUT);
+ AcpSchema.NewSessionResponse secondSession = client
+ .newSession(new AcpSchema.NewSessionRequest("/workspace/two", List.of()))
+ .block(TIMEOUT);
+
+ var prompts = Mono.zip(
+ client.prompt(new AcpSchema.PromptRequest(firstSession.sessionId(),
+ List.of(new AcpSchema.TextContent("first")))),
+ client.prompt(new AcpSchema.PromptRequest(secondSession.sessionId(),
+ List.of(new AcpSchema.TextContent("second")))))
+ .block(TIMEOUT);
+
+ assertThat(prompts.getT1().stopReason()).isEqualTo(AcpSchema.StopReason.END_TURN);
+ assertThat(prompts.getT2().stopReason()).isEqualTo(AcpSchema.StopReason.END_TURN);
+ assertThat(receivedUpdates).hasValue(40);
+ }
+ finally {
+ client.closeGracefully().block(TIMEOUT);
+ }
+ }
+ }
+
+ @Test
+ void rejectsNonInitializeFirstMessage() throws Exception {
+ try (FixtureServer server = FixtureServer.start(simpleAgentFactory())) {
+ CloseRecordingListener listener = new CloseRecordingListener();
+ WebSocket webSocket = HttpClient.newHttpClient()
+ .newWebSocketBuilder()
+ .connectTimeout(TIMEOUT)
+ .buildAsync(server.endpoint(), listener)
+ .get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
+
+ assertThat(listener.openLatch.await(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS)).isTrue();
+ webSocket.sendText("""
+ {"jsonrpc":"2.0","id":"new-1","method":"session/new","params":{"cwd":"/workspace","mcpServers":[]}}
+ """, true).get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
+
+ assertThat(listener.closeLatch.await(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS)).isTrue();
+ assertThat(listener.closeCode.get()).isEqualTo(StatusCode.PROTOCOL);
+ assertEventuallyNoConnections(server.transport());
+ }
+ }
+
+ private static AcpAgentFactory simpleAgentFactory() {
+ AtomicInteger sessionCounter = new AtomicInteger();
+ return AcpAgentFactory.async(transport -> AcpAgent.async(transport)
+ .initializeHandler(request -> Mono.just(new AcpSchema.InitializeResponse(
+ AcpSchema.LATEST_PROTOCOL_VERSION, new AcpSchema.AgentCapabilities(true, null, null), List.of())))
+ .newSessionHandler(request -> Mono.just(new AcpSchema.NewSessionResponse(
+ "sess-" + sessionCounter.incrementAndGet(), null, null)))
+ .promptHandler((request, context) -> context.sendMessage("hello from streamable websocket")
+ .thenReturn(AcpSchema.PromptResponse.endTurn()))
+ .build());
+ }
+
+ private static List readHttpHeaders(Socket socket) throws IOException {
+ BufferedReader reader = new BufferedReader(new InputStreamReader(socket.getInputStream(), StandardCharsets.UTF_8));
+ List lines = new ArrayList<>();
+ String line;
+ while ((line = reader.readLine()) != null && !line.isEmpty()) {
+ lines.add(line);
+ }
+ return lines;
+ }
+
+ private static void assertEventuallyNoConnections(StreamableHttpAcpAgentTransport transport) throws InterruptedException {
+ long deadline = System.nanoTime() + TIMEOUT.toNanos();
+ while (System.nanoTime() < deadline) {
+ if (transport.activeConnectionCount() == 0) {
+ return;
+ }
+ Thread.sleep(25);
+ }
+ assertThat(transport.activeConnectionCount()).isEqualTo(0);
+ }
+
+ private static int freePort() {
+ try (ServerSocket socket = new ServerSocket(0)) {
+ return socket.getLocalPort();
+ }
+ catch (IOException e) {
+ throw new IllegalStateException("Unable to allocate a free port", e);
+ }
+ }
+
+ private static final class FixtureServer implements AutoCloseable {
+
+ private final StreamableHttpAcpAgentTransport transport;
+
+ private FixtureServer(StreamableHttpAcpAgentTransport transport) {
+ this.transport = transport;
+ }
+
+ static FixtureServer start(AcpAgentFactory agentFactory) {
+ StreamableHttpAcpAgentTransport transport = new StreamableHttpAcpAgentTransport(
+ freePort(), AcpJsonMapper.createDefault(), agentFactory);
+ transport.start().block(TIMEOUT);
+ return new FixtureServer(transport);
+ }
+
+ int port() {
+ return transport.getPort();
+ }
+
+ URI endpoint() {
+ return URI.create("ws://127.0.0.1:" + transport.getPort() + "/acp");
+ }
+
+ StreamableHttpAcpAgentTransport transport() {
+ return transport;
+ }
+
+ @Override
+ public void close() {
+ transport.closeGracefully().block(TIMEOUT);
+ }
+
+ }
+
+ private static final class CloseRecordingListener implements WebSocket.Listener {
+
+ private final CountDownLatch openLatch = new CountDownLatch(1);
+
+ private final CountDownLatch closeLatch = new CountDownLatch(1);
+
+ private final AtomicInteger closeCode = new AtomicInteger();
+
+ @Override
+ public void onOpen(WebSocket webSocket) {
+ openLatch.countDown();
+ webSocket.request(1);
+ }
+
+ @Override
+ public CompletionStage> onText(WebSocket webSocket, CharSequence data, boolean last) {
+ webSocket.request(1);
+ return CompletableFuture.completedFuture(null);
+ }
+
+ @Override
+ public CompletionStage> onClose(WebSocket webSocket, int statusCode, String reason) {
+ closeCode.set(statusCode);
+ closeLatch.countDown();
+ return CompletableFuture.completedFuture(null);
+ }
+
+ @Override
+ public void onError(WebSocket webSocket, Throwable error) {
+ closeLatch.countDown();
+ }
+
+ }
+
+}
diff --git a/acp-streamable-http-jetty/src/test/resources/logback-test.xml b/acp-streamable-http-jetty/src/test/resources/logback-test.xml
new file mode 100644
index 0000000..5243e19
--- /dev/null
+++ b/acp-streamable-http-jetty/src/test/resources/logback-test.xml
@@ -0,0 +1,11 @@
+
+
+
+
+
+
+
+ %d{HH:mm:ss.SSS} %-5level %logger{36} - %msg%n
+
+
+
diff --git a/plans/DOCS-ROADMAP.md b/plans/DOCS-ROADMAP.md
deleted file mode 100644
index dc26c67..0000000
--- a/plans/DOCS-ROADMAP.md
+++ /dev/null
@@ -1,329 +0,0 @@
-# Roadmap: ACP Java SDK 0.9.0 Documentation
-
-> **Created**: 2026-02-10
-> **Design version**: 0.9.0
-
-## Overview
-
-Documentation ships in three stages, ordered by launch criticality. Stage 1 creates the Mintlify documentation site (blocks the 0.9.0 blog post). Stage 2 updates tutorial READMEs and SDK metadata for the release. Stage 3 completes remaining pages post-launch. All documentation follows the code-first workflow: verify tutorial code compiles, then write docs based on working code.
-
-## Stage 1: Mintlify Site (Launch-Critical)
-
-### Step 1.1: Navigation and Scaffolding
-
-**Entry criteria**:
-- [ ] Read: Claude Agent SDK Mintlify structure (`~/community/mintlify-docs/claude-agent-sdk/`)
-- [ ] Read: `~/community/mintlify-docs/mint.json`
-
-**Work items**:
-- [ ] UPDATE `~/community/mintlify-docs/mint.json` with ACP Java SDK section under Incubating Projects
-- [ ] CREATE directory: `~/community/mintlify-docs/acp-java-sdk/`
-- [ ] CREATE directory: `~/community/mintlify-docs/acp-java-sdk/reference/`
-- [ ] CREATE directory: `~/community/mintlify-docs/acp-java-sdk/tutorial/`
-
-**Exit criteria**:
-- [ ] mint.json validates and includes ACP Java SDK navigation
-- [ ] Directory structure created
-- [ ] Update `ROADMAP.md` checkboxes
-
-**Deliverables**: Site navigation and directory scaffolding
-
----
-
-### Step 1.2: Index Page
-
-**Entry criteria**:
-- [ ] Step 1.1 complete
-- [ ] Read: `~/community/mintlify-docs/claude-agent-sdk/index.md` (template)
-- [ ] Read: `~/acp/acp-java/README.md` (source material)
-
-**Work items**:
-- [ ] CREATE `~/community/mintlify-docs/acp-java-sdk/index.md` (~120 lines)
-- [ ] Content: Overview, three-API-styles table, quick start (client + annotation-based agent), CardGroup to Reference + Tutorial, resource links
-
-**Exit criteria**:
-- [ ] Index page renders in dev preview
-- [ ] Code examples match SDK README
-- [ ] Update `ROADMAP.md` checkboxes
-
-**Deliverables**: `acp-java-sdk/index.md`
-
----
-
-### Step 1.3: Tutorial Index Page
-
-**Entry criteria**:
-- [ ] Step 1.1 complete
-- [ ] Read: `~/community/mintlify-docs/claude-agent-sdk/tutorial/index.md` (template)
-
-**Work items**:
-- [ ] CREATE `~/community/mintlify-docs/acp-java-sdk/tutorial/index.md` (~60 lines)
-- [ ] Content: Overview, prerequisites, 3-part structure table, getting the code
-
-**Exit criteria**:
-- [ ] Tutorial index renders and links resolve
-- [ ] Update `ROADMAP.md` checkboxes
-
-**Deliverables**: `acp-java-sdk/tutorial/index.md`
-
----
-
-### Step 1.4: Priority Tutorial Pages (10 Pages)
-
-**Entry criteria**:
-- [ ] Step 1.3 complete
-- [ ] Read: `~/community/mintlify-docs/claude-agent-sdk/tutorial/01-hello-world.md` (template)
-- [ ] VERIFY: `cd ~/projects/acp-java-tutorial && ./mvnw compile -pl module-01-first-contact,module-05-streaming-updates,module-12-echo-agent,module-13-agent-handlers,module-14-sending-updates,module-15-agent-requests,module-16-in-memory-testing,module-28-zed-integration,module-29-jetbrains-integration,module-30-vscode-integration -q`
-
-**Work items**:
-- [ ] CREATE `tutorial/01-first-contact.md` — ACP client basics (module-01)
-- [ ] CREATE `tutorial/05-streaming-updates.md` — Receiving real-time updates (module-05)
-- [ ] CREATE `tutorial/12-echo-agent.md` — Building your first agent (module-12)
-- [ ] CREATE `tutorial/13-agent-handlers.md` — All handler types (module-13)
-- [ ] CREATE `tutorial/14-sending-updates.md` — Agent-side streaming (module-14)
-- [ ] CREATE `tutorial/15-agent-requests.md` — File and permission requests (module-15)
-- [ ] CREATE `tutorial/16-in-memory-testing.md` — Testing without subprocesses (module-16)
-- [ ] CREATE `tutorial/28-zed-integration.md` — Running agents in Zed (module-28)
-- [ ] CREATE `tutorial/29-jetbrains-integration.md` — Running agents in JetBrains (module-29)
-- [ ] CREATE `tutorial/30-vscode-integration.md` — Running agents in VS Code (module-30)
-
-Each page follows template structure:
-- What You'll Learn
-- The Code (with explanation)
-- Source Code GitHub link
-- Run Command
-- Next Module
-
-**Exit criteria**:
-- [ ] All 10 pages render in dev preview
-- [ ] Code examples match actual tutorial source
-- [ ] All cross-links resolve
-- [ ] Update `ROADMAP.md` checkboxes
-
-**Deliverables**: 10 tutorial pages in `acp-java-sdk/tutorial/`
-
----
-
-### Step 1.5: API Reference Page
-
-**Entry criteria**:
-- [ ] Step 1.1 complete
-- [ ] Read: `~/community/mintlify-docs/claude-agent-sdk/reference/java.md` (template)
-- [ ] Read: `~/acp/acp-java/README.md` (source material)
-- [ ] Read: `~/acp/acp-java/acp-agent-support/README.md` (source material)
-
-**Work items**:
-- [ ] CREATE `~/community/mintlify-docs/acp-java-sdk/reference/java.md` (~500 lines)
-- [ ] Sections: Installation, Three-API comparison, Client API, Agent API (annotation/sync/async), Protocol types, Capabilities, Transports, Errors, Test utilities
-
-**Exit criteria**:
-- [ ] Reference page renders in dev preview
-- [ ] All code examples verified against SDK
-- [ ] Update `ROADMAP.md` checkboxes
-
-**Deliverables**: `acp-java-sdk/reference/java.md`
-
----
-
-### Step 1.6: Stage 1 Review
-
-**Entry criteria**:
-- [ ] Steps 1.1-1.5 complete
-
-**Work items**:
-- [ ] RUN `~/community/mintlify-docs/dev-preview.sh` to verify all pages render
-- [ ] VERIFY all cross-links work (index → tutorial → reference)
-- [ ] VERIFY code examples match actual tutorial source
-- [ ] CHECK for forbidden marketing language
-- [ ] VERIFY no internal implementation details exposed
-
-**Exit criteria**:
-- [ ] All pages render without errors
-- [ ] Zero forbidden-language violations
-- [ ] All code examples match working tutorial code
-- [ ] Update `ROADMAP.md` checkboxes
-
----
-
-## Stage 2: Tutorial READMEs + SDK Updates
-
-### Step 2.1: Lightweight Module READMEs (10 Priority Modules)
-
-**Entry criteria**:
-- [ ] Stage 1 complete
-- [ ] Read: `~/community/claude-agent-sdk-java-tutorial/module-01-hello-world/README.md` (template)
-
-**Work items**:
-- [ ] CREATE README.md for module-01-first-contact (5-6 lines)
-- [ ] CREATE README.md for module-05-streaming-updates
-- [ ] CREATE README.md for module-12-echo-agent
-- [ ] CREATE README.md for module-13-agent-handlers
-- [ ] CREATE README.md for module-14-sending-updates
-- [ ] CREATE README.md for module-15-agent-requests
-- [ ] CREATE README.md for module-16-in-memory-testing
-- [ ] UPDATE README.md for module-28 — add Mintlify link at top
-- [ ] UPDATE README.md for module-29 — add Mintlify link at top
-- [ ] UPDATE README.md for module-30 — add Mintlify link at top
-
-**Exit criteria**:
-- [ ] All 10 modules have README.md files
-- [ ] Mintlify links point to correct pages
-- [ ] Update `ROADMAP.md` checkboxes
-
-**Deliverables**: 7 new READMEs, 3 updated READMEs
-
----
-
-### Step 2.2: Fix Tutorial README
-
-**Entry criteria**:
-- [ ] Step 2.1 complete
-
-**Work items**:
-- [ ] UPDATE `~/projects/acp-java-tutorial/README.md`
-- [ ] MOVE modules 03, 04, 06, 09, 11 from "Coming Soon" to active (they have source code)
-- [ ] ADD Mintlify docs link at top
-- [ ] REORGANIZE into 3-part structure: Client → Agent → IDE Integration
-
-**Exit criteria**:
-- [ ] No modules with source code listed as "Coming Soon"
-- [ ] Mintlify link works
-- [ ] Update `ROADMAP.md` checkboxes
-
-**Deliverables**: Updated tutorial README
-
----
-
-### Step 2.3: SDK README Updates
-
-**Entry criteria**:
-- [ ] Step 2.2 complete
-
-**Work items**:
-- [ ] UPDATE `~/acp/acp-java/README.md`
-- [ ] ADD Mintlify docs link at top of Overview
-- [ ] UPDATE Installation: change `0.9.0-SNAPSHOT` to `0.9.0`, remove snapshots repository XML
-
-**Exit criteria**:
-- [ ] Version references updated
-- [ ] Mintlify link present
-- [ ] Update `ROADMAP.md` checkboxes
-
-**Deliverables**: Updated SDK README
-
----
-
-### Step 2.4: CHANGELOG for 0.9.0
-
-**Entry criteria**:
-- [ ] Step 2.3 complete
-
-**Work items**:
-- [ ] UPDATE `~/acp/acp-java/CHANGELOG.md`
-- [ ] REPLACE "[Unreleased]" with "[0.9.0] - 2026-02-XX"
-- [ ] EXPAND with full feature list from SDK development
-
-**Exit criteria**:
-- [ ] CHANGELOG reflects 0.9.0 release
-- [ ] All major features listed
-- [ ] Update `ROADMAP.md` checkboxes
-
-**Deliverables**: Updated CHANGELOG
-
----
-
-### Step 2.5: Stage 2 Review
-
-**Entry criteria**:
-- [ ] Steps 2.1-2.4 complete
-
-**Work items**:
-- [ ] VERIFY GitHub rendering of all module READMEs
-- [ ] CLICK all cross-links (SDK README → tutorial modules → Mintlify)
-- [ ] CONFIRM `./mvnw compile` passes for tutorial project
-
-**Exit criteria**:
-- [ ] All links resolve
-- [ ] Tutorial compiles
-- [ ] Update `ROADMAP.md` checkboxes
-
----
-
-## Stage 3: Post-Launch Completion (Not Blocking Release)
-
-### Step 3.1: Remaining Mintlify Tutorial Pages (14 Pages)
-
-**Work items**:
-- [ ] CREATE pages for modules: 02, 03, 04, 06, 07, 08, 09, 10, 11, 17, 18, 19, 21, 22
-- [ ] UPDATE mint.json navigation with expanded tutorial groups
-
----
-
-### Step 3.2: Remaining Tutorial Module READMEs (14 Modules)
-
-**Work items**:
-- [ ] CREATE READMEs for all remaining modules with source code
-
----
-
-### Step 3.3: SDK Module READMEs
-
-**Work items**:
-- [ ] CREATE lightweight READMEs for: acp-core, acp-annotations, acp-test, acp-websocket-jetty
-
----
-
-### Step 3.4: Enhancements
-
-**Work items**:
-- [ ] ADD architecture diagram to Mintlify index
-- [ ] ADD Gradle installation instructions to reference page
-
----
-
-## Execution Order (Stage 1 Priority)
-
-1. mint.json + directory scaffolding (unblocks everything)
-2. Index page + tutorial index (site structure)
-3. Tutorial pages: 12 (echo agent), 28 (Zed), 01 (first contact) — highest impact first
-4. API reference page (largest single item)
-5. Remaining 7 tutorial pages
-6. Stage 1 review
-
-## Verification
-
-- `~/community/mintlify-docs/dev-preview.sh` — all pages render
-- Every code example matches actual tutorial source
-- All cross-links: SDK README → tutorial modules → Mintlify
-- `./mvnw compile` passes for tutorial project
-
-## Writing Agents
-
-| Agent | Role |
-|-------|------|
-| `~/.claude/agents/technical-writer.md` | Primary — writes Mintlify pages and READMEs |
-| `~/.claude/agents/doc-reviewer.md` | Review — validates against style guide |
-| `~/.claude/agents/tutorial-code-sync.md` | Sync — ensures code examples match tutorial source |
-
-## Style Principles
-
-- Direct, plain-spoken, unadorned
-- Assume reader competence
-- Structure: context, mechanism, consequence
-- Forbidden: exciting, game-changing, best-in-class, seamlessly, powerful, intuitive, revolutionary, cutting-edge
-- Short paragraphs (3-4 sentences max), tables for comparisons, code blocks liberally
-- Accuracy over aesthetics
-
-## Conventions
-
-### Commit Convention
-
-```
-Step X.Y: Brief description of what was done
-```
-
-### Code-First Workflow
-
-1. Verify tutorial code compiles: `./mvnw compile -pl module-XX-* -q`
-2. THEN write docs based on working code
-3. Code in docs must match working tutorial code exactly
diff --git a/pom.xml b/pom.xml
index aa98e65..22e5530 100644
--- a/pom.xml
+++ b/pom.xml
@@ -18,6 +18,7 @@
acp-core
acp-agent-support
acp-test
+ acp-streamable-http-jetty
acp-websocket-jetty
@@ -114,6 +115,11 @@
acp-test
${project.version}
+
+ com.agentclientprotocol
+ acp-streamable-http-jetty
+ ${project.version}
+
@@ -133,6 +139,21 @@
jetty-websocket-jetty-api
${jetty.version}
+
+ org.eclipse.jetty
+ jetty-server
+ ${jetty.version}
+
+
+ org.eclipse.jetty.ee10
+ jetty-ee10-servlet
+ ${jetty.version}
+
+
+ org.eclipse.jetty.http2
+ jetty-http2-server
+ ${jetty.version}
+