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} +