From 0e50b35e998864d88c2bc86ea9c92b52ec719ad9 Mon Sep 17 00:00:00 2001
From: Kaiser Dandangi
Date: Sun, 17 May 2026 21:27:51 -0400
Subject: [PATCH 01/15] feat: add streamable http client transport
---
.gitignore | 4 +-
README.md | 3 +-
.../StreamableHttpAcpClientTransport.java | 729 ++++++++++++++++++
.../WebSocketAcpClientTransport.java | 7 +
.../sdk/spec/AcpClientSession.java | 18 +-
...HttpAcpClientTransportIntegrationTest.java | 290 +++++++
.../StreamableHttpAcpClientTransportTest.java | 94 +++
plans/STREAMABLE-HTTP-TRANSPORT.md | 194 +++++
.../streamable-http-server/README.md | 36 +
.../streamable-http-server/dist/protocol.js | 26 +
.../streamable-http-server/dist/scenarios.js | 387 ++++++++++
.../streamable-http-server/dist/server.js | 46 ++
.../streamable-http-server/dist/transcript.js | 52 ++
.../golden/happy-path.json | 139 ++++
.../golden/permission-round-trip.json | 160 ++++
.../golden/session-load.json | 100 +++
.../golden/two-sessions.json | 224 ++++++
.../golden/validation-failures.json | 86 +++
.../golden/wrong-stream-response.json | 130 ++++
.../streamable-http-server/package-lock.json | 566 ++++++++++++++
.../streamable-http-server/package.json | 16 +
.../streamable-http-server/src/protocol.ts | 32 +
.../streamable-http-server/src/scenarios.ts | 574 ++++++++++++++
.../streamable-http-server/src/server.ts | 55 ++
.../streamable-http-server/src/transcript.ts | 101 +++
.../streamable-http-server/tsconfig.json | 13 +
26 files changed, 4079 insertions(+), 3 deletions(-)
create mode 100644 acp-core/src/main/java/com/agentclientprotocol/sdk/client/transport/StreamableHttpAcpClientTransport.java
create mode 100644 acp-core/src/test/java/com/agentclientprotocol/sdk/client/transport/StreamableHttpAcpClientTransportIntegrationTest.java
create mode 100644 acp-core/src/test/java/com/agentclientprotocol/sdk/client/transport/StreamableHttpAcpClientTransportTest.java
create mode 100644 plans/STREAMABLE-HTTP-TRANSPORT.md
create mode 100644 test-fixtures/streamable-http-server/README.md
create mode 100644 test-fixtures/streamable-http-server/dist/protocol.js
create mode 100644 test-fixtures/streamable-http-server/dist/scenarios.js
create mode 100644 test-fixtures/streamable-http-server/dist/server.js
create mode 100644 test-fixtures/streamable-http-server/dist/transcript.js
create mode 100644 test-fixtures/streamable-http-server/golden/happy-path.json
create mode 100644 test-fixtures/streamable-http-server/golden/permission-round-trip.json
create mode 100644 test-fixtures/streamable-http-server/golden/session-load.json
create mode 100644 test-fixtures/streamable-http-server/golden/two-sessions.json
create mode 100644 test-fixtures/streamable-http-server/golden/validation-failures.json
create mode 100644 test-fixtures/streamable-http-server/golden/wrong-stream-response.json
create mode 100644 test-fixtures/streamable-http-server/package-lock.json
create mode 100644 test-fixtures/streamable-http-server/package.json
create mode 100644 test-fixtures/streamable-http-server/src/protocol.ts
create mode 100644 test-fixtures/streamable-http-server/src/scenarios.ts
create mode 100644 test-fixtures/streamable-http-server/src/server.ts
create mode 100644 test-fixtures/streamable-http-server/src/transcript.ts
create mode 100644 test-fixtures/streamable-http-server/tsconfig.json
diff --git a/.gitignore b/.gitignore
index a20568a..a7ae7a1 100644
--- a/.gitignore
+++ b/.gitignore
@@ -57,6 +57,7 @@ derby.log
*.zip
*.tar.gz
*.rar
+node_modules/
### Claude Code ###
.claude/
@@ -68,5 +69,6 @@ hs_err_pid*
replay_pid*
### Planning and Internal Documentation ###
-plans/
+plans/*
+!plans/STREAMABLE-HTTP-TRANSPORT.md
learnings/
diff --git a/README.md b/README.md
index 486ee6a..4d51db7 100644
--- a/README.md
+++ b/README.md
@@ -368,7 +368,7 @@ 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-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 +380,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` | — | acp-core |
---
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..1679775
--- /dev/null
+++ b/acp-core/src/main/java/com/agentclientprotocol/sdk/client/transport/StreamableHttpAcpClientTransport.java
@@ -0,0 +1,729 @@
+/*
+ * 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 Mark Pollack
+ */
+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;
+
+ private final AtomicBoolean connected = new AtomicBoolean(false);
+
+ private final AtomicBoolean initialized = new AtomicBoolean(false);
+
+ private final AtomicBoolean closing = new AtomicBoolean(false);
+
+ private final Map outboundRequestRoutes = new ConcurrentHashMap<>();
+
+ private final Map inboundRequestRoutes = new ConcurrentHashMap<>();
+
+ private final Map sessionStreams = 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) {
+ if (sessionStreams.containsKey(sessionId)) {
+ return Mono.empty();
+ }
+ return openSseStream(RouteScope.session(sessionId))
+ .doOnSuccess(stream -> sessionStreams.putIfAbsent(sessionId, stream))
+ .then();
+ }
+
+ 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(() -> {
+ 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();
+ 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/AcpClientSession.java b/acp-core/src/main/java/com/agentclientprotocol/sdk/spec/AcpClientSession.java
index 8dd0f5e..6c30c5d 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,23 @@ 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.
+ */
+ 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/client/transport/StreamableHttpAcpClientTransportIntegrationTest.java b/acp-core/src/test/java/com/agentclientprotocol/sdk/client/transport/StreamableHttpAcpClientTransportIntegrationTest.java
new file mode 100644
index 0000000..aed6658
--- /dev/null
+++ b/acp-core/src/test/java/com/agentclientprotocol/sdk/client/transport/StreamableHttpAcpClientTransportIntegrationTest.java
@@ -0,0 +1,290 @@
+/*
+ * 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.InputStreamReader;
+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.nio.file.Files;
+import java.nio.file.Path;
+import java.time.Duration;
+import java.util.List;
+import java.util.concurrent.CopyOnWriteArrayList;
+import java.util.concurrent.TimeUnit;
+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.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+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 the in-repo TypeScript Streamable HTTP fixture.
+ */
+class StreamableHttpAcpClientTransportIntegrationTest {
+
+ private static final Duration TIMEOUT = Duration.ofSeconds(5);
+
+ private static final Path FIXTURE_DIR = Path.of("..", "test-fixtures", "streamable-http-server").normalize();
+
+ private static final Path GOLDEN_DIR = FIXTURE_DIR.resolve("golden");
+
+ private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
+
+ @Test
+ void happyPathMatchesFixtureTranscript() throws Exception {
+ try (FixtureServer fixture = FixtureServer.start("happy-path")) {
+ 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);
+
+ client.closeGracefully().block(TIMEOUT);
+ fixture.assertTranscriptMatches("happy-path.json");
+ }
+ }
+
+ @Test
+ void permissionRequestRoundTripsOnSessionStream() throws Exception {
+ try (FixtureServer fixture = FixtureServer.start("permission-round-trip")) {
+ 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(session.sessionId()).isEqualTo("sess-permission");
+ assertThat(prompt.stopReason()).isEqualTo(AcpSchema.StopReason.END_TURN);
+ assertThat(permissionRequests).hasValue(1);
+
+ client.closeGracefully().block(TIMEOUT);
+ fixture.assertTranscriptMatches("permission-round-trip.json");
+ }
+ }
+
+ @Test
+ void loadSessionOpensSessionStreamBeforePosting() throws Exception {
+ try (FixtureServer fixture = FixtureServer.start("session-load")) {
+ 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();
+
+ client.closeGracefully().block(TIMEOUT);
+ fixture.assertTranscriptMatches("session-load.json");
+ }
+ }
+
+ @Test
+ void supportsTwoConcurrentLogicalSessions() throws Exception {
+ try (FixtureServer fixture = FixtureServer.start("two-sessions")) {
+ 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);
+
+ client.closeGracefully().block(TIMEOUT);
+ fixture.assertTranscriptMatches("two-sessions.json");
+ }
+ }
+
+ @Test
+ void wrongStreamResponseFailsPendingExchange() throws Exception {
+ try (FixtureServer fixture = FixtureServer.start("wrong-stream-response")) {
+ 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);
+ fixture.assertTranscriptMatches("wrong-stream-response.json");
+ }
+ }
+
+ @Test
+ void fixtureRejectsMissingConnectionHeadersCookiesAndSseAccept() throws Exception {
+ try (FixtureServer fixture = FixtureServer.start("validation-failures")) {
+ HttpClient rawClient = HttpClient.newHttpClient();
+ HttpResponse initialize = rawClient.send(HttpRequest.newBuilder(fixture.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();
+ String cookie = initialize.headers().firstValue("set-cookie").orElseThrow();
+
+ HttpResponse missingConnection = rawClient.send(HttpRequest.newBuilder(fixture.endpoint())
+ .header("Accept", "text/event-stream")
+ .header("Cookie", cookie)
+ .GET()
+ .build(), HttpResponse.BodyHandlers.discarding());
+ HttpResponse missingCookie = rawClient.send(HttpRequest.newBuilder(fixture.endpoint())
+ .header("Accept", "text/event-stream")
+ .header("Acp-Connection-Id", connectionId)
+ .GET()
+ .build(), HttpResponse.BodyHandlers.discarding());
+ HttpResponse wrongAccept = rawClient.send(HttpRequest.newBuilder(fixture.endpoint())
+ .header("Accept", "application/json")
+ .header("Cookie", cookie)
+ .header("Acp-Connection-Id", connectionId)
+ .GET()
+ .build(), HttpResponse.BodyHandlers.discarding());
+ HttpResponse delete = rawClient.send(HttpRequest.newBuilder(fixture.endpoint())
+ .header("Cookie", cookie)
+ .header("Acp-Connection-Id", connectionId)
+ .DELETE()
+ .build(), HttpResponse.BodyHandlers.discarding());
+
+ assertThat(initialize.statusCode()).isEqualTo(200);
+ assertThat(missingConnection.statusCode()).isEqualTo(400);
+ assertThat(missingCookie.statusCode()).isEqualTo(401);
+ assertThat(wrongAccept.statusCode()).isEqualTo(406);
+ assertThat(delete.statusCode()).isEqualTo(202);
+
+ fixture.assertTranscriptMatches("validation-failures.json");
+ }
+ }
+
+ 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 Process process;
+
+ private final BufferedReader stdout;
+
+ private final URI baseUri;
+
+ private FixtureServer(Process process, BufferedReader stdout, URI baseUri) {
+ this.process = process;
+ this.stdout = stdout;
+ this.baseUri = baseUri;
+ }
+
+ static FixtureServer start(String scenario) throws Exception {
+ Process process = new ProcessBuilder("node", "dist/server.js", "--scenario", scenario, "--port", "0")
+ .directory(FIXTURE_DIR.toFile())
+ .redirectErrorStream(true)
+ .start();
+ BufferedReader stdout = new BufferedReader(
+ new InputStreamReader(process.getInputStream(), StandardCharsets.UTF_8));
+ String readyLine = stdout.readLine();
+ if (readyLine == null) {
+ throw new IllegalStateException("Fixture server exited before becoming ready");
+ }
+ JsonNode ready = OBJECT_MAPPER.readTree(readyLine);
+ if (!"ready".equals(ready.path("status").asText())) {
+ throw new IllegalStateException("Fixture server did not become ready: " + readyLine);
+ }
+ int port = ready.path("port").asInt();
+ return new FixtureServer(process, stdout, URI.create("http://127.0.0.1:" + port));
+ }
+
+ URI endpoint() {
+ return baseUri.resolve("/acp");
+ }
+
+ void assertTranscriptMatches(String goldenName) throws Exception {
+ HttpRequest request = HttpRequest.newBuilder(baseUri.resolve("/__test/transcript")).GET().build();
+ HttpResponse response = HttpClient.newHttpClient()
+ .send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
+ assertThat(response.statusCode()).isEqualTo(200);
+
+ JsonNode actual = OBJECT_MAPPER.readTree(response.body());
+ JsonNode expected = OBJECT_MAPPER.readTree(Files.readString(GOLDEN_DIR.resolve(goldenName)));
+ assertThat(actual).isEqualTo(expected);
+ }
+
+ @Override
+ public void close() throws Exception {
+ process.destroy();
+ if (!process.waitFor(2, TimeUnit.SECONDS)) {
+ process.destroyForcibly();
+ process.waitFor(2, TimeUnit.SECONDS);
+ }
+ try {
+ stdout.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..33d75a1
--- /dev/null
+++ b/acp-core/src/test/java/com/agentclientprotocol/sdk/client/transport/StreamableHttpAcpClientTransportTest.java
@@ -0,0 +1,94 @@
+/*
+ * Copyright 2025-2025 the original author or authors.
+ */
+
+package com.agentclientprotocol.sdk.client.transport;
+
+import java.net.URI;
+import java.net.http.HttpClient;
+import java.util.Map;
+
+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.Mockito.mock;
+
+/**
+ * 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");
+ }
+
+}
diff --git a/plans/STREAMABLE-HTTP-TRANSPORT.md b/plans/STREAMABLE-HTTP-TRANSPORT.md
new file mode 100644
index 0000000..cc2248e
--- /dev/null
+++ b/plans/STREAMABLE-HTTP-TRANSPORT.md
@@ -0,0 +1,194 @@
+# Plan: Streamable HTTP Client Transport
+
+> **Status**: Milestone one implemented
+> **Created**: 2026-05-17
+> **Primary goal**: Java clients can communicate with compliant remote ACP agents over the Streamable HTTP transport.
+
+## Goal
+
+Add a client-side Streamable HTTP transport to `acp-core` so applications can use the existing Java client API against compliant remote ACP agents without changing their own code.
+
+This first milestone is intentionally client-only:
+
+- implement `StreamableHttpAcpClientTransport`
+- preserve the existing ACP client API surface
+- prove the wire contract with an in-repo TypeScript conformance fixture
+- defer remote transport negotiation, Java server support, and reconnect/resume behavior
+
+## Milestone-One Result
+
+Implemented in this branch:
+
+- `StreamableHttpAcpClientTransport`
+- preserved public ACP client API
+- compatibility note + isolated forwarding path for the existing client handler-emission ambiguity
+- in-repo TypeScript fixture with golden transcripts
+- Java unit + integration coverage for:
+ - initialize bootstrap
+ - cookie persistence
+ - connection SSE
+ - `session/new`
+ - prompt flow with session updates
+ - `session/request_permission`
+ - `session/load`
+ - wrong-stream responses
+ - strict-routing rejection
+ - two logical sessions
+ - fixture validation failures for missing connection headers, missing cookies, and invalid SSE `Accept`
+
+## Contract Decisions
+
+### Public API
+
+- Add `StreamableHttpAcpClientTransport` in `acp-core`.
+- Keep construction symmetrical with `WebSocketAcpClientTransport`:
+ - `new StreamableHttpAcpClientTransport(endpointUri, jsonMapper)`
+ - `new StreamableHttpAcpClientTransport(endpointUri, jsonMapper, httpClient)`
+- Use a transport-owned default `CookieManager`, while allowing advanced callers to inject a custom `HttpClient`.
+- Expose a public routing mode:
+ - `COMPATIBLE`
+ - `STRICT`
+
+### Client / Transport Boundary
+
+- `AcpClientSession` remains transport-agnostic and continues to own ACP request/response semantics.
+- Streamable HTTP owns HTTP-only concerns internally:
+ - `Acp-Connection-Id`
+ - `Acp-Session-Id`
+ - SSE stream lifecycle
+ - routing correlation
+ - cookie propagation
+- Preserve current WebSocket-compatible client handler-emission forwarding for behavioral parity in the first implementation.
+- Isolate that compatibility behavior behind a small helper and flag it in code as an unresolved contract ambiguity.
+
+### Lifecycle
+
+- `connect(...)`
+ - registers the inbound handler
+ - prepares resources
+ - performs no network I/O by itself
+- `initialize`
+ - is the first real HTTP exchange
+ - sends `POST /acp` without `Acp-Connection-Id`
+ - captures `Acp-Connection-Id` and cookies from the `200 OK`
+ - opens the connection-scoped SSE stream before delivering the initialize response upward
+- `session/new`
+ - sends the POST first
+ - receives its JSON-RPC response on the connection stream
+ - opens the returned session’s SSE stream
+ - completes only after that session stream is established
+- `session/load`
+ - opens the session stream first
+ - sends the POST second
+ - receives its response on the connection stream
+- Session-scoped outbound messages require an already-open session stream.
+- No automatic reconnect / resume behavior in milestone one.
+- `closeGracefully()`
+ 1. stop accepting new outbound work
+ 2. cancel local SSE readers
+ 3. send `DELETE /acp` with `Acp-Connection-Id`
+ 4. clear local routing and stream state
+
+### Routing
+
+- Use an explicit routing table for known ACP methods.
+- Compatible-mode fallback for unknown outbound requests / notifications:
+ - `params.sessionId` present → session-scoped
+ - otherwise → connection-scoped
+- Strict mode rejects unknown outbound request / notification methods that lack explicit routing.
+- The transport owns a minimal routing ledger:
+ - `outbound request id -> request kind + expected response scope`
+ - `inbound request id -> scope required for the later outbound response`
+ - `session id -> open session SSE stream`
+- Wrong-stream responses are protocol errors.
+- Unknown response ids retain current Java SDK parity and are left to the session layer’s existing behavior.
+
+### SSE Model
+
+- Treat each SSE `data:` payload as one JSON-RPC message.
+- Ignore comments / keep-alives.
+- Preserve order per SSE stream.
+- Do not impose a synthetic global order across different streams.
+- Treat SSE as the source of truth for server feedback and request completion, not as a receipt log for every POST envelope.
+
+## Test Harness
+
+Create an in-repo TypeScript fixture:
+
+```text
+test-fixtures/streamable-http-server/
+```
+
+The fixture will be:
+
+- HTTP-only in the first milestone
+- strict by default
+- scenario-driven with named startup-selected scenarios
+- runnable manually and from Java integration tests
+- the single owner of canonical transcript serialization
+
+Golden transcripts will live beside the fixture:
+
+```text
+test-fixtures/streamable-http-server/golden/
+```
+
+### Milestone-One Scenarios
+
+- initialize bootstrap
+- cookie persistence
+- connection SSE stream
+- `session/new`
+- prompt flow with session updates
+- agent → client `session/request_permission`
+- `session/load`
+- validation failures for wrong / missing headers
+- missing cookie
+- wrong-stream response
+- strict-routing rejection
+- light two-session coverage
+
+### Future Harness Scenarios
+
+- reconnect / resume behavior
+- concurrency / stress coverage
+- broader interop matrix
+- WebSocket scenarios for a future composite remote transport
+
+## Deferred Work
+
+- Composite `RemoteAcpClientTransport`
+ - prefer WebSocket
+ - fall back to Streamable HTTP
+- Java server-side Streamable HTTP transport
+- reconnect / resume behavior once the protocol defines it
+- richer debugging / observability hooks
+- broader interoperability testing against an official compliant server when one exists
+- deeper multi-session stress coverage
+
+## Known Ambiguity / Follow-Up Decision
+
+### Client handler-emission forwarding
+
+The existing WebSocket client transport forwards any message emitted by the registered handler back onto the transport. `AcpClientSession` also sends responses explicitly through `sendMessage(...)`, so the client-side contract is currently ambiguous.
+
+Decision for this milestone:
+
+- preserve WebSocket-compatible forwarding in the new HTTP transport for parity
+- isolate the forwarding path in a small helper
+- document the ambiguity in code and in this plan
+- have the default `AcpClientSession` consume inbound messages without re-emitting them,
+ because it already sends legitimate outbound replies explicitly through `sendMessage(...)`
+
+Follow-up:
+
+- decide whether `AcpClientTransport` should become explicitly receive-only on the client side
+- if so, remove the compatibility forwarding path from both transports in a focused cleanup
+
+## Non-Goals for the First Milestone
+
+- WebSocket fallback orchestration
+- server-side Java transport
+- automatic reconnect / resume
+- transport-specific public debugging APIs
+- global ordering across streams
diff --git a/test-fixtures/streamable-http-server/README.md b/test-fixtures/streamable-http-server/README.md
new file mode 100644
index 0000000..09a952e
--- /dev/null
+++ b/test-fixtures/streamable-http-server/README.md
@@ -0,0 +1,36 @@
+# Streamable HTTP Fixture Server
+
+This in-repo TypeScript fixture is the conformance harness for the Java Streamable HTTP client transport.
+
+Current scope:
+
+- strict fixture behavior
+- HTTP-only
+- named startup-selected scenarios
+- canonical transcript serialization owned by the fixture
+
+Current scenarios:
+
+- `happy-path`
+- `permission-round-trip`
+- `session-load`
+- `two-sessions`
+- `wrong-stream-response`
+- `validation-failures`
+
+Fixture-wide validation already covers cookies and transport headers. Strict client-side
+routing rejection is tested in the Java unit tests because it should fail before the
+fixture ever sees a request.
+
+Manual use:
+
+```bash
+npm install
+npm run build
+node dist/server.js --scenario happy-path --port 8080
+```
+
+The server prints a single JSON `ready` line on startup. Golden transcripts live in
+`golden/` and are compared by the Java integration tests.
+
+The harness is intentionally small and local to this repository for now. It may later become a reusable ACP conformance fixture once the remote transport ecosystem settles.
diff --git a/test-fixtures/streamable-http-server/dist/protocol.js b/test-fixtures/streamable-http-server/dist/protocol.js
new file mode 100644
index 0000000..725eb81
--- /dev/null
+++ b/test-fixtures/streamable-http-server/dist/protocol.js
@@ -0,0 +1,26 @@
+export const ACP_PATH = "/acp";
+export const CONNECTION_HEADER = "acp-connection-id";
+export const SESSION_HEADER = "acp-session-id";
+export const FIXTURE_COOKIE = "fixture=streamable-http";
+export function sendJson(response, status, body, headers = {}) {
+ response.writeHead(status, {
+ "content-type": "application/json",
+ ...headers,
+ });
+ if (body) {
+ response.end(JSON.stringify(body));
+ }
+ else {
+ response.end();
+ }
+}
+export function openEventStream(response) {
+ response.writeHead(200, {
+ "content-type": "text/event-stream",
+ "cache-control": "no-cache",
+ });
+ response.flushHeaders();
+}
+export function writeSse(response, message) {
+ response.write(`data: ${JSON.stringify(message)}\n\n`);
+}
diff --git a/test-fixtures/streamable-http-server/dist/scenarios.js b/test-fixtures/streamable-http-server/dist/scenarios.js
new file mode 100644
index 0000000..8a00ec7
--- /dev/null
+++ b/test-fixtures/streamable-http-server/dist/scenarios.js
@@ -0,0 +1,387 @@
+import { CONNECTION_HEADER, FIXTURE_COOKIE, SESSION_HEADER, openEventStream, sendJson, writeSse, } from "./protocol.js";
+export function createScenario(name) {
+ switch (name) {
+ case "happy-path":
+ return new HappyPathScenario();
+ case "permission-round-trip":
+ return new PermissionRoundTripScenario();
+ case "session-load":
+ return new SessionLoadScenario();
+ case "two-sessions":
+ return new TwoSessionsScenario();
+ case "wrong-stream-response":
+ return new WrongStreamResponseScenario();
+ case "validation-failures":
+ return new ValidationFailuresScenario();
+ default:
+ throw new Error(`Unknown scenario: ${name}`);
+ }
+}
+class BaseScenario {
+ connectionId = "conn-1";
+ connectionStream = null;
+ sessionStreams = new Map();
+ handle(response, headers, method, body, recorder) {
+ const request = this.recordRequest(method, headers, body, recorder);
+ if (!this.validateRequest(response, request, body, recorder)) {
+ return;
+ }
+ if (request.method === "POST" && body?.method === "initialize") {
+ this.handleInitialize(response, body, recorder);
+ return;
+ }
+ if (request.method === "GET" &&
+ request.connectionId === this.connectionId &&
+ !request.sessionId) {
+ this.connectionStream = response;
+ openEventStream(response);
+ this.recordResponse(recorder, 200, "connection", null);
+ return;
+ }
+ if (request.method === "GET" &&
+ request.connectionId === this.connectionId &&
+ request.sessionId) {
+ this.sessionStreams.set(request.sessionId, response);
+ openEventStream(response);
+ this.recordResponse(recorder, 200, "session", request.sessionId);
+ return;
+ }
+ if (request.method === "DELETE" && request.connectionId === this.connectionId) {
+ sendJson(response, 202);
+ this.recordResponse(recorder, 202, "connection", null);
+ this.connectionStream?.end();
+ this.sessionStreams.forEach((sessionStream) => sessionStream.end());
+ return;
+ }
+ if (this.handleScenarioRequest(response, request, body, recorder)) {
+ return;
+ }
+ this.reject(response, recorder, request.scope, request.sessionId, 400, "Unexpected fixture request", body?.id);
+ }
+ sendSessionNewResponse(responseStream, request, body, recorder, sessionId) {
+ sendJson(responseStream, 202);
+ this.recordResponse(recorder, 202, request.scope, request.sessionId);
+ const response = {
+ jsonrpc: "2.0",
+ id: body.id,
+ result: {
+ sessionId,
+ },
+ };
+ this.writeConnectionEvent(response, recorder);
+ }
+ sendPromptFlow(responseStream, request, body, recorder, sessionId) {
+ sendJson(responseStream, 202);
+ this.recordResponse(recorder, 202, request.scope, request.sessionId);
+ const update = {
+ jsonrpc: "2.0",
+ method: "session/update",
+ params: {
+ sessionId,
+ update: {
+ sessionUpdate: "agent_message_chunk",
+ content: { type: "text", text: "hello" },
+ },
+ },
+ };
+ const response = {
+ jsonrpc: "2.0",
+ id: body.id,
+ result: {
+ stopReason: "end_turn",
+ },
+ };
+ this.writeSessionEvent(sessionId, update, recorder);
+ this.writeSessionEvent(sessionId, response, recorder);
+ }
+ writeConnectionEvent(message, recorder) {
+ if (!this.connectionStream) {
+ throw new Error("connection stream must be open");
+ }
+ writeSse(this.connectionStream, message);
+ recorder.record({
+ kind: "sse_event",
+ stream: "connection",
+ sessionId: null,
+ jsonRpc: recorder.summarizeJsonRpc(message),
+ });
+ }
+ writeSessionEvent(sessionId, message, recorder) {
+ const stream = this.sessionStreams.get(sessionId);
+ if (!stream) {
+ throw new Error(`session stream ${sessionId} must be open`);
+ }
+ writeSse(stream, message);
+ recorder.record({
+ kind: "sse_event",
+ stream: "session",
+ sessionId,
+ jsonRpc: recorder.summarizeJsonRpc(message),
+ });
+ }
+ handleInitialize(response, body, recorder) {
+ sendJson(response, 200, {
+ jsonrpc: "2.0",
+ id: body.id,
+ result: {
+ protocolVersion: 1,
+ agentCapabilities: {
+ loadSession: true,
+ },
+ authMethods: [],
+ },
+ }, {
+ "Acp-Connection-Id": this.connectionId,
+ "set-cookie": FIXTURE_COOKIE,
+ });
+ this.recordResponse(recorder, 200, "bootstrap", null);
+ }
+ validateRequest(response, request, body, recorder) {
+ if (request.scope === "bootstrap") {
+ if (request.method !== "POST" || body?.method !== "initialize") {
+ this.reject(response, recorder, request.scope, request.sessionId, 400, "Expected initialize bootstrap", body?.id);
+ return false;
+ }
+ return true;
+ }
+ if (request.connectionId !== this.connectionId) {
+ this.reject(response, recorder, request.scope, request.sessionId, 400, "Missing or invalid connection id", body?.id);
+ return false;
+ }
+ if (!request.cookie?.includes(FIXTURE_COOKIE)) {
+ this.reject(response, recorder, request.scope, request.sessionId, 401, "Missing fixture cookie", body?.id);
+ return false;
+ }
+ if (request.method === "GET" && !request.accept?.includes("text/event-stream")) {
+ this.reject(response, recorder, request.scope, request.sessionId, 406, "Expected text/event-stream", body?.id);
+ return false;
+ }
+ if (request.method === "POST") {
+ if (!request.accept?.includes("application/json")) {
+ this.reject(response, recorder, request.scope, request.sessionId, 406, "Expected application/json accept", body?.id);
+ return false;
+ }
+ if (!request.contentType?.includes("application/json")) {
+ this.reject(response, recorder, request.scope, request.sessionId, 415, "Expected application/json body", body?.id);
+ return false;
+ }
+ }
+ return true;
+ }
+ recordRequest(method, headers, body, recorder) {
+ const connectionId = header(headers, CONNECTION_HEADER);
+ const sessionId = header(headers, SESSION_HEADER);
+ const cookie = header(headers, "cookie");
+ const accept = header(headers, "accept");
+ const contentType = header(headers, "content-type");
+ const scope = sessionId ? "session" : connectionId ? "connection" : "bootstrap";
+ recorder.record({
+ kind: "http_request",
+ method,
+ scope,
+ connectionId,
+ sessionId,
+ cookie,
+ jsonRpc: recorder.summarizeJsonRpc(body),
+ });
+ return { method, scope, connectionId, sessionId, cookie, accept, contentType };
+ }
+ recordResponse(recorder, status, scope, sessionId) {
+ recorder.record({
+ kind: "http_response",
+ status,
+ scope,
+ connectionId: scope === "bootstrap" ? this.connectionId : this.connectionId,
+ sessionId,
+ });
+ }
+ reject(response, recorder, scope, sessionId, status, message, id) {
+ sendJson(response, status, {
+ jsonrpc: "2.0",
+ id: typeof id === "string" || typeof id === "number" ? id : null,
+ error: {
+ code: -32600,
+ message,
+ },
+ });
+ this.recordResponse(recorder, status, scope, sessionId);
+ }
+}
+class HappyPathScenario extends BaseScenario {
+ name = "happy-path";
+ sessionId = "sess-1";
+ handleScenarioRequest(response, request, body, recorder) {
+ if (request.method === "POST" && request.scope === "connection" && body?.method === "session/new") {
+ this.sendSessionNewResponse(response, request, body, recorder, this.sessionId);
+ return true;
+ }
+ if (request.method === "POST" &&
+ request.scope === "session" &&
+ request.sessionId === this.sessionId &&
+ body?.method === "session/prompt") {
+ this.sendPromptFlow(response, request, body, recorder, this.sessionId);
+ return true;
+ }
+ return false;
+ }
+}
+class PermissionRoundTripScenario extends BaseScenario {
+ name = "permission-round-trip";
+ sessionId = "sess-permission";
+ pendingPromptId;
+ handleScenarioRequest(response, request, body, recorder) {
+ if (request.method === "POST" && request.scope === "connection" && body?.method === "session/new") {
+ this.sendSessionNewResponse(response, request, body, recorder, this.sessionId);
+ return true;
+ }
+ if (request.method === "POST" &&
+ request.scope === "session" &&
+ request.sessionId === this.sessionId &&
+ body?.method === "session/prompt") {
+ sendJson(response, 202);
+ this.recordScenarioResponse(recorder, 202, request);
+ this.pendingPromptId = body.id;
+ this.writeSessionEvent(this.sessionId, {
+ jsonrpc: "2.0",
+ id: "perm-1",
+ method: "session/request_permission",
+ params: {
+ sessionId: this.sessionId,
+ toolCall: {
+ toolCallId: "tool-1",
+ title: "Write File",
+ kind: "edit",
+ status: "pending",
+ },
+ options: [
+ { optionId: "allow", name: "Allow", kind: "allow_once" },
+ { optionId: "deny", name: "Deny", kind: "reject_once" },
+ ],
+ },
+ }, recorder);
+ return true;
+ }
+ if (request.method === "POST" &&
+ request.scope === "session" &&
+ request.sessionId === this.sessionId &&
+ body?.id === "perm-1" &&
+ "result" in body) {
+ sendJson(response, 202);
+ this.recordScenarioResponse(recorder, 202, request);
+ this.writeSessionEvent(this.sessionId, {
+ jsonrpc: "2.0",
+ id: this.pendingPromptId,
+ result: {
+ stopReason: "end_turn",
+ },
+ }, recorder);
+ return true;
+ }
+ return false;
+ }
+ recordScenarioResponse(recorder, status, request) {
+ recorder.record({
+ kind: "http_response",
+ status,
+ scope: request.scope,
+ connectionId: this.connectionId,
+ sessionId: request.sessionId,
+ });
+ }
+}
+class SessionLoadScenario extends BaseScenario {
+ name = "session-load";
+ sessionId = "sess-load";
+ handleScenarioRequest(response, request, body, recorder) {
+ if (request.method === "POST" &&
+ request.scope === "session" &&
+ request.sessionId === this.sessionId &&
+ body?.method === "session/load") {
+ sendJson(response, 202);
+ this.recordScenarioResponse(recorder, 202, request);
+ this.writeConnectionEvent({
+ jsonrpc: "2.0",
+ id: body.id,
+ result: {},
+ }, recorder);
+ return true;
+ }
+ return false;
+ }
+ recordScenarioResponse(recorder, status, request) {
+ recorder.record({
+ kind: "http_response",
+ status,
+ scope: request.scope,
+ connectionId: this.connectionId,
+ sessionId: request.sessionId,
+ });
+ }
+}
+class TwoSessionsScenario extends BaseScenario {
+ name = "two-sessions";
+ nextSessionNumber = 1;
+ handleScenarioRequest(response, request, body, recorder) {
+ if (request.method === "POST" && request.scope === "connection" && body?.method === "session/new") {
+ const sessionId = `sess-${this.nextSessionNumber++}`;
+ this.sendSessionNewResponse(response, request, body, recorder, sessionId);
+ return true;
+ }
+ if (request.method === "POST" &&
+ request.scope === "session" &&
+ request.sessionId &&
+ body?.method === "session/prompt") {
+ this.sendPromptFlow(response, request, body, recorder, request.sessionId);
+ return true;
+ }
+ return false;
+ }
+}
+class WrongStreamResponseScenario extends BaseScenario {
+ name = "wrong-stream-response";
+ sessionId = "sess-wrong";
+ handleScenarioRequest(response, request, body, recorder) {
+ if (request.method === "POST" && request.scope === "connection" && body?.method === "session/new") {
+ this.sendSessionNewResponse(response, request, body, recorder, this.sessionId);
+ return true;
+ }
+ if (request.method === "POST" &&
+ request.scope === "session" &&
+ request.sessionId === this.sessionId &&
+ body?.method === "session/prompt") {
+ sendJson(response, 202);
+ this.recordScenarioResponse(recorder, 202, request);
+ this.writeConnectionEvent({
+ jsonrpc: "2.0",
+ id: body.id,
+ result: {
+ stopReason: "end_turn",
+ },
+ }, recorder);
+ return true;
+ }
+ return false;
+ }
+ recordScenarioResponse(recorder, status, request) {
+ recorder.record({
+ kind: "http_response",
+ status,
+ scope: request.scope,
+ connectionId: this.connectionId,
+ sessionId: request.sessionId,
+ });
+ }
+}
+class ValidationFailuresScenario extends BaseScenario {
+ name = "validation-failures";
+ handleScenarioRequest(_response, _request, _body, _recorder) {
+ return false;
+ }
+}
+function header(headers, name) {
+ const value = headers[name];
+ if (Array.isArray(value)) {
+ return value[0] ?? null;
+ }
+ return typeof value === "string" ? value : null;
+}
diff --git a/test-fixtures/streamable-http-server/dist/server.js b/test-fixtures/streamable-http-server/dist/server.js
new file mode 100644
index 0000000..a1da7df
--- /dev/null
+++ b/test-fixtures/streamable-http-server/dist/server.js
@@ -0,0 +1,46 @@
+import { createServer } from "node:http";
+import { ACP_PATH } from "./protocol.js";
+import { createScenario } from "./scenarios.js";
+import { TranscriptRecorder } from "./transcript.js";
+const scenarioName = readArg("--scenario") ?? "happy-path";
+const port = Number(readArg("--port") ?? "0");
+const recorder = new TranscriptRecorder();
+const scenario = createScenario(scenarioName);
+const server = createServer();
+server.on("request", async (request, response) => {
+ const path = request.url ?? "";
+ if (path === "/__test/transcript") {
+ response.writeHead(200, {
+ "content-type": "application/json",
+ });
+ response.end(recorder.serialize());
+ return;
+ }
+ if (path !== ACP_PATH) {
+ response.writeHead(404);
+ response.end();
+ return;
+ }
+ const body = await readJsonBody(request);
+ scenario.handle(response, request.headers, request.method ?? "", body, recorder);
+});
+server.listen(port, () => {
+ const address = server.address();
+ const actualPort = typeof address === "object" && address ? address.port : port;
+ process.stdout.write(JSON.stringify({ status: "ready", port: actualPort, scenario: scenario.name }) + "\n");
+});
+function readArg(name) {
+ const index = process.argv.indexOf(name);
+ return index >= 0 ? process.argv[index + 1] : undefined;
+}
+async function readJsonBody(request) {
+ const method = request.method ?? "";
+ if (method === "GET" || method === "DELETE") {
+ return null;
+ }
+ let raw = "";
+ for await (const chunk of request) {
+ raw += chunk.toString("utf8");
+ }
+ return raw ? JSON.parse(raw) : null;
+}
diff --git a/test-fixtures/streamable-http-server/dist/transcript.js b/test-fixtures/streamable-http-server/dist/transcript.js
new file mode 100644
index 0000000..9193e07
--- /dev/null
+++ b/test-fixtures/streamable-http-server/dist/transcript.js
@@ -0,0 +1,52 @@
+export class TranscriptRecorder {
+ events = [];
+ idAliases = new Map();
+ record(event) {
+ this.events.push(event);
+ }
+ summarizeJsonRpc(message) {
+ if (!message || typeof message !== "object") {
+ return null;
+ }
+ const candidate = message;
+ if (typeof candidate.method === "string" && "id" in candidate) {
+ return {
+ type: "request",
+ id: this.normalizeId(candidate.id),
+ method: candidate.method,
+ };
+ }
+ if (typeof candidate.method === "string") {
+ return {
+ type: "notification",
+ method: candidate.method,
+ };
+ }
+ if ("result" in candidate || "error" in candidate) {
+ return {
+ type: "response",
+ id: this.normalizeId(candidate.id),
+ hasError: candidate.error != null,
+ };
+ }
+ return null;
+ }
+ toJSON() {
+ return [...this.events];
+ }
+ serialize() {
+ return JSON.stringify(this.events, null, 2);
+ }
+ normalizeId(id) {
+ if (typeof id !== "string" && typeof id !== "number") {
+ return null;
+ }
+ const existing = this.idAliases.get(id);
+ if (existing) {
+ return existing;
+ }
+ const next = `id-${this.idAliases.size + 1}`;
+ this.idAliases.set(id, next);
+ return next;
+ }
+}
diff --git a/test-fixtures/streamable-http-server/golden/happy-path.json b/test-fixtures/streamable-http-server/golden/happy-path.json
new file mode 100644
index 0000000..171bf6c
--- /dev/null
+++ b/test-fixtures/streamable-http-server/golden/happy-path.json
@@ -0,0 +1,139 @@
+[
+ {
+ "kind": "http_request",
+ "method": "POST",
+ "scope": "bootstrap",
+ "connectionId": null,
+ "sessionId": null,
+ "cookie": null,
+ "jsonRpc": {
+ "type": "request",
+ "id": "id-1",
+ "method": "initialize"
+ }
+ },
+ {
+ "kind": "http_response",
+ "status": 200,
+ "scope": "bootstrap",
+ "connectionId": "conn-1",
+ "sessionId": null
+ },
+ {
+ "kind": "http_request",
+ "method": "GET",
+ "scope": "connection",
+ "connectionId": "conn-1",
+ "sessionId": null,
+ "cookie": "fixture=streamable-http",
+ "jsonRpc": null
+ },
+ {
+ "kind": "http_response",
+ "status": 200,
+ "scope": "connection",
+ "connectionId": "conn-1",
+ "sessionId": null
+ },
+ {
+ "kind": "http_request",
+ "method": "POST",
+ "scope": "connection",
+ "connectionId": "conn-1",
+ "sessionId": null,
+ "cookie": "fixture=streamable-http",
+ "jsonRpc": {
+ "type": "request",
+ "id": "id-2",
+ "method": "session/new"
+ }
+ },
+ {
+ "kind": "http_response",
+ "status": 202,
+ "scope": "connection",
+ "connectionId": "conn-1",
+ "sessionId": null
+ },
+ {
+ "kind": "sse_event",
+ "stream": "connection",
+ "sessionId": null,
+ "jsonRpc": {
+ "type": "response",
+ "id": "id-2",
+ "hasError": false
+ }
+ },
+ {
+ "kind": "http_request",
+ "method": "GET",
+ "scope": "session",
+ "connectionId": "conn-1",
+ "sessionId": "sess-1",
+ "cookie": "fixture=streamable-http",
+ "jsonRpc": null
+ },
+ {
+ "kind": "http_response",
+ "status": 200,
+ "scope": "session",
+ "connectionId": "conn-1",
+ "sessionId": "sess-1"
+ },
+ {
+ "kind": "http_request",
+ "method": "POST",
+ "scope": "session",
+ "connectionId": "conn-1",
+ "sessionId": "sess-1",
+ "cookie": "fixture=streamable-http",
+ "jsonRpc": {
+ "type": "request",
+ "id": "id-3",
+ "method": "session/prompt"
+ }
+ },
+ {
+ "kind": "http_response",
+ "status": 202,
+ "scope": "session",
+ "connectionId": "conn-1",
+ "sessionId": "sess-1"
+ },
+ {
+ "kind": "sse_event",
+ "stream": "session",
+ "sessionId": "sess-1",
+ "jsonRpc": {
+ "type": "notification",
+ "method": "session/update"
+ }
+ },
+ {
+ "kind": "sse_event",
+ "stream": "session",
+ "sessionId": "sess-1",
+ "jsonRpc": {
+ "type": "response",
+ "id": "id-3",
+ "hasError": false
+ }
+ },
+ {
+ "kind": "http_request",
+ "method": "DELETE",
+ "scope": "connection",
+ "connectionId": "conn-1",
+ "sessionId": null,
+ "cookie": "fixture=streamable-http",
+ "jsonRpc": null
+ },
+ {
+ "kind": "http_response",
+ "status": 202,
+ "scope": "connection",
+ "connectionId": "conn-1",
+ "sessionId": null
+ }
+]
diff --git a/test-fixtures/streamable-http-server/golden/permission-round-trip.json b/test-fixtures/streamable-http-server/golden/permission-round-trip.json
new file mode 100644
index 0000000..0ba9132
--- /dev/null
+++ b/test-fixtures/streamable-http-server/golden/permission-round-trip.json
@@ -0,0 +1,160 @@
+[
+ {
+ "kind": "http_request",
+ "method": "POST",
+ "scope": "bootstrap",
+ "connectionId": null,
+ "sessionId": null,
+ "cookie": null,
+ "jsonRpc": {
+ "type": "request",
+ "id": "id-1",
+ "method": "initialize"
+ }
+ },
+ {
+ "kind": "http_response",
+ "status": 200,
+ "scope": "bootstrap",
+ "connectionId": "conn-1",
+ "sessionId": null
+ },
+ {
+ "kind": "http_request",
+ "method": "GET",
+ "scope": "connection",
+ "connectionId": "conn-1",
+ "sessionId": null,
+ "cookie": "fixture=streamable-http",
+ "jsonRpc": null
+ },
+ {
+ "kind": "http_response",
+ "status": 200,
+ "scope": "connection",
+ "connectionId": "conn-1",
+ "sessionId": null
+ },
+ {
+ "kind": "http_request",
+ "method": "POST",
+ "scope": "connection",
+ "connectionId": "conn-1",
+ "sessionId": null,
+ "cookie": "fixture=streamable-http",
+ "jsonRpc": {
+ "type": "request",
+ "id": "id-2",
+ "method": "session/new"
+ }
+ },
+ {
+ "kind": "http_response",
+ "status": 202,
+ "scope": "connection",
+ "connectionId": "conn-1",
+ "sessionId": null
+ },
+ {
+ "kind": "sse_event",
+ "stream": "connection",
+ "sessionId": null,
+ "jsonRpc": {
+ "type": "response",
+ "id": "id-2",
+ "hasError": false
+ }
+ },
+ {
+ "kind": "http_request",
+ "method": "GET",
+ "scope": "session",
+ "connectionId": "conn-1",
+ "sessionId": "sess-permission",
+ "cookie": "fixture=streamable-http",
+ "jsonRpc": null
+ },
+ {
+ "kind": "http_response",
+ "status": 200,
+ "scope": "session",
+ "connectionId": "conn-1",
+ "sessionId": "sess-permission"
+ },
+ {
+ "kind": "http_request",
+ "method": "POST",
+ "scope": "session",
+ "connectionId": "conn-1",
+ "sessionId": "sess-permission",
+ "cookie": "fixture=streamable-http",
+ "jsonRpc": {
+ "type": "request",
+ "id": "id-3",
+ "method": "session/prompt"
+ }
+ },
+ {
+ "kind": "http_response",
+ "status": 202,
+ "scope": "session",
+ "connectionId": "conn-1",
+ "sessionId": "sess-permission"
+ },
+ {
+ "kind": "sse_event",
+ "stream": "session",
+ "sessionId": "sess-permission",
+ "jsonRpc": {
+ "type": "request",
+ "id": "id-4",
+ "method": "session/request_permission"
+ }
+ },
+ {
+ "kind": "http_request",
+ "method": "POST",
+ "scope": "session",
+ "connectionId": "conn-1",
+ "sessionId": "sess-permission",
+ "cookie": "fixture=streamable-http",
+ "jsonRpc": {
+ "type": "response",
+ "id": "id-4",
+ "hasError": false
+ }
+ },
+ {
+ "kind": "http_response",
+ "status": 202,
+ "scope": "session",
+ "connectionId": "conn-1",
+ "sessionId": "sess-permission"
+ },
+ {
+ "kind": "sse_event",
+ "stream": "session",
+ "sessionId": "sess-permission",
+ "jsonRpc": {
+ "type": "response",
+ "id": "id-3",
+ "hasError": false
+ }
+ },
+ {
+ "kind": "http_request",
+ "method": "DELETE",
+ "scope": "connection",
+ "connectionId": "conn-1",
+ "sessionId": null,
+ "cookie": "fixture=streamable-http",
+ "jsonRpc": null
+ },
+ {
+ "kind": "http_response",
+ "status": 202,
+ "scope": "connection",
+ "connectionId": "conn-1",
+ "sessionId": null
+ }
+]
diff --git a/test-fixtures/streamable-http-server/golden/session-load.json b/test-fixtures/streamable-http-server/golden/session-load.json
new file mode 100644
index 0000000..fd932fb
--- /dev/null
+++ b/test-fixtures/streamable-http-server/golden/session-load.json
@@ -0,0 +1,100 @@
+[
+ {
+ "kind": "http_request",
+ "method": "POST",
+ "scope": "bootstrap",
+ "connectionId": null,
+ "sessionId": null,
+ "cookie": null,
+ "jsonRpc": {
+ "type": "request",
+ "id": "id-1",
+ "method": "initialize"
+ }
+ },
+ {
+ "kind": "http_response",
+ "status": 200,
+ "scope": "bootstrap",
+ "connectionId": "conn-1",
+ "sessionId": null
+ },
+ {
+ "kind": "http_request",
+ "method": "GET",
+ "scope": "connection",
+ "connectionId": "conn-1",
+ "sessionId": null,
+ "cookie": "fixture=streamable-http",
+ "jsonRpc": null
+ },
+ {
+ "kind": "http_response",
+ "status": 200,
+ "scope": "connection",
+ "connectionId": "conn-1",
+ "sessionId": null
+ },
+ {
+ "kind": "http_request",
+ "method": "GET",
+ "scope": "session",
+ "connectionId": "conn-1",
+ "sessionId": "sess-load",
+ "cookie": "fixture=streamable-http",
+ "jsonRpc": null
+ },
+ {
+ "kind": "http_response",
+ "status": 200,
+ "scope": "session",
+ "connectionId": "conn-1",
+ "sessionId": "sess-load"
+ },
+ {
+ "kind": "http_request",
+ "method": "POST",
+ "scope": "session",
+ "connectionId": "conn-1",
+ "sessionId": "sess-load",
+ "cookie": "fixture=streamable-http",
+ "jsonRpc": {
+ "type": "request",
+ "id": "id-2",
+ "method": "session/load"
+ }
+ },
+ {
+ "kind": "http_response",
+ "status": 202,
+ "scope": "session",
+ "connectionId": "conn-1",
+ "sessionId": "sess-load"
+ },
+ {
+ "kind": "sse_event",
+ "stream": "connection",
+ "sessionId": null,
+ "jsonRpc": {
+ "type": "response",
+ "id": "id-2",
+ "hasError": false
+ }
+ },
+ {
+ "kind": "http_request",
+ "method": "DELETE",
+ "scope": "connection",
+ "connectionId": "conn-1",
+ "sessionId": null,
+ "cookie": "fixture=streamable-http",
+ "jsonRpc": null
+ },
+ {
+ "kind": "http_response",
+ "status": 202,
+ "scope": "connection",
+ "connectionId": "conn-1",
+ "sessionId": null
+ }
+]
diff --git a/test-fixtures/streamable-http-server/golden/two-sessions.json b/test-fixtures/streamable-http-server/golden/two-sessions.json
new file mode 100644
index 0000000..0d5eacb
--- /dev/null
+++ b/test-fixtures/streamable-http-server/golden/two-sessions.json
@@ -0,0 +1,224 @@
+[
+ {
+ "kind": "http_request",
+ "method": "POST",
+ "scope": "bootstrap",
+ "connectionId": null,
+ "sessionId": null,
+ "cookie": null,
+ "jsonRpc": {
+ "type": "request",
+ "id": "id-1",
+ "method": "initialize"
+ }
+ },
+ {
+ "kind": "http_response",
+ "status": 200,
+ "scope": "bootstrap",
+ "connectionId": "conn-1",
+ "sessionId": null
+ },
+ {
+ "kind": "http_request",
+ "method": "GET",
+ "scope": "connection",
+ "connectionId": "conn-1",
+ "sessionId": null,
+ "cookie": "fixture=streamable-http",
+ "jsonRpc": null
+ },
+ {
+ "kind": "http_response",
+ "status": 200,
+ "scope": "connection",
+ "connectionId": "conn-1",
+ "sessionId": null
+ },
+ {
+ "kind": "http_request",
+ "method": "POST",
+ "scope": "connection",
+ "connectionId": "conn-1",
+ "sessionId": null,
+ "cookie": "fixture=streamable-http",
+ "jsonRpc": {
+ "type": "request",
+ "id": "id-2",
+ "method": "session/new"
+ }
+ },
+ {
+ "kind": "http_response",
+ "status": 202,
+ "scope": "connection",
+ "connectionId": "conn-1",
+ "sessionId": null
+ },
+ {
+ "kind": "sse_event",
+ "stream": "connection",
+ "sessionId": null,
+ "jsonRpc": {
+ "type": "response",
+ "id": "id-2",
+ "hasError": false
+ }
+ },
+ {
+ "kind": "http_request",
+ "method": "GET",
+ "scope": "session",
+ "connectionId": "conn-1",
+ "sessionId": "sess-1",
+ "cookie": "fixture=streamable-http",
+ "jsonRpc": null
+ },
+ {
+ "kind": "http_response",
+ "status": 200,
+ "scope": "session",
+ "connectionId": "conn-1",
+ "sessionId": "sess-1"
+ },
+ {
+ "kind": "http_request",
+ "method": "POST",
+ "scope": "connection",
+ "connectionId": "conn-1",
+ "sessionId": null,
+ "cookie": "fixture=streamable-http",
+ "jsonRpc": {
+ "type": "request",
+ "id": "id-3",
+ "method": "session/new"
+ }
+ },
+ {
+ "kind": "http_response",
+ "status": 202,
+ "scope": "connection",
+ "connectionId": "conn-1",
+ "sessionId": null
+ },
+ {
+ "kind": "sse_event",
+ "stream": "connection",
+ "sessionId": null,
+ "jsonRpc": {
+ "type": "response",
+ "id": "id-3",
+ "hasError": false
+ }
+ },
+ {
+ "kind": "http_request",
+ "method": "GET",
+ "scope": "session",
+ "connectionId": "conn-1",
+ "sessionId": "sess-2",
+ "cookie": "fixture=streamable-http",
+ "jsonRpc": null
+ },
+ {
+ "kind": "http_response",
+ "status": 200,
+ "scope": "session",
+ "connectionId": "conn-1",
+ "sessionId": "sess-2"
+ },
+ {
+ "kind": "http_request",
+ "method": "POST",
+ "scope": "session",
+ "connectionId": "conn-1",
+ "sessionId": "sess-1",
+ "cookie": "fixture=streamable-http",
+ "jsonRpc": {
+ "type": "request",
+ "id": "id-4",
+ "method": "session/prompt"
+ }
+ },
+ {
+ "kind": "http_response",
+ "status": 202,
+ "scope": "session",
+ "connectionId": "conn-1",
+ "sessionId": "sess-1"
+ },
+ {
+ "kind": "sse_event",
+ "stream": "session",
+ "sessionId": "sess-1",
+ "jsonRpc": {
+ "type": "notification",
+ "method": "session/update"
+ }
+ },
+ {
+ "kind": "sse_event",
+ "stream": "session",
+ "sessionId": "sess-1",
+ "jsonRpc": {
+ "type": "response",
+ "id": "id-4",
+ "hasError": false
+ }
+ },
+ {
+ "kind": "http_request",
+ "method": "POST",
+ "scope": "session",
+ "connectionId": "conn-1",
+ "sessionId": "sess-2",
+ "cookie": "fixture=streamable-http",
+ "jsonRpc": {
+ "type": "request",
+ "id": "id-5",
+ "method": "session/prompt"
+ }
+ },
+ {
+ "kind": "http_response",
+ "status": 202,
+ "scope": "session",
+ "connectionId": "conn-1",
+ "sessionId": "sess-2"
+ },
+ {
+ "kind": "sse_event",
+ "stream": "session",
+ "sessionId": "sess-2",
+ "jsonRpc": {
+ "type": "notification",
+ "method": "session/update"
+ }
+ },
+ {
+ "kind": "sse_event",
+ "stream": "session",
+ "sessionId": "sess-2",
+ "jsonRpc": {
+ "type": "response",
+ "id": "id-5",
+ "hasError": false
+ }
+ },
+ {
+ "kind": "http_request",
+ "method": "DELETE",
+ "scope": "connection",
+ "connectionId": "conn-1",
+ "sessionId": null,
+ "cookie": "fixture=streamable-http",
+ "jsonRpc": null
+ },
+ {
+ "kind": "http_response",
+ "status": 202,
+ "scope": "connection",
+ "connectionId": "conn-1",
+ "sessionId": null
+ }
+]
diff --git a/test-fixtures/streamable-http-server/golden/validation-failures.json b/test-fixtures/streamable-http-server/golden/validation-failures.json
new file mode 100644
index 0000000..51cbd36
--- /dev/null
+++ b/test-fixtures/streamable-http-server/golden/validation-failures.json
@@ -0,0 +1,86 @@
+[
+ {
+ "kind": "http_request",
+ "method": "POST",
+ "scope": "bootstrap",
+ "connectionId": null,
+ "sessionId": null,
+ "cookie": null,
+ "jsonRpc": {
+ "type": "request",
+ "id": "id-1",
+ "method": "initialize"
+ }
+ },
+ {
+ "kind": "http_response",
+ "status": 200,
+ "scope": "bootstrap",
+ "connectionId": "conn-1",
+ "sessionId": null
+ },
+ {
+ "kind": "http_request",
+ "method": "GET",
+ "scope": "bootstrap",
+ "connectionId": null,
+ "sessionId": null,
+ "cookie": "fixture=streamable-http",
+ "jsonRpc": null
+ },
+ {
+ "kind": "http_response",
+ "status": 400,
+ "scope": "bootstrap",
+ "connectionId": "conn-1",
+ "sessionId": null
+ },
+ {
+ "kind": "http_request",
+ "method": "GET",
+ "scope": "connection",
+ "connectionId": "conn-1",
+ "sessionId": null,
+ "cookie": null,
+ "jsonRpc": null
+ },
+ {
+ "kind": "http_response",
+ "status": 401,
+ "scope": "connection",
+ "connectionId": "conn-1",
+ "sessionId": null
+ },
+ {
+ "kind": "http_request",
+ "method": "GET",
+ "scope": "connection",
+ "connectionId": "conn-1",
+ "sessionId": null,
+ "cookie": "fixture=streamable-http",
+ "jsonRpc": null
+ },
+ {
+ "kind": "http_response",
+ "status": 406,
+ "scope": "connection",
+ "connectionId": "conn-1",
+ "sessionId": null
+ },
+ {
+ "kind": "http_request",
+ "method": "DELETE",
+ "scope": "connection",
+ "connectionId": "conn-1",
+ "sessionId": null,
+ "cookie": "fixture=streamable-http",
+ "jsonRpc": null
+ },
+ {
+ "kind": "http_response",
+ "status": 202,
+ "scope": "connection",
+ "connectionId": "conn-1",
+ "sessionId": null
+ }
+]
diff --git a/test-fixtures/streamable-http-server/golden/wrong-stream-response.json b/test-fixtures/streamable-http-server/golden/wrong-stream-response.json
new file mode 100644
index 0000000..97e80ea
--- /dev/null
+++ b/test-fixtures/streamable-http-server/golden/wrong-stream-response.json
@@ -0,0 +1,130 @@
+[
+ {
+ "kind": "http_request",
+ "method": "POST",
+ "scope": "bootstrap",
+ "connectionId": null,
+ "sessionId": null,
+ "cookie": null,
+ "jsonRpc": {
+ "type": "request",
+ "id": "id-1",
+ "method": "initialize"
+ }
+ },
+ {
+ "kind": "http_response",
+ "status": 200,
+ "scope": "bootstrap",
+ "connectionId": "conn-1",
+ "sessionId": null
+ },
+ {
+ "kind": "http_request",
+ "method": "GET",
+ "scope": "connection",
+ "connectionId": "conn-1",
+ "sessionId": null,
+ "cookie": "fixture=streamable-http",
+ "jsonRpc": null
+ },
+ {
+ "kind": "http_response",
+ "status": 200,
+ "scope": "connection",
+ "connectionId": "conn-1",
+ "sessionId": null
+ },
+ {
+ "kind": "http_request",
+ "method": "POST",
+ "scope": "connection",
+ "connectionId": "conn-1",
+ "sessionId": null,
+ "cookie": "fixture=streamable-http",
+ "jsonRpc": {
+ "type": "request",
+ "id": "id-2",
+ "method": "session/new"
+ }
+ },
+ {
+ "kind": "http_response",
+ "status": 202,
+ "scope": "connection",
+ "connectionId": "conn-1",
+ "sessionId": null
+ },
+ {
+ "kind": "sse_event",
+ "stream": "connection",
+ "sessionId": null,
+ "jsonRpc": {
+ "type": "response",
+ "id": "id-2",
+ "hasError": false
+ }
+ },
+ {
+ "kind": "http_request",
+ "method": "GET",
+ "scope": "session",
+ "connectionId": "conn-1",
+ "sessionId": "sess-wrong",
+ "cookie": "fixture=streamable-http",
+ "jsonRpc": null
+ },
+ {
+ "kind": "http_response",
+ "status": 200,
+ "scope": "session",
+ "connectionId": "conn-1",
+ "sessionId": "sess-wrong"
+ },
+ {
+ "kind": "http_request",
+ "method": "POST",
+ "scope": "session",
+ "connectionId": "conn-1",
+ "sessionId": "sess-wrong",
+ "cookie": "fixture=streamable-http",
+ "jsonRpc": {
+ "type": "request",
+ "id": "id-3",
+ "method": "session/prompt"
+ }
+ },
+ {
+ "kind": "http_response",
+ "status": 202,
+ "scope": "session",
+ "connectionId": "conn-1",
+ "sessionId": "sess-wrong"
+ },
+ {
+ "kind": "sse_event",
+ "stream": "connection",
+ "sessionId": null,
+ "jsonRpc": {
+ "type": "response",
+ "id": "id-3",
+ "hasError": false
+ }
+ },
+ {
+ "kind": "http_request",
+ "method": "DELETE",
+ "scope": "connection",
+ "connectionId": "conn-1",
+ "sessionId": null,
+ "cookie": "fixture=streamable-http",
+ "jsonRpc": null
+ },
+ {
+ "kind": "http_response",
+ "status": 202,
+ "scope": "connection",
+ "connectionId": "conn-1",
+ "sessionId": null
+ }
+]
diff --git a/test-fixtures/streamable-http-server/package-lock.json b/test-fixtures/streamable-http-server/package-lock.json
new file mode 100644
index 0000000..1a89fc5
--- /dev/null
+++ b/test-fixtures/streamable-http-server/package-lock.json
@@ -0,0 +1,566 @@
+{
+ "name": "acp-streamable-http-fixture",
+ "version": "0.1.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "acp-streamable-http-fixture",
+ "version": "0.1.0",
+ "devDependencies": {
+ "@types/node": "^22.15.0",
+ "tsx": "^4.19.4",
+ "typescript": "^5.8.3"
+ }
+ },
+ "node_modules/@esbuild/aix-ppc64": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz",
+ "integrity": "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "aix"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.0.tgz",
+ "integrity": "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm64": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.0.tgz",
+ "integrity": "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-x64": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.0.tgz",
+ "integrity": "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-arm64": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.0.tgz",
+ "integrity": "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-x64": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.0.tgz",
+ "integrity": "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-arm64": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.0.tgz",
+ "integrity": "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-x64": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.0.tgz",
+ "integrity": "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.0.tgz",
+ "integrity": "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm64": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.0.tgz",
+ "integrity": "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ia32": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.0.tgz",
+ "integrity": "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-loong64": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.0.tgz",
+ "integrity": "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-mips64el": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.0.tgz",
+ "integrity": "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==",
+ "cpu": [
+ "mips64el"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ppc64": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.0.tgz",
+ "integrity": "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-riscv64": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.0.tgz",
+ "integrity": "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-s390x": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.0.tgz",
+ "integrity": "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-x64": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.0.tgz",
+ "integrity": "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-arm64": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.0.tgz",
+ "integrity": "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-x64": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.0.tgz",
+ "integrity": "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-arm64": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.0.tgz",
+ "integrity": "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-x64": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.0.tgz",
+ "integrity": "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openharmony-arm64": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.0.tgz",
+ "integrity": "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/sunos-x64": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.0.tgz",
+ "integrity": "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "sunos"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-arm64": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.0.tgz",
+ "integrity": "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-ia32": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.0.tgz",
+ "integrity": "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-x64": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.0.tgz",
+ "integrity": "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@types/node": {
+ "version": "22.19.19",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.19.tgz",
+ "integrity": "sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "undici-types": "~6.21.0"
+ }
+ },
+ "node_modules/esbuild": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.0.tgz",
+ "integrity": "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "bin": {
+ "esbuild": "bin/esbuild"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "@esbuild/aix-ppc64": "0.28.0",
+ "@esbuild/android-arm": "0.28.0",
+ "@esbuild/android-arm64": "0.28.0",
+ "@esbuild/android-x64": "0.28.0",
+ "@esbuild/darwin-arm64": "0.28.0",
+ "@esbuild/darwin-x64": "0.28.0",
+ "@esbuild/freebsd-arm64": "0.28.0",
+ "@esbuild/freebsd-x64": "0.28.0",
+ "@esbuild/linux-arm": "0.28.0",
+ "@esbuild/linux-arm64": "0.28.0",
+ "@esbuild/linux-ia32": "0.28.0",
+ "@esbuild/linux-loong64": "0.28.0",
+ "@esbuild/linux-mips64el": "0.28.0",
+ "@esbuild/linux-ppc64": "0.28.0",
+ "@esbuild/linux-riscv64": "0.28.0",
+ "@esbuild/linux-s390x": "0.28.0",
+ "@esbuild/linux-x64": "0.28.0",
+ "@esbuild/netbsd-arm64": "0.28.0",
+ "@esbuild/netbsd-x64": "0.28.0",
+ "@esbuild/openbsd-arm64": "0.28.0",
+ "@esbuild/openbsd-x64": "0.28.0",
+ "@esbuild/openharmony-arm64": "0.28.0",
+ "@esbuild/sunos-x64": "0.28.0",
+ "@esbuild/win32-arm64": "0.28.0",
+ "@esbuild/win32-ia32": "0.28.0",
+ "@esbuild/win32-x64": "0.28.0"
+ }
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/tsx": {
+ "version": "4.22.1",
+ "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.1.tgz",
+ "integrity": "sha512-TvncJykhxAzFCk0VQZKBTClall4Pm7qXDSodb6uxi8QFa8X8mT6ABjxxsQ2opDRYxG7AzcRWXaFtruz5HJKuWg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "esbuild": "~0.28.0"
+ },
+ "bin": {
+ "tsx": "dist/cli.mjs"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.3"
+ }
+ },
+ "node_modules/typescript": {
+ "version": "5.9.3",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
+ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "tsc": "bin/tsc",
+ "tsserver": "bin/tsserver"
+ },
+ "engines": {
+ "node": ">=14.17"
+ }
+ },
+ "node_modules/undici-types": {
+ "version": "6.21.0",
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
+ "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
+ "dev": true,
+ "license": "MIT"
+ }
+ }
+}
diff --git a/test-fixtures/streamable-http-server/package.json b/test-fixtures/streamable-http-server/package.json
new file mode 100644
index 0000000..11a8163
--- /dev/null
+++ b/test-fixtures/streamable-http-server/package.json
@@ -0,0 +1,16 @@
+{
+ "name": "acp-streamable-http-fixture",
+ "private": true,
+ "version": "0.1.0",
+ "type": "module",
+ "scripts": {
+ "build": "tsc -p tsconfig.json",
+ "start": "tsx src/server.ts",
+ "start:happy-path": "tsx src/server.ts --scenario happy-path"
+ },
+ "devDependencies": {
+ "@types/node": "^22.15.0",
+ "tsx": "^4.19.4",
+ "typescript": "^5.8.3"
+ }
+}
diff --git a/test-fixtures/streamable-http-server/src/protocol.ts b/test-fixtures/streamable-http-server/src/protocol.ts
new file mode 100644
index 0000000..df82cfd
--- /dev/null
+++ b/test-fixtures/streamable-http-server/src/protocol.ts
@@ -0,0 +1,32 @@
+import { ServerResponse } from "node:http";
+
+export const ACP_PATH = "/acp";
+export const CONNECTION_HEADER = "acp-connection-id";
+export const SESSION_HEADER = "acp-session-id";
+export const FIXTURE_COOKIE = "fixture=streamable-http";
+
+export type JsonRpcMessage = Record;
+
+export function sendJson(response: ServerResponse, status: number, body?: JsonRpcMessage, headers: Record = {}): void {
+ response.writeHead(status, {
+ "content-type": "application/json",
+ ...headers,
+ });
+ if (body) {
+ response.end(JSON.stringify(body));
+ } else {
+ response.end();
+ }
+}
+
+export function openEventStream(response: ServerResponse): void {
+ response.writeHead(200, {
+ "content-type": "text/event-stream",
+ "cache-control": "no-cache",
+ });
+ response.flushHeaders();
+}
+
+export function writeSse(response: ServerResponse, message: JsonRpcMessage): void {
+ response.write(`data: ${JSON.stringify(message)}\n\n`);
+}
diff --git a/test-fixtures/streamable-http-server/src/scenarios.ts b/test-fixtures/streamable-http-server/src/scenarios.ts
new file mode 100644
index 0000000..c4f49f3
--- /dev/null
+++ b/test-fixtures/streamable-http-server/src/scenarios.ts
@@ -0,0 +1,574 @@
+import { IncomingHttpHeaders, ServerResponse } from "node:http";
+import {
+ CONNECTION_HEADER,
+ FIXTURE_COOKIE,
+ JsonRpcMessage,
+ SESSION_HEADER,
+ openEventStream,
+ sendJson,
+ writeSse,
+} from "./protocol.js";
+import { TranscriptRecorder } from "./transcript.js";
+
+export interface FixtureScenario {
+ readonly name: string;
+ handle(
+ response: ServerResponse,
+ headers: IncomingHttpHeaders,
+ method: string,
+ body: JsonRpcMessage | null,
+ recorder: TranscriptRecorder,
+ ): void;
+}
+
+export function createScenario(name: string): FixtureScenario {
+ switch (name) {
+ case "happy-path":
+ return new HappyPathScenario();
+ case "permission-round-trip":
+ return new PermissionRoundTripScenario();
+ case "session-load":
+ return new SessionLoadScenario();
+ case "two-sessions":
+ return new TwoSessionsScenario();
+ case "wrong-stream-response":
+ return new WrongStreamResponseScenario();
+ case "validation-failures":
+ return new ValidationFailuresScenario();
+ default:
+ throw new Error(`Unknown scenario: ${name}`);
+ }
+}
+
+abstract class BaseScenario implements FixtureScenario {
+ abstract readonly name: string;
+
+ protected readonly connectionId = "conn-1";
+ protected connectionStream: ServerResponse | null = null;
+ protected readonly sessionStreams = new Map();
+
+ handle(
+ response: ServerResponse,
+ headers: IncomingHttpHeaders,
+ method: string,
+ body: JsonRpcMessage | null,
+ recorder: TranscriptRecorder,
+ ): void {
+ const request = this.recordRequest(method, headers, body, recorder);
+ if (!this.validateRequest(response, request, body, recorder)) {
+ return;
+ }
+
+ if (request.method === "POST" && body?.method === "initialize") {
+ this.handleInitialize(response, body, recorder);
+ return;
+ }
+
+ if (
+ request.method === "GET" &&
+ request.connectionId === this.connectionId &&
+ !request.sessionId
+ ) {
+ this.connectionStream = response;
+ openEventStream(response);
+ this.recordResponse(recorder, 200, "connection", null);
+ return;
+ }
+
+ if (
+ request.method === "GET" &&
+ request.connectionId === this.connectionId &&
+ request.sessionId
+ ) {
+ this.sessionStreams.set(request.sessionId, response);
+ openEventStream(response);
+ this.recordResponse(recorder, 200, "session", request.sessionId);
+ return;
+ }
+
+ if (request.method === "DELETE" && request.connectionId === this.connectionId) {
+ sendJson(response, 202);
+ this.recordResponse(recorder, 202, "connection", null);
+ this.connectionStream?.end();
+ this.sessionStreams.forEach((sessionStream) => sessionStream.end());
+ return;
+ }
+
+ if (this.handleScenarioRequest(response, request, body, recorder)) {
+ return;
+ }
+
+ this.reject(response, recorder, request.scope, request.sessionId, 400, "Unexpected fixture request", body?.id);
+ }
+
+ protected abstract handleScenarioRequest(
+ response: ServerResponse,
+ request: RecordedRequest,
+ body: JsonRpcMessage | null,
+ recorder: TranscriptRecorder,
+ ): boolean;
+
+ protected sendSessionNewResponse(
+ responseStream: ServerResponse,
+ request: RecordedRequest,
+ body: JsonRpcMessage,
+ recorder: TranscriptRecorder,
+ sessionId: string,
+ ): void {
+ sendJson(responseStream, 202);
+ this.recordResponse(recorder, 202, request.scope, request.sessionId);
+ const response = {
+ jsonrpc: "2.0",
+ id: body.id,
+ result: {
+ sessionId,
+ },
+ };
+ this.writeConnectionEvent(response, recorder);
+ }
+
+ protected sendPromptFlow(
+ responseStream: ServerResponse,
+ request: RecordedRequest,
+ body: JsonRpcMessage,
+ recorder: TranscriptRecorder,
+ sessionId: string,
+ ): void {
+ sendJson(responseStream, 202);
+ this.recordResponse(recorder, 202, request.scope, request.sessionId);
+ const update = {
+ jsonrpc: "2.0",
+ method: "session/update",
+ params: {
+ sessionId,
+ update: {
+ sessionUpdate: "agent_message_chunk",
+ content: { type: "text", text: "hello" },
+ },
+ },
+ };
+ const response = {
+ jsonrpc: "2.0",
+ id: body.id,
+ result: {
+ stopReason: "end_turn",
+ },
+ };
+ this.writeSessionEvent(sessionId, update, recorder);
+ this.writeSessionEvent(sessionId, response, recorder);
+ }
+
+ protected writeConnectionEvent(message: JsonRpcMessage, recorder: TranscriptRecorder): void {
+ if (!this.connectionStream) {
+ throw new Error("connection stream must be open");
+ }
+ writeSse(this.connectionStream, message);
+ recorder.record({
+ kind: "sse_event",
+ stream: "connection",
+ sessionId: null,
+ jsonRpc: recorder.summarizeJsonRpc(message)!,
+ });
+ }
+
+ protected writeSessionEvent(sessionId: string, message: JsonRpcMessage, recorder: TranscriptRecorder): void {
+ const stream = this.sessionStreams.get(sessionId);
+ if (!stream) {
+ throw new Error(`session stream ${sessionId} must be open`);
+ }
+ writeSse(stream, message);
+ recorder.record({
+ kind: "sse_event",
+ stream: "session",
+ sessionId,
+ jsonRpc: recorder.summarizeJsonRpc(message)!,
+ });
+ }
+
+ private handleInitialize(response: ServerResponse, body: JsonRpcMessage, recorder: TranscriptRecorder): void {
+ sendJson(
+ response,
+ 200,
+ {
+ jsonrpc: "2.0",
+ id: body.id,
+ result: {
+ protocolVersion: 1,
+ agentCapabilities: {
+ loadSession: true,
+ },
+ authMethods: [],
+ },
+ },
+ {
+ "Acp-Connection-Id": this.connectionId,
+ "set-cookie": FIXTURE_COOKIE,
+ },
+ );
+ this.recordResponse(recorder, 200, "bootstrap", null);
+ }
+
+ private validateRequest(
+ response: ServerResponse,
+ request: RecordedRequest,
+ body: JsonRpcMessage | null,
+ recorder: TranscriptRecorder,
+ ): boolean {
+ if (request.scope === "bootstrap") {
+ if (request.method !== "POST" || body?.method !== "initialize") {
+ this.reject(response, recorder, request.scope, request.sessionId, 400, "Expected initialize bootstrap", body?.id);
+ return false;
+ }
+ return true;
+ }
+
+ if (request.connectionId !== this.connectionId) {
+ this.reject(response, recorder, request.scope, request.sessionId, 400, "Missing or invalid connection id", body?.id);
+ return false;
+ }
+
+ if (!request.cookie?.includes(FIXTURE_COOKIE)) {
+ this.reject(response, recorder, request.scope, request.sessionId, 401, "Missing fixture cookie", body?.id);
+ return false;
+ }
+
+ if (request.method === "GET" && !request.accept?.includes("text/event-stream")) {
+ this.reject(response, recorder, request.scope, request.sessionId, 406, "Expected text/event-stream", body?.id);
+ return false;
+ }
+
+ if (request.method === "POST") {
+ if (!request.accept?.includes("application/json")) {
+ this.reject(response, recorder, request.scope, request.sessionId, 406, "Expected application/json accept", body?.id);
+ return false;
+ }
+ if (!request.contentType?.includes("application/json")) {
+ this.reject(response, recorder, request.scope, request.sessionId, 415, "Expected application/json body", body?.id);
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ private recordRequest(
+ method: string,
+ headers: IncomingHttpHeaders,
+ body: JsonRpcMessage | null,
+ recorder: TranscriptRecorder,
+ ): RecordedRequest {
+ const connectionId = header(headers, CONNECTION_HEADER);
+ const sessionId = header(headers, SESSION_HEADER);
+ const cookie = header(headers, "cookie");
+ const accept = header(headers, "accept");
+ const contentType = header(headers, "content-type");
+ const scope = sessionId ? "session" : connectionId ? "connection" : "bootstrap";
+ recorder.record({
+ kind: "http_request",
+ method,
+ scope,
+ connectionId,
+ sessionId,
+ cookie,
+ jsonRpc: recorder.summarizeJsonRpc(body),
+ });
+ return { method, scope, connectionId, sessionId, cookie, accept, contentType };
+ }
+
+ private recordResponse(
+ recorder: TranscriptRecorder,
+ status: number,
+ scope: RequestScope,
+ sessionId: string | null,
+ ): void {
+ recorder.record({
+ kind: "http_response",
+ status,
+ scope,
+ connectionId: scope === "bootstrap" ? this.connectionId : this.connectionId,
+ sessionId,
+ });
+ }
+
+ private reject(
+ response: ServerResponse,
+ recorder: TranscriptRecorder,
+ scope: RequestScope,
+ sessionId: string | null,
+ status: number,
+ message: string,
+ id: unknown,
+ ): void {
+ sendJson(response, status, {
+ jsonrpc: "2.0",
+ id: typeof id === "string" || typeof id === "number" ? id : null,
+ error: {
+ code: -32600,
+ message,
+ },
+ });
+ this.recordResponse(recorder, status, scope, sessionId);
+ }
+}
+
+class HappyPathScenario extends BaseScenario {
+ readonly name = "happy-path";
+ private readonly sessionId = "sess-1";
+
+ protected handleScenarioRequest(
+ response: ServerResponse,
+ request: RecordedRequest,
+ body: JsonRpcMessage | null,
+ recorder: TranscriptRecorder,
+ ): boolean {
+ if (request.method === "POST" && request.scope === "connection" && body?.method === "session/new") {
+ this.sendSessionNewResponse(response, request, body, recorder, this.sessionId);
+ return true;
+ }
+ if (
+ request.method === "POST" &&
+ request.scope === "session" &&
+ request.sessionId === this.sessionId &&
+ body?.method === "session/prompt"
+ ) {
+ this.sendPromptFlow(response, request, body, recorder, this.sessionId);
+ return true;
+ }
+ return false;
+ }
+}
+
+class PermissionRoundTripScenario extends BaseScenario {
+ readonly name = "permission-round-trip";
+ private readonly sessionId = "sess-permission";
+ private pendingPromptId: unknown;
+
+ protected handleScenarioRequest(
+ response: ServerResponse,
+ request: RecordedRequest,
+ body: JsonRpcMessage | null,
+ recorder: TranscriptRecorder,
+ ): boolean {
+ if (request.method === "POST" && request.scope === "connection" && body?.method === "session/new") {
+ this.sendSessionNewResponse(response, request, body, recorder, this.sessionId);
+ return true;
+ }
+ if (
+ request.method === "POST" &&
+ request.scope === "session" &&
+ request.sessionId === this.sessionId &&
+ body?.method === "session/prompt"
+ ) {
+ sendJson(response, 202);
+ this.recordScenarioResponse(recorder, 202, request);
+ this.pendingPromptId = body.id;
+ this.writeSessionEvent(
+ this.sessionId,
+ {
+ jsonrpc: "2.0",
+ id: "perm-1",
+ method: "session/request_permission",
+ params: {
+ sessionId: this.sessionId,
+ toolCall: {
+ toolCallId: "tool-1",
+ title: "Write File",
+ kind: "edit",
+ status: "pending",
+ },
+ options: [
+ { optionId: "allow", name: "Allow", kind: "allow_once" },
+ { optionId: "deny", name: "Deny", kind: "reject_once" },
+ ],
+ },
+ },
+ recorder,
+ );
+ return true;
+ }
+ if (
+ request.method === "POST" &&
+ request.scope === "session" &&
+ request.sessionId === this.sessionId &&
+ body?.id === "perm-1" &&
+ "result" in body
+ ) {
+ sendJson(response, 202);
+ this.recordScenarioResponse(recorder, 202, request);
+ this.writeSessionEvent(
+ this.sessionId,
+ {
+ jsonrpc: "2.0",
+ id: this.pendingPromptId,
+ result: {
+ stopReason: "end_turn",
+ },
+ },
+ recorder,
+ );
+ return true;
+ }
+ return false;
+ }
+
+ private recordScenarioResponse(recorder: TranscriptRecorder, status: number, request: RecordedRequest): void {
+ recorder.record({
+ kind: "http_response",
+ status,
+ scope: request.scope,
+ connectionId: this.connectionId,
+ sessionId: request.sessionId,
+ });
+ }
+}
+
+class SessionLoadScenario extends BaseScenario {
+ readonly name = "session-load";
+ private readonly sessionId = "sess-load";
+
+ protected handleScenarioRequest(
+ response: ServerResponse,
+ request: RecordedRequest,
+ body: JsonRpcMessage | null,
+ recorder: TranscriptRecorder,
+ ): boolean {
+ if (
+ request.method === "POST" &&
+ request.scope === "session" &&
+ request.sessionId === this.sessionId &&
+ body?.method === "session/load"
+ ) {
+ sendJson(response, 202);
+ this.recordScenarioResponse(recorder, 202, request);
+ this.writeConnectionEvent(
+ {
+ jsonrpc: "2.0",
+ id: body.id,
+ result: {},
+ },
+ recorder,
+ );
+ return true;
+ }
+ return false;
+ }
+
+ private recordScenarioResponse(recorder: TranscriptRecorder, status: number, request: RecordedRequest): void {
+ recorder.record({
+ kind: "http_response",
+ status,
+ scope: request.scope,
+ connectionId: this.connectionId,
+ sessionId: request.sessionId,
+ });
+ }
+}
+
+class TwoSessionsScenario extends BaseScenario {
+ readonly name = "two-sessions";
+ private nextSessionNumber = 1;
+
+ protected handleScenarioRequest(
+ response: ServerResponse,
+ request: RecordedRequest,
+ body: JsonRpcMessage | null,
+ recorder: TranscriptRecorder,
+ ): boolean {
+ if (request.method === "POST" && request.scope === "connection" && body?.method === "session/new") {
+ const sessionId = `sess-${this.nextSessionNumber++}`;
+ this.sendSessionNewResponse(response, request, body, recorder, sessionId);
+ return true;
+ }
+ if (
+ request.method === "POST" &&
+ request.scope === "session" &&
+ request.sessionId &&
+ body?.method === "session/prompt"
+ ) {
+ this.sendPromptFlow(response, request, body, recorder, request.sessionId);
+ return true;
+ }
+ return false;
+ }
+}
+
+class WrongStreamResponseScenario extends BaseScenario {
+ readonly name = "wrong-stream-response";
+ private readonly sessionId = "sess-wrong";
+
+ protected handleScenarioRequest(
+ response: ServerResponse,
+ request: RecordedRequest,
+ body: JsonRpcMessage | null,
+ recorder: TranscriptRecorder,
+ ): boolean {
+ if (request.method === "POST" && request.scope === "connection" && body?.method === "session/new") {
+ this.sendSessionNewResponse(response, request, body, recorder, this.sessionId);
+ return true;
+ }
+ if (
+ request.method === "POST" &&
+ request.scope === "session" &&
+ request.sessionId === this.sessionId &&
+ body?.method === "session/prompt"
+ ) {
+ sendJson(response, 202);
+ this.recordScenarioResponse(recorder, 202, request);
+ this.writeConnectionEvent(
+ {
+ jsonrpc: "2.0",
+ id: body.id,
+ result: {
+ stopReason: "end_turn",
+ },
+ },
+ recorder,
+ );
+ return true;
+ }
+ return false;
+ }
+
+ private recordScenarioResponse(recorder: TranscriptRecorder, status: number, request: RecordedRequest): void {
+ recorder.record({
+ kind: "http_response",
+ status,
+ scope: request.scope,
+ connectionId: this.connectionId,
+ sessionId: request.sessionId,
+ });
+ }
+}
+
+class ValidationFailuresScenario extends BaseScenario {
+ readonly name = "validation-failures";
+
+ protected handleScenarioRequest(
+ _response: ServerResponse,
+ _request: RecordedRequest,
+ _body: JsonRpcMessage | null,
+ _recorder: TranscriptRecorder,
+ ): boolean {
+ return false;
+ }
+}
+
+type RequestScope = "bootstrap" | "connection" | "session";
+
+type RecordedRequest = {
+ method: string;
+ scope: RequestScope;
+ connectionId: string | null;
+ sessionId: string | null;
+ cookie: string | null;
+ accept: string | null;
+ contentType: string | null;
+};
+
+function header(headers: IncomingHttpHeaders, name: string): string | null {
+ const value = headers[name];
+ if (Array.isArray(value)) {
+ return value[0] ?? null;
+ }
+ return typeof value === "string" ? value : null;
+}
diff --git a/test-fixtures/streamable-http-server/src/server.ts b/test-fixtures/streamable-http-server/src/server.ts
new file mode 100644
index 0000000..57d9279
--- /dev/null
+++ b/test-fixtures/streamable-http-server/src/server.ts
@@ -0,0 +1,55 @@
+import { createServer, IncomingMessage } from "node:http";
+import { ACP_PATH, JsonRpcMessage } from "./protocol.js";
+import { createScenario } from "./scenarios.js";
+import { TranscriptRecorder } from "./transcript.js";
+
+const scenarioName = readArg("--scenario") ?? "happy-path";
+const port = Number(readArg("--port") ?? "0");
+const recorder = new TranscriptRecorder();
+const scenario = createScenario(scenarioName);
+
+const server = createServer();
+
+server.on("request", async (request, response) => {
+ const path = request.url ?? "";
+ if (path === "/__test/transcript") {
+ response.writeHead(200, {
+ "content-type": "application/json",
+ });
+ response.end(recorder.serialize());
+ return;
+ }
+
+ if (path !== ACP_PATH) {
+ response.writeHead(404);
+ response.end();
+ return;
+ }
+
+ const body = await readJsonBody(request);
+ scenario.handle(response, request.headers, request.method ?? "", body, recorder);
+});
+
+server.listen(port, () => {
+ const address = server.address();
+ const actualPort = typeof address === "object" && address ? address.port : port;
+ process.stdout.write(JSON.stringify({ status: "ready", port: actualPort, scenario: scenario.name }) + "\n");
+});
+
+function readArg(name: string): string | undefined {
+ const index = process.argv.indexOf(name);
+ return index >= 0 ? process.argv[index + 1] : undefined;
+}
+
+async function readJsonBody(request: IncomingMessage): Promise {
+ const method = request.method ?? "";
+ if (method === "GET" || method === "DELETE") {
+ return null;
+ }
+
+ let raw = "";
+ for await (const chunk of request) {
+ raw += chunk.toString("utf8");
+ }
+ return raw ? (JSON.parse(raw) as JsonRpcMessage) : null;
+}
diff --git a/test-fixtures/streamable-http-server/src/transcript.ts b/test-fixtures/streamable-http-server/src/transcript.ts
new file mode 100644
index 0000000..19f3440
--- /dev/null
+++ b/test-fixtures/streamable-http-server/src/transcript.ts
@@ -0,0 +1,101 @@
+export type JsonRpcSummary =
+ | {
+ type: "request";
+ id: string | number | null;
+ method: string;
+ }
+ | {
+ type: "notification";
+ method: string;
+ }
+ | {
+ type: "response";
+ id: string | number | null;
+ hasError: boolean;
+ };
+
+export type TranscriptEvent =
+ | {
+ kind: "http_request";
+ method: string;
+ scope: "bootstrap" | "connection" | "session";
+ connectionId: string | null;
+ sessionId: string | null;
+ cookie: string | null;
+ jsonRpc: JsonRpcSummary | null;
+ }
+ | {
+ kind: "http_response";
+ status: number;
+ scope: "bootstrap" | "connection" | "session";
+ connectionId: string | null;
+ sessionId: string | null;
+ }
+ | {
+ kind: "sse_event";
+ stream: "connection" | "session";
+ sessionId: string | null;
+ jsonRpc: JsonRpcSummary;
+ };
+
+export class TranscriptRecorder {
+ private readonly events: TranscriptEvent[] = [];
+ private readonly idAliases = new Map();
+
+ record(event: TranscriptEvent): void {
+ this.events.push(event);
+ }
+
+ summarizeJsonRpc(message: unknown): JsonRpcSummary | null {
+ if (!message || typeof message !== "object") {
+ return null;
+ }
+
+ const candidate = message as Record;
+ if (typeof candidate.method === "string" && "id" in candidate) {
+ return {
+ type: "request",
+ id: this.normalizeId(candidate.id),
+ method: candidate.method,
+ };
+ }
+
+ if (typeof candidate.method === "string") {
+ return {
+ type: "notification",
+ method: candidate.method,
+ };
+ }
+
+ if ("result" in candidate || "error" in candidate) {
+ return {
+ type: "response",
+ id: this.normalizeId(candidate.id),
+ hasError: candidate.error != null,
+ };
+ }
+
+ return null;
+ }
+
+ toJSON(): TranscriptEvent[] {
+ return [...this.events];
+ }
+
+ serialize(): string {
+ return JSON.stringify(this.events, null, 2);
+ }
+
+ private normalizeId(id: unknown): string | null {
+ if (typeof id !== "string" && typeof id !== "number") {
+ return null;
+ }
+ const existing = this.idAliases.get(id);
+ if (existing) {
+ return existing;
+ }
+ const next = `id-${this.idAliases.size + 1}`;
+ this.idAliases.set(id, next);
+ return next;
+ }
+}
diff --git a/test-fixtures/streamable-http-server/tsconfig.json b/test-fixtures/streamable-http-server/tsconfig.json
new file mode 100644
index 0000000..ce68458
--- /dev/null
+++ b/test-fixtures/streamable-http-server/tsconfig.json
@@ -0,0 +1,13 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "module": "NodeNext",
+ "moduleResolution": "NodeNext",
+ "strict": true,
+ "outDir": "dist",
+ "rootDir": "src",
+ "esModuleInterop": true,
+ "forceConsistentCasingInFileNames": true
+ },
+ "include": ["src/**/*.ts"]
+}
From e179831a16b0f1c79f0fbfcdbfb3e05bad38601f Mon Sep 17 00:00:00 2001
From: Kaiser Dandangi
Date: Sun, 17 May 2026 23:12:41 -0400
Subject: [PATCH 02/15] docs: clarify streamable http routing state
---
.../client/transport/StreamableHttpAcpClientTransport.java | 4 +++-
.../com/agentclientprotocol/sdk/spec/AcpClientSession.java | 5 ++++-
2 files changed, 7 insertions(+), 2 deletions(-)
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
index 1679775..0cde983 100644
--- 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
@@ -48,7 +48,7 @@
* JSON-RPC messages.
*
*
- * @author Mark Pollack
+ * @author Kaiser Dandangi
*/
public class StreamableHttpAcpClientTransport implements AcpClientTransport {
@@ -152,8 +152,10 @@ private record HttpClientBundle(HttpClient httpClient, ExecutorService ownedExec
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<>();
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 6c30c5d..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
@@ -154,7 +154,10 @@ public AcpClientSession(Duration requestTimeout, AcpClientTransport transport,
* 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.
+ * 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();
}
From 08ef73963f2c4911e8f9e9ed3e835efad876c2b7 Mon Sep 17 00:00:00 2001
From: Kaiser Dandangi
Date: Mon, 18 May 2026 11:39:21 -0400
Subject: [PATCH 03/15] fix: dedupe session stream opens
---
.../StreamableHttpAcpClientTransport.java | 15 +++-
.../StreamableHttpAcpClientTransportTest.java | 85 +++++++++++++++++++
2 files changed, 96 insertions(+), 4 deletions(-)
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
index 0cde983..7913a87 100644
--- 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
@@ -160,6 +160,9 @@ private record HttpClientBundle(HttpClient httpClient, ExecutorService ownedExec
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;
@@ -399,12 +402,15 @@ private Mono openConnectionStream() {
}
private Mono openSessionStream(String sessionId) {
- if (sessionStreams.containsKey(sessionId)) {
- return Mono.empty();
- }
+ return sessionStreamOpenOperations.computeIfAbsent(sessionId, this::createSessionStreamOpenMono);
+ }
+
+ private Mono createSessionStreamOpenMono(String sessionId) {
return openSseStream(RouteScope.session(sessionId))
.doOnSuccess(stream -> sessionStreams.putIfAbsent(sessionId, stream))
- .then();
+ .then()
+ .doOnError(error -> sessionStreamOpenOperations.remove(sessionId))
+ .cache();
}
private Mono openSseStream(RouteScope scope) {
@@ -612,6 +618,7 @@ public Mono closeGracefully() {
private void clearState() {
connectionStream = null;
sessionStreams.clear();
+ sessionStreamOpenOperations.clear();
inboundRequestRoutes.clear();
outboundRequestRoutes.clear();
connectionId = null;
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
index 33d75a1..5f9bec3 100644
--- 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
@@ -4,10 +4,22 @@
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;
@@ -16,7 +28,9 @@
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}.
@@ -91,4 +105,75 @@ void strictRoutingRejectsUnknownOutboundMethods() {
.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;
+ }
+
}
From 23858f1acb6d5f4d58ada0deacade7c96d71ec82 Mon Sep 17 00:00:00 2001
From: Kaiser Dandangi
Date: Mon, 18 May 2026 16:08:41 -0400
Subject: [PATCH 04/15] feat: add streamable HTTP agent transport
---
.gitignore | 2 +
README.md | 12 +-
.../sdk/agent/AcpAgentFactory.java | 60 ++
.../sdk/agent/AcpAgentFactoryTest.java | 41 +
acp-streamable-http-jetty/pom.xml | 60 ++
.../StreamableHttpAcpAgentTransport.java | 988 ++++++++++++++++++
...eHttpAcpAgentTransportIntegrationTest.java | 222 ++++
.../src/test/resources/logback-test.xml | 11 +
plans/STREAMABLE-HTTP-AGENT-TRANSPORT.md | 119 +++
pom.xml | 16 +
.../streamable-http-client/README.md | 27 +
.../streamable-http-client/dist/client.js | 16 +
.../streamable-http-client/dist/protocol.js | 245 +++++
.../streamable-http-client/dist/scenarios.js | 127 +++
.../streamable-http-client/dist/transcript.js | 69 ++
.../golden/happy-path.json | 127 +++
.../golden/permission-round-trip.json | 154 +++
.../golden/session-load.json | 92 ++
.../golden/two-sessions.json | 203 ++++
.../golden/validation-failures.json | 79 ++
.../golden/wrong-stream-response.json | 137 +++
.../streamable-http-client/package-lock.json | 45 +
.../streamable-http-client/package.json | 12 +
.../streamable-http-client/src/client.ts | 19 +
.../streamable-http-client/src/protocol.ts | 278 +++++
.../streamable-http-client/src/scenarios.ts | 134 +++
.../streamable-http-client/src/transcript.ts | 115 ++
.../streamable-http-client/tsconfig.json | 12 +
28 files changed, 3421 insertions(+), 1 deletion(-)
create mode 100644 acp-core/src/main/java/com/agentclientprotocol/sdk/agent/AcpAgentFactory.java
create mode 100644 acp-core/src/test/java/com/agentclientprotocol/sdk/agent/AcpAgentFactoryTest.java
create mode 100644 acp-streamable-http-jetty/pom.xml
create mode 100644 acp-streamable-http-jetty/src/main/java/com/agentclientprotocol/sdk/agent/transport/StreamableHttpAcpAgentTransport.java
create mode 100644 acp-streamable-http-jetty/src/test/java/com/agentclientprotocol/sdk/agent/transport/StreamableHttpAcpAgentTransportIntegrationTest.java
create mode 100644 acp-streamable-http-jetty/src/test/resources/logback-test.xml
create mode 100644 plans/STREAMABLE-HTTP-AGENT-TRANSPORT.md
create mode 100644 test-fixtures/streamable-http-client/README.md
create mode 100644 test-fixtures/streamable-http-client/dist/client.js
create mode 100644 test-fixtures/streamable-http-client/dist/protocol.js
create mode 100644 test-fixtures/streamable-http-client/dist/scenarios.js
create mode 100644 test-fixtures/streamable-http-client/dist/transcript.js
create mode 100644 test-fixtures/streamable-http-client/golden/happy-path.json
create mode 100644 test-fixtures/streamable-http-client/golden/permission-round-trip.json
create mode 100644 test-fixtures/streamable-http-client/golden/session-load.json
create mode 100644 test-fixtures/streamable-http-client/golden/two-sessions.json
create mode 100644 test-fixtures/streamable-http-client/golden/validation-failures.json
create mode 100644 test-fixtures/streamable-http-client/golden/wrong-stream-response.json
create mode 100644 test-fixtures/streamable-http-client/package-lock.json
create mode 100644 test-fixtures/streamable-http-client/package.json
create mode 100644 test-fixtures/streamable-http-client/src/client.ts
create mode 100644 test-fixtures/streamable-http-client/src/protocol.ts
create mode 100644 test-fixtures/streamable-http-client/src/scenarios.ts
create mode 100644 test-fixtures/streamable-http-client/src/transcript.ts
create mode 100644 test-fixtures/streamable-http-client/tsconfig.json
diff --git a/.gitignore b/.gitignore
index a7ae7a1..7aa4b83 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/
@@ -71,4 +72,5 @@ replay_pid*
### Planning and Internal Documentation ###
plans/*
!plans/STREAMABLE-HTTP-TRANSPORT.md
+!plans/STREAMABLE-HTTP-AGENT-TRANSPORT.md
learnings/
diff --git a/README.md b/README.md
index 4d51db7..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
@@ -369,6 +378,7 @@ 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, 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,7 +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` | — | acp-core |
+| 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/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-streamable-http-jetty/pom.xml b/acp-streamable-http-jetty/pom.xml
new file mode 100644
index 0000000..defe354
--- /dev/null
+++ b/acp-streamable-http-jetty/pom.xml
@@ -0,0 +1,60 @@
+
+
+ 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.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..911b3a9
--- /dev/null
+++ b/acp-streamable-http-jetty/src/main/java/com/agentclientprotocol/sdk/agent/transport/StreamableHttpAcpAgentTransport.java
@@ -0,0 +1,988 @@
+/*
+ * 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.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 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 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.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 a Jetty HTTP endpoint and creates one fresh agent runtime per
+ * remote ACP connection through {@link AcpAgentFactory}. The accepted connection then
+ * owns its own per-connection {@link AcpAgentTransport}, while the listener remains
+ * responsible only for HTTP concerns such as headers, SSE streams, and request routing.
+ *
+ *
+ *
+ * The current implementation is intentionally HTTP-only. The shared remote transport
+ * core that should eventually also back WebSocket remains a follow-up migration step so
+ * the existing WebSocket behavior can be preserved until parity is proven here first.
+ *
+ *
+ * @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 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);
+ 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();
+ 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();
+ }
+
+ private ConnectionState createConnection() {
+ String connectionId = UUID.randomUUID().toString();
+ ConnectionState connection = new ConnectionState(connectionId);
+ connection.start();
+ return connection;
+ }
+
+ private Optional connection(String connectionId) {
+ return Optional.ofNullable(connections.get(connectionId));
+ }
+
+ 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 ConnectionTransport transport;
+
+ 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;
+
+ private volatile AcpAsyncAgent agent;
+
+ ConnectionState(String id) {
+ this.id = id;
+ this.transport = new ConnectionTransport(this::routeAgentMessage);
+ }
+
+ String id() {
+ return id;
+ }
+
+ void start() {
+ this.agent = agentFactory.create(transport);
+ this.agent.start().block(INITIALIZE_TIMEOUT);
+ }
+
+ Mono initialize(AcpSchema.JSONRPCRequest request) {
+ this.initializeRequestId = request.id();
+ transport.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);
+ transport.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());
+ }
+ transport.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);
+ transport.closeGracefully().subscribe();
+ AcpAsyncAgent currentAgent = this.agent;
+ if (currentAgent != null) {
+ currentAgent.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) {
+ transport.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 ConnectionTransport implements AcpAgentTransport {
+
+ private final Consumer outboundConsumer;
+
+ private final Sinks.Many inboundSink = Sinks.many().unicast().onBackpressureBuffer();
+
+ private final Sinks.One terminationSink = Sinks.one();
+
+ private final AtomicBoolean started = new AtomicBoolean(false);
+
+ private final AtomicBoolean closing = new AtomicBoolean(false);
+
+ private volatile Consumer exceptionHandler = t -> logger.error("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 (!started.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)
+ .subscribe();
+ return Mono.empty();
+ }
+
+ void acceptInbound(JSONRPCMessage message) {
+ if (closing.get()) {
+ throw new AcpConnectionException("Connection transport is closing");
+ }
+ 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 (closing.get()) {
+ throw new AcpConnectionException("Connection transport 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(() -> {
+ if (closing.compareAndSet(false, true)) {
+ inboundSink.tryEmitComplete();
+ terminationSink.tryEmitValue(null);
+ }
+ });
+ }
+
+ @Override
+ public void setExceptionHandler(Consumer handler) {
+ this.exceptionHandler = handler;
+ }
+
+ @Override
+ public Mono awaitTermination() {
+ return terminationSink.asMono();
+ }
+
+ }
+
+ 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 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..cf34646
--- /dev/null
+++ b/acp-streamable-http-jetty/src/test/java/com/agentclientprotocol/sdk/agent/transport/StreamableHttpAcpAgentTransportIntegrationTest.java
@@ -0,0 +1,222 @@
+/*
+ * 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.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.nio.file.Files;
+import java.nio.file.Path;
+import java.time.Duration;
+import java.util.List;
+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.spec.AcpSchema;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+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 in-repo TypeScript Streamable HTTP fixture client.
+ */
+class StreamableHttpAcpAgentTransportIntegrationTest {
+
+ private static final Duration TIMEOUT = Duration.ofSeconds(5);
+
+ private static final Path FIXTURE_DIR = Path.of("..", "test-fixtures", "streamable-http-client").normalize();
+
+ private static final Path GOLDEN_DIR = FIXTURE_DIR.resolve("golden");
+
+ private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
+
+ @Test
+ void happyPathMatchesFixtureTranscript() throws Exception {
+ try (FixtureServer server = FixtureServer.start(StreamableHttpAcpAgentTransport.RoutingMode.COMPATIBLE)) {
+ JsonNode transcript = FixtureClient.run(server.endpoint(), "happy-path");
+ assertTranscriptMatches(transcript, "happy-path.json");
+ }
+ }
+
+ @Test
+ void permissionRoundTripMatchesFixtureTranscript() throws Exception {
+ try (FixtureServer server = FixtureServer.start(StreamableHttpAcpAgentTransport.RoutingMode.COMPATIBLE)) {
+ JsonNode transcript = FixtureClient.run(server.endpoint(), "permission-round-trip");
+ assertTranscriptMatches(transcript, "permission-round-trip.json");
+ }
+ }
+
+ @Test
+ void compatibleModeAllowsSessionLoadPreopen() throws Exception {
+ try (FixtureServer server = FixtureServer.start(StreamableHttpAcpAgentTransport.RoutingMode.COMPATIBLE)) {
+ JsonNode transcript = FixtureClient.run(server.endpoint(), "session-load");
+ assertTranscriptMatches(transcript, "session-load.json");
+ }
+ }
+
+ @Test
+ void supportsTwoLogicalSessions() throws Exception {
+ try (FixtureServer server = FixtureServer.start(StreamableHttpAcpAgentTransport.RoutingMode.COMPATIBLE)) {
+ JsonNode transcript = FixtureClient.run(server.endpoint(), "two-sessions");
+ assertTranscriptMatches(transcript, "two-sessions.json");
+ }
+ }
+
+ @Test
+ void wrongStreamResponseIsRejected() throws Exception {
+ try (FixtureServer server = FixtureServer.start(StreamableHttpAcpAgentTransport.RoutingMode.COMPATIBLE)) {
+ JsonNode transcript = FixtureClient.run(server.endpoint(), "wrong-stream-response");
+ assertTranscriptMatches(transcript, "wrong-stream-response.json");
+ }
+ }
+
+ @Test
+ void validationFailuresMatchFixtureTranscript() throws Exception {
+ try (FixtureServer server = FixtureServer.start(StreamableHttpAcpAgentTransport.RoutingMode.COMPATIBLE)) {
+ JsonNode transcript = FixtureClient.run(server.endpoint(), "validation-failures");
+ assertTranscriptMatches(transcript, "validation-failures.json");
+ }
+ }
+
+ @Test
+ void javaClientCanTalkToRunningJavaServer() throws Exception {
+ try (FixtureServer server = FixtureServer.start(StreamableHttpAcpAgentTransport.RoutingMode.COMPATIBLE)) {
+ AcpAsyncClient client = AcpClient
+ .async(new StreamableHttpAcpClientTransport(server.endpoint(), AcpJsonMapper.createDefault()))
+ .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 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 void assertTranscriptMatches(JsonNode actual, String goldenName) throws Exception {
+ JsonNode expected = OBJECT_MAPPER.readTree(Files.readString(GOLDEN_DIR.resolve(goldenName)));
+ assertThat(actual).isEqualTo(expected);
+ }
+
+ 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 FixtureClient {
+
+ static JsonNode run(URI endpoint, String scenario) throws Exception {
+ Process process = new ProcessBuilder("node", "dist/client.js", "--endpoint", endpoint.toString(), "--scenario",
+ scenario)
+ .directory(FIXTURE_DIR.toFile())
+ .redirectErrorStream(true)
+ .start();
+ try (BufferedReader stdout = new BufferedReader(
+ new InputStreamReader(process.getInputStream(), StandardCharsets.UTF_8))) {
+ String output = stdout.lines().reduce("", (left, right) -> left + right + System.lineSeparator());
+ if (!process.waitFor(5, TimeUnit.SECONDS)) {
+ process.destroyForcibly();
+ throw new IllegalStateException("Fixture client timed out");
+ }
+ if (process.exitValue() != 0) {
+ throw new IllegalStateException("Fixture client failed: " + output);
+ }
+ return OBJECT_MAPPER.readTree(output);
+ }
+ }
+
+ }
+
+}
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/STREAMABLE-HTTP-AGENT-TRANSPORT.md b/plans/STREAMABLE-HTTP-AGENT-TRANSPORT.md
new file mode 100644
index 0000000..a3c47e6
--- /dev/null
+++ b/plans/STREAMABLE-HTTP-AGENT-TRANSPORT.md
@@ -0,0 +1,119 @@
+# Plan: Streamable HTTP Agent Transport
+
+> **Status**: Milestone one implemented
+> **Created**: 2026-05-18
+> **Primary goal**: Java agents can be served from a running Java web server over the Streamable HTTP transport while preserving current WebSocket behavior until parity is proven.
+
+## Goal
+
+Add an agent-side Streamable HTTP transport backed by Jetty, with:
+
+- one fresh ACP agent runtime per accepted remote connection
+- a public `AcpAgentFactory` seam for listener-backed transports
+- RFD-oriented HTTP + SSE behavior
+- strict and compatible routing modes
+- a fixture-driven conformance harness that exercises a real running Java listener
+
+## Public Shape
+
+- Add `AcpAgentFactory` in `acp-core`.
+- Make the async seam explicit:
+ - `AcpAgentFactory.async(...)`
+ - `AcpAgentFactory.sync(...)`
+- Add `StreamableHttpAcpAgentTransport` in a dedicated Jetty adapter module:
+ - `acp-streamable-http-jetty`
+- Keep the current WebSocket single-agent API intact in this milestone.
+- PLAN: once Streamable HTTP reaches behavioral parity, migrate remote WebSocket handling toward the same factory-backed listener model.
+
+## Runtime Model
+
+```text
+StreamableHttpAcpAgentTransport
+ accepts remote connection
+ -> AcpAgentFactory creates fresh agent runtime
+ -> per-connection AcpAgentTransport drives that runtime
+ -> one ACP connection contains many logical ACP sessionIds
+```
+
+- `Acp-Connection-Id` identifies one remote peer relationship.
+- `Acp-Session-Id` identifies one logical ACP session inside that connection.
+- The transport owns routing; the agent owns protocol meaning.
+
+## Routing / Lifecycle Decisions
+
+- `initialize`
+ - creates a provisional connection
+ - starts a fresh agent runtime
+ - returns `200 OK` with JSON-RPC response + `Acp-Connection-Id`
+ - publishes the connection only after successful initialize
+- non-initialize POSTs require `Acp-Connection-Id`
+- connection-scoped SSE streams carry:
+ - initialize follow-up traffic
+ - responses to `session/new`
+ - responses to `session/load`
+- session-scoped SSE streams carry:
+ - responses to ordinary session-scoped requests
+ - session updates
+ - agent-originated session-scoped requests such as permission prompts
+- DELETE tears down the connection and releases transport state.
+
+### Routing ledgers
+
+- client request id -> expected outbound response scope
+- agent request id -> expected client response scope
+
+Wrong-stream client replies are protocol errors. Unknown response ids preserve current SDK parity and are allowed through for the session layer to decide.
+
+### Strict vs compatible
+
+- `STRICT`
+ - rejects unknown methods without explicit routing
+ - rejects unknown session stream opens with `404`
+- `COMPATIBLE`
+ - falls back to `params.sessionId` inference for unknown methods
+ - permits provisional session streams before `session/load`
+
+## Known RFD Gap
+
+The RFD says unknown session-scoped GETs should return `404`, but the resume flow also asks clients to open the session SSE stream before sending `session/load`. This transport keeps that tension explicit:
+
+- strict mode preserves the literal 404 rule
+- compatible mode creates a provisional `PENDING_LOAD` session stream
+
+PLAN: revisit this once the protocol resolves the resume/session-load ordering contract more precisely.
+
+## Test Harness
+
+Create an in-repo fixture:
+
+```text
+test-fixtures/streamable-http-client/
+```
+
+The fixture is:
+
+- TypeScript
+- HTTP-only
+- scenario-driven
+- the single owner of canonical transcript serialization
+- run against a real Java Jetty listener
+
+Covered scenarios:
+
+- happy path
+- permission round-trip
+- session load / provisional pre-open
+- two logical sessions
+- wrong-stream response rejection
+- validation failures
+
+The Java module also keeps focused integration coverage for strict unknown-session behavior.
+
+## PLAN / Follow-Up Work
+
+- extract a shared remote-core layer only after HTTP parity is proven
+- migrate WebSocket toward the same factory-backed listener model
+- add idle/provisional-session eviction and replay retention policies
+- revisit per-logical-session active-prompt tracking in `AcpAgentSession`
+- expose richer diagnostics / observability hooks
+- decide whether compatible provisional session streams remain necessary after the RFD is clarified
diff --git a/pom.xml b/pom.xml
index aa98e65..3ccfd64 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
@@ -133,6 +134,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}
+
diff --git a/test-fixtures/streamable-http-client/README.md b/test-fixtures/streamable-http-client/README.md
new file mode 100644
index 0000000..37e1bb2
--- /dev/null
+++ b/test-fixtures/streamable-http-client/README.md
@@ -0,0 +1,27 @@
+# Streamable HTTP Fixture Client
+
+This in-repo TypeScript fixture drives the Java Streamable HTTP agent transport
+through raw HTTP and SSE exchanges. It is intentionally small, strict, and scenario
+driven so the wire contract stays visible while the Java server implementation evolves.
+
+Scenarios:
+
+- `happy-path`
+- `permission-round-trip`
+- `session-load`
+- `two-sessions`
+- `wrong-stream-response`
+- `validation-failures`
+
+Build once:
+
+```bash
+npm install
+npm run build
+```
+
+Run against a local Java listener:
+
+```bash
+node dist/client.js --endpoint http://127.0.0.1:8080/acp --scenario happy-path
+```
diff --git a/test-fixtures/streamable-http-client/dist/client.js b/test-fixtures/streamable-http-client/dist/client.js
new file mode 100644
index 0000000..26f4522
--- /dev/null
+++ b/test-fixtures/streamable-http-client/dist/client.js
@@ -0,0 +1,16 @@
+import { StreamableHttpFixtureClient } from "./protocol.js";
+import { runScenario } from "./scenarios.js";
+import { TranscriptRecorder } from "./transcript.js";
+const endpoint = readArg("--endpoint");
+if (!endpoint) {
+ throw new Error("--endpoint is required");
+}
+const scenario = readArg("--scenario") ?? "happy-path";
+const recorder = new TranscriptRecorder();
+const client = new StreamableHttpFixtureClient(endpoint, recorder);
+await runScenario(scenario, endpoint, client);
+process.stdout.write(recorder.serialize());
+function readArg(name) {
+ const index = process.argv.indexOf(name);
+ return index >= 0 ? process.argv[index + 1] : undefined;
+}
diff --git a/test-fixtures/streamable-http-client/dist/protocol.js b/test-fixtures/streamable-http-client/dist/protocol.js
new file mode 100644
index 0000000..e6df323
--- /dev/null
+++ b/test-fixtures/streamable-http-client/dist/protocol.js
@@ -0,0 +1,245 @@
+export const CONNECTION_HEADER = "Acp-Connection-Id";
+export const SESSION_HEADER = "Acp-Session-Id";
+export class StreamableHttpFixtureClient {
+ endpoint;
+ recorder;
+ connectionId = null;
+ nextId = 1;
+ constructor(endpoint, recorder) {
+ this.endpoint = endpoint;
+ this.recorder = recorder;
+ }
+ async initialize() {
+ const request = this.request("initialize", {
+ protocolVersion: 1,
+ clientCapabilities: {},
+ });
+ const response = await this.post(request, "bootstrap", null, true);
+ const connectionId = response.headers.get(CONNECTION_HEADER);
+ if (!connectionId) {
+ throw new Error("initialize response missing connection id");
+ }
+ this.connectionId = connectionId;
+ return await response.json();
+ }
+ async postMessage(message, sessionId = null) {
+ return this.post(message, sessionId ? "session" : "connection", sessionId, false);
+ }
+ async openConnectionStream() {
+ return SseStream.open(this.endpoint, this.recorder, "connection", this.requireConnectionId(), null);
+ }
+ async openSessionStream(sessionId) {
+ return SseStream.open(this.endpoint, this.recorder, "session", this.requireConnectionId(), sessionId);
+ }
+ async close() {
+ const headers = {
+ [CONNECTION_HEADER]: this.requireConnectionId(),
+ };
+ this.recorder.record({
+ kind: "http_request",
+ method: "DELETE",
+ scope: "connection",
+ connectionId: this.connectionId,
+ sessionId: null,
+ jsonRpc: null,
+ });
+ const response = await fetch(this.endpoint, {
+ method: "DELETE",
+ headers,
+ });
+ this.recorder.record({
+ kind: "http_response",
+ status: response.status,
+ scope: "connection",
+ connectionId: this.connectionId,
+ sessionId: null,
+ jsonRpc: null,
+ });
+ return response;
+ }
+ async rawRequest(method, scope, headers, body, sessionId = null) {
+ this.recorder.record({
+ kind: "http_request",
+ method,
+ scope,
+ connectionId: headers[CONNECTION_HEADER] ?? null,
+ sessionId,
+ jsonRpc: this.recorder.summarizeJsonRpc(body),
+ });
+ const response = await fetch(this.endpoint, {
+ method,
+ headers,
+ ...(body ? { body: JSON.stringify(body) } : {}),
+ });
+ this.recorder.record({
+ kind: "http_response",
+ status: response.status,
+ scope,
+ connectionId: response.headers.get(CONNECTION_HEADER) ?? headers[CONNECTION_HEADER] ?? null,
+ sessionId: response.headers.get(SESSION_HEADER) ?? sessionId,
+ jsonRpc: null,
+ });
+ return response;
+ }
+ request(method, params) {
+ return {
+ jsonrpc: "2.0",
+ id: `req-${this.nextId++}`,
+ method,
+ params,
+ };
+ }
+ response(id, result) {
+ return {
+ jsonrpc: "2.0",
+ id,
+ result,
+ };
+ }
+ async post(message, scope, sessionId, expectJson) {
+ const headers = {
+ "content-type": "application/json",
+ accept: "application/json",
+ };
+ if (scope !== "bootstrap") {
+ headers[CONNECTION_HEADER] = this.requireConnectionId();
+ }
+ if (sessionId) {
+ headers[SESSION_HEADER] = sessionId;
+ }
+ this.recorder.record({
+ kind: "http_request",
+ method: "POST",
+ scope,
+ connectionId: this.connectionId,
+ sessionId,
+ jsonRpc: this.recorder.summarizeJsonRpc(message),
+ });
+ const response = await fetch(this.endpoint, {
+ method: "POST",
+ headers,
+ body: JSON.stringify(message),
+ });
+ let jsonRpc = null;
+ if (expectJson) {
+ jsonRpc = this.recorder.summarizeJsonRpc(await response.clone().json());
+ }
+ this.recorder.record({
+ kind: "http_response",
+ status: response.status,
+ scope,
+ connectionId: response.headers.get(CONNECTION_HEADER) ?? this.connectionId,
+ sessionId,
+ jsonRpc,
+ });
+ return response;
+ }
+ requireConnectionId() {
+ if (!this.connectionId) {
+ throw new Error("connection id not initialized");
+ }
+ return this.connectionId;
+ }
+}
+export class SseStream {
+ recorder;
+ stream;
+ sessionId;
+ response;
+ messages = [];
+ waiters = [];
+ abortController = new AbortController();
+ constructor(recorder, stream, sessionId, response) {
+ this.recorder = recorder;
+ this.stream = stream;
+ this.sessionId = sessionId;
+ this.response = response;
+ }
+ static async open(endpoint, recorder, stream, connectionId, sessionId) {
+ recorder.record({
+ kind: "http_request",
+ method: "GET",
+ scope: stream,
+ connectionId,
+ sessionId,
+ jsonRpc: null,
+ });
+ const response = await fetch(endpoint, {
+ method: "GET",
+ headers: {
+ accept: "text/event-stream",
+ [CONNECTION_HEADER]: connectionId,
+ ...(sessionId ? { [SESSION_HEADER]: sessionId } : {}),
+ },
+ });
+ recorder.record({
+ kind: "http_response",
+ status: response.status,
+ scope: stream,
+ connectionId: response.headers.get(CONNECTION_HEADER),
+ sessionId: response.headers.get(SESSION_HEADER),
+ jsonRpc: null,
+ });
+ const result = new SseStream(recorder, stream, sessionId, response);
+ void result.readLoop();
+ return result;
+ }
+ async next() {
+ const existing = this.messages.shift();
+ if (existing) {
+ return existing;
+ }
+ return new Promise((resolve) => this.waiters.push(resolve));
+ }
+ close() {
+ this.abortController.abort();
+ }
+ async readLoop() {
+ if (!this.response.body) {
+ return;
+ }
+ const reader = this.response.body.getReader();
+ const decoder = new TextDecoder();
+ let buffer = "";
+ for (;;) {
+ const { value, done } = await reader.read();
+ if (done) {
+ break;
+ }
+ buffer += decoder.decode(value, { stream: true });
+ for (;;) {
+ const boundary = buffer.indexOf("\n\n");
+ if (boundary < 0) {
+ break;
+ }
+ const rawEvent = buffer.slice(0, boundary);
+ buffer = buffer.slice(boundary + 2);
+ const data = rawEvent
+ .split("\n")
+ .filter((line) => line.startsWith("data:"))
+ .map((line) => line.slice(5).trimStart())
+ .join("\n");
+ if (!data) {
+ continue;
+ }
+ const message = JSON.parse(data);
+ const summary = this.recorder.summarizeJsonRpc(message);
+ if (summary) {
+ this.recorder.record({
+ kind: "sse_event",
+ stream: this.stream,
+ sessionId: this.sessionId,
+ jsonRpc: summary,
+ });
+ }
+ const waiter = this.waiters.shift();
+ if (waiter) {
+ waiter(message);
+ }
+ else {
+ this.messages.push(message);
+ }
+ }
+ }
+ }
+}
diff --git a/test-fixtures/streamable-http-client/dist/scenarios.js b/test-fixtures/streamable-http-client/dist/scenarios.js
new file mode 100644
index 0000000..95f8c41
--- /dev/null
+++ b/test-fixtures/streamable-http-client/dist/scenarios.js
@@ -0,0 +1,127 @@
+import { CONNECTION_HEADER } from "./protocol.js";
+export async function runScenario(name, endpoint, client) {
+ switch (name) {
+ case "happy-path":
+ await happyPath(client);
+ return;
+ case "permission-round-trip":
+ await permissionRoundTrip(client);
+ return;
+ case "session-load":
+ await sessionLoad(client);
+ return;
+ case "two-sessions":
+ await twoSessions(client);
+ return;
+ case "wrong-stream-response":
+ await wrongStreamResponse(client);
+ return;
+ case "validation-failures":
+ await validationFailures(endpoint, client);
+ return;
+ default:
+ throw new Error(`Unknown scenario ${name}`);
+ }
+}
+async function happyPath(client) {
+ await client.initialize();
+ const connection = await client.openConnectionStream();
+ const newSession = client.request("session/new", { cwd: "/workspace", mcpServers: [] });
+ await client.postMessage(newSession);
+ const sessionResponse = await connection.next();
+ const sessionId = sessionResponse.result.sessionId;
+ const session = await client.openSessionStream(sessionId);
+ await client.postMessage(client.request("session/prompt", {
+ sessionId,
+ prompt: [{ type: "text", text: "hello" }],
+ }), sessionId);
+ await session.next();
+ await session.next();
+ await client.close();
+}
+async function permissionRoundTrip(client) {
+ await client.initialize();
+ const connection = await client.openConnectionStream();
+ await client.postMessage(client.request("session/new", { cwd: "/workspace", mcpServers: [] }));
+ const sessionResponse = await connection.next();
+ const sessionId = sessionResponse.result.sessionId;
+ const session = await client.openSessionStream(sessionId);
+ await client.postMessage(client.request("session/prompt", {
+ sessionId,
+ prompt: [{ type: "text", text: "needs permission" }],
+ }), sessionId);
+ const permissionRequest = await session.next();
+ await client.postMessage(client.response(permissionRequest.id, { outcome: { outcome: "selected", optionId: "allow" } }), sessionId);
+ await session.next();
+ await session.next();
+ await client.close();
+}
+async function sessionLoad(client) {
+ await client.initialize();
+ const connection = await client.openConnectionStream();
+ const session = await client.openSessionStream("sess-load");
+ await client.postMessage(client.request("session/load", {
+ sessionId: "sess-load",
+ cwd: "/workspace",
+ mcpServers: [],
+ }), "sess-load");
+ await connection.next();
+ session.close();
+ await client.close();
+}
+async function twoSessions(client) {
+ await client.initialize();
+ const connection = await client.openConnectionStream();
+ await client.postMessage(client.request("session/new", { cwd: "/workspace/one", mcpServers: [] }));
+ const first = await connection.next();
+ const firstId = first.result.sessionId;
+ await client.postMessage(client.request("session/new", { cwd: "/workspace/two", mcpServers: [] }));
+ const second = await connection.next();
+ const secondId = second.result.sessionId;
+ const firstStream = await client.openSessionStream(firstId);
+ const secondStream = await client.openSessionStream(secondId);
+ await client.postMessage(client.request("session/prompt", {
+ sessionId: firstId,
+ prompt: [{ type: "text", text: "one" }],
+ }), firstId);
+ await firstStream.next();
+ await firstStream.next();
+ await client.postMessage(client.request("session/prompt", {
+ sessionId: secondId,
+ prompt: [{ type: "text", text: "two" }],
+ }), secondId);
+ await secondStream.next();
+ await secondStream.next();
+ await client.close();
+}
+async function wrongStreamResponse(client) {
+ await client.initialize();
+ const connection = await client.openConnectionStream();
+ await client.postMessage(client.request("session/new", { cwd: "/workspace", mcpServers: [] }));
+ const sessionResponse = await connection.next();
+ const sessionId = sessionResponse.result.sessionId;
+ const session = await client.openSessionStream(sessionId);
+ await client.postMessage(client.request("session/prompt", {
+ sessionId,
+ prompt: [{ type: "text", text: "needs permission" }],
+ }), sessionId);
+ const permissionRequest = await session.next();
+ await client.postMessage(client.response(permissionRequest.id, { outcome: { outcome: "selected", optionId: "allow" } }));
+ await client.close();
+}
+async function validationFailures(endpoint, client) {
+ await client.initialize();
+ await client.rawRequest("POST", "connection", {
+ accept: "application/json",
+ "content-type": "text/plain",
+ [CONNECTION_HEADER]: "conn-invalid",
+ }, {});
+ await client.rawRequest("GET", "connection", {
+ accept: "text/event-stream",
+ }, null);
+ await client.rawRequest("GET", "connection", {
+ accept: "application/json",
+ [CONNECTION_HEADER]: "missing",
+ }, null);
+ await client.close();
+}
diff --git a/test-fixtures/streamable-http-client/dist/transcript.js b/test-fixtures/streamable-http-client/dist/transcript.js
new file mode 100644
index 0000000..cb9391f
--- /dev/null
+++ b/test-fixtures/streamable-http-client/dist/transcript.js
@@ -0,0 +1,69 @@
+export class TranscriptRecorder {
+ events = [];
+ idAliases = new Map();
+ connectionAliases = new Map();
+ record(event) {
+ if ("connectionId" in event) {
+ this.events.push({
+ ...event,
+ connectionId: this.normalizeConnectionId(event.connectionId),
+ });
+ return;
+ }
+ this.events.push(event);
+ }
+ summarizeJsonRpc(message) {
+ if (!message || typeof message !== "object") {
+ return null;
+ }
+ const candidate = message;
+ if (typeof candidate.method === "string" && "id" in candidate) {
+ return {
+ type: "request",
+ id: this.normalizeId(candidate.id),
+ method: candidate.method,
+ };
+ }
+ if (typeof candidate.method === "string") {
+ return {
+ type: "notification",
+ method: candidate.method,
+ };
+ }
+ if ("result" in candidate || "error" in candidate) {
+ return {
+ type: "response",
+ id: this.normalizeId(candidate.id),
+ hasError: candidate.error != null,
+ };
+ }
+ return null;
+ }
+ serialize() {
+ return JSON.stringify(this.events, null, 2);
+ }
+ normalizeId(id) {
+ if (typeof id !== "string" && typeof id !== "number") {
+ return null;
+ }
+ const existing = this.idAliases.get(id);
+ if (existing) {
+ return existing;
+ }
+ const next = `id-${this.idAliases.size + 1}`;
+ this.idAliases.set(id, next);
+ return next;
+ }
+ normalizeConnectionId(connectionId) {
+ if (!connectionId) {
+ return null;
+ }
+ const existing = this.connectionAliases.get(connectionId);
+ if (existing) {
+ return existing;
+ }
+ const next = `conn-${this.connectionAliases.size + 1}`;
+ this.connectionAliases.set(connectionId, next);
+ return next;
+ }
+}
diff --git a/test-fixtures/streamable-http-client/golden/happy-path.json b/test-fixtures/streamable-http-client/golden/happy-path.json
new file mode 100644
index 0000000..ae468a0
--- /dev/null
+++ b/test-fixtures/streamable-http-client/golden/happy-path.json
@@ -0,0 +1,127 @@
+[ {
+ "kind" : "http_request",
+ "method" : "POST",
+ "scope" : "bootstrap",
+ "connectionId" : null,
+ "sessionId" : null,
+ "jsonRpc" : {
+ "type" : "request",
+ "id" : "id-1",
+ "method" : "initialize"
+ }
+}, {
+ "kind" : "http_response",
+ "status" : 200,
+ "scope" : "bootstrap",
+ "connectionId" : "conn-1",
+ "sessionId" : null,
+ "jsonRpc" : {
+ "type" : "response",
+ "id" : "id-1",
+ "hasError" : false
+ }
+}, {
+ "kind" : "http_request",
+ "method" : "GET",
+ "scope" : "connection",
+ "connectionId" : "conn-1",
+ "sessionId" : null,
+ "jsonRpc" : null
+}, {
+ "kind" : "http_response",
+ "status" : 200,
+ "scope" : "connection",
+ "connectionId" : "conn-1",
+ "sessionId" : null,
+ "jsonRpc" : null
+}, {
+ "kind" : "http_request",
+ "method" : "POST",
+ "scope" : "connection",
+ "connectionId" : "conn-1",
+ "sessionId" : null,
+ "jsonRpc" : {
+ "type" : "request",
+ "id" : "id-2",
+ "method" : "session/new"
+ }
+}, {
+ "kind" : "sse_event",
+ "stream" : "connection",
+ "sessionId" : null,
+ "jsonRpc" : {
+ "type" : "response",
+ "id" : "id-2",
+ "hasError" : false
+ }
+}, {
+ "kind" : "http_response",
+ "status" : 202,
+ "scope" : "connection",
+ "connectionId" : "conn-1",
+ "sessionId" : null,
+ "jsonRpc" : null
+}, {
+ "kind" : "http_request",
+ "method" : "GET",
+ "scope" : "session",
+ "connectionId" : "conn-1",
+ "sessionId" : "sess-1",
+ "jsonRpc" : null
+}, {
+ "kind" : "http_response",
+ "status" : 200,
+ "scope" : "session",
+ "connectionId" : "conn-1",
+ "sessionId" : "sess-1",
+ "jsonRpc" : null
+}, {
+ "kind" : "http_request",
+ "method" : "POST",
+ "scope" : "session",
+ "connectionId" : "conn-1",
+ "sessionId" : "sess-1",
+ "jsonRpc" : {
+ "type" : "request",
+ "id" : "id-3",
+ "method" : "session/prompt"
+ }
+}, {
+ "kind" : "sse_event",
+ "stream" : "session",
+ "sessionId" : "sess-1",
+ "jsonRpc" : {
+ "type" : "notification",
+ "method" : "session/update"
+ }
+}, {
+ "kind" : "sse_event",
+ "stream" : "session",
+ "sessionId" : "sess-1",
+ "jsonRpc" : {
+ "type" : "response",
+ "id" : "id-3",
+ "hasError" : false
+ }
+}, {
+ "kind" : "http_response",
+ "status" : 202,
+ "scope" : "session",
+ "connectionId" : "conn-1",
+ "sessionId" : "sess-1",
+ "jsonRpc" : null
+}, {
+ "kind" : "http_request",
+ "method" : "DELETE",
+ "scope" : "connection",
+ "connectionId" : "conn-1",
+ "sessionId" : null,
+ "jsonRpc" : null
+}, {
+ "kind" : "http_response",
+ "status" : 202,
+ "scope" : "connection",
+ "connectionId" : "conn-1",
+ "sessionId" : null,
+ "jsonRpc" : null
+} ]
\ No newline at end of file
diff --git a/test-fixtures/streamable-http-client/golden/permission-round-trip.json b/test-fixtures/streamable-http-client/golden/permission-round-trip.json
new file mode 100644
index 0000000..c3af4a5
--- /dev/null
+++ b/test-fixtures/streamable-http-client/golden/permission-round-trip.json
@@ -0,0 +1,154 @@
+[ {
+ "kind" : "http_request",
+ "method" : "POST",
+ "scope" : "bootstrap",
+ "connectionId" : null,
+ "sessionId" : null,
+ "jsonRpc" : {
+ "type" : "request",
+ "id" : "id-1",
+ "method" : "initialize"
+ }
+}, {
+ "kind" : "http_response",
+ "status" : 200,
+ "scope" : "bootstrap",
+ "connectionId" : "conn-1",
+ "sessionId" : null,
+ "jsonRpc" : {
+ "type" : "response",
+ "id" : "id-1",
+ "hasError" : false
+ }
+}, {
+ "kind" : "http_request",
+ "method" : "GET",
+ "scope" : "connection",
+ "connectionId" : "conn-1",
+ "sessionId" : null,
+ "jsonRpc" : null
+}, {
+ "kind" : "http_response",
+ "status" : 200,
+ "scope" : "connection",
+ "connectionId" : "conn-1",
+ "sessionId" : null,
+ "jsonRpc" : null
+}, {
+ "kind" : "http_request",
+ "method" : "POST",
+ "scope" : "connection",
+ "connectionId" : "conn-1",
+ "sessionId" : null,
+ "jsonRpc" : {
+ "type" : "request",
+ "id" : "id-2",
+ "method" : "session/new"
+ }
+}, {
+ "kind" : "sse_event",
+ "stream" : "connection",
+ "sessionId" : null,
+ "jsonRpc" : {
+ "type" : "response",
+ "id" : "id-2",
+ "hasError" : false
+ }
+}, {
+ "kind" : "http_response",
+ "status" : 202,
+ "scope" : "connection",
+ "connectionId" : "conn-1",
+ "sessionId" : null,
+ "jsonRpc" : null
+}, {
+ "kind" : "http_request",
+ "method" : "GET",
+ "scope" : "session",
+ "connectionId" : "conn-1",
+ "sessionId" : "sess-1",
+ "jsonRpc" : null
+}, {
+ "kind" : "http_response",
+ "status" : 200,
+ "scope" : "session",
+ "connectionId" : "conn-1",
+ "sessionId" : "sess-1",
+ "jsonRpc" : null
+}, {
+ "kind" : "http_request",
+ "method" : "POST",
+ "scope" : "session",
+ "connectionId" : "conn-1",
+ "sessionId" : "sess-1",
+ "jsonRpc" : {
+ "type" : "request",
+ "id" : "id-3",
+ "method" : "session/prompt"
+ }
+}, {
+ "kind" : "sse_event",
+ "stream" : "session",
+ "sessionId" : "sess-1",
+ "jsonRpc" : {
+ "type" : "request",
+ "id" : "id-4",
+ "method" : "session/request_permission"
+ }
+}, {
+ "kind" : "http_response",
+ "status" : 202,
+ "scope" : "session",
+ "connectionId" : "conn-1",
+ "sessionId" : "sess-1",
+ "jsonRpc" : null
+}, {
+ "kind" : "http_request",
+ "method" : "POST",
+ "scope" : "session",
+ "connectionId" : "conn-1",
+ "sessionId" : "sess-1",
+ "jsonRpc" : {
+ "type" : "response",
+ "id" : "id-4",
+ "hasError" : false
+ }
+}, {
+ "kind" : "sse_event",
+ "stream" : "session",
+ "sessionId" : "sess-1",
+ "jsonRpc" : {
+ "type" : "notification",
+ "method" : "session/update"
+ }
+}, {
+ "kind" : "sse_event",
+ "stream" : "session",
+ "sessionId" : "sess-1",
+ "jsonRpc" : {
+ "type" : "response",
+ "id" : "id-3",
+ "hasError" : false
+ }
+}, {
+ "kind" : "http_response",
+ "status" : 202,
+ "scope" : "session",
+ "connectionId" : "conn-1",
+ "sessionId" : "sess-1",
+ "jsonRpc" : null
+}, {
+ "kind" : "http_request",
+ "method" : "DELETE",
+ "scope" : "connection",
+ "connectionId" : "conn-1",
+ "sessionId" : null,
+ "jsonRpc" : null
+}, {
+ "kind" : "http_response",
+ "status" : 202,
+ "scope" : "connection",
+ "connectionId" : "conn-1",
+ "sessionId" : null,
+ "jsonRpc" : null
+} ]
\ No newline at end of file
diff --git a/test-fixtures/streamable-http-client/golden/session-load.json b/test-fixtures/streamable-http-client/golden/session-load.json
new file mode 100644
index 0000000..24086cb
--- /dev/null
+++ b/test-fixtures/streamable-http-client/golden/session-load.json
@@ -0,0 +1,92 @@
+[ {
+ "kind" : "http_request",
+ "method" : "POST",
+ "scope" : "bootstrap",
+ "connectionId" : null,
+ "sessionId" : null,
+ "jsonRpc" : {
+ "type" : "request",
+ "id" : "id-1",
+ "method" : "initialize"
+ }
+}, {
+ "kind" : "http_response",
+ "status" : 200,
+ "scope" : "bootstrap",
+ "connectionId" : "conn-1",
+ "sessionId" : null,
+ "jsonRpc" : {
+ "type" : "response",
+ "id" : "id-1",
+ "hasError" : false
+ }
+}, {
+ "kind" : "http_request",
+ "method" : "GET",
+ "scope" : "connection",
+ "connectionId" : "conn-1",
+ "sessionId" : null,
+ "jsonRpc" : null
+}, {
+ "kind" : "http_response",
+ "status" : 200,
+ "scope" : "connection",
+ "connectionId" : "conn-1",
+ "sessionId" : null,
+ "jsonRpc" : null
+}, {
+ "kind" : "http_request",
+ "method" : "GET",
+ "scope" : "session",
+ "connectionId" : "conn-1",
+ "sessionId" : "sess-load",
+ "jsonRpc" : null
+}, {
+ "kind" : "http_response",
+ "status" : 200,
+ "scope" : "session",
+ "connectionId" : "conn-1",
+ "sessionId" : "sess-load",
+ "jsonRpc" : null
+}, {
+ "kind" : "http_request",
+ "method" : "POST",
+ "scope" : "session",
+ "connectionId" : "conn-1",
+ "sessionId" : "sess-load",
+ "jsonRpc" : {
+ "type" : "request",
+ "id" : "id-2",
+ "method" : "session/load"
+ }
+}, {
+ "kind" : "sse_event",
+ "stream" : "connection",
+ "sessionId" : null,
+ "jsonRpc" : {
+ "type" : "response",
+ "id" : "id-2",
+ "hasError" : false
+ }
+}, {
+ "kind" : "http_response",
+ "status" : 202,
+ "scope" : "session",
+ "connectionId" : "conn-1",
+ "sessionId" : "sess-load",
+ "jsonRpc" : null
+}, {
+ "kind" : "http_request",
+ "method" : "DELETE",
+ "scope" : "connection",
+ "connectionId" : "conn-1",
+ "sessionId" : null,
+ "jsonRpc" : null
+}, {
+ "kind" : "http_response",
+ "status" : 202,
+ "scope" : "connection",
+ "connectionId" : "conn-1",
+ "sessionId" : null,
+ "jsonRpc" : null
+} ]
\ No newline at end of file
diff --git a/test-fixtures/streamable-http-client/golden/two-sessions.json b/test-fixtures/streamable-http-client/golden/two-sessions.json
new file mode 100644
index 0000000..307c720
--- /dev/null
+++ b/test-fixtures/streamable-http-client/golden/two-sessions.json
@@ -0,0 +1,203 @@
+[ {
+ "kind" : "http_request",
+ "method" : "POST",
+ "scope" : "bootstrap",
+ "connectionId" : null,
+ "sessionId" : null,
+ "jsonRpc" : {
+ "type" : "request",
+ "id" : "id-1",
+ "method" : "initialize"
+ }
+}, {
+ "kind" : "http_response",
+ "status" : 200,
+ "scope" : "bootstrap",
+ "connectionId" : "conn-1",
+ "sessionId" : null,
+ "jsonRpc" : {
+ "type" : "response",
+ "id" : "id-1",
+ "hasError" : false
+ }
+}, {
+ "kind" : "http_request",
+ "method" : "GET",
+ "scope" : "connection",
+ "connectionId" : "conn-1",
+ "sessionId" : null,
+ "jsonRpc" : null
+}, {
+ "kind" : "http_response",
+ "status" : 200,
+ "scope" : "connection",
+ "connectionId" : "conn-1",
+ "sessionId" : null,
+ "jsonRpc" : null
+}, {
+ "kind" : "http_request",
+ "method" : "POST",
+ "scope" : "connection",
+ "connectionId" : "conn-1",
+ "sessionId" : null,
+ "jsonRpc" : {
+ "type" : "request",
+ "id" : "id-2",
+ "method" : "session/new"
+ }
+}, {
+ "kind" : "sse_event",
+ "stream" : "connection",
+ "sessionId" : null,
+ "jsonRpc" : {
+ "type" : "response",
+ "id" : "id-2",
+ "hasError" : false
+ }
+}, {
+ "kind" : "http_response",
+ "status" : 202,
+ "scope" : "connection",
+ "connectionId" : "conn-1",
+ "sessionId" : null,
+ "jsonRpc" : null
+}, {
+ "kind" : "http_request",
+ "method" : "POST",
+ "scope" : "connection",
+ "connectionId" : "conn-1",
+ "sessionId" : null,
+ "jsonRpc" : {
+ "type" : "request",
+ "id" : "id-3",
+ "method" : "session/new"
+ }
+}, {
+ "kind" : "sse_event",
+ "stream" : "connection",
+ "sessionId" : null,
+ "jsonRpc" : {
+ "type" : "response",
+ "id" : "id-3",
+ "hasError" : false
+ }
+}, {
+ "kind" : "http_response",
+ "status" : 202,
+ "scope" : "connection",
+ "connectionId" : "conn-1",
+ "sessionId" : null,
+ "jsonRpc" : null
+}, {
+ "kind" : "http_request",
+ "method" : "GET",
+ "scope" : "session",
+ "connectionId" : "conn-1",
+ "sessionId" : "sess-1",
+ "jsonRpc" : null
+}, {
+ "kind" : "http_response",
+ "status" : 200,
+ "scope" : "session",
+ "connectionId" : "conn-1",
+ "sessionId" : "sess-1",
+ "jsonRpc" : null
+}, {
+ "kind" : "http_request",
+ "method" : "GET",
+ "scope" : "session",
+ "connectionId" : "conn-1",
+ "sessionId" : "sess-2",
+ "jsonRpc" : null
+}, {
+ "kind" : "http_response",
+ "status" : 200,
+ "scope" : "session",
+ "connectionId" : "conn-1",
+ "sessionId" : "sess-2",
+ "jsonRpc" : null
+}, {
+ "kind" : "http_request",
+ "method" : "POST",
+ "scope" : "session",
+ "connectionId" : "conn-1",
+ "sessionId" : "sess-1",
+ "jsonRpc" : {
+ "type" : "request",
+ "id" : "id-4",
+ "method" : "session/prompt"
+ }
+}, {
+ "kind" : "sse_event",
+ "stream" : "session",
+ "sessionId" : "sess-1",
+ "jsonRpc" : {
+ "type" : "notification",
+ "method" : "session/update"
+ }
+}, {
+ "kind" : "sse_event",
+ "stream" : "session",
+ "sessionId" : "sess-1",
+ "jsonRpc" : {
+ "type" : "response",
+ "id" : "id-4",
+ "hasError" : false
+ }
+}, {
+ "kind" : "http_response",
+ "status" : 202,
+ "scope" : "session",
+ "connectionId" : "conn-1",
+ "sessionId" : "sess-1",
+ "jsonRpc" : null
+}, {
+ "kind" : "http_request",
+ "method" : "POST",
+ "scope" : "session",
+ "connectionId" : "conn-1",
+ "sessionId" : "sess-2",
+ "jsonRpc" : {
+ "type" : "request",
+ "id" : "id-5",
+ "method" : "session/prompt"
+ }
+}, {
+ "kind" : "sse_event",
+ "stream" : "session",
+ "sessionId" : "sess-2",
+ "jsonRpc" : {
+ "type" : "notification",
+ "method" : "session/update"
+ }
+}, {
+ "kind" : "sse_event",
+ "stream" : "session",
+ "sessionId" : "sess-2",
+ "jsonRpc" : {
+ "type" : "response",
+ "id" : "id-5",
+ "hasError" : false
+ }
+}, {
+ "kind" : "http_response",
+ "status" : 202,
+ "scope" : "session",
+ "connectionId" : "conn-1",
+ "sessionId" : "sess-2",
+ "jsonRpc" : null
+}, {
+ "kind" : "http_request",
+ "method" : "DELETE",
+ "scope" : "connection",
+ "connectionId" : "conn-1",
+ "sessionId" : null,
+ "jsonRpc" : null
+}, {
+ "kind" : "http_response",
+ "status" : 202,
+ "scope" : "connection",
+ "connectionId" : "conn-1",
+ "sessionId" : null,
+ "jsonRpc" : null
+} ]
\ No newline at end of file
diff --git a/test-fixtures/streamable-http-client/golden/validation-failures.json b/test-fixtures/streamable-http-client/golden/validation-failures.json
new file mode 100644
index 0000000..3ae0b9b
--- /dev/null
+++ b/test-fixtures/streamable-http-client/golden/validation-failures.json
@@ -0,0 +1,79 @@
+[ {
+ "kind" : "http_request",
+ "method" : "POST",
+ "scope" : "bootstrap",
+ "connectionId" : null,
+ "sessionId" : null,
+ "jsonRpc" : {
+ "type" : "request",
+ "id" : "id-1",
+ "method" : "initialize"
+ }
+}, {
+ "kind" : "http_response",
+ "status" : 200,
+ "scope" : "bootstrap",
+ "connectionId" : "conn-1",
+ "sessionId" : null,
+ "jsonRpc" : {
+ "type" : "response",
+ "id" : "id-1",
+ "hasError" : false
+ }
+}, {
+ "kind" : "http_request",
+ "method" : "POST",
+ "scope" : "connection",
+ "connectionId" : "conn-2",
+ "sessionId" : null,
+ "jsonRpc" : null
+}, {
+ "kind" : "http_response",
+ "status" : 415,
+ "scope" : "connection",
+ "connectionId" : "conn-2",
+ "sessionId" : null,
+ "jsonRpc" : null
+}, {
+ "kind" : "http_request",
+ "method" : "GET",
+ "scope" : "connection",
+ "connectionId" : null,
+ "sessionId" : null,
+ "jsonRpc" : null
+}, {
+ "kind" : "http_response",
+ "status" : 400,
+ "scope" : "connection",
+ "connectionId" : null,
+ "sessionId" : null,
+ "jsonRpc" : null
+}, {
+ "kind" : "http_request",
+ "method" : "GET",
+ "scope" : "connection",
+ "connectionId" : "conn-3",
+ "sessionId" : null,
+ "jsonRpc" : null
+}, {
+ "kind" : "http_response",
+ "status" : 406,
+ "scope" : "connection",
+ "connectionId" : "conn-3",
+ "sessionId" : null,
+ "jsonRpc" : null
+}, {
+ "kind" : "http_request",
+ "method" : "DELETE",
+ "scope" : "connection",
+ "connectionId" : "conn-1",
+ "sessionId" : null,
+ "jsonRpc" : null
+}, {
+ "kind" : "http_response",
+ "status" : 202,
+ "scope" : "connection",
+ "connectionId" : "conn-1",
+ "sessionId" : null,
+ "jsonRpc" : null
+} ]
\ No newline at end of file
diff --git a/test-fixtures/streamable-http-client/golden/wrong-stream-response.json b/test-fixtures/streamable-http-client/golden/wrong-stream-response.json
new file mode 100644
index 0000000..83d292b
--- /dev/null
+++ b/test-fixtures/streamable-http-client/golden/wrong-stream-response.json
@@ -0,0 +1,137 @@
+[ {
+ "kind" : "http_request",
+ "method" : "POST",
+ "scope" : "bootstrap",
+ "connectionId" : null,
+ "sessionId" : null,
+ "jsonRpc" : {
+ "type" : "request",
+ "id" : "id-1",
+ "method" : "initialize"
+ }
+}, {
+ "kind" : "http_response",
+ "status" : 200,
+ "scope" : "bootstrap",
+ "connectionId" : "conn-1",
+ "sessionId" : null,
+ "jsonRpc" : {
+ "type" : "response",
+ "id" : "id-1",
+ "hasError" : false
+ }
+}, {
+ "kind" : "http_request",
+ "method" : "GET",
+ "scope" : "connection",
+ "connectionId" : "conn-1",
+ "sessionId" : null,
+ "jsonRpc" : null
+}, {
+ "kind" : "http_response",
+ "status" : 200,
+ "scope" : "connection",
+ "connectionId" : "conn-1",
+ "sessionId" : null,
+ "jsonRpc" : null
+}, {
+ "kind" : "http_request",
+ "method" : "POST",
+ "scope" : "connection",
+ "connectionId" : "conn-1",
+ "sessionId" : null,
+ "jsonRpc" : {
+ "type" : "request",
+ "id" : "id-2",
+ "method" : "session/new"
+ }
+}, {
+ "kind" : "sse_event",
+ "stream" : "connection",
+ "sessionId" : null,
+ "jsonRpc" : {
+ "type" : "response",
+ "id" : "id-2",
+ "hasError" : false
+ }
+}, {
+ "kind" : "http_response",
+ "status" : 202,
+ "scope" : "connection",
+ "connectionId" : "conn-1",
+ "sessionId" : null,
+ "jsonRpc" : null
+}, {
+ "kind" : "http_request",
+ "method" : "GET",
+ "scope" : "session",
+ "connectionId" : "conn-1",
+ "sessionId" : "sess-1",
+ "jsonRpc" : null
+}, {
+ "kind" : "http_response",
+ "status" : 200,
+ "scope" : "session",
+ "connectionId" : "conn-1",
+ "sessionId" : "sess-1",
+ "jsonRpc" : null
+}, {
+ "kind" : "http_request",
+ "method" : "POST",
+ "scope" : "session",
+ "connectionId" : "conn-1",
+ "sessionId" : "sess-1",
+ "jsonRpc" : {
+ "type" : "request",
+ "id" : "id-3",
+ "method" : "session/prompt"
+ }
+}, {
+ "kind" : "sse_event",
+ "stream" : "session",
+ "sessionId" : "sess-1",
+ "jsonRpc" : {
+ "type" : "request",
+ "id" : "id-4",
+ "method" : "session/request_permission"
+ }
+}, {
+ "kind" : "http_response",
+ "status" : 202,
+ "scope" : "session",
+ "connectionId" : "conn-1",
+ "sessionId" : "sess-1",
+ "jsonRpc" : null
+}, {
+ "kind" : "http_request",
+ "method" : "POST",
+ "scope" : "connection",
+ "connectionId" : "conn-1",
+ "sessionId" : null,
+ "jsonRpc" : {
+ "type" : "response",
+ "id" : "id-4",
+ "hasError" : false
+ }
+}, {
+ "kind" : "http_response",
+ "status" : 400,
+ "scope" : "connection",
+ "connectionId" : "conn-1",
+ "sessionId" : null,
+ "jsonRpc" : null
+}, {
+ "kind" : "http_request",
+ "method" : "DELETE",
+ "scope" : "connection",
+ "connectionId" : "conn-1",
+ "sessionId" : null,
+ "jsonRpc" : null
+}, {
+ "kind" : "http_response",
+ "status" : 202,
+ "scope" : "connection",
+ "connectionId" : "conn-1",
+ "sessionId" : null,
+ "jsonRpc" : null
+} ]
\ No newline at end of file
diff --git a/test-fixtures/streamable-http-client/package-lock.json b/test-fixtures/streamable-http-client/package-lock.json
new file mode 100644
index 0000000..c1fceaa
--- /dev/null
+++ b/test-fixtures/streamable-http-client/package-lock.json
@@ -0,0 +1,45 @@
+{
+ "name": "acp-streamable-http-client-fixture",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "acp-streamable-http-client-fixture",
+ "devDependencies": {
+ "@types/node": "^22.10.2",
+ "typescript": "^5.7.2"
+ }
+ },
+ "node_modules/@types/node": {
+ "version": "22.19.19",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.19.tgz",
+ "integrity": "sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "undici-types": "~6.21.0"
+ }
+ },
+ "node_modules/typescript": {
+ "version": "5.9.3",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
+ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "tsc": "bin/tsc",
+ "tsserver": "bin/tsserver"
+ },
+ "engines": {
+ "node": ">=14.17"
+ }
+ },
+ "node_modules/undici-types": {
+ "version": "6.21.0",
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
+ "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
+ "dev": true,
+ "license": "MIT"
+ }
+ }
+}
diff --git a/test-fixtures/streamable-http-client/package.json b/test-fixtures/streamable-http-client/package.json
new file mode 100644
index 0000000..75235c1
--- /dev/null
+++ b/test-fixtures/streamable-http-client/package.json
@@ -0,0 +1,12 @@
+{
+ "name": "acp-streamable-http-client-fixture",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "build": "tsc"
+ },
+ "devDependencies": {
+ "@types/node": "^22.10.2",
+ "typescript": "^5.7.2"
+ }
+}
diff --git a/test-fixtures/streamable-http-client/src/client.ts b/test-fixtures/streamable-http-client/src/client.ts
new file mode 100644
index 0000000..eaf151d
--- /dev/null
+++ b/test-fixtures/streamable-http-client/src/client.ts
@@ -0,0 +1,19 @@
+import { StreamableHttpFixtureClient } from "./protocol.js";
+import { runScenario } from "./scenarios.js";
+import { TranscriptRecorder } from "./transcript.js";
+
+const endpoint = readArg("--endpoint");
+if (!endpoint) {
+ throw new Error("--endpoint is required");
+}
+const scenario = readArg("--scenario") ?? "happy-path";
+const recorder = new TranscriptRecorder();
+const client = new StreamableHttpFixtureClient(endpoint, recorder);
+
+await runScenario(scenario, endpoint, client);
+process.stdout.write(recorder.serialize());
+
+function readArg(name: string): string | undefined {
+ const index = process.argv.indexOf(name);
+ return index >= 0 ? process.argv[index + 1] : undefined;
+}
diff --git a/test-fixtures/streamable-http-client/src/protocol.ts b/test-fixtures/streamable-http-client/src/protocol.ts
new file mode 100644
index 0000000..7f44734
--- /dev/null
+++ b/test-fixtures/streamable-http-client/src/protocol.ts
@@ -0,0 +1,278 @@
+import { TranscriptRecorder } from "./transcript.js";
+
+export const CONNECTION_HEADER = "Acp-Connection-Id";
+export const SESSION_HEADER = "Acp-Session-Id";
+
+export type JsonRpcMessage = Record;
+export type Scope = "bootstrap" | "connection" | "session";
+
+export class StreamableHttpFixtureClient {
+ private connectionId: string | null = null;
+ private nextId = 1;
+
+ constructor(
+ private readonly endpoint: string,
+ private readonly recorder: TranscriptRecorder,
+ ) {}
+
+ async initialize(): Promise {
+ const request = this.request("initialize", {
+ protocolVersion: 1,
+ clientCapabilities: {},
+ });
+ const response = await this.post(request, "bootstrap", null, true);
+ const connectionId = response.headers.get(CONNECTION_HEADER);
+ if (!connectionId) {
+ throw new Error("initialize response missing connection id");
+ }
+ this.connectionId = connectionId;
+ return await response.json();
+ }
+
+ async postMessage(message: JsonRpcMessage, sessionId: string | null = null): Promise {
+ return this.post(message, sessionId ? "session" : "connection", sessionId, false);
+ }
+
+ async openConnectionStream(): Promise {
+ return SseStream.open(this.endpoint, this.recorder, "connection", this.requireConnectionId(), null);
+ }
+
+ async openSessionStream(sessionId: string): Promise {
+ return SseStream.open(this.endpoint, this.recorder, "session", this.requireConnectionId(), sessionId);
+ }
+
+ async close(): Promise {
+ const headers = {
+ [CONNECTION_HEADER]: this.requireConnectionId(),
+ };
+ this.recorder.record({
+ kind: "http_request",
+ method: "DELETE",
+ scope: "connection",
+ connectionId: this.connectionId,
+ sessionId: null,
+ jsonRpc: null,
+ });
+ const response = await fetch(this.endpoint, {
+ method: "DELETE",
+ headers,
+ });
+ this.recorder.record({
+ kind: "http_response",
+ status: response.status,
+ scope: "connection",
+ connectionId: this.connectionId,
+ sessionId: null,
+ jsonRpc: null,
+ });
+ return response;
+ }
+
+ async rawRequest(
+ method: string,
+ scope: Scope,
+ headers: Record,
+ body: JsonRpcMessage | null,
+ sessionId: string | null = null,
+ ): Promise {
+ this.recorder.record({
+ kind: "http_request",
+ method,
+ scope,
+ connectionId: headers[CONNECTION_HEADER] ?? null,
+ sessionId,
+ jsonRpc: this.recorder.summarizeJsonRpc(body),
+ });
+ const response = await fetch(this.endpoint, {
+ method,
+ headers,
+ ...(body ? { body: JSON.stringify(body) } : {}),
+ });
+ this.recorder.record({
+ kind: "http_response",
+ status: response.status,
+ scope,
+ connectionId: response.headers.get(CONNECTION_HEADER) ?? headers[CONNECTION_HEADER] ?? null,
+ sessionId: response.headers.get(SESSION_HEADER) ?? sessionId,
+ jsonRpc: null,
+ });
+ return response;
+ }
+
+ request(method: string, params: Record): JsonRpcMessage {
+ return {
+ jsonrpc: "2.0",
+ id: `req-${this.nextId++}`,
+ method,
+ params,
+ };
+ }
+
+ response(id: unknown, result: Record): JsonRpcMessage {
+ return {
+ jsonrpc: "2.0",
+ id,
+ result,
+ };
+ }
+
+ private async post(
+ message: JsonRpcMessage,
+ scope: Scope,
+ sessionId: string | null,
+ expectJson: boolean,
+ ): Promise {
+ const headers: Record = {
+ "content-type": "application/json",
+ accept: "application/json",
+ };
+ if (scope !== "bootstrap") {
+ headers[CONNECTION_HEADER] = this.requireConnectionId();
+ }
+ if (sessionId) {
+ headers[SESSION_HEADER] = sessionId;
+ }
+ this.recorder.record({
+ kind: "http_request",
+ method: "POST",
+ scope,
+ connectionId: this.connectionId,
+ sessionId,
+ jsonRpc: this.recorder.summarizeJsonRpc(message),
+ });
+ const response = await fetch(this.endpoint, {
+ method: "POST",
+ headers,
+ body: JSON.stringify(message),
+ });
+ let jsonRpc = null;
+ if (expectJson) {
+ jsonRpc = this.recorder.summarizeJsonRpc(await response.clone().json());
+ }
+ this.recorder.record({
+ kind: "http_response",
+ status: response.status,
+ scope,
+ connectionId: response.headers.get(CONNECTION_HEADER) ?? this.connectionId,
+ sessionId,
+ jsonRpc,
+ });
+ return response;
+ }
+
+ private requireConnectionId(): string {
+ if (!this.connectionId) {
+ throw new Error("connection id not initialized");
+ }
+ return this.connectionId;
+ }
+}
+
+export class SseStream {
+ private readonly messages: JsonRpcMessage[] = [];
+ private readonly waiters: Array<(message: JsonRpcMessage) => void> = [];
+ private readonly abortController = new AbortController();
+
+ private constructor(
+ private readonly recorder: TranscriptRecorder,
+ private readonly stream: "connection" | "session",
+ private readonly sessionId: string | null,
+ private readonly response: Response,
+ ) {}
+
+ static async open(
+ endpoint: string,
+ recorder: TranscriptRecorder,
+ stream: "connection" | "session",
+ connectionId: string,
+ sessionId: string | null,
+ ): Promise {
+ recorder.record({
+ kind: "http_request",
+ method: "GET",
+ scope: stream,
+ connectionId,
+ sessionId,
+ jsonRpc: null,
+ });
+ const response = await fetch(endpoint, {
+ method: "GET",
+ headers: {
+ accept: "text/event-stream",
+ [CONNECTION_HEADER]: connectionId,
+ ...(sessionId ? { [SESSION_HEADER]: sessionId } : {}),
+ },
+ });
+ recorder.record({
+ kind: "http_response",
+ status: response.status,
+ scope: stream,
+ connectionId: response.headers.get(CONNECTION_HEADER),
+ sessionId: response.headers.get(SESSION_HEADER),
+ jsonRpc: null,
+ });
+ const result = new SseStream(recorder, stream, sessionId, response);
+ void result.readLoop();
+ return result;
+ }
+
+ async next(): Promise {
+ const existing = this.messages.shift();
+ if (existing) {
+ return existing;
+ }
+ return new Promise((resolve) => this.waiters.push(resolve));
+ }
+
+ close(): void {
+ this.abortController.abort();
+ }
+
+ private async readLoop(): Promise {
+ if (!this.response.body) {
+ return;
+ }
+ const reader = this.response.body.getReader();
+ const decoder = new TextDecoder();
+ let buffer = "";
+ for (;;) {
+ const { value, done } = await reader.read();
+ if (done) {
+ break;
+ }
+ buffer += decoder.decode(value, { stream: true });
+ for (;;) {
+ const boundary = buffer.indexOf("\n\n");
+ if (boundary < 0) {
+ break;
+ }
+ const rawEvent = buffer.slice(0, boundary);
+ buffer = buffer.slice(boundary + 2);
+ const data = rawEvent
+ .split("\n")
+ .filter((line) => line.startsWith("data:"))
+ .map((line) => line.slice(5).trimStart())
+ .join("\n");
+ if (!data) {
+ continue;
+ }
+ const message = JSON.parse(data) as JsonRpcMessage;
+ const summary = this.recorder.summarizeJsonRpc(message);
+ if (summary) {
+ this.recorder.record({
+ kind: "sse_event",
+ stream: this.stream,
+ sessionId: this.sessionId,
+ jsonRpc: summary,
+ });
+ }
+ const waiter = this.waiters.shift();
+ if (waiter) {
+ waiter(message);
+ } else {
+ this.messages.push(message);
+ }
+ }
+ }
+ }
+}
diff --git a/test-fixtures/streamable-http-client/src/scenarios.ts b/test-fixtures/streamable-http-client/src/scenarios.ts
new file mode 100644
index 0000000..ef5ac89
--- /dev/null
+++ b/test-fixtures/streamable-http-client/src/scenarios.ts
@@ -0,0 +1,134 @@
+import { CONNECTION_HEADER, StreamableHttpFixtureClient } from "./protocol.js";
+
+export async function runScenario(name: string, endpoint: string, client: StreamableHttpFixtureClient): Promise {
+ switch (name) {
+ case "happy-path":
+ await happyPath(client);
+ return;
+ case "permission-round-trip":
+ await permissionRoundTrip(client);
+ return;
+ case "session-load":
+ await sessionLoad(client);
+ return;
+ case "two-sessions":
+ await twoSessions(client);
+ return;
+ case "wrong-stream-response":
+ await wrongStreamResponse(client);
+ return;
+ case "validation-failures":
+ await validationFailures(endpoint, client);
+ return;
+ default:
+ throw new Error(`Unknown scenario ${name}`);
+ }
+}
+
+async function happyPath(client: StreamableHttpFixtureClient): Promise {
+ await client.initialize();
+ const connection = await client.openConnectionStream();
+ const newSession = client.request("session/new", { cwd: "/workspace", mcpServers: [] });
+ await client.postMessage(newSession);
+ const sessionResponse = await connection.next();
+ const sessionId = ((sessionResponse.result as Record).sessionId as string);
+ const session = await client.openSessionStream(sessionId);
+ await client.postMessage(client.request("session/prompt", {
+ sessionId,
+ prompt: [{ type: "text", text: "hello" }],
+ }), sessionId);
+ await session.next();
+ await session.next();
+ await client.close();
+}
+
+async function permissionRoundTrip(client: StreamableHttpFixtureClient): Promise {
+ await client.initialize();
+ const connection = await client.openConnectionStream();
+ await client.postMessage(client.request("session/new", { cwd: "/workspace", mcpServers: [] }));
+ const sessionResponse = await connection.next();
+ const sessionId = ((sessionResponse.result as Record).sessionId as string);
+ const session = await client.openSessionStream(sessionId);
+ await client.postMessage(client.request("session/prompt", {
+ sessionId,
+ prompt: [{ type: "text", text: "needs permission" }],
+ }), sessionId);
+ const permissionRequest = await session.next();
+ await client.postMessage(client.response(permissionRequest.id, { outcome: { outcome: "selected", optionId: "allow" } }), sessionId);
+ await session.next();
+ await session.next();
+ await client.close();
+}
+
+async function sessionLoad(client: StreamableHttpFixtureClient): Promise {
+ await client.initialize();
+ const connection = await client.openConnectionStream();
+ const session = await client.openSessionStream("sess-load");
+ await client.postMessage(client.request("session/load", {
+ sessionId: "sess-load",
+ cwd: "/workspace",
+ mcpServers: [],
+ }), "sess-load");
+ await connection.next();
+ session.close();
+ await client.close();
+}
+
+async function twoSessions(client: StreamableHttpFixtureClient): Promise {
+ await client.initialize();
+ const connection = await client.openConnectionStream();
+ await client.postMessage(client.request("session/new", { cwd: "/workspace/one", mcpServers: [] }));
+ const first = await connection.next();
+ const firstId = ((first.result as Record).sessionId as string);
+ await client.postMessage(client.request("session/new", { cwd: "/workspace/two", mcpServers: [] }));
+ const second = await connection.next();
+ const secondId = ((second.result as Record).sessionId as string);
+ const firstStream = await client.openSessionStream(firstId);
+ const secondStream = await client.openSessionStream(secondId);
+ await client.postMessage(client.request("session/prompt", {
+ sessionId: firstId,
+ prompt: [{ type: "text", text: "one" }],
+ }), firstId);
+ await firstStream.next();
+ await firstStream.next();
+ await client.postMessage(client.request("session/prompt", {
+ sessionId: secondId,
+ prompt: [{ type: "text", text: "two" }],
+ }), secondId);
+ await secondStream.next();
+ await secondStream.next();
+ await client.close();
+}
+
+async function wrongStreamResponse(client: StreamableHttpFixtureClient): Promise {
+ await client.initialize();
+ const connection = await client.openConnectionStream();
+ await client.postMessage(client.request("session/new", { cwd: "/workspace", mcpServers: [] }));
+ const sessionResponse = await connection.next();
+ const sessionId = ((sessionResponse.result as Record).sessionId as string);
+ const session = await client.openSessionStream(sessionId);
+ await client.postMessage(client.request("session/prompt", {
+ sessionId,
+ prompt: [{ type: "text", text: "needs permission" }],
+ }), sessionId);
+ const permissionRequest = await session.next();
+ await client.postMessage(client.response(permissionRequest.id, { outcome: { outcome: "selected", optionId: "allow" } }));
+ await client.close();
+}
+
+async function validationFailures(endpoint: string, client: StreamableHttpFixtureClient): Promise {
+ await client.initialize();
+ await client.rawRequest("POST", "connection", {
+ accept: "application/json",
+ "content-type": "text/plain",
+ [CONNECTION_HEADER]: "conn-invalid",
+ }, {});
+ await client.rawRequest("GET", "connection", {
+ accept: "text/event-stream",
+ }, null);
+ await client.rawRequest("GET", "connection", {
+ accept: "application/json",
+ [CONNECTION_HEADER]: "missing",
+ }, null);
+ await client.close();
+}
diff --git a/test-fixtures/streamable-http-client/src/transcript.ts b/test-fixtures/streamable-http-client/src/transcript.ts
new file mode 100644
index 0000000..ac8a8ff
--- /dev/null
+++ b/test-fixtures/streamable-http-client/src/transcript.ts
@@ -0,0 +1,115 @@
+export type JsonRpcSummary =
+ | {
+ type: "request";
+ id: string | null;
+ method: string;
+ }
+ | {
+ type: "notification";
+ method: string;
+ }
+ | {
+ type: "response";
+ id: string | null;
+ hasError: boolean;
+ };
+
+export type TranscriptEvent =
+ | {
+ kind: "http_request";
+ method: string;
+ scope: "bootstrap" | "connection" | "session";
+ connectionId: string | null;
+ sessionId: string | null;
+ jsonRpc: JsonRpcSummary | null;
+ }
+ | {
+ kind: "http_response";
+ status: number;
+ scope: "bootstrap" | "connection" | "session";
+ connectionId: string | null;
+ sessionId: string | null;
+ jsonRpc: JsonRpcSummary | null;
+ }
+ | {
+ kind: "sse_event";
+ stream: "connection" | "session";
+ sessionId: string | null;
+ jsonRpc: JsonRpcSummary;
+ };
+
+export class TranscriptRecorder {
+ private readonly events: TranscriptEvent[] = [];
+ private readonly idAliases = new Map();
+ private readonly connectionAliases = new Map();
+
+ record(event: TranscriptEvent): void {
+ if ("connectionId" in event) {
+ this.events.push({
+ ...event,
+ connectionId: this.normalizeConnectionId(event.connectionId),
+ });
+ return;
+ }
+ this.events.push(event);
+ }
+
+ summarizeJsonRpc(message: unknown): JsonRpcSummary | null {
+ if (!message || typeof message !== "object") {
+ return null;
+ }
+
+ const candidate = message as Record;
+ if (typeof candidate.method === "string" && "id" in candidate) {
+ return {
+ type: "request",
+ id: this.normalizeId(candidate.id),
+ method: candidate.method,
+ };
+ }
+ if (typeof candidate.method === "string") {
+ return {
+ type: "notification",
+ method: candidate.method,
+ };
+ }
+ if ("result" in candidate || "error" in candidate) {
+ return {
+ type: "response",
+ id: this.normalizeId(candidate.id),
+ hasError: candidate.error != null,
+ };
+ }
+ return null;
+ }
+
+ serialize(): string {
+ return JSON.stringify(this.events, null, 2);
+ }
+
+ private normalizeId(id: unknown): string | null {
+ if (typeof id !== "string" && typeof id !== "number") {
+ return null;
+ }
+ const existing = this.idAliases.get(id);
+ if (existing) {
+ return existing;
+ }
+ const next = `id-${this.idAliases.size + 1}`;
+ this.idAliases.set(id, next);
+ return next;
+ }
+
+ private normalizeConnectionId(connectionId: string | null): string | null {
+ if (!connectionId) {
+ return null;
+ }
+ const existing = this.connectionAliases.get(connectionId);
+ if (existing) {
+ return existing;
+ }
+ const next = `conn-${this.connectionAliases.size + 1}`;
+ this.connectionAliases.set(connectionId, next);
+ return next;
+ }
+}
diff --git a/test-fixtures/streamable-http-client/tsconfig.json b/test-fixtures/streamable-http-client/tsconfig.json
new file mode 100644
index 0000000..78c0292
--- /dev/null
+++ b/test-fixtures/streamable-http-client/tsconfig.json
@@ -0,0 +1,12 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "module": "NodeNext",
+ "moduleResolution": "NodeNext",
+ "outDir": "dist",
+ "rootDir": "src",
+ "strict": true,
+ "esModuleInterop": true
+ },
+ "include": ["src/**/*.ts"]
+}
From e2ba51c6d56a0110574713d5d2e347ea26084b06 Mon Sep 17 00:00:00 2001
From: Kaiser Dandangi
Date: Mon, 18 May 2026 17:53:55 -0400
Subject: [PATCH 05/15] test: add streamable HTTP demo agent server
---
README.md | 11 ++
plans/STREAMABLE-HTTP-AGENT-TRANSPORT.md | 12 ++
pom.xml | 6 +
.../streamable-http-agent-server/README.md | 29 ++++
.../streamable-http-agent-server/pom.xml | 64 +++++++
.../StreamableHttpAgentDemoServer.java | 164 ++++++++++++++++++
.../src/main/resources/logback.xml | 10 ++
7 files changed, 296 insertions(+)
create mode 100644 test-fixtures/streamable-http-agent-server/README.md
create mode 100644 test-fixtures/streamable-http-agent-server/pom.xml
create mode 100644 test-fixtures/streamable-http-agent-server/src/main/java/com/agentclientprotocol/sdk/fixtures/StreamableHttpAgentDemoServer.java
create mode 100644 test-fixtures/streamable-http-agent-server/src/main/resources/logback.xml
diff --git a/README.md b/README.md
index 0d75e92..0776ded 100644
--- a/README.md
+++ b/README.md
@@ -392,6 +392,17 @@ agent.start().block(); // Starts WebSocket server on port 8080
| WebSocket | `WebSocketAcpClientTransport` | `WebSocketAcpAgentTransport` | acp-core / acp-websocket-jetty |
| Streamable HTTP | `StreamableHttpAcpClientTransport` | `StreamableHttpAcpAgentTransport` | acp-core / acp-streamable-http-jetty |
+### Streamable HTTP Demo Server
+
+Build and run a local demo ACP agent over HTTP/SSE:
+
+```bash
+./mvnw -q -pl test-fixtures/streamable-http-agent-server -am -DskipTests package
+java -jar test-fixtures/streamable-http-agent-server/target/acp-streamable-http-agent-server.jar --port 8080
+```
+
+The endpoint will be available at `http://127.0.0.1:8080/acp`.
+
---
## Building
diff --git a/plans/STREAMABLE-HTTP-AGENT-TRANSPORT.md b/plans/STREAMABLE-HTTP-AGENT-TRANSPORT.md
index a3c47e6..ff0728b 100644
--- a/plans/STREAMABLE-HTTP-AGENT-TRANSPORT.md
+++ b/plans/STREAMABLE-HTTP-AGENT-TRANSPORT.md
@@ -109,6 +109,18 @@ Covered scenarios:
The Java module also keeps focused integration coverage for strict unknown-session behavior.
+## Demo Server
+
+Add a runnable Java demo server at:
+
+```text
+test-fixtures/streamable-http-agent-server/
+```
+
+It packages a small echo-style ACP agent into a runnable jar backed by the real
+Jetty `StreamableHttpAcpAgentTransport`, so manual testing can exercise a live
+HTTP/SSE endpoint instead of only the integration-test fixture lifecycle.
+
## PLAN / Follow-Up Work
- extract a shared remote-core layer only after HTTP parity is proven
diff --git a/pom.xml b/pom.xml
index 3ccfd64..51e7c93 100644
--- a/pom.xml
+++ b/pom.xml
@@ -19,6 +19,7 @@
acp-agent-support
acp-test
acp-streamable-http-jetty
+ test-fixtures/streamable-http-agent-server
acp-websocket-jetty
@@ -115,6 +116,11 @@
acp-test
${project.version}
+
+ com.agentclientprotocol
+ acp-streamable-http-jetty
+ ${project.version}
+
diff --git a/test-fixtures/streamable-http-agent-server/README.md b/test-fixtures/streamable-http-agent-server/README.md
new file mode 100644
index 0000000..fb39d6c
--- /dev/null
+++ b/test-fixtures/streamable-http-agent-server/README.md
@@ -0,0 +1,29 @@
+# Streamable HTTP Agent Demo Server
+
+This is a small runnable Java ACP agent that serves the new Streamable HTTP
+agent transport from a real Jetty web server.
+
+Build the runnable jar:
+
+```bash
+./mvnw -q -pl test-fixtures/streamable-http-agent-server -am -DskipTests package
+```
+
+Run it:
+
+```bash
+java -jar test-fixtures/streamable-http-agent-server/target/acp-streamable-http-agent-server.jar --port 8080
+```
+
+Then drive it with the fixture client from another shell:
+
+```bash
+cd test-fixtures/streamable-http-client
+npm install
+npm run build
+node dist/client.js --endpoint http://127.0.0.1:8080/acp --scenario happy-path
+```
+
+The demo supports `initialize`, `session/new`, `session/load`, `session/prompt`,
+and `session/cancel`. Prompts containing the word `permission` also exercise the
+agent-to-client `session/request_permission` round trip.
diff --git a/test-fixtures/streamable-http-agent-server/pom.xml b/test-fixtures/streamable-http-agent-server/pom.xml
new file mode 100644
index 0000000..aeca87a
--- /dev/null
+++ b/test-fixtures/streamable-http-agent-server/pom.xml
@@ -0,0 +1,64 @@
+
+
+ 4.0.0
+
+
+ com.agentclientprotocol
+ acp-java-sdk
+ 0.12.0-SNAPSHOT
+ ../../pom.xml
+
+
+ acp-streamable-http-agent-server
+ jar
+
+ ACP Streamable HTTP Agent Server Demo
+ Runnable demo server for the Streamable HTTP agent transport
+
+
+
+ true
+
+
+
+
+ com.agentclientprotocol
+ acp-streamable-http-jetty
+
+
+ ch.qos.logback
+ logback-classic
+ runtime
+
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-shade-plugin
+ 3.5.3
+
+
+ package
+
+ shade
+
+
+ false
+ acp-streamable-http-agent-server
+
+
+ com.agentclientprotocol.sdk.fixtures.StreamableHttpAgentDemoServer
+
+
+
+
+
+
+
+
+
+
diff --git a/test-fixtures/streamable-http-agent-server/src/main/java/com/agentclientprotocol/sdk/fixtures/StreamableHttpAgentDemoServer.java b/test-fixtures/streamable-http-agent-server/src/main/java/com/agentclientprotocol/sdk/fixtures/StreamableHttpAgentDemoServer.java
new file mode 100644
index 0000000..4ba9967
--- /dev/null
+++ b/test-fixtures/streamable-http-agent-server/src/main/java/com/agentclientprotocol/sdk/fixtures/StreamableHttpAgentDemoServer.java
@@ -0,0 +1,164 @@
+/*
+ * Copyright 2025-2026 the original author or authors.
+ */
+
+package com.agentclientprotocol.sdk.fixtures;
+
+import java.time.Duration;
+import java.util.Locale;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import com.agentclientprotocol.sdk.agent.AcpAgent;
+import com.agentclientprotocol.sdk.agent.AcpAgentFactory;
+import com.agentclientprotocol.sdk.agent.transport.StreamableHttpAcpAgentTransport;
+import com.agentclientprotocol.sdk.json.AcpJsonMapper;
+import com.agentclientprotocol.sdk.spec.AcpSchema;
+import reactor.core.publisher.Mono;
+
+/**
+ * Small runnable ACP agent server for manually exercising the Streamable HTTP transport.
+ *
+ * @author Kaiser Dandangi
+ */
+public final class StreamableHttpAgentDemoServer {
+
+ private static final Duration START_TIMEOUT = Duration.ofSeconds(30);
+
+ private static final Duration STOP_TIMEOUT = Duration.ofSeconds(5);
+
+ private StreamableHttpAgentDemoServer() {
+ }
+
+ public static void main(String[] args) {
+ Options options;
+ try {
+ options = Options.parse(args);
+ }
+ catch (IllegalArgumentException e) {
+ System.err.println(e.getMessage());
+ printUsage();
+ System.exit(2);
+ return;
+ }
+
+ if (options.help()) {
+ printUsage();
+ return;
+ }
+
+ Map sessionCwds = new ConcurrentHashMap<>();
+ AtomicInteger sessionCounter = new AtomicInteger();
+
+ AcpAgentFactory agentFactory = AcpAgentFactory.async(transport -> AcpAgent.async(transport)
+ .requestTimeout(Duration.ofMinutes(2))
+ .initializeHandler(request -> Mono.just(AcpSchema.InitializeResponse.ok(
+ new AcpSchema.AgentCapabilities(true, new AcpSchema.McpCapabilities(),
+ new AcpSchema.PromptCapabilities()))))
+ .newSessionHandler(request -> {
+ String sessionId = "demo-session-" + sessionCounter.incrementAndGet();
+ sessionCwds.put(sessionId, request.cwd());
+ return Mono.just(new AcpSchema.NewSessionResponse(sessionId, null, null));
+ })
+ .loadSessionHandler(request -> {
+ sessionCwds.put(request.sessionId(), request.cwd());
+ return Mono.just(new AcpSchema.LoadSessionResponse(null, null));
+ })
+ .promptHandler((request, context) -> {
+ String text = request.text();
+ String normalized = text == null || text.isBlank() ? "(empty prompt)" : text;
+ String cwd = sessionCwds.getOrDefault(request.sessionId(), "(unknown cwd)");
+ Mono response = normalized.toLowerCase(Locale.ROOT).contains("permission")
+ ? context.askPermission("Demo agent permission check for session " + request.sessionId())
+ .flatMap(allowed -> context.sendMessage(
+ "Permission " + (allowed ? "granted" : "denied") + ". Prompt: " + normalized))
+ .onErrorResume(error -> context.sendMessage(
+ "Permission request failed in demo server: " + error.getMessage()))
+ : context.sendMessage("Demo agent received: " + normalized + " [cwd=" + cwd + "]");
+ return response.thenReturn(AcpSchema.PromptResponse.endTurn());
+ })
+ .cancelHandler(notification -> {
+ System.out.println("Received cancel for session " + notification.sessionId());
+ return Mono.empty();
+ })
+ .build());
+
+ StreamableHttpAcpAgentTransport server = new StreamableHttpAcpAgentTransport(options.port(), options.path(),
+ AcpJsonMapper.createDefault(), agentFactory)
+ .routingMode(options.routingMode());
+
+ Runtime.getRuntime().addShutdownHook(new Thread(() -> server.closeGracefully().block(STOP_TIMEOUT),
+ "acp-demo-shutdown"));
+
+ server.start().block(START_TIMEOUT);
+ System.out.println("ACP Streamable HTTP demo agent listening at http://127.0.0.1:" + server.getPort()
+ + options.path());
+ System.out.println("Press Ctrl-C to stop.");
+ server.awaitTermination().block();
+ }
+
+ private static void printUsage() {
+ System.out.println("""
+ Usage: java -jar acp-streamable-http-agent-server.jar [options]
+
+ Options:
+ --port Port to listen on. Defaults to 8080.
+ --path ACP endpoint path. Defaults to /acp.
+ --strict Use strict transport routing.
+ --compatible Use compatible transport routing. This is the default.
+ -h, --help Show this help.
+ """);
+ }
+
+ private record Options(int port, String path, StreamableHttpAcpAgentTransport.RoutingMode routingMode,
+ boolean help) {
+
+ static Options parse(String[] args) {
+ int port = 8080;
+ String path = StreamableHttpAcpAgentTransport.DEFAULT_ACP_PATH;
+ StreamableHttpAcpAgentTransport.RoutingMode routingMode =
+ StreamableHttpAcpAgentTransport.RoutingMode.COMPATIBLE;
+ boolean help = false;
+
+ for (int i = 0; i < args.length; i++) {
+ String arg = args[i];
+ switch (arg) {
+ case "--port" -> port = parsePort(requireValue(args, ++i, "--port"));
+ case "--path" -> path = requireValue(args, ++i, "--path");
+ case "--strict" -> routingMode = StreamableHttpAcpAgentTransport.RoutingMode.STRICT;
+ case "--compatible" -> routingMode = StreamableHttpAcpAgentTransport.RoutingMode.COMPATIBLE;
+ case "-h", "--help" -> help = true;
+ default -> throw new IllegalArgumentException("Unknown option: " + arg);
+ }
+ }
+
+ if (!path.startsWith("/")) {
+ throw new IllegalArgumentException("--path must start with /");
+ }
+ return new Options(port, path, routingMode, help);
+ }
+
+ private static String requireValue(String[] args, int index, String option) {
+ if (index >= args.length || args[index].startsWith("--")) {
+ throw new IllegalArgumentException(option + " requires a value");
+ }
+ return args[index];
+ }
+
+ private static int parsePort(String value) {
+ try {
+ int port = Integer.parseInt(value);
+ if (port <= 0) {
+ throw new IllegalArgumentException("--port must be positive");
+ }
+ return port;
+ }
+ catch (NumberFormatException e) {
+ throw new IllegalArgumentException("--port must be a number", e);
+ }
+ }
+
+ }
+
+}
diff --git a/test-fixtures/streamable-http-agent-server/src/main/resources/logback.xml b/test-fixtures/streamable-http-agent-server/src/main/resources/logback.xml
new file mode 100644
index 0000000..2bcf49b
--- /dev/null
+++ b/test-fixtures/streamable-http-agent-server/src/main/resources/logback.xml
@@ -0,0 +1,10 @@
+
+
+
+ %d{HH:mm:ss.SSS} %-5level %logger{36} - %msg%n
+
+
+
+
+
+
From 060be4d1d01f940b0bd5569aeeac7927c0ab3dd7 Mon Sep 17 00:00:00 2001
From: Kaiser Dandangi
Date: Mon, 18 May 2026 21:09:08 -0400
Subject: [PATCH 06/15] test: add OpenAI backend to streamable HTTP demo
---
.../streamable-http-agent-server/README.md | 16 ++
.../streamable-http-agent-server/pom.xml | 17 ++
.../StreamableHttpAgentDemoServer.java | 196 +++++++++++++++++-
3 files changed, 219 insertions(+), 10 deletions(-)
diff --git a/test-fixtures/streamable-http-agent-server/README.md b/test-fixtures/streamable-http-agent-server/README.md
index fb39d6c..3d5c753 100644
--- a/test-fixtures/streamable-http-agent-server/README.md
+++ b/test-fixtures/streamable-http-agent-server/README.md
@@ -27,3 +27,19 @@ node dist/client.js --endpoint http://127.0.0.1:8080/acp --scenario happy-path
The demo supports `initialize`, `session/new`, `session/load`, `session/prompt`,
and `session/cancel`. Prompts containing the word `permission` also exercise the
agent-to-client `session/request_permission` round trip.
+
+By default, the server uses a deterministic echo backend. To exercise the same
+ACP transport with a real OpenAI-backed agent through Spring AI:
+
+```bash
+export OPENAI_API_KEY=...
+# Optional; defaults to OPENAI_MODEL or gpt-4o-mini.
+export OPENAI_MODEL=gpt-4o-mini
+
+java -jar test-fixtures/streamable-http-agent-server/target/acp-streamable-http-agent-server.jar \
+ --port 8080 \
+ --backend spring-ai-openai
+```
+
+The Spring AI backend is intentionally scoped to this runnable fixture. It is not
+part of the core SDK transport implementation.
diff --git a/test-fixtures/streamable-http-agent-server/pom.xml b/test-fixtures/streamable-http-agent-server/pom.xml
index aeca87a..f054b91 100644
--- a/test-fixtures/streamable-http-agent-server/pom.xml
+++ b/test-fixtures/streamable-http-agent-server/pom.xml
@@ -20,13 +20,30 @@
true
+ 1.1.6
+
+
+
+ org.springframework.ai
+ spring-ai-bom
+ ${spring-ai.version}
+ pom
+ import
+
+
+
+
com.agentclientprotocol
acp-streamable-http-jetty
+
+ org.springframework.ai
+ spring-ai-openai
+
ch.qos.logback
logback-classic
diff --git a/test-fixtures/streamable-http-agent-server/src/main/java/com/agentclientprotocol/sdk/fixtures/StreamableHttpAgentDemoServer.java b/test-fixtures/streamable-http-agent-server/src/main/java/com/agentclientprotocol/sdk/fixtures/StreamableHttpAgentDemoServer.java
index 4ba9967..3061624 100644
--- a/test-fixtures/streamable-http-agent-server/src/main/java/com/agentclientprotocol/sdk/fixtures/StreamableHttpAgentDemoServer.java
+++ b/test-fixtures/streamable-http-agent-server/src/main/java/com/agentclientprotocol/sdk/fixtures/StreamableHttpAgentDemoServer.java
@@ -5,9 +5,12 @@
package com.agentclientprotocol.sdk.fixtures;
import java.time.Duration;
+import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
import java.util.concurrent.atomic.AtomicInteger;
import com.agentclientprotocol.sdk.agent.AcpAgent;
@@ -15,7 +18,17 @@
import com.agentclientprotocol.sdk.agent.transport.StreamableHttpAcpAgentTransport;
import com.agentclientprotocol.sdk.json.AcpJsonMapper;
import com.agentclientprotocol.sdk.spec.AcpSchema;
+import org.springframework.ai.chat.messages.SystemMessage;
+import org.springframework.ai.chat.messages.UserMessage;
+import org.springframework.ai.chat.model.ChatResponse;
+import org.springframework.ai.chat.model.Generation;
+import org.springframework.ai.chat.prompt.Prompt;
+import org.springframework.ai.openai.OpenAiChatModel;
+import org.springframework.ai.openai.OpenAiChatOptions;
+import org.springframework.ai.openai.api.OpenAiApi;
import reactor.core.publisher.Mono;
+import reactor.core.scheduler.Scheduler;
+import reactor.core.scheduler.Schedulers;
/**
* Small runnable ACP agent server for manually exercising the Streamable HTTP transport.
@@ -28,6 +41,13 @@ public final class StreamableHttpAgentDemoServer {
private static final Duration STOP_TIMEOUT = Duration.ofSeconds(5);
+ private static final String OPENAI_SYSTEM_PROMPT = """
+ You are a small ACP demo agent running inside the Java SDK Streamable HTTP fixture.
+ Answer concisely. If the user asks about implementation details, say that this
+ fixture is exercising the ACP Streamable HTTP transport, not providing a full
+ production agent runtime.
+ """;
+
private StreamableHttpAgentDemoServer() {
}
@@ -50,6 +70,15 @@ public static void main(String[] args) {
Map sessionCwds = new ConcurrentHashMap<>();
AtomicInteger sessionCounter = new AtomicInteger();
+ PromptBackend promptBackend;
+ try {
+ promptBackend = options.backend().create();
+ }
+ catch (IllegalArgumentException e) {
+ System.err.println(e.getMessage());
+ System.exit(2);
+ return;
+ }
AcpAgentFactory agentFactory = AcpAgentFactory.async(transport -> AcpAgent.async(transport)
.requestTimeout(Duration.ofMinutes(2))
@@ -75,7 +104,7 @@ public static void main(String[] args) {
"Permission " + (allowed ? "granted" : "denied") + ". Prompt: " + normalized))
.onErrorResume(error -> context.sendMessage(
"Permission request failed in demo server: " + error.getMessage()))
- : context.sendMessage("Demo agent received: " + normalized + " [cwd=" + cwd + "]");
+ : promptBackend.generate(normalized, request.sessionId(), cwd).flatMap(context::sendMessage);
return response.thenReturn(AcpSchema.PromptResponse.endTurn());
})
.cancelHandler(notification -> {
@@ -88,8 +117,14 @@ public static void main(String[] args) {
AcpJsonMapper.createDefault(), agentFactory)
.routingMode(options.routingMode());
- Runtime.getRuntime().addShutdownHook(new Thread(() -> server.closeGracefully().block(STOP_TIMEOUT),
- "acp-demo-shutdown"));
+ Runtime.getRuntime().addShutdownHook(new Thread(() -> {
+ try {
+ server.closeGracefully().block(STOP_TIMEOUT);
+ }
+ finally {
+ promptBackend.close();
+ }
+ }, "acp-demo-shutdown"));
server.start().block(START_TIMEOUT);
System.out.println("ACP Streamable HTTP demo agent listening at http://127.0.0.1:" + server.getPort()
@@ -103,22 +138,30 @@ private static void printUsage() {
Usage: java -jar acp-streamable-http-agent-server.jar [options]
Options:
- --port Port to listen on. Defaults to 8080.
- --path ACP endpoint path. Defaults to /acp.
- --strict Use strict transport routing.
- --compatible Use compatible transport routing. This is the default.
- -h, --help Show this help.
+ --port Port to listen on. Defaults to 8080.
+ --path ACP endpoint path. Defaults to /acp.
+ --backend Agent backend: echo or spring-ai-openai. Defaults to echo.
+ --openai-model OpenAI model for spring-ai-openai. Defaults to OPENAI_MODEL or gpt-4o-mini.
+ --strict Use strict transport routing.
+ --compatible Use compatible transport routing. This is the default.
+ -h, --help Show this help.
+
+ Environment:
+ OPENAI_API_KEY Required when --backend spring-ai-openai is used.
+ OPENAI_MODEL Optional default model for --backend spring-ai-openai.
""");
}
private record Options(int port, String path, StreamableHttpAcpAgentTransport.RoutingMode routingMode,
- boolean help) {
+ Backend backend, boolean help) {
static Options parse(String[] args) {
int port = 8080;
String path = StreamableHttpAcpAgentTransport.DEFAULT_ACP_PATH;
StreamableHttpAcpAgentTransport.RoutingMode routingMode =
StreamableHttpAcpAgentTransport.RoutingMode.COMPATIBLE;
+ String backendName = "echo";
+ String openAiModel = null;
boolean help = false;
for (int i = 0; i < args.length; i++) {
@@ -126,6 +169,8 @@ static Options parse(String[] args) {
switch (arg) {
case "--port" -> port = parsePort(requireValue(args, ++i, "--port"));
case "--path" -> path = requireValue(args, ++i, "--path");
+ case "--backend" -> backendName = requireValue(args, ++i, "--backend");
+ case "--openai-model" -> openAiModel = requireValue(args, ++i, "--openai-model");
case "--strict" -> routingMode = StreamableHttpAcpAgentTransport.RoutingMode.STRICT;
case "--compatible" -> routingMode = StreamableHttpAcpAgentTransport.RoutingMode.COMPATIBLE;
case "-h", "--help" -> help = true;
@@ -136,7 +181,8 @@ static Options parse(String[] args) {
if (!path.startsWith("/")) {
throw new IllegalArgumentException("--path must start with /");
}
- return new Options(port, path, routingMode, help);
+ Backend backend = Backend.parse(backendName, openAiModel);
+ return new Options(port, path, routingMode, backend, help);
}
private static String requireValue(String[] args, int index, String option) {
@@ -161,4 +207,134 @@ private static int parsePort(String value) {
}
+ private sealed interface Backend permits EchoBackend, SpringAiOpenAiBackend {
+
+ PromptBackend create();
+
+ static Backend echo() {
+ return new EchoBackend();
+ }
+
+ static Backend springAiOpenAi(String model) {
+ return new SpringAiOpenAiBackend(model);
+ }
+
+ static Backend parse(String value, String openAiModel) {
+ return switch (value) {
+ case "echo" -> echo();
+ case "spring-ai-openai" -> springAiOpenAi(openAiModel);
+ default -> throw new IllegalArgumentException("Unknown backend: " + value);
+ };
+ }
+
+ }
+
+ @FunctionalInterface
+ private interface PromptBackend {
+
+ Mono generate(String prompt, String sessionId, String cwd);
+
+ default void close() {
+ }
+
+ }
+
+ private record EchoBackend() implements Backend {
+
+ @Override
+ public PromptBackend create() {
+ return new EchoPromptBackend();
+ }
+
+ }
+
+ private static final class EchoPromptBackend implements PromptBackend {
+
+ @Override
+ public Mono generate(String prompt, String sessionId, String cwd) {
+ return Mono.just("Demo agent received: " + prompt + " [cwd=" + cwd + "]");
+ }
+
+ }
+
+ private record SpringAiOpenAiBackend(String model) implements Backend {
+
+ @Override
+ public PromptBackend create() {
+ String apiKey = System.getenv("OPENAI_API_KEY");
+ if (apiKey == null || apiKey.isBlank()) {
+ throw new IllegalArgumentException(
+ "OPENAI_API_KEY is required when --backend spring-ai-openai is used");
+ }
+
+ OpenAiApi openAiApi = OpenAiApi.builder().apiKey(apiKey).build();
+ OpenAiChatOptions chatOptions = OpenAiChatOptions.builder()
+ .model(resolveOpenAiModel(this.model))
+ .temperature(0.2)
+ .maxTokens(800)
+ .build();
+ OpenAiChatModel chatModel = OpenAiChatModel.builder()
+ .openAiApi(openAiApi)
+ .defaultOptions(chatOptions)
+ .build();
+
+ return new SpringAiOpenAiPromptBackend(chatModel);
+ }
+
+ }
+
+ private static final class SpringAiOpenAiPromptBackend implements PromptBackend {
+
+ private final OpenAiChatModel chatModel;
+
+ private final ExecutorService executorService;
+
+ private final Scheduler scheduler;
+
+ private SpringAiOpenAiPromptBackend(OpenAiChatModel chatModel) {
+ this.chatModel = chatModel;
+ AtomicInteger threadCounter = new AtomicInteger();
+ this.executorService = Executors.newCachedThreadPool(task -> {
+ Thread thread = new Thread(task, "acp-demo-openai-" + threadCounter.incrementAndGet());
+ thread.setDaemon(true);
+ return thread;
+ });
+ this.scheduler = Schedulers.fromExecutorService(this.executorService, "acp-demo-openai");
+ }
+
+ @Override
+ public Mono generate(String prompt, String sessionId, String cwd) {
+ return Mono.fromCallable(() -> generatePrompt(prompt, sessionId, cwd)).subscribeOn(this.scheduler);
+ }
+
+ @Override
+ public void close() {
+ this.scheduler.dispose();
+ this.executorService.shutdownNow();
+ }
+
+ private String generatePrompt(String prompt, String sessionId, String cwd) {
+ ChatResponse response = chatModel.call(new Prompt(List.of(new SystemMessage(OPENAI_SYSTEM_PROMPT),
+ new UserMessage("Session: " + sessionId + "\nCWD: " + cwd + "\n\nUser prompt:\n" + prompt))));
+ Generation generation = response.getResult();
+ if (generation == null || generation.getOutput() == null || generation.getOutput().getText() == null
+ || generation.getOutput().getText().isBlank()) {
+ return "(OpenAI returned an empty response)";
+ }
+ return generation.getOutput().getText();
+ }
+
+ }
+
+ private static String resolveOpenAiModel(String model) {
+ if (model != null && !model.isBlank()) {
+ return model;
+ }
+ String envModel = System.getenv("OPENAI_MODEL");
+ if (envModel != null && !envModel.isBlank()) {
+ return envModel;
+ }
+ return "gpt-4o-mini";
+ }
+
}
From f1d91d8a173967bfbfb65424ad0c49c88f118a38 Mon Sep 17 00:00:00 2001
From: Kaiser Dandangi
Date: Mon, 18 May 2026 21:18:17 -0400
Subject: [PATCH 07/15] chore: ignore environment files
---
.gitignore | 6 ++++++
1 file changed, 6 insertions(+)
diff --git a/.gitignore b/.gitignore
index 7aa4b83..02608ab 100644
--- a/.gitignore
+++ b/.gitignore
@@ -47,6 +47,12 @@ build.log
shell.log
derby.log
+### Environment Files ###
+.env
+.env.*
+**/.env
+**/.env.*
+
### Compiled Files ###
*.class
From e486eff5b4520393c2b3726103135765d29a1649 Mon Sep 17 00:00:00 2001
From: Kaiser Dandangi
Date: Mon, 18 May 2026 21:43:19 -0400
Subject: [PATCH 08/15] docs: clarify remote core follow-up
---
plans/STREAMABLE-HTTP-AGENT-TRANSPORT.md | 11 ++++++++++-
1 file changed, 10 insertions(+), 1 deletion(-)
diff --git a/plans/STREAMABLE-HTTP-AGENT-TRANSPORT.md b/plans/STREAMABLE-HTTP-AGENT-TRANSPORT.md
index ff0728b..a97f203 100644
--- a/plans/STREAMABLE-HTTP-AGENT-TRANSPORT.md
+++ b/plans/STREAMABLE-HTTP-AGENT-TRANSPORT.md
@@ -123,7 +123,16 @@ HTTP/SSE endpoint instead of only the integration-test fixture lifecycle.
## PLAN / Follow-Up Work
-- extract a shared remote-core layer only after HTTP parity is proven
+- extract a shared remote-core layer only after HTTP parity is proven.
+ Here, "remote-core" means the transport-independent runtime machinery that
+ both remote listener transports need: per-connection agent factory creation,
+ connection/session registries, lifecycle teardown, request/response routing
+ ledgers, timeout/error propagation, and observability hooks. The actual wire
+ adapters should remain transport-specific: WebSocket text frames stay in the
+ WebSocket module, and HTTP methods, headers, cookies, SSE parsing, and status
+ codes stay in the Streamable HTTP module. Deferring this extraction keeps the
+ first HTTP implementation close to the RFD and avoids prematurely forcing the
+ existing WebSocket behavior through an abstraction before parity is proven.
- migrate WebSocket toward the same factory-backed listener model
- add idle/provisional-session eviction and replay retention policies
- revisit per-logical-session active-prompt tracking in `AcpAgentSession`
From f706d170ebbb848bf85d8dcaf9ddec829d2a1fbe Mon Sep 17 00:00:00 2001
From: Kaiser Dandangi
Date: Sat, 23 May 2026 22:26:27 -0400
Subject: [PATCH 09/15] Track active prompts per ACP session
---
.../sdk/spec/AcpAgentSession.java | 80 +++++--
.../sdk/spec/AcpAgentSessionTest.java | 225 ++++++++++++------
2 files changed, 204 insertions(+), 101 deletions(-)
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/test/java/com/agentclientprotocol/sdk/spec/AcpAgentSessionTest.java b/acp-core/src/test/java/com/agentclientprotocol/sdk/spec/AcpAgentSessionTest.java
index 4ce255d..bc8934a 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,11 +5,12 @@
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;
@@ -26,6 +27,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 +72,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 +86,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 +108,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 +122,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 +152,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 +171,104 @@ 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();
+
+ Map> requestHandlers = Map.of(AcpSchema.METHOD_SESSION_PROMPT,
+ params -> Mono.defer(() -> {
+ handlerInvocations.incrementAndGet();
+ handlersStarted.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());
+
+ 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);
- // 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 +279,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 +329,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 +346,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 +359,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 +374,26 @@ 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 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);
+ }
+
}
From ef9bb275c6df66203c74a93dd640b6f573d3161f Mon Sep 17 00:00:00 2001
From: Kaiser Dandangi
Date: Sat, 23 May 2026 23:44:27 -0400
Subject: [PATCH 10/15] Add remote WebSocket agent transport
---
.../agent/transport/RemoteAcpConnection.java | 244 ++++++++++++
.../sdk/spec/AcpAgentSessionTest.java | 33 +-
.../StreamableHttpAcpAgentTransport.java | 122 +-----
.../RemoteWebSocketAcpAgentTransport.java | 363 ++++++++++++++++++
...ocketAcpAgentTransportIntegrationTest.java | 343 +++++++++++++++++
5 files changed, 993 insertions(+), 112 deletions(-)
create mode 100644 acp-core/src/main/java/com/agentclientprotocol/sdk/agent/transport/RemoteAcpConnection.java
create mode 100644 acp-websocket-jetty/src/main/java/com/agentclientprotocol/sdk/agent/transport/RemoteWebSocketAcpAgentTransport.java
create mode 100644 acp-websocket-jetty/src/test/java/com/agentclientprotocol/sdk/agent/transport/RemoteWebSocketAcpAgentTransportIntegrationTest.java
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..d2162dd
--- /dev/null
+++ b/acp-core/src/main/java/com/agentclientprotocol/sdk/agent/transport/RemoteAcpConnection.java
@@ -0,0 +1,244 @@
+/*
+ * 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();
+
+ 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");
+ }
+ 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/test/java/com/agentclientprotocol/sdk/spec/AcpAgentSessionTest.java b/acp-core/src/test/java/com/agentclientprotocol/sdk/spec/AcpAgentSessionTest.java
index bc8934a..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
@@ -16,6 +16,7 @@
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;
@@ -228,13 +229,17 @@ void singleTurnEnforcementAllowsConcurrentPromptsForDifferentSessions() throws E
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();
- return Mono.delay(PROMPT_RESPONSE_DELAY)
- .map(ignored -> new AcpSchema.PromptResponse(AcpSchema.StopReason.END_TURN));
+ 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,
@@ -262,6 +267,13 @@ void singleTurnEnforcementAllowsConcurrentPromptsForDifferentSessions() throws E
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);
+
assertThat(responseLatch.await(5, TimeUnit.SECONDS)).isTrue();
assertThat(responseById(responses, "1").error()).isNull();
@@ -383,6 +395,23 @@ private static AcpSchema.JSONRPCResponse responseById(List 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
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
index 911b3a9..843860a 100644
--- 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
@@ -19,15 +19,11 @@
import java.util.concurrent.ConcurrentMap;
import java.util.concurrent.CopyOnWriteArrayList;
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;
@@ -54,14 +50,14 @@
*
* This transport hosts a Jetty HTTP endpoint and creates one fresh agent runtime per
* remote ACP connection through {@link AcpAgentFactory}. The accepted connection then
- * owns its own per-connection {@link AcpAgentTransport}, while the listener remains
+ * owns its own per-connection {@link RemoteAcpConnection}, while the listener remains
* responsible only for HTTP concerns such as headers, SSE streams, and request routing.
*
*
*
- * The current implementation is intentionally HTTP-only. The shared remote transport
- * core that should eventually also back WebSocket remains a follow-up migration step so
- * the existing WebSocket behavior can be preserved until parity is proven here first.
+ * Streamable HTTP and the RFD-compliant remote WebSocket listener share
+ * {@link RemoteAcpConnection}; this class keeps HTTP-specific routing, headers, SSE
+ * stream ownership, and replay behavior local to the HTTP adapter.
*
*
* @author Kaiser Dandangi
@@ -458,7 +454,7 @@ private final class ConnectionState {
private final String id;
- private final ConnectionTransport transport;
+ private final RemoteAcpConnection connection;
private final OutboundStream connectionStream = new OutboundStream();
@@ -478,11 +474,9 @@ private final class ConnectionState {
private volatile Object initializeRequestId;
- private volatile AcpAsyncAgent agent;
-
ConnectionState(String id) {
this.id = id;
- this.transport = new ConnectionTransport(this::routeAgentMessage);
+ this.connection = new RemoteAcpConnection(id, jsonMapper, this::routeAgentMessage);
}
String id() {
@@ -490,20 +484,19 @@ String id() {
}
void start() {
- this.agent = agentFactory.create(transport);
- this.agent.start().block(INITIALIZE_TIMEOUT);
+ this.connection.start(agentFactory).block(INITIALIZE_TIMEOUT);
}
Mono initialize(AcpSchema.JSONRPCRequest request) {
this.initializeRequestId = request.id();
- transport.acceptInbound(request);
+ 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);
- transport.acceptInbound(message);
+ connection.acceptInbound(message);
return;
}
@@ -515,7 +508,7 @@ void acceptClientPost(JSONRPCMessage message, String sessionHeader) {
&& resolved.requestRoute() != null) {
clientRequestRoutes.put(request.id(), resolved.requestRoute());
}
- transport.acceptInbound(message);
+ connection.acceptInbound(message);
}
void openStream(HttpServletRequest request, HttpServletResponse response, String sessionId)
@@ -544,11 +537,7 @@ void openStream(HttpServletRequest request, HttpServletResponse response, String
void close() {
connectionStream.close();
sessionStreams.values().forEach(OutboundStream::close);
- transport.closeGracefully().subscribe();
- AcpAsyncAgent currentAgent = this.agent;
- if (currentAgent != null) {
- currentAgent.closeGracefully().subscribe();
- }
+ connection.closeGracefully().subscribe();
}
private void routeAgentMessage(JSONRPCMessage message) {
@@ -569,7 +558,7 @@ private void routeAgentMessage(JSONRPCMessage message) {
}
}
catch (Exception e) {
- transport.signalException(e);
+ connection.signalException(e);
}
}
@@ -788,93 +777,6 @@ private String requireSessionId(Object params, String method) {
.orElseThrow(() -> new AcpConnectionException("Missing sessionId for method " + method));
}
- private final class ConnectionTransport implements AcpAgentTransport {
-
- private final Consumer outboundConsumer;
-
- private final Sinks.Many inboundSink = Sinks.many().unicast().onBackpressureBuffer();
-
- private final Sinks.One terminationSink = Sinks.one();
-
- private final AtomicBoolean started = new AtomicBoolean(false);
-
- private final AtomicBoolean closing = new AtomicBoolean(false);
-
- private volatile Consumer exceptionHandler = t -> logger.error("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 (!started.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)
- .subscribe();
- return Mono.empty();
- }
-
- void acceptInbound(JSONRPCMessage message) {
- if (closing.get()) {
- throw new AcpConnectionException("Connection transport is closing");
- }
- 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 (closing.get()) {
- throw new AcpConnectionException("Connection transport 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(() -> {
- if (closing.compareAndSet(false, true)) {
- inboundSink.tryEmitComplete();
- terminationSink.tryEmitValue(null);
- }
- });
- }
-
- @Override
- public void setExceptionHandler(Consumer handler) {
- this.exceptionHandler = handler;
- }
-
- @Override
- public Mono awaitTermination() {
- return terminationSink.asMono();
- }
-
- }
-
private final class OutboundStream {
private final ArrayDeque replay = new ArrayDeque<>();
diff --git a/acp-websocket-jetty/src/main/java/com/agentclientprotocol/sdk/agent/transport/RemoteWebSocketAcpAgentTransport.java b/acp-websocket-jetty/src/main/java/com/agentclientprotocol/sdk/agent/transport/RemoteWebSocketAcpAgentTransport.java
new file mode 100644
index 0000000..6881ab1
--- /dev/null
+++ b/acp-websocket-jetty/src/main/java/com/agentclientprotocol/sdk/agent/transport/RemoteWebSocketAcpAgentTransport.java
@@ -0,0 +1,363 @@
+/*
+ * Copyright 2025-2026 the original author or authors.
+ */
+
+package com.agentclientprotocol.sdk.agent.transport;
+
+import java.time.Duration;
+import java.util.UUID;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ConcurrentMap;
+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.spec.AcpSchema;
+import com.agentclientprotocol.sdk.spec.AcpSchema.JSONRPCMessage;
+import com.agentclientprotocol.sdk.util.Assert;
+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;
+
+/**
+ * RFD-compliant listener-backed ACP WebSocket transport for agents.
+ *
+ *
+ * This transport accepts WebSocket upgrades on one ACP endpoint and creates one fresh
+ * agent runtime per accepted remote connection via {@link AcpAgentFactory}. The
+ * resulting per-connection {@link RemoteAcpConnection} owns ACP JSON-RPC semantics,
+ * while this class owns only Jetty listener setup, WebSocket framing, connection IDs,
+ * and close/error lifecycle.
+ *
+ *
+ *
+ * Unlike the legacy {@link WebSocketAcpAgentTransport}, this class is intentionally not
+ * an {@code AcpAgentTransport} itself. It is a listener that creates one
+ * connection-bound transport per WebSocket client.
+ *
+ *
+ * @author Kaiser Dandangi
+ */
+public class RemoteWebSocketAcpAgentTransport {
+
+ private static final Logger logger = LoggerFactory.getLogger(RemoteWebSocketAcpAgentTransport.class);
+
+ public static final String DEFAULT_ACP_PATH = "/acp";
+
+ public static final String HEADER_CONNECTION_ID = "Acp-Connection-Id";
+
+ private static final Duration INITIALIZE_TIMEOUT = Duration.ofSeconds(30);
+
+ private final int configuredPort;
+
+ private final String path;
+
+ private final AcpJsonMapper jsonMapper;
+
+ private final AcpAgentFactory agentFactory;
+
+ private final ConcurrentMap connections = 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 Duration idleTimeout = Duration.ofMinutes(30);
+
+ private volatile Server server;
+
+ private volatile ServerConnector connector;
+
+ /**
+ * Creates a new RFD-compliant WebSocket 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 RemoteWebSocketAcpAgentTransport(int port, AcpJsonMapper jsonMapper, AcpAgentFactory agentFactory) {
+ this(port, DEFAULT_ACP_PATH, jsonMapper, agentFactory);
+ }
+
+ /**
+ * Creates a new RFD-compliant WebSocket 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 RemoteWebSocketAcpAgentTransport(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 WebSocket idle timeout.
+ * @param timeout idle timeout
+ * @return this transport
+ */
+ public RemoteWebSocketAcpAgentTransport idleTimeout(Duration timeout) {
+ Assert.notNull(timeout, "The timeout can not be null");
+ this.idleTimeout = timeout;
+ return this;
+ }
+
+ /**
+ * Starts the embedded Jetty WebSocket listener.
+ * @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.fromRunnable(() -> {
+ Server jettyServer = new Server();
+ ServerConnector jettyConnector = new ServerConnector(jettyServer);
+ jettyConnector.setPort(configuredPort);
+ jettyServer.addConnector(jettyConnector);
+
+ WebSocketUpgradeHandler wsHandler = WebSocketUpgradeHandler.from(jettyServer, container -> {
+ container.setIdleTimeout(idleTimeout);
+ container.addMapping(path, (request, response, callback) -> {
+ ConnectionState connection = createConnection();
+ try {
+ connection.start();
+ connections.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;
+ }
+ });
+ });
+ jettyServer.setHandler(wsHandler);
+
+ try {
+ jettyServer.start();
+ }
+ catch (Exception e) {
+ throw new AcpConnectionException("Failed to start Remote WebSocket listener", e);
+ }
+ this.server = jettyServer;
+ this.connector = jettyConnector;
+ logger.info("Remote WebSocket ACP agent listener started on port {} at path {}", getPort(), path);
+ });
+ }
+
+ /**
+ * Returns the bound port.
+ * @return listener port
+ */
+ public int getPort() {
+ ServerConnector currentConnector = this.connector;
+ return currentConnector != null ? currentConnector.getLocalPort() : configuredPort;
+ }
+
+ /**
+ * Closes all active WebSocket 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();
+ Server currentServer = this.server;
+ if (currentServer != null) {
+ try {
+ currentServer.stop();
+ }
+ catch (Exception e) {
+ throw new AcpConnectionException("Failed to stop Remote WebSocket listener", e);
+ }
+ }
+ terminationSink.tryEmitValue(null);
+ });
+ }
+
+ /**
+ * Closes all active WebSocket connections and stops the listener immediately.
+ */
+ public void close() {
+ closeGracefully().block(Duration.ofSeconds(5));
+ }
+
+ /**
+ * Returns a mono that completes once the listener terminates.
+ * @return termination mono
+ */
+ public Mono awaitTermination() {
+ return terminationSink.asMono();
+ }
+
+ int activeConnectionCount() {
+ return connections.size();
+ }
+
+ private ConnectionState createConnection() {
+ String connectionId = UUID.randomUUID().toString();
+ return new ConnectionState(connectionId);
+ }
+
+ private boolean isInitializeRequest(JSONRPCMessage message) {
+ return message instanceof AcpSchema.JSONRPCRequest request
+ && AcpSchema.METHOD_INITIALIZE.equals(request.method()) && request.id() != null;
+ }
+
+ private final class ConnectionState {
+
+ private final String id;
+
+ private final RemoteAcpConnection remoteConnection;
+
+ private final AtomicBoolean initialized = new AtomicBoolean(false);
+
+ private final AtomicBoolean closed = new AtomicBoolean(false);
+
+ private volatile Session session;
+
+ ConnectionState(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 RFD makes initialize the first client-originated
+ // JSON-RPC request. Enforce that at the wire adapter before the
+ // shared connection core sees application messages.
+ 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 {
+ Session currentSession = this.session;
+ if (closed.get() || currentSession == null || !currentSession.isOpen()) {
+ throw new AcpConnectionException("Remote WebSocket connection is closed");
+ }
+ String payload = jsonMapper.writeValueAsString(message);
+ logger.debug("Sending remote WebSocket message: {}", payload);
+ currentSession.sendText(payload, Callback.from(() -> {
+ }, error -> {
+ if (!closed.get()) {
+ remoteConnection.signalException(error);
+ }
+ }));
+ }
+ 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;
+ }
+ connections.remove(id, this);
+ Session currentSession = this.session;
+ if (currentSession != null && currentSession.isOpen()) {
+ currentSession.close(statusCode, reason, Callback.NOOP);
+ }
+ remoteConnection.closeGracefully().subscribe();
+ }
+
+ }
+
+ /**
+ * Jetty WebSocket endpoint for one accepted ACP connection.
+ */
+ @WebSocket
+ public class AcpWebSocketEndpoint {
+
+ private final ConnectionState connection;
+
+ AcpWebSocketEndpoint(ConnectionState connection) {
+ this.connection = connection;
+ }
+
+ @OnWebSocketOpen
+ public void onOpen(Session session) {
+ logger.info("Remote WebSocket ACP client connected from {}", session.getRemoteSocketAddress());
+ connection.open(session);
+ }
+
+ @OnWebSocketMessage
+ public void onMessage(Session session, String message) {
+ logger.debug("Received remote WebSocket message: {}", message);
+
+ try {
+ JSONRPCMessage jsonRpcMessage = AcpSchema.deserializeJsonRpcMessage(jsonMapper, message);
+ connection.acceptFromClient(jsonRpcMessage);
+ }
+ catch (Exception e) {
+ logger.warn("Closing remote WebSocket ACP 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("Remote WebSocket ACP client disconnected: {} - {}", statusCode, reason);
+ connection.close(statusCode, reason);
+ }
+
+ @OnWebSocketError
+ public void onError(Session session, Throwable error) {
+ logger.error("Remote WebSocket ACP error", error);
+ connection.remoteConnection.signalException(error);
+ connection.close(StatusCode.SERVER_ERROR, "WebSocket error");
+ }
+
+ }
+
+}
diff --git a/acp-websocket-jetty/src/test/java/com/agentclientprotocol/sdk/agent/transport/RemoteWebSocketAcpAgentTransportIntegrationTest.java b/acp-websocket-jetty/src/test/java/com/agentclientprotocol/sdk/agent/transport/RemoteWebSocketAcpAgentTransportIntegrationTest.java
new file mode 100644
index 0000000..2280e2c
--- /dev/null
+++ b/acp-websocket-jetty/src/test/java/com/agentclientprotocol/sdk/agent/transport/RemoteWebSocketAcpAgentTransportIntegrationTest.java
@@ -0,0 +1,343 @@
+/*
+ * 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.Mono;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/**
+ * End-to-end tests for the RFD-compliant listener-backed WebSocket transport.
+ */
+class RemoteWebSocketAcpAgentTransportIntegrationTest {
+
+ private static final Duration TIMEOUT = Duration.ofSeconds(5);
+
+ @Test
+ void constructorValidatesRequiredArguments() {
+ AcpJsonMapper jsonMapper = AcpJsonMapper.createDefault();
+ AcpAgentFactory agentFactory = simpleAgentFactory();
+
+ assertThatThrownBy(() -> new RemoteWebSocketAcpAgentTransport(0, jsonMapper, agentFactory))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("Port");
+ assertThatThrownBy(() -> new RemoteWebSocketAcpAgentTransport(8080, "", jsonMapper, agentFactory))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("Path");
+ assertThatThrownBy(() -> new RemoteWebSocketAcpAgentTransport(8080, null, agentFactory))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("JsonMapper");
+ assertThatThrownBy(() -> new RemoteWebSocketAcpAgentTransport(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 javaClientCanTalkToRemoteWebSocketServer() 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 permissionRequestRoundTripsOverRemoteWebSocket() 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("remote 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 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 remote 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(RemoteWebSocketAcpAgentTransport 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 RemoteWebSocketAcpAgentTransport transport;
+
+ private FixtureServer(RemoteWebSocketAcpAgentTransport transport) {
+ this.transport = transport;
+ }
+
+ static FixtureServer start(AcpAgentFactory agentFactory) {
+ RemoteWebSocketAcpAgentTransport transport = new RemoteWebSocketAcpAgentTransport(
+ 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");
+ }
+
+ RemoteWebSocketAcpAgentTransport 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();
+ }
+
+ }
+
+}
From f0d8578a55ab07fa029f1ad5b6689b600f1d36a5 Mon Sep 17 00:00:00 2001
From: Kaiser Dandangi
Date: Sun, 24 May 2026 18:02:39 -0400
Subject: [PATCH 11/15] feat: unify streamable agent transport
---
...HttpAcpClientTransportIntegrationTest.java | 450 ++++++++++----
acp-streamable-http-jetty/pom.xml | 8 +
.../StreamableHttpAcpAgentTransport.java | 200 +++++-
...eHttpAcpAgentTransportIntegrationTest.java | 297 ++++++---
...gentTransportWebSocketIntegrationTest.java | 30 +-
.../RemoteWebSocketAcpAgentTransport.java | 363 -----------
plans/STREAMABLE-HTTP-AGENT-TRANSPORT.md | 48 +-
plans/STREAMABLE-HTTP-TRANSPORT.md | 30 +-
.../streamable-http-agent-server/README.md | 10 +-
.../StreamableHttpAgentDemoServer.java | 2 +
.../streamable-http-client/README.md | 27 -
.../streamable-http-client/dist/client.js | 16 -
.../streamable-http-client/dist/protocol.js | 245 --------
.../streamable-http-client/dist/scenarios.js | 127 ----
.../streamable-http-client/dist/transcript.js | 69 ---
.../golden/happy-path.json | 127 ----
.../golden/permission-round-trip.json | 154 -----
.../golden/session-load.json | 92 ---
.../golden/two-sessions.json | 203 -------
.../golden/validation-failures.json | 79 ---
.../golden/wrong-stream-response.json | 137 -----
.../streamable-http-client/package-lock.json | 45 --
.../streamable-http-client/package.json | 12 -
.../streamable-http-client/src/client.ts | 19 -
.../streamable-http-client/src/protocol.ts | 278 ---------
.../streamable-http-client/src/scenarios.ts | 134 ----
.../streamable-http-client/src/transcript.ts | 115 ----
.../streamable-http-client/tsconfig.json | 12 -
.../streamable-http-server/README.md | 36 --
.../streamable-http-server/dist/protocol.js | 26 -
.../streamable-http-server/dist/scenarios.js | 387 ------------
.../streamable-http-server/dist/server.js | 46 --
.../streamable-http-server/dist/transcript.js | 52 --
.../golden/happy-path.json | 139 -----
.../golden/permission-round-trip.json | 160 -----
.../golden/session-load.json | 100 ---
.../golden/two-sessions.json | 224 -------
.../golden/validation-failures.json | 86 ---
.../golden/wrong-stream-response.json | 130 ----
.../streamable-http-server/package-lock.json | 566 -----------------
.../streamable-http-server/package.json | 16 -
.../streamable-http-server/src/protocol.ts | 32 -
.../streamable-http-server/src/scenarios.ts | 574 ------------------
.../streamable-http-server/src/server.ts | 55 --
.../streamable-http-server/src/transcript.ts | 101 ---
.../streamable-http-server/tsconfig.json | 13 -
46 files changed, 822 insertions(+), 5250 deletions(-)
rename acp-websocket-jetty/src/test/java/com/agentclientprotocol/sdk/agent/transport/RemoteWebSocketAcpAgentTransportIntegrationTest.java => acp-streamable-http-jetty/src/test/java/com/agentclientprotocol/sdk/agent/transport/StreamableHttpAcpAgentTransportWebSocketIntegrationTest.java (90%)
delete mode 100644 acp-websocket-jetty/src/main/java/com/agentclientprotocol/sdk/agent/transport/RemoteWebSocketAcpAgentTransport.java
delete mode 100644 test-fixtures/streamable-http-client/README.md
delete mode 100644 test-fixtures/streamable-http-client/dist/client.js
delete mode 100644 test-fixtures/streamable-http-client/dist/protocol.js
delete mode 100644 test-fixtures/streamable-http-client/dist/scenarios.js
delete mode 100644 test-fixtures/streamable-http-client/dist/transcript.js
delete mode 100644 test-fixtures/streamable-http-client/golden/happy-path.json
delete mode 100644 test-fixtures/streamable-http-client/golden/permission-round-trip.json
delete mode 100644 test-fixtures/streamable-http-client/golden/session-load.json
delete mode 100644 test-fixtures/streamable-http-client/golden/two-sessions.json
delete mode 100644 test-fixtures/streamable-http-client/golden/validation-failures.json
delete mode 100644 test-fixtures/streamable-http-client/golden/wrong-stream-response.json
delete mode 100644 test-fixtures/streamable-http-client/package-lock.json
delete mode 100644 test-fixtures/streamable-http-client/package.json
delete mode 100644 test-fixtures/streamable-http-client/src/client.ts
delete mode 100644 test-fixtures/streamable-http-client/src/protocol.ts
delete mode 100644 test-fixtures/streamable-http-client/src/scenarios.ts
delete mode 100644 test-fixtures/streamable-http-client/src/transcript.ts
delete mode 100644 test-fixtures/streamable-http-client/tsconfig.json
delete mode 100644 test-fixtures/streamable-http-server/README.md
delete mode 100644 test-fixtures/streamable-http-server/dist/protocol.js
delete mode 100644 test-fixtures/streamable-http-server/dist/scenarios.js
delete mode 100644 test-fixtures/streamable-http-server/dist/server.js
delete mode 100644 test-fixtures/streamable-http-server/dist/transcript.js
delete mode 100644 test-fixtures/streamable-http-server/golden/happy-path.json
delete mode 100644 test-fixtures/streamable-http-server/golden/permission-round-trip.json
delete mode 100644 test-fixtures/streamable-http-server/golden/session-load.json
delete mode 100644 test-fixtures/streamable-http-server/golden/two-sessions.json
delete mode 100644 test-fixtures/streamable-http-server/golden/validation-failures.json
delete mode 100644 test-fixtures/streamable-http-server/golden/wrong-stream-response.json
delete mode 100644 test-fixtures/streamable-http-server/package-lock.json
delete mode 100644 test-fixtures/streamable-http-server/package.json
delete mode 100644 test-fixtures/streamable-http-server/src/protocol.ts
delete mode 100644 test-fixtures/streamable-http-server/src/scenarios.ts
delete mode 100644 test-fixtures/streamable-http-server/src/server.ts
delete mode 100644 test-fixtures/streamable-http-server/src/transcript.ts
delete mode 100644 test-fixtures/streamable-http-server/tsconfig.json
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
index aed6658..1cf2e51 100644
--- 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
@@ -4,20 +4,27 @@
package com.agentclientprotocol.sdk.client.transport;
-import java.io.BufferedReader;
import java.io.IOException;
-import java.io.InputStreamReader;
+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.nio.file.Files;
-import java.nio.file.Path;
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;
@@ -26,8 +33,8 @@
import com.agentclientprotocol.sdk.error.AcpConnectionException;
import com.agentclientprotocol.sdk.json.AcpJsonMapper;
import com.agentclientprotocol.sdk.spec.AcpSchema;
-import com.fasterxml.jackson.databind.JsonNode;
-import com.fasterxml.jackson.databind.ObjectMapper;
+import com.sun.net.httpserver.HttpExchange;
+import com.sun.net.httpserver.HttpServer;
import org.junit.jupiter.api.Test;
import reactor.core.publisher.Mono;
@@ -35,21 +42,25 @@
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/**
- * End-to-end tests against the in-repo TypeScript Streamable HTTP fixture.
+ * 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 Path FIXTURE_DIR = Path.of("..", "test-fixtures", "streamable-http-server").normalize();
+ private static final String CONNECTION_ID = "conn-test";
- private static final Path GOLDEN_DIR = FIXTURE_DIR.resolve("golden");
+ private static final String CONNECTION_STREAM = "connection";
- private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
+ 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 happyPathMatchesFixtureTranscript() throws Exception {
- try (FixtureServer fixture = FixtureServer.start("happy-path")) {
+ void happyPathUsesConnectionAndSessionStreams() throws Exception {
+ try (FixtureServer fixture = FixtureServer.start()) {
CopyOnWriteArrayList updates = new CopyOnWriteArrayList<>();
AcpAsyncClient client = newClient(fixture.endpoint())
.sessionUpdateConsumer(notification -> {
@@ -69,20 +80,23 @@ void happyPathMatchesFixtureTranscript() throws Exception {
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);
- fixture.assertTranscriptMatches("happy-path.json");
+ assertThat(fixture.deleteReceived()).isTrue();
}
}
@Test
void permissionRequestRoundTripsOnSessionStream() throws Exception {
- try (FixtureServer fixture = FixtureServer.start("permission-round-trip")) {
+ 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")));
+ return Mono.just(new AcpSchema.RequestPermissionResponse(
+ new AcpSchema.PermissionSelected("allow")));
})
.build();
@@ -94,18 +108,17 @@ void permissionRequestRoundTripsOnSessionStream() throws Exception {
.prompt(AcpTestFixtures.createPromptRequest(session.sessionId(), "needs permission"))
.block(TIMEOUT);
- assertThat(session.sessionId()).isEqualTo("sess-permission");
assertThat(prompt.stopReason()).isEqualTo(AcpSchema.StopReason.END_TURN);
assertThat(permissionRequests).hasValue(1);
+ assertThat(fixture.permissionResponseReceived()).isTrue();
client.closeGracefully().block(TIMEOUT);
- fixture.assertTranscriptMatches("permission-round-trip.json");
}
}
@Test
void loadSessionOpensSessionStreamBeforePosting() throws Exception {
- try (FixtureServer fixture = FixtureServer.start("session-load")) {
+ try (FixtureServer fixture = FixtureServer.start()) {
AcpAsyncClient client = newClient(fixture.endpoint()).build();
client.initialize().block(TIMEOUT);
@@ -114,15 +127,15 @@ void loadSessionOpensSessionStreamBeforePosting() throws Exception {
.block(TIMEOUT);
assertThat(response).isNotNull();
+ assertThat(fixture.sessionLoadStreamWasOpenBeforePost()).isTrue();
client.closeGracefully().block(TIMEOUT);
- fixture.assertTranscriptMatches("session-load.json");
}
}
@Test
void supportsTwoConcurrentLogicalSessions() throws Exception {
- try (FixtureServer fixture = FixtureServer.start("two-sessions")) {
+ try (FixtureServer fixture = FixtureServer.start()) {
AcpAsyncClient client = newClient(fixture.endpoint()).build();
client.initialize().block(TIMEOUT);
@@ -143,15 +156,17 @@ void supportsTwoConcurrentLogicalSessions() throws Exception {
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);
- fixture.assertTranscriptMatches("two-sessions.json");
}
}
@Test
void wrongStreamResponseFailsPendingExchange() throws Exception {
- try (FixtureServer fixture = FixtureServer.start("wrong-stream-response")) {
+ try (FixtureServer fixture = FixtureServer.start()) {
+ fixture.routePromptResponsesOnConnectionStream();
AcpAsyncClient client = newClient(fixture.endpoint()).build();
client.initialize().block(TIMEOUT);
@@ -166,54 +181,20 @@ void wrongStreamResponseFailsPendingExchange() throws Exception {
.hasMessageContaining("arrived on RouteScope");
client.closeGracefully().block(TIMEOUT);
- fixture.assertTranscriptMatches("wrong-stream-response.json");
}
}
@Test
- void fixtureRejectsMissingConnectionHeadersCookiesAndSseAccept() throws Exception {
- try (FixtureServer fixture = FixtureServer.start("validation-failures")) {
- HttpClient rawClient = HttpClient.newHttpClient();
- HttpResponse initialize = rawClient.send(HttpRequest.newBuilder(fixture.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();
- String cookie = initialize.headers().firstValue("set-cookie").orElseThrow();
-
- HttpResponse missingConnection = rawClient.send(HttpRequest.newBuilder(fixture.endpoint())
- .header("Accept", "text/event-stream")
- .header("Cookie", cookie)
- .GET()
- .build(), HttpResponse.BodyHandlers.discarding());
- HttpResponse missingCookie = rawClient.send(HttpRequest.newBuilder(fixture.endpoint())
- .header("Accept", "text/event-stream")
- .header("Acp-Connection-Id", connectionId)
- .GET()
- .build(), HttpResponse.BodyHandlers.discarding());
- HttpResponse wrongAccept = rawClient.send(HttpRequest.newBuilder(fixture.endpoint())
- .header("Accept", "application/json")
- .header("Cookie", cookie)
- .header("Acp-Connection-Id", connectionId)
- .GET()
- .build(), HttpResponse.BodyHandlers.discarding());
- HttpResponse delete = rawClient.send(HttpRequest.newBuilder(fixture.endpoint())
- .header("Cookie", cookie)
- .header("Acp-Connection-Id", connectionId)
- .DELETE()
- .build(), HttpResponse.BodyHandlers.discarding());
-
- assertThat(initialize.statusCode()).isEqualTo(200);
- assertThat(missingConnection.statusCode()).isEqualTo(400);
- assertThat(missingCookie.statusCode()).isEqualTo(401);
- assertThat(wrongAccept.statusCode()).isEqualTo(406);
- assertThat(delete.statusCode()).isEqualTo(202);
-
- fixture.assertTranscriptMatches("validation-failures.json");
+ 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);
}
}
@@ -225,63 +206,322 @@ private AcpClient.AsyncSpec newClient(URI endpoint) {
private static final class FixtureServer implements AutoCloseable {
- private final Process process;
+ 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 BufferedReader stdout;
+ private final AtomicBoolean sessionLoadStreamWasOpenBeforePost = new AtomicBoolean(false);
- private final URI baseUri;
+ private final CompletableFuture permissionResponse = new CompletableFuture<>();
- private FixtureServer(Process process, BufferedReader stdout, URI baseUri) {
- this.process = process;
- this.stdout = stdout;
- this.baseUri = baseUri;
+ private FixtureServer(HttpServer server, ExecutorService executor) {
+ this.server = server;
+ this.executor = executor;
}
- static FixtureServer start(String scenario) throws Exception {
- Process process = new ProcessBuilder("node", "dist/server.js", "--scenario", scenario, "--port", "0")
- .directory(FIXTURE_DIR.toFile())
- .redirectErrorStream(true)
- .start();
- BufferedReader stdout = new BufferedReader(
- new InputStreamReader(process.getInputStream(), StandardCharsets.UTF_8));
- String readyLine = stdout.readLine();
- if (readyLine == null) {
- throw new IllegalStateException("Fixture server exited before becoming ready");
+ 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;
}
- JsonNode ready = OBJECT_MAPPER.readTree(readyLine);
- if (!"ready".equals(ready.path("status").asText())) {
- throw new IllegalStateException("Fixture server did not become ready: " + readyLine);
+
+ 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;
}
- int port = ready.path("port").asInt();
- return new FixtureServer(process, stdout, URI.create("http://127.0.0.1:" + port));
+
+ String sessionId = exchange.getRequestHeaders().getFirst("Acp-Session-Id");
+ exchange.sendResponseHeaders(202, -1);
+ exchange.close();
+ handleAcceptedMessage(message, sessionId);
}
- URI endpoint() {
- return baseUri.resolve("/acp");
+ 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()));
+ }
}
- void assertTranscriptMatches(String goldenName) throws Exception {
- HttpRequest request = HttpRequest.newBuilder(baseUri.resolve("/__test/transcript")).GET().build();
- HttpResponse response = HttpClient.newHttpClient()
- .send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
- assertThat(response.statusCode()).isEqualTo(200);
+ 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;
+ }
- JsonNode actual = OBJECT_MAPPER.readTree(response.body());
- JsonNode expected = OBJECT_MAPPER.readTree(Files.readString(GOLDEN_DIR.resolve(goldenName)));
- assertThat(actual).isEqualTo(expected);
+ 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() throws Exception {
- process.destroy();
- if (!process.waitFor(2, TimeUnit.SECONDS)) {
- process.destroyForcibly();
- process.waitFor(2, TimeUnit.SECONDS);
+ 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 {
- stdout.close();
+ 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) {
}
- catch (IOException ignored) {
+ finally {
+ try {
+ outputStream.close();
+ }
+ catch (IOException ignored) {
+ }
}
}
diff --git a/acp-streamable-http-jetty/pom.xml b/acp-streamable-http-jetty/pom.xml
index defe354..c31405a 100644
--- a/acp-streamable-http-jetty/pom.xml
+++ b/acp-streamable-http-jetty/pom.xml
@@ -34,6 +34,14 @@
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
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
index 843860a..56ef14e 100644
--- 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
@@ -6,6 +6,7 @@
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;
@@ -39,6 +40,15 @@
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;
@@ -48,16 +58,19 @@
* Listener-backed ACP Streamable HTTP transport for agents.
*
*
- * This transport hosts a Jetty HTTP endpoint and 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 HTTP concerns such as headers, SSE streams, and request routing.
+ * 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.
*
*
*
- * Streamable HTTP and the RFD-compliant remote WebSocket listener share
- * {@link RemoteAcpConnection}; this class keeps HTTP-specific routing, headers, SSE
- * stream ownership, and replay behavior local to the HTTP adapter.
+ * 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
@@ -160,6 +173,8 @@ private record ResolvedInboundRoute(JSONRPCMessage message, RouteScope requestSc
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);
@@ -232,6 +247,25 @@ public Mono start() {
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();
@@ -262,6 +296,8 @@ public Mono closeGracefully() {
}
connections.values().forEach(ConnectionState::close);
connections.clear();
+ webSocketConnections.values().forEach(WebSocketConnectionState::close);
+ webSocketConnections.clear();
Server currentServer = this.server;
if (currentServer != null) {
try {
@@ -283,6 +319,10 @@ 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);
@@ -290,8 +330,14 @@ private ConnectionState createConnection() {
return connection;
}
- private Optional connection(String connectionId) {
- return Optional.ofNullable(connections.get(connectionId));
+ 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 {
@@ -879,6 +925,142 @@ void close() {
}
+ 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 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 {
+ Session currentSession = this.session;
+ if (closed.get() || currentSession == null || !currentSession.isOpen()) {
+ throw new AcpConnectionException("Streamable ACP WebSocket connection is closed");
+ }
+ String payload = jsonMapper.writeValueAsString(message);
+ logger.debug("Sending streamable ACP WebSocket message: {}", payload);
+ currentSession.sendText(payload, Callback.from(() -> {
+ // Jetty requires an explicit success callback; there is no
+ // follow-up work after the frame has been accepted for writing.
+ }, error -> {
+ if (!closed.get()) {
+ remoteConnection.signalException(error);
+ }
+ }));
+ }
+ 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;
+ }
+ webSocketConnections.remove(id, this);
+ Session currentSession = this.session;
+ if (currentSession != null && currentSession.isOpen()) {
+ currentSession.close(statusCode, reason, Callback.NOOP);
+ }
+ remoteConnection.closeGracefully().subscribe();
+ }
+
+ }
+
+ /**
+ * 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) {
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
index cf34646..c42d2fa 100644
--- 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
@@ -6,6 +6,7 @@
import java.io.BufferedReader;
import java.io.IOException;
+import java.io.InputStream;
import java.io.InputStreamReader;
import java.net.ServerSocket;
import java.net.URI;
@@ -13,10 +14,12 @@
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
-import java.nio.file.Files;
-import java.nio.file.Path;
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;
@@ -26,98 +29,195 @@
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 com.fasterxml.jackson.databind.JsonNode;
-import com.fasterxml.jackson.databind.ObjectMapper;
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 in-repo TypeScript Streamable HTTP fixture client.
+ * End-to-end tests against the Java Streamable HTTP agent transport.
*/
class StreamableHttpAcpAgentTransportIntegrationTest {
private static final Duration TIMEOUT = Duration.ofSeconds(5);
- private static final Path FIXTURE_DIR = Path.of("..", "test-fixtures", "streamable-http-client").normalize();
-
- private static final Path GOLDEN_DIR = FIXTURE_DIR.resolve("golden");
-
- private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
+ private static final AcpJsonMapper JSON_MAPPER = AcpJsonMapper.createDefault();
@Test
- void happyPathMatchesFixtureTranscript() throws Exception {
+ void javaClientCanTalkToRunningJavaServer() throws Exception {
try (FixtureServer server = FixtureServer.start(StreamableHttpAcpAgentTransport.RoutingMode.COMPATIBLE)) {
- JsonNode transcript = FixtureClient.run(server.endpoint(), "happy-path");
- assertTranscriptMatches(transcript, "happy-path.json");
+ 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 permissionRoundTripMatchesFixtureTranscript() throws Exception {
+ void permissionRequestRoundTripsOverSessionStream() throws Exception {
try (FixtureServer server = FixtureServer.start(StreamableHttpAcpAgentTransport.RoutingMode.COMPATIBLE)) {
- JsonNode transcript = FixtureClient.run(server.endpoint(), "permission-round-trip");
- assertTranscriptMatches(transcript, "permission-round-trip.json");
+ 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)) {
- JsonNode transcript = FixtureClient.run(server.endpoint(), "session-load");
- assertTranscriptMatches(transcript, "session-load.json");
- }
- }
+ AcpAsyncClient client = AcpClient
+ .async(new StreamableHttpAcpClientTransport(server.endpoint(), AcpJsonMapper.createDefault()))
+ .requestTimeout(TIMEOUT)
+ .build();
- @Test
- void supportsTwoLogicalSessions() throws Exception {
- try (FixtureServer server = FixtureServer.start(StreamableHttpAcpAgentTransport.RoutingMode.COMPATIBLE)) {
- JsonNode transcript = FixtureClient.run(server.endpoint(), "two-sessions");
- assertTranscriptMatches(transcript, "two-sessions.json");
- }
- }
+ client.initialize().block(TIMEOUT);
+ AcpSchema.LoadSessionResponse response = client
+ .loadSession(new AcpSchema.LoadSessionRequest("sess-load", "/workspace", List.of()))
+ .block(TIMEOUT);
- @Test
- void wrongStreamResponseIsRejected() throws Exception {
- try (FixtureServer server = FixtureServer.start(StreamableHttpAcpAgentTransport.RoutingMode.COMPATIBLE)) {
- JsonNode transcript = FixtureClient.run(server.endpoint(), "wrong-stream-response");
- assertTranscriptMatches(transcript, "wrong-stream-response.json");
- }
- }
+ assertThat(response).isNotNull();
- @Test
- void validationFailuresMatchFixtureTranscript() throws Exception {
- try (FixtureServer server = FixtureServer.start(StreamableHttpAcpAgentTransport.RoutingMode.COMPATIBLE)) {
- JsonNode transcript = FixtureClient.run(server.endpoint(), "validation-failures");
- assertTranscriptMatches(transcript, "validation-failures.json");
+ client.closeGracefully().block(TIMEOUT);
}
}
@Test
- void javaClientCanTalkToRunningJavaServer() throws Exception {
+ 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 session = client
- .newSession(new AcpSchema.NewSessionRequest("/workspace", List.of(), null))
+ AcpSchema.NewSessionResponse first = client
+ .newSession(new AcpSchema.NewSessionRequest("/workspace/one", List.of(), null))
.block(TIMEOUT);
- AcpSchema.PromptResponse prompt = client
- .prompt(new AcpSchema.PromptRequest(session.sessionId(),
- List.of(new AcpSchema.TextContent("hello")), null))
+ 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(session.sessionId()).isEqualTo("sess-1");
- assertThat(prompt.stopReason()).isEqualTo(AcpSchema.StopReason.END_TURN);
+ 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)) {
@@ -143,9 +243,17 @@ void strictModeRejectsUnknownSessionStream() throws Exception {
}
}
- private static void assertTranscriptMatches(JsonNode actual, String goldenName) throws Exception {
- JsonNode expected = OBJECT_MAPPER.readTree(Files.readString(GOLDEN_DIR.resolve(goldenName)));
- assertThat(actual).isEqualTo(expected);
+ 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 {
@@ -195,26 +303,79 @@ private static int freePort() throws IOException {
}
- private static final class FixtureClient {
-
- static JsonNode run(URI endpoint, String scenario) throws Exception {
- Process process = new ProcessBuilder("node", "dist/client.js", "--endpoint", endpoint.toString(), "--scenario",
- scenario)
- .directory(FIXTURE_DIR.toFile())
- .redirectErrorStream(true)
- .start();
- try (BufferedReader stdout = new BufferedReader(
- new InputStreamReader(process.getInputStream(), StandardCharsets.UTF_8))) {
- String output = stdout.lines().reduce("", (left, right) -> left + right + System.lineSeparator());
- if (!process.waitFor(5, TimeUnit.SECONDS)) {
- process.destroyForcibly();
- throw new IllegalStateException("Fixture client timed out");
- }
- if (process.exitValue() != 0) {
- throw new IllegalStateException("Fixture client failed: " + output);
+ 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());
+ }
}
- return OBJECT_MAPPER.readTree(output);
}
+ 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-websocket-jetty/src/test/java/com/agentclientprotocol/sdk/agent/transport/RemoteWebSocketAcpAgentTransportIntegrationTest.java b/acp-streamable-http-jetty/src/test/java/com/agentclientprotocol/sdk/agent/transport/StreamableHttpAcpAgentTransportWebSocketIntegrationTest.java
similarity index 90%
rename from acp-websocket-jetty/src/test/java/com/agentclientprotocol/sdk/agent/transport/RemoteWebSocketAcpAgentTransportIntegrationTest.java
rename to acp-streamable-http-jetty/src/test/java/com/agentclientprotocol/sdk/agent/transport/StreamableHttpAcpAgentTransportWebSocketIntegrationTest.java
index 2280e2c..60e4432 100644
--- a/acp-websocket-jetty/src/test/java/com/agentclientprotocol/sdk/agent/transport/RemoteWebSocketAcpAgentTransportIntegrationTest.java
+++ b/acp-streamable-http-jetty/src/test/java/com/agentclientprotocol/sdk/agent/transport/StreamableHttpAcpAgentTransportWebSocketIntegrationTest.java
@@ -43,9 +43,9 @@
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/**
- * End-to-end tests for the RFD-compliant listener-backed WebSocket transport.
+ * End-to-end tests for the WebSocket upgrade path on the Streamable HTTP transport.
*/
-class RemoteWebSocketAcpAgentTransportIntegrationTest {
+class StreamableHttpAcpAgentTransportWebSocketIntegrationTest {
private static final Duration TIMEOUT = Duration.ofSeconds(5);
@@ -54,16 +54,16 @@ void constructorValidatesRequiredArguments() {
AcpJsonMapper jsonMapper = AcpJsonMapper.createDefault();
AcpAgentFactory agentFactory = simpleAgentFactory();
- assertThatThrownBy(() -> new RemoteWebSocketAcpAgentTransport(0, jsonMapper, agentFactory))
+ assertThatThrownBy(() -> new StreamableHttpAcpAgentTransport(0, jsonMapper, agentFactory))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("Port");
- assertThatThrownBy(() -> new RemoteWebSocketAcpAgentTransport(8080, "", jsonMapper, agentFactory))
+ assertThatThrownBy(() -> new StreamableHttpAcpAgentTransport(8080, "", jsonMapper, agentFactory))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("Path");
- assertThatThrownBy(() -> new RemoteWebSocketAcpAgentTransport(8080, null, agentFactory))
+ assertThatThrownBy(() -> new StreamableHttpAcpAgentTransport(8080, null, agentFactory))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("JsonMapper");
- assertThatThrownBy(() -> new RemoteWebSocketAcpAgentTransport(8080, jsonMapper, null))
+ assertThatThrownBy(() -> new StreamableHttpAcpAgentTransport(8080, jsonMapper, null))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("agentFactory");
}
@@ -96,7 +96,7 @@ void handshakeReturnsConnectionIdHeader() throws Exception {
}
@Test
- void javaClientCanTalkToRemoteWebSocketServer() throws Exception {
+ void javaClientCanTalkToStreamableWebSocketUpgrade() throws Exception {
AtomicReference receivedUpdate = new AtomicReference<>();
try (FixtureServer server = FixtureServer.start(simpleAgentFactory())) {
@@ -131,13 +131,13 @@ void javaClientCanTalkToRemoteWebSocketServer() throws Exception {
}
@Test
- void permissionRequestRoundTripsOverRemoteWebSocket() throws Exception {
+ 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("remote websocket edit")
+ .promptHandler((request, context) -> context.askPermission("streamable websocket edit")
.map(allowed -> {
assertThat(allowed).isTrue();
return AcpSchema.PromptResponse.endTurn();
@@ -237,7 +237,7 @@ private static AcpAgentFactory simpleAgentFactory() {
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 remote websocket")
+ .promptHandler((request, context) -> context.sendMessage("hello from streamable websocket")
.thenReturn(AcpSchema.PromptResponse.endTurn()))
.build());
}
@@ -252,7 +252,7 @@ private static List readHttpHeaders(Socket socket) throws IOException {
return lines;
}
- private static void assertEventuallyNoConnections(RemoteWebSocketAcpAgentTransport transport) throws InterruptedException {
+ private static void assertEventuallyNoConnections(StreamableHttpAcpAgentTransport transport) throws InterruptedException {
long deadline = System.nanoTime() + TIMEOUT.toNanos();
while (System.nanoTime() < deadline) {
if (transport.activeConnectionCount() == 0) {
@@ -274,14 +274,14 @@ private static int freePort() {
private static final class FixtureServer implements AutoCloseable {
- private final RemoteWebSocketAcpAgentTransport transport;
+ private final StreamableHttpAcpAgentTransport transport;
- private FixtureServer(RemoteWebSocketAcpAgentTransport transport) {
+ private FixtureServer(StreamableHttpAcpAgentTransport transport) {
this.transport = transport;
}
static FixtureServer start(AcpAgentFactory agentFactory) {
- RemoteWebSocketAcpAgentTransport transport = new RemoteWebSocketAcpAgentTransport(
+ StreamableHttpAcpAgentTransport transport = new StreamableHttpAcpAgentTransport(
freePort(), AcpJsonMapper.createDefault(), agentFactory);
transport.start().block(TIMEOUT);
return new FixtureServer(transport);
@@ -295,7 +295,7 @@ URI endpoint() {
return URI.create("ws://127.0.0.1:" + transport.getPort() + "/acp");
}
- RemoteWebSocketAcpAgentTransport transport() {
+ StreamableHttpAcpAgentTransport transport() {
return transport;
}
diff --git a/acp-websocket-jetty/src/main/java/com/agentclientprotocol/sdk/agent/transport/RemoteWebSocketAcpAgentTransport.java b/acp-websocket-jetty/src/main/java/com/agentclientprotocol/sdk/agent/transport/RemoteWebSocketAcpAgentTransport.java
deleted file mode 100644
index 6881ab1..0000000
--- a/acp-websocket-jetty/src/main/java/com/agentclientprotocol/sdk/agent/transport/RemoteWebSocketAcpAgentTransport.java
+++ /dev/null
@@ -1,363 +0,0 @@
-/*
- * Copyright 2025-2026 the original author or authors.
- */
-
-package com.agentclientprotocol.sdk.agent.transport;
-
-import java.time.Duration;
-import java.util.UUID;
-import java.util.concurrent.ConcurrentHashMap;
-import java.util.concurrent.ConcurrentMap;
-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.spec.AcpSchema;
-import com.agentclientprotocol.sdk.spec.AcpSchema.JSONRPCMessage;
-import com.agentclientprotocol.sdk.util.Assert;
-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;
-
-/**
- * RFD-compliant listener-backed ACP WebSocket transport for agents.
- *
- *
- * This transport accepts WebSocket upgrades on one ACP endpoint and creates one fresh
- * agent runtime per accepted remote connection via {@link AcpAgentFactory}. The
- * resulting per-connection {@link RemoteAcpConnection} owns ACP JSON-RPC semantics,
- * while this class owns only Jetty listener setup, WebSocket framing, connection IDs,
- * and close/error lifecycle.
- *
- *
- *
- * Unlike the legacy {@link WebSocketAcpAgentTransport}, this class is intentionally not
- * an {@code AcpAgentTransport} itself. It is a listener that creates one
- * connection-bound transport per WebSocket client.
- *
- *
- * @author Kaiser Dandangi
- */
-public class RemoteWebSocketAcpAgentTransport {
-
- private static final Logger logger = LoggerFactory.getLogger(RemoteWebSocketAcpAgentTransport.class);
-
- public static final String DEFAULT_ACP_PATH = "/acp";
-
- public static final String HEADER_CONNECTION_ID = "Acp-Connection-Id";
-
- private static final Duration INITIALIZE_TIMEOUT = Duration.ofSeconds(30);
-
- private final int configuredPort;
-
- private final String path;
-
- private final AcpJsonMapper jsonMapper;
-
- private final AcpAgentFactory agentFactory;
-
- private final ConcurrentMap connections = 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 Duration idleTimeout = Duration.ofMinutes(30);
-
- private volatile Server server;
-
- private volatile ServerConnector connector;
-
- /**
- * Creates a new RFD-compliant WebSocket 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 RemoteWebSocketAcpAgentTransport(int port, AcpJsonMapper jsonMapper, AcpAgentFactory agentFactory) {
- this(port, DEFAULT_ACP_PATH, jsonMapper, agentFactory);
- }
-
- /**
- * Creates a new RFD-compliant WebSocket 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 RemoteWebSocketAcpAgentTransport(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 WebSocket idle timeout.
- * @param timeout idle timeout
- * @return this transport
- */
- public RemoteWebSocketAcpAgentTransport idleTimeout(Duration timeout) {
- Assert.notNull(timeout, "The timeout can not be null");
- this.idleTimeout = timeout;
- return this;
- }
-
- /**
- * Starts the embedded Jetty WebSocket listener.
- * @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.fromRunnable(() -> {
- Server jettyServer = new Server();
- ServerConnector jettyConnector = new ServerConnector(jettyServer);
- jettyConnector.setPort(configuredPort);
- jettyServer.addConnector(jettyConnector);
-
- WebSocketUpgradeHandler wsHandler = WebSocketUpgradeHandler.from(jettyServer, container -> {
- container.setIdleTimeout(idleTimeout);
- container.addMapping(path, (request, response, callback) -> {
- ConnectionState connection = createConnection();
- try {
- connection.start();
- connections.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;
- }
- });
- });
- jettyServer.setHandler(wsHandler);
-
- try {
- jettyServer.start();
- }
- catch (Exception e) {
- throw new AcpConnectionException("Failed to start Remote WebSocket listener", e);
- }
- this.server = jettyServer;
- this.connector = jettyConnector;
- logger.info("Remote WebSocket ACP agent listener started on port {} at path {}", getPort(), path);
- });
- }
-
- /**
- * Returns the bound port.
- * @return listener port
- */
- public int getPort() {
- ServerConnector currentConnector = this.connector;
- return currentConnector != null ? currentConnector.getLocalPort() : configuredPort;
- }
-
- /**
- * Closes all active WebSocket 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();
- Server currentServer = this.server;
- if (currentServer != null) {
- try {
- currentServer.stop();
- }
- catch (Exception e) {
- throw new AcpConnectionException("Failed to stop Remote WebSocket listener", e);
- }
- }
- terminationSink.tryEmitValue(null);
- });
- }
-
- /**
- * Closes all active WebSocket connections and stops the listener immediately.
- */
- public void close() {
- closeGracefully().block(Duration.ofSeconds(5));
- }
-
- /**
- * Returns a mono that completes once the listener terminates.
- * @return termination mono
- */
- public Mono awaitTermination() {
- return terminationSink.asMono();
- }
-
- int activeConnectionCount() {
- return connections.size();
- }
-
- private ConnectionState createConnection() {
- String connectionId = UUID.randomUUID().toString();
- return new ConnectionState(connectionId);
- }
-
- private boolean isInitializeRequest(JSONRPCMessage message) {
- return message instanceof AcpSchema.JSONRPCRequest request
- && AcpSchema.METHOD_INITIALIZE.equals(request.method()) && request.id() != null;
- }
-
- private final class ConnectionState {
-
- private final String id;
-
- private final RemoteAcpConnection remoteConnection;
-
- private final AtomicBoolean initialized = new AtomicBoolean(false);
-
- private final AtomicBoolean closed = new AtomicBoolean(false);
-
- private volatile Session session;
-
- ConnectionState(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 RFD makes initialize the first client-originated
- // JSON-RPC request. Enforce that at the wire adapter before the
- // shared connection core sees application messages.
- 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 {
- Session currentSession = this.session;
- if (closed.get() || currentSession == null || !currentSession.isOpen()) {
- throw new AcpConnectionException("Remote WebSocket connection is closed");
- }
- String payload = jsonMapper.writeValueAsString(message);
- logger.debug("Sending remote WebSocket message: {}", payload);
- currentSession.sendText(payload, Callback.from(() -> {
- }, error -> {
- if (!closed.get()) {
- remoteConnection.signalException(error);
- }
- }));
- }
- 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;
- }
- connections.remove(id, this);
- Session currentSession = this.session;
- if (currentSession != null && currentSession.isOpen()) {
- currentSession.close(statusCode, reason, Callback.NOOP);
- }
- remoteConnection.closeGracefully().subscribe();
- }
-
- }
-
- /**
- * Jetty WebSocket endpoint for one accepted ACP connection.
- */
- @WebSocket
- public class AcpWebSocketEndpoint {
-
- private final ConnectionState connection;
-
- AcpWebSocketEndpoint(ConnectionState connection) {
- this.connection = connection;
- }
-
- @OnWebSocketOpen
- public void onOpen(Session session) {
- logger.info("Remote WebSocket ACP client connected from {}", session.getRemoteSocketAddress());
- connection.open(session);
- }
-
- @OnWebSocketMessage
- public void onMessage(Session session, String message) {
- logger.debug("Received remote WebSocket message: {}", message);
-
- try {
- JSONRPCMessage jsonRpcMessage = AcpSchema.deserializeJsonRpcMessage(jsonMapper, message);
- connection.acceptFromClient(jsonRpcMessage);
- }
- catch (Exception e) {
- logger.warn("Closing remote WebSocket ACP 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("Remote WebSocket ACP client disconnected: {} - {}", statusCode, reason);
- connection.close(statusCode, reason);
- }
-
- @OnWebSocketError
- public void onError(Session session, Throwable error) {
- logger.error("Remote WebSocket ACP error", error);
- connection.remoteConnection.signalException(error);
- connection.close(StatusCode.SERVER_ERROR, "WebSocket error");
- }
-
- }
-
-}
diff --git a/plans/STREAMABLE-HTTP-AGENT-TRANSPORT.md b/plans/STREAMABLE-HTTP-AGENT-TRANSPORT.md
index a97f203..26410fb 100644
--- a/plans/STREAMABLE-HTTP-AGENT-TRANSPORT.md
+++ b/plans/STREAMABLE-HTTP-AGENT-TRANSPORT.md
@@ -2,7 +2,7 @@
> **Status**: Milestone one implemented
> **Created**: 2026-05-18
-> **Primary goal**: Java agents can be served from a running Java web server over the Streamable HTTP transport while preserving current WebSocket behavior until parity is proven.
+> **Primary goal**: Java agents can be served from a running Java web server over the Streamable HTTP transport, including WebSocket upgrades on the same ACP endpoint.
## Goal
@@ -11,6 +11,7 @@ Add an agent-side Streamable HTTP transport backed by Jetty, with:
- one fresh ACP agent runtime per accepted remote connection
- a public `AcpAgentFactory` seam for listener-backed transports
- RFD-oriented HTTP + SSE behavior
+- WebSocket upgrade handling on the same ACP path
- strict and compatible routing modes
- a fixture-driven conformance harness that exercises a real running Java listener
@@ -22,8 +23,8 @@ Add an agent-side Streamable HTTP transport backed by Jetty, with:
- `AcpAgentFactory.sync(...)`
- Add `StreamableHttpAcpAgentTransport` in a dedicated Jetty adapter module:
- `acp-streamable-http-jetty`
-- Keep the current WebSocket single-agent API intact in this milestone.
-- PLAN: once Streamable HTTP reaches behavioral parity, migrate remote WebSocket handling toward the same factory-backed listener model.
+- Keep the current legacy WebSocket single-agent API intact in `acp-websocket-jetty`.
+- Serve the RFD-compatible remote WebSocket upgrade path from `StreamableHttpAcpAgentTransport`, matching the Rust SDK shape where one HTTP server owns POST/SSE and WebSocket upgrades.
## Runtime Model
@@ -56,6 +57,11 @@ StreamableHttpAcpAgentTransport
- session updates
- agent-originated session-scoped requests such as permission prompts
- DELETE tears down the connection and releases transport state.
+- WebSocket upgrades:
+ - create one connection during the upgrade handshake
+ - return `Acp-Connection-Id` on the `101 Switching Protocols` response
+ - require the first client-originated JSON-RPC message on the socket to be `initialize`
+ - exchange JSON-RPC messages as text frames until the socket closes
### Routing ledgers
@@ -84,30 +90,19 @@ PLAN: revisit this once the protocol resolves the resume/session-load ordering c
## Test Harness
-Create an in-repo fixture:
-
-```text
-test-fixtures/streamable-http-client/
-```
-
-The fixture is:
-
-- TypeScript
-- HTTP-only
-- scenario-driven
-- the single owner of canonical transcript serialization
-- run against a real Java Jetty listener
+Use Java integration tests only for this branch so the PR stays focused on SDK
+transport behavior instead of adding a separate fixture harness.
Covered scenarios:
-- happy path
+- happy path over Streamable HTTP POST + SSE
- permission round-trip
- session load / provisional pre-open
- two logical sessions
- wrong-stream response rejection
- validation failures
-
-The Java module also keeps focused integration coverage for strict unknown-session behavior.
+- strict unknown-session behavior
+- WebSocket upgrade behavior on the same Java listener
## Demo Server
@@ -119,7 +114,8 @@ test-fixtures/streamable-http-agent-server/
It packages a small echo-style ACP agent into a runnable jar backed by the real
Jetty `StreamableHttpAcpAgentTransport`, so manual testing can exercise a live
-HTTP/SSE endpoint instead of only the integration-test fixture lifecycle.
+HTTP/SSE endpoint and WebSocket upgrade endpoint instead of only the
+integration-test fixture lifecycle.
## PLAN / Follow-Up Work
@@ -128,12 +124,12 @@ HTTP/SSE endpoint instead of only the integration-test fixture lifecycle.
both remote listener transports need: per-connection agent factory creation,
connection/session registries, lifecycle teardown, request/response routing
ledgers, timeout/error propagation, and observability hooks. The actual wire
- adapters should remain transport-specific: WebSocket text frames stay in the
- WebSocket module, and HTTP methods, headers, cookies, SSE parsing, and status
- codes stay in the Streamable HTTP module. Deferring this extraction keeps the
- first HTTP implementation close to the RFD and avoids prematurely forcing the
- existing WebSocket behavior through an abstraction before parity is proven.
-- migrate WebSocket toward the same factory-backed listener model
+ adapters should remain transport-specific: legacy WebSocket framing stays in
+ the WebSocket module, while the RFD Streamable HTTP endpoint owns HTTP
+ methods, headers, SSE parsing, status codes, and its WebSocket upgrade branch.
+ Deferring this extraction keeps the first implementation close to the RFD and
+ avoids prematurely forcing the existing legacy WebSocket behavior through an
+ abstraction before parity is proven.
- add idle/provisional-session eviction and replay retention policies
- revisit per-logical-session active-prompt tracking in `AcpAgentSession`
- expose richer diagnostics / observability hooks
diff --git a/plans/STREAMABLE-HTTP-TRANSPORT.md b/plans/STREAMABLE-HTTP-TRANSPORT.md
index cc2248e..188b3cc 100644
--- a/plans/STREAMABLE-HTTP-TRANSPORT.md
+++ b/plans/STREAMABLE-HTTP-TRANSPORT.md
@@ -12,7 +12,7 @@ This first milestone is intentionally client-only:
- implement `StreamableHttpAcpClientTransport`
- preserve the existing ACP client API surface
-- prove the wire contract with an in-repo TypeScript conformance fixture
+- prove the wire contract with focused Java integration coverage
- defer remote transport negotiation, Java server support, and reconnect/resume behavior
## Milestone-One Result
@@ -22,7 +22,7 @@ Implemented in this branch:
- `StreamableHttpAcpClientTransport`
- preserved public ACP client API
- compatibility note + isolated forwarding path for the existing client handler-emission ambiguity
-- in-repo TypeScript fixture with golden transcripts
+- Java integration coverage against an in-process Streamable HTTP fixture server
- Java unit + integration coverage for:
- initialize bootstrap
- cookie persistence
@@ -34,7 +34,7 @@ Implemented in this branch:
- wrong-stream responses
- strict-routing rejection
- two logical sessions
- - fixture validation failures for missing connection headers, missing cookies, and invalid SSE `Accept`
+ - validation failures for missing connection headers and invalid SSE `Accept`
## Contract Decisions
@@ -113,37 +113,19 @@ Implemented in this branch:
## Test Harness
-Create an in-repo TypeScript fixture:
-
-```text
-test-fixtures/streamable-http-server/
-```
-
-The fixture will be:
-
-- HTTP-only in the first milestone
-- strict by default
-- scenario-driven with named startup-selected scenarios
-- runnable manually and from Java integration tests
-- the single owner of canonical transcript serialization
-
-Golden transcripts will live beside the fixture:
-
-```text
-test-fixtures/streamable-http-server/golden/
-```
+Use an in-process Java Streamable HTTP fixture server for client transport tests.
+This keeps the PR focused on Java SDK transport behavior and avoids adding a
+separate conformance harness to the repository.
### Milestone-One Scenarios
- initialize bootstrap
-- cookie persistence
- connection SSE stream
- `session/new`
- prompt flow with session updates
- agent → client `session/request_permission`
- `session/load`
- validation failures for wrong / missing headers
-- missing cookie
- wrong-stream response
- strict-routing rejection
- light two-session coverage
diff --git a/test-fixtures/streamable-http-agent-server/README.md b/test-fixtures/streamable-http-agent-server/README.md
index 3d5c753..066944c 100644
--- a/test-fixtures/streamable-http-agent-server/README.md
+++ b/test-fixtures/streamable-http-agent-server/README.md
@@ -15,13 +15,11 @@ Run it:
java -jar test-fixtures/streamable-http-agent-server/target/acp-streamable-http-agent-server.jar --port 8080
```
-Then drive it with the fixture client from another shell:
+Then connect any ACP client to either endpoint printed at startup:
-```bash
-cd test-fixtures/streamable-http-client
-npm install
-npm run build
-node dist/client.js --endpoint http://127.0.0.1:8080/acp --scenario happy-path
+```text
+http://127.0.0.1:8080/acp # Streamable HTTP POST + SSE
+ws://127.0.0.1:8080/acp # WebSocket upgrade
```
The demo supports `initialize`, `session/new`, `session/load`, `session/prompt`,
diff --git a/test-fixtures/streamable-http-agent-server/src/main/java/com/agentclientprotocol/sdk/fixtures/StreamableHttpAgentDemoServer.java b/test-fixtures/streamable-http-agent-server/src/main/java/com/agentclientprotocol/sdk/fixtures/StreamableHttpAgentDemoServer.java
index 3061624..ab01011 100644
--- a/test-fixtures/streamable-http-agent-server/src/main/java/com/agentclientprotocol/sdk/fixtures/StreamableHttpAgentDemoServer.java
+++ b/test-fixtures/streamable-http-agent-server/src/main/java/com/agentclientprotocol/sdk/fixtures/StreamableHttpAgentDemoServer.java
@@ -129,6 +129,8 @@ public static void main(String[] args) {
server.start().block(START_TIMEOUT);
System.out.println("ACP Streamable HTTP demo agent listening at http://127.0.0.1:" + server.getPort()
+ options.path());
+ System.out.println("ACP WebSocket upgrade endpoint available at ws://127.0.0.1:" + server.getPort()
+ + options.path());
System.out.println("Press Ctrl-C to stop.");
server.awaitTermination().block();
}
diff --git a/test-fixtures/streamable-http-client/README.md b/test-fixtures/streamable-http-client/README.md
deleted file mode 100644
index 37e1bb2..0000000
--- a/test-fixtures/streamable-http-client/README.md
+++ /dev/null
@@ -1,27 +0,0 @@
-# Streamable HTTP Fixture Client
-
-This in-repo TypeScript fixture drives the Java Streamable HTTP agent transport
-through raw HTTP and SSE exchanges. It is intentionally small, strict, and scenario
-driven so the wire contract stays visible while the Java server implementation evolves.
-
-Scenarios:
-
-- `happy-path`
-- `permission-round-trip`
-- `session-load`
-- `two-sessions`
-- `wrong-stream-response`
-- `validation-failures`
-
-Build once:
-
-```bash
-npm install
-npm run build
-```
-
-Run against a local Java listener:
-
-```bash
-node dist/client.js --endpoint http://127.0.0.1:8080/acp --scenario happy-path
-```
diff --git a/test-fixtures/streamable-http-client/dist/client.js b/test-fixtures/streamable-http-client/dist/client.js
deleted file mode 100644
index 26f4522..0000000
--- a/test-fixtures/streamable-http-client/dist/client.js
+++ /dev/null
@@ -1,16 +0,0 @@
-import { StreamableHttpFixtureClient } from "./protocol.js";
-import { runScenario } from "./scenarios.js";
-import { TranscriptRecorder } from "./transcript.js";
-const endpoint = readArg("--endpoint");
-if (!endpoint) {
- throw new Error("--endpoint is required");
-}
-const scenario = readArg("--scenario") ?? "happy-path";
-const recorder = new TranscriptRecorder();
-const client = new StreamableHttpFixtureClient(endpoint, recorder);
-await runScenario(scenario, endpoint, client);
-process.stdout.write(recorder.serialize());
-function readArg(name) {
- const index = process.argv.indexOf(name);
- return index >= 0 ? process.argv[index + 1] : undefined;
-}
diff --git a/test-fixtures/streamable-http-client/dist/protocol.js b/test-fixtures/streamable-http-client/dist/protocol.js
deleted file mode 100644
index e6df323..0000000
--- a/test-fixtures/streamable-http-client/dist/protocol.js
+++ /dev/null
@@ -1,245 +0,0 @@
-export const CONNECTION_HEADER = "Acp-Connection-Id";
-export const SESSION_HEADER = "Acp-Session-Id";
-export class StreamableHttpFixtureClient {
- endpoint;
- recorder;
- connectionId = null;
- nextId = 1;
- constructor(endpoint, recorder) {
- this.endpoint = endpoint;
- this.recorder = recorder;
- }
- async initialize() {
- const request = this.request("initialize", {
- protocolVersion: 1,
- clientCapabilities: {},
- });
- const response = await this.post(request, "bootstrap", null, true);
- const connectionId = response.headers.get(CONNECTION_HEADER);
- if (!connectionId) {
- throw new Error("initialize response missing connection id");
- }
- this.connectionId = connectionId;
- return await response.json();
- }
- async postMessage(message, sessionId = null) {
- return this.post(message, sessionId ? "session" : "connection", sessionId, false);
- }
- async openConnectionStream() {
- return SseStream.open(this.endpoint, this.recorder, "connection", this.requireConnectionId(), null);
- }
- async openSessionStream(sessionId) {
- return SseStream.open(this.endpoint, this.recorder, "session", this.requireConnectionId(), sessionId);
- }
- async close() {
- const headers = {
- [CONNECTION_HEADER]: this.requireConnectionId(),
- };
- this.recorder.record({
- kind: "http_request",
- method: "DELETE",
- scope: "connection",
- connectionId: this.connectionId,
- sessionId: null,
- jsonRpc: null,
- });
- const response = await fetch(this.endpoint, {
- method: "DELETE",
- headers,
- });
- this.recorder.record({
- kind: "http_response",
- status: response.status,
- scope: "connection",
- connectionId: this.connectionId,
- sessionId: null,
- jsonRpc: null,
- });
- return response;
- }
- async rawRequest(method, scope, headers, body, sessionId = null) {
- this.recorder.record({
- kind: "http_request",
- method,
- scope,
- connectionId: headers[CONNECTION_HEADER] ?? null,
- sessionId,
- jsonRpc: this.recorder.summarizeJsonRpc(body),
- });
- const response = await fetch(this.endpoint, {
- method,
- headers,
- ...(body ? { body: JSON.stringify(body) } : {}),
- });
- this.recorder.record({
- kind: "http_response",
- status: response.status,
- scope,
- connectionId: response.headers.get(CONNECTION_HEADER) ?? headers[CONNECTION_HEADER] ?? null,
- sessionId: response.headers.get(SESSION_HEADER) ?? sessionId,
- jsonRpc: null,
- });
- return response;
- }
- request(method, params) {
- return {
- jsonrpc: "2.0",
- id: `req-${this.nextId++}`,
- method,
- params,
- };
- }
- response(id, result) {
- return {
- jsonrpc: "2.0",
- id,
- result,
- };
- }
- async post(message, scope, sessionId, expectJson) {
- const headers = {
- "content-type": "application/json",
- accept: "application/json",
- };
- if (scope !== "bootstrap") {
- headers[CONNECTION_HEADER] = this.requireConnectionId();
- }
- if (sessionId) {
- headers[SESSION_HEADER] = sessionId;
- }
- this.recorder.record({
- kind: "http_request",
- method: "POST",
- scope,
- connectionId: this.connectionId,
- sessionId,
- jsonRpc: this.recorder.summarizeJsonRpc(message),
- });
- const response = await fetch(this.endpoint, {
- method: "POST",
- headers,
- body: JSON.stringify(message),
- });
- let jsonRpc = null;
- if (expectJson) {
- jsonRpc = this.recorder.summarizeJsonRpc(await response.clone().json());
- }
- this.recorder.record({
- kind: "http_response",
- status: response.status,
- scope,
- connectionId: response.headers.get(CONNECTION_HEADER) ?? this.connectionId,
- sessionId,
- jsonRpc,
- });
- return response;
- }
- requireConnectionId() {
- if (!this.connectionId) {
- throw new Error("connection id not initialized");
- }
- return this.connectionId;
- }
-}
-export class SseStream {
- recorder;
- stream;
- sessionId;
- response;
- messages = [];
- waiters = [];
- abortController = new AbortController();
- constructor(recorder, stream, sessionId, response) {
- this.recorder = recorder;
- this.stream = stream;
- this.sessionId = sessionId;
- this.response = response;
- }
- static async open(endpoint, recorder, stream, connectionId, sessionId) {
- recorder.record({
- kind: "http_request",
- method: "GET",
- scope: stream,
- connectionId,
- sessionId,
- jsonRpc: null,
- });
- const response = await fetch(endpoint, {
- method: "GET",
- headers: {
- accept: "text/event-stream",
- [CONNECTION_HEADER]: connectionId,
- ...(sessionId ? { [SESSION_HEADER]: sessionId } : {}),
- },
- });
- recorder.record({
- kind: "http_response",
- status: response.status,
- scope: stream,
- connectionId: response.headers.get(CONNECTION_HEADER),
- sessionId: response.headers.get(SESSION_HEADER),
- jsonRpc: null,
- });
- const result = new SseStream(recorder, stream, sessionId, response);
- void result.readLoop();
- return result;
- }
- async next() {
- const existing = this.messages.shift();
- if (existing) {
- return existing;
- }
- return new Promise((resolve) => this.waiters.push(resolve));
- }
- close() {
- this.abortController.abort();
- }
- async readLoop() {
- if (!this.response.body) {
- return;
- }
- const reader = this.response.body.getReader();
- const decoder = new TextDecoder();
- let buffer = "";
- for (;;) {
- const { value, done } = await reader.read();
- if (done) {
- break;
- }
- buffer += decoder.decode(value, { stream: true });
- for (;;) {
- const boundary = buffer.indexOf("\n\n");
- if (boundary < 0) {
- break;
- }
- const rawEvent = buffer.slice(0, boundary);
- buffer = buffer.slice(boundary + 2);
- const data = rawEvent
- .split("\n")
- .filter((line) => line.startsWith("data:"))
- .map((line) => line.slice(5).trimStart())
- .join("\n");
- if (!data) {
- continue;
- }
- const message = JSON.parse(data);
- const summary = this.recorder.summarizeJsonRpc(message);
- if (summary) {
- this.recorder.record({
- kind: "sse_event",
- stream: this.stream,
- sessionId: this.sessionId,
- jsonRpc: summary,
- });
- }
- const waiter = this.waiters.shift();
- if (waiter) {
- waiter(message);
- }
- else {
- this.messages.push(message);
- }
- }
- }
- }
-}
diff --git a/test-fixtures/streamable-http-client/dist/scenarios.js b/test-fixtures/streamable-http-client/dist/scenarios.js
deleted file mode 100644
index 95f8c41..0000000
--- a/test-fixtures/streamable-http-client/dist/scenarios.js
+++ /dev/null
@@ -1,127 +0,0 @@
-import { CONNECTION_HEADER } from "./protocol.js";
-export async function runScenario(name, endpoint, client) {
- switch (name) {
- case "happy-path":
- await happyPath(client);
- return;
- case "permission-round-trip":
- await permissionRoundTrip(client);
- return;
- case "session-load":
- await sessionLoad(client);
- return;
- case "two-sessions":
- await twoSessions(client);
- return;
- case "wrong-stream-response":
- await wrongStreamResponse(client);
- return;
- case "validation-failures":
- await validationFailures(endpoint, client);
- return;
- default:
- throw new Error(`Unknown scenario ${name}`);
- }
-}
-async function happyPath(client) {
- await client.initialize();
- const connection = await client.openConnectionStream();
- const newSession = client.request("session/new", { cwd: "/workspace", mcpServers: [] });
- await client.postMessage(newSession);
- const sessionResponse = await connection.next();
- const sessionId = sessionResponse.result.sessionId;
- const session = await client.openSessionStream(sessionId);
- await client.postMessage(client.request("session/prompt", {
- sessionId,
- prompt: [{ type: "text", text: "hello" }],
- }), sessionId);
- await session.next();
- await session.next();
- await client.close();
-}
-async function permissionRoundTrip(client) {
- await client.initialize();
- const connection = await client.openConnectionStream();
- await client.postMessage(client.request("session/new", { cwd: "/workspace", mcpServers: [] }));
- const sessionResponse = await connection.next();
- const sessionId = sessionResponse.result.sessionId;
- const session = await client.openSessionStream(sessionId);
- await client.postMessage(client.request("session/prompt", {
- sessionId,
- prompt: [{ type: "text", text: "needs permission" }],
- }), sessionId);
- const permissionRequest = await session.next();
- await client.postMessage(client.response(permissionRequest.id, { outcome: { outcome: "selected", optionId: "allow" } }), sessionId);
- await session.next();
- await session.next();
- await client.close();
-}
-async function sessionLoad(client) {
- await client.initialize();
- const connection = await client.openConnectionStream();
- const session = await client.openSessionStream("sess-load");
- await client.postMessage(client.request("session/load", {
- sessionId: "sess-load",
- cwd: "/workspace",
- mcpServers: [],
- }), "sess-load");
- await connection.next();
- session.close();
- await client.close();
-}
-async function twoSessions(client) {
- await client.initialize();
- const connection = await client.openConnectionStream();
- await client.postMessage(client.request("session/new", { cwd: "/workspace/one", mcpServers: [] }));
- const first = await connection.next();
- const firstId = first.result.sessionId;
- await client.postMessage(client.request("session/new", { cwd: "/workspace/two", mcpServers: [] }));
- const second = await connection.next();
- const secondId = second.result.sessionId;
- const firstStream = await client.openSessionStream(firstId);
- const secondStream = await client.openSessionStream(secondId);
- await client.postMessage(client.request("session/prompt", {
- sessionId: firstId,
- prompt: [{ type: "text", text: "one" }],
- }), firstId);
- await firstStream.next();
- await firstStream.next();
- await client.postMessage(client.request("session/prompt", {
- sessionId: secondId,
- prompt: [{ type: "text", text: "two" }],
- }), secondId);
- await secondStream.next();
- await secondStream.next();
- await client.close();
-}
-async function wrongStreamResponse(client) {
- await client.initialize();
- const connection = await client.openConnectionStream();
- await client.postMessage(client.request("session/new", { cwd: "/workspace", mcpServers: [] }));
- const sessionResponse = await connection.next();
- const sessionId = sessionResponse.result.sessionId;
- const session = await client.openSessionStream(sessionId);
- await client.postMessage(client.request("session/prompt", {
- sessionId,
- prompt: [{ type: "text", text: "needs permission" }],
- }), sessionId);
- const permissionRequest = await session.next();
- await client.postMessage(client.response(permissionRequest.id, { outcome: { outcome: "selected", optionId: "allow" } }));
- await client.close();
-}
-async function validationFailures(endpoint, client) {
- await client.initialize();
- await client.rawRequest("POST", "connection", {
- accept: "application/json",
- "content-type": "text/plain",
- [CONNECTION_HEADER]: "conn-invalid",
- }, {});
- await client.rawRequest("GET", "connection", {
- accept: "text/event-stream",
- }, null);
- await client.rawRequest("GET", "connection", {
- accept: "application/json",
- [CONNECTION_HEADER]: "missing",
- }, null);
- await client.close();
-}
diff --git a/test-fixtures/streamable-http-client/dist/transcript.js b/test-fixtures/streamable-http-client/dist/transcript.js
deleted file mode 100644
index cb9391f..0000000
--- a/test-fixtures/streamable-http-client/dist/transcript.js
+++ /dev/null
@@ -1,69 +0,0 @@
-export class TranscriptRecorder {
- events = [];
- idAliases = new Map();
- connectionAliases = new Map();
- record(event) {
- if ("connectionId" in event) {
- this.events.push({
- ...event,
- connectionId: this.normalizeConnectionId(event.connectionId),
- });
- return;
- }
- this.events.push(event);
- }
- summarizeJsonRpc(message) {
- if (!message || typeof message !== "object") {
- return null;
- }
- const candidate = message;
- if (typeof candidate.method === "string" && "id" in candidate) {
- return {
- type: "request",
- id: this.normalizeId(candidate.id),
- method: candidate.method,
- };
- }
- if (typeof candidate.method === "string") {
- return {
- type: "notification",
- method: candidate.method,
- };
- }
- if ("result" in candidate || "error" in candidate) {
- return {
- type: "response",
- id: this.normalizeId(candidate.id),
- hasError: candidate.error != null,
- };
- }
- return null;
- }
- serialize() {
- return JSON.stringify(this.events, null, 2);
- }
- normalizeId(id) {
- if (typeof id !== "string" && typeof id !== "number") {
- return null;
- }
- const existing = this.idAliases.get(id);
- if (existing) {
- return existing;
- }
- const next = `id-${this.idAliases.size + 1}`;
- this.idAliases.set(id, next);
- return next;
- }
- normalizeConnectionId(connectionId) {
- if (!connectionId) {
- return null;
- }
- const existing = this.connectionAliases.get(connectionId);
- if (existing) {
- return existing;
- }
- const next = `conn-${this.connectionAliases.size + 1}`;
- this.connectionAliases.set(connectionId, next);
- return next;
- }
-}
diff --git a/test-fixtures/streamable-http-client/golden/happy-path.json b/test-fixtures/streamable-http-client/golden/happy-path.json
deleted file mode 100644
index ae468a0..0000000
--- a/test-fixtures/streamable-http-client/golden/happy-path.json
+++ /dev/null
@@ -1,127 +0,0 @@
-[ {
- "kind" : "http_request",
- "method" : "POST",
- "scope" : "bootstrap",
- "connectionId" : null,
- "sessionId" : null,
- "jsonRpc" : {
- "type" : "request",
- "id" : "id-1",
- "method" : "initialize"
- }
-}, {
- "kind" : "http_response",
- "status" : 200,
- "scope" : "bootstrap",
- "connectionId" : "conn-1",
- "sessionId" : null,
- "jsonRpc" : {
- "type" : "response",
- "id" : "id-1",
- "hasError" : false
- }
-}, {
- "kind" : "http_request",
- "method" : "GET",
- "scope" : "connection",
- "connectionId" : "conn-1",
- "sessionId" : null,
- "jsonRpc" : null
-}, {
- "kind" : "http_response",
- "status" : 200,
- "scope" : "connection",
- "connectionId" : "conn-1",
- "sessionId" : null,
- "jsonRpc" : null
-}, {
- "kind" : "http_request",
- "method" : "POST",
- "scope" : "connection",
- "connectionId" : "conn-1",
- "sessionId" : null,
- "jsonRpc" : {
- "type" : "request",
- "id" : "id-2",
- "method" : "session/new"
- }
-}, {
- "kind" : "sse_event",
- "stream" : "connection",
- "sessionId" : null,
- "jsonRpc" : {
- "type" : "response",
- "id" : "id-2",
- "hasError" : false
- }
-}, {
- "kind" : "http_response",
- "status" : 202,
- "scope" : "connection",
- "connectionId" : "conn-1",
- "sessionId" : null,
- "jsonRpc" : null
-}, {
- "kind" : "http_request",
- "method" : "GET",
- "scope" : "session",
- "connectionId" : "conn-1",
- "sessionId" : "sess-1",
- "jsonRpc" : null
-}, {
- "kind" : "http_response",
- "status" : 200,
- "scope" : "session",
- "connectionId" : "conn-1",
- "sessionId" : "sess-1",
- "jsonRpc" : null
-}, {
- "kind" : "http_request",
- "method" : "POST",
- "scope" : "session",
- "connectionId" : "conn-1",
- "sessionId" : "sess-1",
- "jsonRpc" : {
- "type" : "request",
- "id" : "id-3",
- "method" : "session/prompt"
- }
-}, {
- "kind" : "sse_event",
- "stream" : "session",
- "sessionId" : "sess-1",
- "jsonRpc" : {
- "type" : "notification",
- "method" : "session/update"
- }
-}, {
- "kind" : "sse_event",
- "stream" : "session",
- "sessionId" : "sess-1",
- "jsonRpc" : {
- "type" : "response",
- "id" : "id-3",
- "hasError" : false
- }
-}, {
- "kind" : "http_response",
- "status" : 202,
- "scope" : "session",
- "connectionId" : "conn-1",
- "sessionId" : "sess-1",
- "jsonRpc" : null
-}, {
- "kind" : "http_request",
- "method" : "DELETE",
- "scope" : "connection",
- "connectionId" : "conn-1",
- "sessionId" : null,
- "jsonRpc" : null
-}, {
- "kind" : "http_response",
- "status" : 202,
- "scope" : "connection",
- "connectionId" : "conn-1",
- "sessionId" : null,
- "jsonRpc" : null
-} ]
\ No newline at end of file
diff --git a/test-fixtures/streamable-http-client/golden/permission-round-trip.json b/test-fixtures/streamable-http-client/golden/permission-round-trip.json
deleted file mode 100644
index c3af4a5..0000000
--- a/test-fixtures/streamable-http-client/golden/permission-round-trip.json
+++ /dev/null
@@ -1,154 +0,0 @@
-[ {
- "kind" : "http_request",
- "method" : "POST",
- "scope" : "bootstrap",
- "connectionId" : null,
- "sessionId" : null,
- "jsonRpc" : {
- "type" : "request",
- "id" : "id-1",
- "method" : "initialize"
- }
-}, {
- "kind" : "http_response",
- "status" : 200,
- "scope" : "bootstrap",
- "connectionId" : "conn-1",
- "sessionId" : null,
- "jsonRpc" : {
- "type" : "response",
- "id" : "id-1",
- "hasError" : false
- }
-}, {
- "kind" : "http_request",
- "method" : "GET",
- "scope" : "connection",
- "connectionId" : "conn-1",
- "sessionId" : null,
- "jsonRpc" : null
-}, {
- "kind" : "http_response",
- "status" : 200,
- "scope" : "connection",
- "connectionId" : "conn-1",
- "sessionId" : null,
- "jsonRpc" : null
-}, {
- "kind" : "http_request",
- "method" : "POST",
- "scope" : "connection",
- "connectionId" : "conn-1",
- "sessionId" : null,
- "jsonRpc" : {
- "type" : "request",
- "id" : "id-2",
- "method" : "session/new"
- }
-}, {
- "kind" : "sse_event",
- "stream" : "connection",
- "sessionId" : null,
- "jsonRpc" : {
- "type" : "response",
- "id" : "id-2",
- "hasError" : false
- }
-}, {
- "kind" : "http_response",
- "status" : 202,
- "scope" : "connection",
- "connectionId" : "conn-1",
- "sessionId" : null,
- "jsonRpc" : null
-}, {
- "kind" : "http_request",
- "method" : "GET",
- "scope" : "session",
- "connectionId" : "conn-1",
- "sessionId" : "sess-1",
- "jsonRpc" : null
-}, {
- "kind" : "http_response",
- "status" : 200,
- "scope" : "session",
- "connectionId" : "conn-1",
- "sessionId" : "sess-1",
- "jsonRpc" : null
-}, {
- "kind" : "http_request",
- "method" : "POST",
- "scope" : "session",
- "connectionId" : "conn-1",
- "sessionId" : "sess-1",
- "jsonRpc" : {
- "type" : "request",
- "id" : "id-3",
- "method" : "session/prompt"
- }
-}, {
- "kind" : "sse_event",
- "stream" : "session",
- "sessionId" : "sess-1",
- "jsonRpc" : {
- "type" : "request",
- "id" : "id-4",
- "method" : "session/request_permission"
- }
-}, {
- "kind" : "http_response",
- "status" : 202,
- "scope" : "session",
- "connectionId" : "conn-1",
- "sessionId" : "sess-1",
- "jsonRpc" : null
-}, {
- "kind" : "http_request",
- "method" : "POST",
- "scope" : "session",
- "connectionId" : "conn-1",
- "sessionId" : "sess-1",
- "jsonRpc" : {
- "type" : "response",
- "id" : "id-4",
- "hasError" : false
- }
-}, {
- "kind" : "sse_event",
- "stream" : "session",
- "sessionId" : "sess-1",
- "jsonRpc" : {
- "type" : "notification",
- "method" : "session/update"
- }
-}, {
- "kind" : "sse_event",
- "stream" : "session",
- "sessionId" : "sess-1",
- "jsonRpc" : {
- "type" : "response",
- "id" : "id-3",
- "hasError" : false
- }
-}, {
- "kind" : "http_response",
- "status" : 202,
- "scope" : "session",
- "connectionId" : "conn-1",
- "sessionId" : "sess-1",
- "jsonRpc" : null
-}, {
- "kind" : "http_request",
- "method" : "DELETE",
- "scope" : "connection",
- "connectionId" : "conn-1",
- "sessionId" : null,
- "jsonRpc" : null
-}, {
- "kind" : "http_response",
- "status" : 202,
- "scope" : "connection",
- "connectionId" : "conn-1",
- "sessionId" : null,
- "jsonRpc" : null
-} ]
\ No newline at end of file
diff --git a/test-fixtures/streamable-http-client/golden/session-load.json b/test-fixtures/streamable-http-client/golden/session-load.json
deleted file mode 100644
index 24086cb..0000000
--- a/test-fixtures/streamable-http-client/golden/session-load.json
+++ /dev/null
@@ -1,92 +0,0 @@
-[ {
- "kind" : "http_request",
- "method" : "POST",
- "scope" : "bootstrap",
- "connectionId" : null,
- "sessionId" : null,
- "jsonRpc" : {
- "type" : "request",
- "id" : "id-1",
- "method" : "initialize"
- }
-}, {
- "kind" : "http_response",
- "status" : 200,
- "scope" : "bootstrap",
- "connectionId" : "conn-1",
- "sessionId" : null,
- "jsonRpc" : {
- "type" : "response",
- "id" : "id-1",
- "hasError" : false
- }
-}, {
- "kind" : "http_request",
- "method" : "GET",
- "scope" : "connection",
- "connectionId" : "conn-1",
- "sessionId" : null,
- "jsonRpc" : null
-}, {
- "kind" : "http_response",
- "status" : 200,
- "scope" : "connection",
- "connectionId" : "conn-1",
- "sessionId" : null,
- "jsonRpc" : null
-}, {
- "kind" : "http_request",
- "method" : "GET",
- "scope" : "session",
- "connectionId" : "conn-1",
- "sessionId" : "sess-load",
- "jsonRpc" : null
-}, {
- "kind" : "http_response",
- "status" : 200,
- "scope" : "session",
- "connectionId" : "conn-1",
- "sessionId" : "sess-load",
- "jsonRpc" : null
-}, {
- "kind" : "http_request",
- "method" : "POST",
- "scope" : "session",
- "connectionId" : "conn-1",
- "sessionId" : "sess-load",
- "jsonRpc" : {
- "type" : "request",
- "id" : "id-2",
- "method" : "session/load"
- }
-}, {
- "kind" : "sse_event",
- "stream" : "connection",
- "sessionId" : null,
- "jsonRpc" : {
- "type" : "response",
- "id" : "id-2",
- "hasError" : false
- }
-}, {
- "kind" : "http_response",
- "status" : 202,
- "scope" : "session",
- "connectionId" : "conn-1",
- "sessionId" : "sess-load",
- "jsonRpc" : null
-}, {
- "kind" : "http_request",
- "method" : "DELETE",
- "scope" : "connection",
- "connectionId" : "conn-1",
- "sessionId" : null,
- "jsonRpc" : null
-}, {
- "kind" : "http_response",
- "status" : 202,
- "scope" : "connection",
- "connectionId" : "conn-1",
- "sessionId" : null,
- "jsonRpc" : null
-} ]
\ No newline at end of file
diff --git a/test-fixtures/streamable-http-client/golden/two-sessions.json b/test-fixtures/streamable-http-client/golden/two-sessions.json
deleted file mode 100644
index 307c720..0000000
--- a/test-fixtures/streamable-http-client/golden/two-sessions.json
+++ /dev/null
@@ -1,203 +0,0 @@
-[ {
- "kind" : "http_request",
- "method" : "POST",
- "scope" : "bootstrap",
- "connectionId" : null,
- "sessionId" : null,
- "jsonRpc" : {
- "type" : "request",
- "id" : "id-1",
- "method" : "initialize"
- }
-}, {
- "kind" : "http_response",
- "status" : 200,
- "scope" : "bootstrap",
- "connectionId" : "conn-1",
- "sessionId" : null,
- "jsonRpc" : {
- "type" : "response",
- "id" : "id-1",
- "hasError" : false
- }
-}, {
- "kind" : "http_request",
- "method" : "GET",
- "scope" : "connection",
- "connectionId" : "conn-1",
- "sessionId" : null,
- "jsonRpc" : null
-}, {
- "kind" : "http_response",
- "status" : 200,
- "scope" : "connection",
- "connectionId" : "conn-1",
- "sessionId" : null,
- "jsonRpc" : null
-}, {
- "kind" : "http_request",
- "method" : "POST",
- "scope" : "connection",
- "connectionId" : "conn-1",
- "sessionId" : null,
- "jsonRpc" : {
- "type" : "request",
- "id" : "id-2",
- "method" : "session/new"
- }
-}, {
- "kind" : "sse_event",
- "stream" : "connection",
- "sessionId" : null,
- "jsonRpc" : {
- "type" : "response",
- "id" : "id-2",
- "hasError" : false
- }
-}, {
- "kind" : "http_response",
- "status" : 202,
- "scope" : "connection",
- "connectionId" : "conn-1",
- "sessionId" : null,
- "jsonRpc" : null
-}, {
- "kind" : "http_request",
- "method" : "POST",
- "scope" : "connection",
- "connectionId" : "conn-1",
- "sessionId" : null,
- "jsonRpc" : {
- "type" : "request",
- "id" : "id-3",
- "method" : "session/new"
- }
-}, {
- "kind" : "sse_event",
- "stream" : "connection",
- "sessionId" : null,
- "jsonRpc" : {
- "type" : "response",
- "id" : "id-3",
- "hasError" : false
- }
-}, {
- "kind" : "http_response",
- "status" : 202,
- "scope" : "connection",
- "connectionId" : "conn-1",
- "sessionId" : null,
- "jsonRpc" : null
-}, {
- "kind" : "http_request",
- "method" : "GET",
- "scope" : "session",
- "connectionId" : "conn-1",
- "sessionId" : "sess-1",
- "jsonRpc" : null
-}, {
- "kind" : "http_response",
- "status" : 200,
- "scope" : "session",
- "connectionId" : "conn-1",
- "sessionId" : "sess-1",
- "jsonRpc" : null
-}, {
- "kind" : "http_request",
- "method" : "GET",
- "scope" : "session",
- "connectionId" : "conn-1",
- "sessionId" : "sess-2",
- "jsonRpc" : null
-}, {
- "kind" : "http_response",
- "status" : 200,
- "scope" : "session",
- "connectionId" : "conn-1",
- "sessionId" : "sess-2",
- "jsonRpc" : null
-}, {
- "kind" : "http_request",
- "method" : "POST",
- "scope" : "session",
- "connectionId" : "conn-1",
- "sessionId" : "sess-1",
- "jsonRpc" : {
- "type" : "request",
- "id" : "id-4",
- "method" : "session/prompt"
- }
-}, {
- "kind" : "sse_event",
- "stream" : "session",
- "sessionId" : "sess-1",
- "jsonRpc" : {
- "type" : "notification",
- "method" : "session/update"
- }
-}, {
- "kind" : "sse_event",
- "stream" : "session",
- "sessionId" : "sess-1",
- "jsonRpc" : {
- "type" : "response",
- "id" : "id-4",
- "hasError" : false
- }
-}, {
- "kind" : "http_response",
- "status" : 202,
- "scope" : "session",
- "connectionId" : "conn-1",
- "sessionId" : "sess-1",
- "jsonRpc" : null
-}, {
- "kind" : "http_request",
- "method" : "POST",
- "scope" : "session",
- "connectionId" : "conn-1",
- "sessionId" : "sess-2",
- "jsonRpc" : {
- "type" : "request",
- "id" : "id-5",
- "method" : "session/prompt"
- }
-}, {
- "kind" : "sse_event",
- "stream" : "session",
- "sessionId" : "sess-2",
- "jsonRpc" : {
- "type" : "notification",
- "method" : "session/update"
- }
-}, {
- "kind" : "sse_event",
- "stream" : "session",
- "sessionId" : "sess-2",
- "jsonRpc" : {
- "type" : "response",
- "id" : "id-5",
- "hasError" : false
- }
-}, {
- "kind" : "http_response",
- "status" : 202,
- "scope" : "session",
- "connectionId" : "conn-1",
- "sessionId" : "sess-2",
- "jsonRpc" : null
-}, {
- "kind" : "http_request",
- "method" : "DELETE",
- "scope" : "connection",
- "connectionId" : "conn-1",
- "sessionId" : null,
- "jsonRpc" : null
-}, {
- "kind" : "http_response",
- "status" : 202,
- "scope" : "connection",
- "connectionId" : "conn-1",
- "sessionId" : null,
- "jsonRpc" : null
-} ]
\ No newline at end of file
diff --git a/test-fixtures/streamable-http-client/golden/validation-failures.json b/test-fixtures/streamable-http-client/golden/validation-failures.json
deleted file mode 100644
index 3ae0b9b..0000000
--- a/test-fixtures/streamable-http-client/golden/validation-failures.json
+++ /dev/null
@@ -1,79 +0,0 @@
-[ {
- "kind" : "http_request",
- "method" : "POST",
- "scope" : "bootstrap",
- "connectionId" : null,
- "sessionId" : null,
- "jsonRpc" : {
- "type" : "request",
- "id" : "id-1",
- "method" : "initialize"
- }
-}, {
- "kind" : "http_response",
- "status" : 200,
- "scope" : "bootstrap",
- "connectionId" : "conn-1",
- "sessionId" : null,
- "jsonRpc" : {
- "type" : "response",
- "id" : "id-1",
- "hasError" : false
- }
-}, {
- "kind" : "http_request",
- "method" : "POST",
- "scope" : "connection",
- "connectionId" : "conn-2",
- "sessionId" : null,
- "jsonRpc" : null
-}, {
- "kind" : "http_response",
- "status" : 415,
- "scope" : "connection",
- "connectionId" : "conn-2",
- "sessionId" : null,
- "jsonRpc" : null
-}, {
- "kind" : "http_request",
- "method" : "GET",
- "scope" : "connection",
- "connectionId" : null,
- "sessionId" : null,
- "jsonRpc" : null
-}, {
- "kind" : "http_response",
- "status" : 400,
- "scope" : "connection",
- "connectionId" : null,
- "sessionId" : null,
- "jsonRpc" : null
-}, {
- "kind" : "http_request",
- "method" : "GET",
- "scope" : "connection",
- "connectionId" : "conn-3",
- "sessionId" : null,
- "jsonRpc" : null
-}, {
- "kind" : "http_response",
- "status" : 406,
- "scope" : "connection",
- "connectionId" : "conn-3",
- "sessionId" : null,
- "jsonRpc" : null
-}, {
- "kind" : "http_request",
- "method" : "DELETE",
- "scope" : "connection",
- "connectionId" : "conn-1",
- "sessionId" : null,
- "jsonRpc" : null
-}, {
- "kind" : "http_response",
- "status" : 202,
- "scope" : "connection",
- "connectionId" : "conn-1",
- "sessionId" : null,
- "jsonRpc" : null
-} ]
\ No newline at end of file
diff --git a/test-fixtures/streamable-http-client/golden/wrong-stream-response.json b/test-fixtures/streamable-http-client/golden/wrong-stream-response.json
deleted file mode 100644
index 83d292b..0000000
--- a/test-fixtures/streamable-http-client/golden/wrong-stream-response.json
+++ /dev/null
@@ -1,137 +0,0 @@
-[ {
- "kind" : "http_request",
- "method" : "POST",
- "scope" : "bootstrap",
- "connectionId" : null,
- "sessionId" : null,
- "jsonRpc" : {
- "type" : "request",
- "id" : "id-1",
- "method" : "initialize"
- }
-}, {
- "kind" : "http_response",
- "status" : 200,
- "scope" : "bootstrap",
- "connectionId" : "conn-1",
- "sessionId" : null,
- "jsonRpc" : {
- "type" : "response",
- "id" : "id-1",
- "hasError" : false
- }
-}, {
- "kind" : "http_request",
- "method" : "GET",
- "scope" : "connection",
- "connectionId" : "conn-1",
- "sessionId" : null,
- "jsonRpc" : null
-}, {
- "kind" : "http_response",
- "status" : 200,
- "scope" : "connection",
- "connectionId" : "conn-1",
- "sessionId" : null,
- "jsonRpc" : null
-}, {
- "kind" : "http_request",
- "method" : "POST",
- "scope" : "connection",
- "connectionId" : "conn-1",
- "sessionId" : null,
- "jsonRpc" : {
- "type" : "request",
- "id" : "id-2",
- "method" : "session/new"
- }
-}, {
- "kind" : "sse_event",
- "stream" : "connection",
- "sessionId" : null,
- "jsonRpc" : {
- "type" : "response",
- "id" : "id-2",
- "hasError" : false
- }
-}, {
- "kind" : "http_response",
- "status" : 202,
- "scope" : "connection",
- "connectionId" : "conn-1",
- "sessionId" : null,
- "jsonRpc" : null
-}, {
- "kind" : "http_request",
- "method" : "GET",
- "scope" : "session",
- "connectionId" : "conn-1",
- "sessionId" : "sess-1",
- "jsonRpc" : null
-}, {
- "kind" : "http_response",
- "status" : 200,
- "scope" : "session",
- "connectionId" : "conn-1",
- "sessionId" : "sess-1",
- "jsonRpc" : null
-}, {
- "kind" : "http_request",
- "method" : "POST",
- "scope" : "session",
- "connectionId" : "conn-1",
- "sessionId" : "sess-1",
- "jsonRpc" : {
- "type" : "request",
- "id" : "id-3",
- "method" : "session/prompt"
- }
-}, {
- "kind" : "sse_event",
- "stream" : "session",
- "sessionId" : "sess-1",
- "jsonRpc" : {
- "type" : "request",
- "id" : "id-4",
- "method" : "session/request_permission"
- }
-}, {
- "kind" : "http_response",
- "status" : 202,
- "scope" : "session",
- "connectionId" : "conn-1",
- "sessionId" : "sess-1",
- "jsonRpc" : null
-}, {
- "kind" : "http_request",
- "method" : "POST",
- "scope" : "connection",
- "connectionId" : "conn-1",
- "sessionId" : null,
- "jsonRpc" : {
- "type" : "response",
- "id" : "id-4",
- "hasError" : false
- }
-}, {
- "kind" : "http_response",
- "status" : 400,
- "scope" : "connection",
- "connectionId" : "conn-1",
- "sessionId" : null,
- "jsonRpc" : null
-}, {
- "kind" : "http_request",
- "method" : "DELETE",
- "scope" : "connection",
- "connectionId" : "conn-1",
- "sessionId" : null,
- "jsonRpc" : null
-}, {
- "kind" : "http_response",
- "status" : 202,
- "scope" : "connection",
- "connectionId" : "conn-1",
- "sessionId" : null,
- "jsonRpc" : null
-} ]
\ No newline at end of file
diff --git a/test-fixtures/streamable-http-client/package-lock.json b/test-fixtures/streamable-http-client/package-lock.json
deleted file mode 100644
index c1fceaa..0000000
--- a/test-fixtures/streamable-http-client/package-lock.json
+++ /dev/null
@@ -1,45 +0,0 @@
-{
- "name": "acp-streamable-http-client-fixture",
- "lockfileVersion": 3,
- "requires": true,
- "packages": {
- "": {
- "name": "acp-streamable-http-client-fixture",
- "devDependencies": {
- "@types/node": "^22.10.2",
- "typescript": "^5.7.2"
- }
- },
- "node_modules/@types/node": {
- "version": "22.19.19",
- "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.19.tgz",
- "integrity": "sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "undici-types": "~6.21.0"
- }
- },
- "node_modules/typescript": {
- "version": "5.9.3",
- "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
- "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
- "dev": true,
- "license": "Apache-2.0",
- "bin": {
- "tsc": "bin/tsc",
- "tsserver": "bin/tsserver"
- },
- "engines": {
- "node": ">=14.17"
- }
- },
- "node_modules/undici-types": {
- "version": "6.21.0",
- "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
- "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
- "dev": true,
- "license": "MIT"
- }
- }
-}
diff --git a/test-fixtures/streamable-http-client/package.json b/test-fixtures/streamable-http-client/package.json
deleted file mode 100644
index 75235c1..0000000
--- a/test-fixtures/streamable-http-client/package.json
+++ /dev/null
@@ -1,12 +0,0 @@
-{
- "name": "acp-streamable-http-client-fixture",
- "private": true,
- "type": "module",
- "scripts": {
- "build": "tsc"
- },
- "devDependencies": {
- "@types/node": "^22.10.2",
- "typescript": "^5.7.2"
- }
-}
diff --git a/test-fixtures/streamable-http-client/src/client.ts b/test-fixtures/streamable-http-client/src/client.ts
deleted file mode 100644
index eaf151d..0000000
--- a/test-fixtures/streamable-http-client/src/client.ts
+++ /dev/null
@@ -1,19 +0,0 @@
-import { StreamableHttpFixtureClient } from "./protocol.js";
-import { runScenario } from "./scenarios.js";
-import { TranscriptRecorder } from "./transcript.js";
-
-const endpoint = readArg("--endpoint");
-if (!endpoint) {
- throw new Error("--endpoint is required");
-}
-const scenario = readArg("--scenario") ?? "happy-path";
-const recorder = new TranscriptRecorder();
-const client = new StreamableHttpFixtureClient(endpoint, recorder);
-
-await runScenario(scenario, endpoint, client);
-process.stdout.write(recorder.serialize());
-
-function readArg(name: string): string | undefined {
- const index = process.argv.indexOf(name);
- return index >= 0 ? process.argv[index + 1] : undefined;
-}
diff --git a/test-fixtures/streamable-http-client/src/protocol.ts b/test-fixtures/streamable-http-client/src/protocol.ts
deleted file mode 100644
index 7f44734..0000000
--- a/test-fixtures/streamable-http-client/src/protocol.ts
+++ /dev/null
@@ -1,278 +0,0 @@
-import { TranscriptRecorder } from "./transcript.js";
-
-export const CONNECTION_HEADER = "Acp-Connection-Id";
-export const SESSION_HEADER = "Acp-Session-Id";
-
-export type JsonRpcMessage = Record;
-export type Scope = "bootstrap" | "connection" | "session";
-
-export class StreamableHttpFixtureClient {
- private connectionId: string | null = null;
- private nextId = 1;
-
- constructor(
- private readonly endpoint: string,
- private readonly recorder: TranscriptRecorder,
- ) {}
-
- async initialize(): Promise {
- const request = this.request("initialize", {
- protocolVersion: 1,
- clientCapabilities: {},
- });
- const response = await this.post(request, "bootstrap", null, true);
- const connectionId = response.headers.get(CONNECTION_HEADER);
- if (!connectionId) {
- throw new Error("initialize response missing connection id");
- }
- this.connectionId = connectionId;
- return await response.json();
- }
-
- async postMessage(message: JsonRpcMessage, sessionId: string | null = null): Promise {
- return this.post(message, sessionId ? "session" : "connection", sessionId, false);
- }
-
- async openConnectionStream(): Promise {
- return SseStream.open(this.endpoint, this.recorder, "connection", this.requireConnectionId(), null);
- }
-
- async openSessionStream(sessionId: string): Promise {
- return SseStream.open(this.endpoint, this.recorder, "session", this.requireConnectionId(), sessionId);
- }
-
- async close(): Promise {
- const headers = {
- [CONNECTION_HEADER]: this.requireConnectionId(),
- };
- this.recorder.record({
- kind: "http_request",
- method: "DELETE",
- scope: "connection",
- connectionId: this.connectionId,
- sessionId: null,
- jsonRpc: null,
- });
- const response = await fetch(this.endpoint, {
- method: "DELETE",
- headers,
- });
- this.recorder.record({
- kind: "http_response",
- status: response.status,
- scope: "connection",
- connectionId: this.connectionId,
- sessionId: null,
- jsonRpc: null,
- });
- return response;
- }
-
- async rawRequest(
- method: string,
- scope: Scope,
- headers: Record,
- body: JsonRpcMessage | null,
- sessionId: string | null = null,
- ): Promise {
- this.recorder.record({
- kind: "http_request",
- method,
- scope,
- connectionId: headers[CONNECTION_HEADER] ?? null,
- sessionId,
- jsonRpc: this.recorder.summarizeJsonRpc(body),
- });
- const response = await fetch(this.endpoint, {
- method,
- headers,
- ...(body ? { body: JSON.stringify(body) } : {}),
- });
- this.recorder.record({
- kind: "http_response",
- status: response.status,
- scope,
- connectionId: response.headers.get(CONNECTION_HEADER) ?? headers[CONNECTION_HEADER] ?? null,
- sessionId: response.headers.get(SESSION_HEADER) ?? sessionId,
- jsonRpc: null,
- });
- return response;
- }
-
- request(method: string, params: Record): JsonRpcMessage {
- return {
- jsonrpc: "2.0",
- id: `req-${this.nextId++}`,
- method,
- params,
- };
- }
-
- response(id: unknown, result: Record): JsonRpcMessage {
- return {
- jsonrpc: "2.0",
- id,
- result,
- };
- }
-
- private async post(
- message: JsonRpcMessage,
- scope: Scope,
- sessionId: string | null,
- expectJson: boolean,
- ): Promise {
- const headers: Record = {
- "content-type": "application/json",
- accept: "application/json",
- };
- if (scope !== "bootstrap") {
- headers[CONNECTION_HEADER] = this.requireConnectionId();
- }
- if (sessionId) {
- headers[SESSION_HEADER] = sessionId;
- }
- this.recorder.record({
- kind: "http_request",
- method: "POST",
- scope,
- connectionId: this.connectionId,
- sessionId,
- jsonRpc: this.recorder.summarizeJsonRpc(message),
- });
- const response = await fetch(this.endpoint, {
- method: "POST",
- headers,
- body: JSON.stringify(message),
- });
- let jsonRpc = null;
- if (expectJson) {
- jsonRpc = this.recorder.summarizeJsonRpc(await response.clone().json());
- }
- this.recorder.record({
- kind: "http_response",
- status: response.status,
- scope,
- connectionId: response.headers.get(CONNECTION_HEADER) ?? this.connectionId,
- sessionId,
- jsonRpc,
- });
- return response;
- }
-
- private requireConnectionId(): string {
- if (!this.connectionId) {
- throw new Error("connection id not initialized");
- }
- return this.connectionId;
- }
-}
-
-export class SseStream {
- private readonly messages: JsonRpcMessage[] = [];
- private readonly waiters: Array<(message: JsonRpcMessage) => void> = [];
- private readonly abortController = new AbortController();
-
- private constructor(
- private readonly recorder: TranscriptRecorder,
- private readonly stream: "connection" | "session",
- private readonly sessionId: string | null,
- private readonly response: Response,
- ) {}
-
- static async open(
- endpoint: string,
- recorder: TranscriptRecorder,
- stream: "connection" | "session",
- connectionId: string,
- sessionId: string | null,
- ): Promise {
- recorder.record({
- kind: "http_request",
- method: "GET",
- scope: stream,
- connectionId,
- sessionId,
- jsonRpc: null,
- });
- const response = await fetch(endpoint, {
- method: "GET",
- headers: {
- accept: "text/event-stream",
- [CONNECTION_HEADER]: connectionId,
- ...(sessionId ? { [SESSION_HEADER]: sessionId } : {}),
- },
- });
- recorder.record({
- kind: "http_response",
- status: response.status,
- scope: stream,
- connectionId: response.headers.get(CONNECTION_HEADER),
- sessionId: response.headers.get(SESSION_HEADER),
- jsonRpc: null,
- });
- const result = new SseStream(recorder, stream, sessionId, response);
- void result.readLoop();
- return result;
- }
-
- async next(): Promise {
- const existing = this.messages.shift();
- if (existing) {
- return existing;
- }
- return new Promise((resolve) => this.waiters.push(resolve));
- }
-
- close(): void {
- this.abortController.abort();
- }
-
- private async readLoop(): Promise {
- if (!this.response.body) {
- return;
- }
- const reader = this.response.body.getReader();
- const decoder = new TextDecoder();
- let buffer = "";
- for (;;) {
- const { value, done } = await reader.read();
- if (done) {
- break;
- }
- buffer += decoder.decode(value, { stream: true });
- for (;;) {
- const boundary = buffer.indexOf("\n\n");
- if (boundary < 0) {
- break;
- }
- const rawEvent = buffer.slice(0, boundary);
- buffer = buffer.slice(boundary + 2);
- const data = rawEvent
- .split("\n")
- .filter((line) => line.startsWith("data:"))
- .map((line) => line.slice(5).trimStart())
- .join("\n");
- if (!data) {
- continue;
- }
- const message = JSON.parse(data) as JsonRpcMessage;
- const summary = this.recorder.summarizeJsonRpc(message);
- if (summary) {
- this.recorder.record({
- kind: "sse_event",
- stream: this.stream,
- sessionId: this.sessionId,
- jsonRpc: summary,
- });
- }
- const waiter = this.waiters.shift();
- if (waiter) {
- waiter(message);
- } else {
- this.messages.push(message);
- }
- }
- }
- }
-}
diff --git a/test-fixtures/streamable-http-client/src/scenarios.ts b/test-fixtures/streamable-http-client/src/scenarios.ts
deleted file mode 100644
index ef5ac89..0000000
--- a/test-fixtures/streamable-http-client/src/scenarios.ts
+++ /dev/null
@@ -1,134 +0,0 @@
-import { CONNECTION_HEADER, StreamableHttpFixtureClient } from "./protocol.js";
-
-export async function runScenario(name: string, endpoint: string, client: StreamableHttpFixtureClient): Promise {
- switch (name) {
- case "happy-path":
- await happyPath(client);
- return;
- case "permission-round-trip":
- await permissionRoundTrip(client);
- return;
- case "session-load":
- await sessionLoad(client);
- return;
- case "two-sessions":
- await twoSessions(client);
- return;
- case "wrong-stream-response":
- await wrongStreamResponse(client);
- return;
- case "validation-failures":
- await validationFailures(endpoint, client);
- return;
- default:
- throw new Error(`Unknown scenario ${name}`);
- }
-}
-
-async function happyPath(client: StreamableHttpFixtureClient): Promise {
- await client.initialize();
- const connection = await client.openConnectionStream();
- const newSession = client.request("session/new", { cwd: "/workspace", mcpServers: [] });
- await client.postMessage(newSession);
- const sessionResponse = await connection.next();
- const sessionId = ((sessionResponse.result as Record).sessionId as string);
- const session = await client.openSessionStream(sessionId);
- await client.postMessage(client.request("session/prompt", {
- sessionId,
- prompt: [{ type: "text", text: "hello" }],
- }), sessionId);
- await session.next();
- await session.next();
- await client.close();
-}
-
-async function permissionRoundTrip(client: StreamableHttpFixtureClient): Promise {
- await client.initialize();
- const connection = await client.openConnectionStream();
- await client.postMessage(client.request("session/new", { cwd: "/workspace", mcpServers: [] }));
- const sessionResponse = await connection.next();
- const sessionId = ((sessionResponse.result as Record).sessionId as string);
- const session = await client.openSessionStream(sessionId);
- await client.postMessage(client.request("session/prompt", {
- sessionId,
- prompt: [{ type: "text", text: "needs permission" }],
- }), sessionId);
- const permissionRequest = await session.next();
- await client.postMessage(client.response(permissionRequest.id, { outcome: { outcome: "selected", optionId: "allow" } }), sessionId);
- await session.next();
- await session.next();
- await client.close();
-}
-
-async function sessionLoad(client: StreamableHttpFixtureClient): Promise {
- await client.initialize();
- const connection = await client.openConnectionStream();
- const session = await client.openSessionStream("sess-load");
- await client.postMessage(client.request("session/load", {
- sessionId: "sess-load",
- cwd: "/workspace",
- mcpServers: [],
- }), "sess-load");
- await connection.next();
- session.close();
- await client.close();
-}
-
-async function twoSessions(client: StreamableHttpFixtureClient): Promise {
- await client.initialize();
- const connection = await client.openConnectionStream();
- await client.postMessage(client.request("session/new", { cwd: "/workspace/one", mcpServers: [] }));
- const first = await connection.next();
- const firstId = ((first.result as Record).sessionId as string);
- await client.postMessage(client.request("session/new", { cwd: "/workspace/two", mcpServers: [] }));
- const second = await connection.next();
- const secondId = ((second.result as Record).sessionId as string);
- const firstStream = await client.openSessionStream(firstId);
- const secondStream = await client.openSessionStream(secondId);
- await client.postMessage(client.request("session/prompt", {
- sessionId: firstId,
- prompt: [{ type: "text", text: "one" }],
- }), firstId);
- await firstStream.next();
- await firstStream.next();
- await client.postMessage(client.request("session/prompt", {
- sessionId: secondId,
- prompt: [{ type: "text", text: "two" }],
- }), secondId);
- await secondStream.next();
- await secondStream.next();
- await client.close();
-}
-
-async function wrongStreamResponse(client: StreamableHttpFixtureClient): Promise {
- await client.initialize();
- const connection = await client.openConnectionStream();
- await client.postMessage(client.request("session/new", { cwd: "/workspace", mcpServers: [] }));
- const sessionResponse = await connection.next();
- const sessionId = ((sessionResponse.result as Record).sessionId as string);
- const session = await client.openSessionStream(sessionId);
- await client.postMessage(client.request("session/prompt", {
- sessionId,
- prompt: [{ type: "text", text: "needs permission" }],
- }), sessionId);
- const permissionRequest = await session.next();
- await client.postMessage(client.response(permissionRequest.id, { outcome: { outcome: "selected", optionId: "allow" } }));
- await client.close();
-}
-
-async function validationFailures(endpoint: string, client: StreamableHttpFixtureClient): Promise {
- await client.initialize();
- await client.rawRequest("POST", "connection", {
- accept: "application/json",
- "content-type": "text/plain",
- [CONNECTION_HEADER]: "conn-invalid",
- }, {});
- await client.rawRequest("GET", "connection", {
- accept: "text/event-stream",
- }, null);
- await client.rawRequest("GET", "connection", {
- accept: "application/json",
- [CONNECTION_HEADER]: "missing",
- }, null);
- await client.close();
-}
diff --git a/test-fixtures/streamable-http-client/src/transcript.ts b/test-fixtures/streamable-http-client/src/transcript.ts
deleted file mode 100644
index ac8a8ff..0000000
--- a/test-fixtures/streamable-http-client/src/transcript.ts
+++ /dev/null
@@ -1,115 +0,0 @@
-export type JsonRpcSummary =
- | {
- type: "request";
- id: string | null;
- method: string;
- }
- | {
- type: "notification";
- method: string;
- }
- | {
- type: "response";
- id: string | null;
- hasError: boolean;
- };
-
-export type TranscriptEvent =
- | {
- kind: "http_request";
- method: string;
- scope: "bootstrap" | "connection" | "session";
- connectionId: string | null;
- sessionId: string | null;
- jsonRpc: JsonRpcSummary | null;
- }
- | {
- kind: "http_response";
- status: number;
- scope: "bootstrap" | "connection" | "session";
- connectionId: string | null;
- sessionId: string | null;
- jsonRpc: JsonRpcSummary | null;
- }
- | {
- kind: "sse_event";
- stream: "connection" | "session";
- sessionId: string | null;
- jsonRpc: JsonRpcSummary;
- };
-
-export class TranscriptRecorder {
- private readonly events: TranscriptEvent[] = [];
- private readonly idAliases = new Map();
- private readonly connectionAliases = new Map();
-
- record(event: TranscriptEvent): void {
- if ("connectionId" in event) {
- this.events.push({
- ...event,
- connectionId: this.normalizeConnectionId(event.connectionId),
- });
- return;
- }
- this.events.push(event);
- }
-
- summarizeJsonRpc(message: unknown): JsonRpcSummary | null {
- if (!message || typeof message !== "object") {
- return null;
- }
-
- const candidate = message as Record;
- if (typeof candidate.method === "string" && "id" in candidate) {
- return {
- type: "request",
- id: this.normalizeId(candidate.id),
- method: candidate.method,
- };
- }
- if (typeof candidate.method === "string") {
- return {
- type: "notification",
- method: candidate.method,
- };
- }
- if ("result" in candidate || "error" in candidate) {
- return {
- type: "response",
- id: this.normalizeId(candidate.id),
- hasError: candidate.error != null,
- };
- }
- return null;
- }
-
- serialize(): string {
- return JSON.stringify(this.events, null, 2);
- }
-
- private normalizeId(id: unknown): string | null {
- if (typeof id !== "string" && typeof id !== "number") {
- return null;
- }
- const existing = this.idAliases.get(id);
- if (existing) {
- return existing;
- }
- const next = `id-${this.idAliases.size + 1}`;
- this.idAliases.set(id, next);
- return next;
- }
-
- private normalizeConnectionId(connectionId: string | null): string | null {
- if (!connectionId) {
- return null;
- }
- const existing = this.connectionAliases.get(connectionId);
- if (existing) {
- return existing;
- }
- const next = `conn-${this.connectionAliases.size + 1}`;
- this.connectionAliases.set(connectionId, next);
- return next;
- }
-}
diff --git a/test-fixtures/streamable-http-client/tsconfig.json b/test-fixtures/streamable-http-client/tsconfig.json
deleted file mode 100644
index 78c0292..0000000
--- a/test-fixtures/streamable-http-client/tsconfig.json
+++ /dev/null
@@ -1,12 +0,0 @@
-{
- "compilerOptions": {
- "target": "ES2022",
- "module": "NodeNext",
- "moduleResolution": "NodeNext",
- "outDir": "dist",
- "rootDir": "src",
- "strict": true,
- "esModuleInterop": true
- },
- "include": ["src/**/*.ts"]
-}
diff --git a/test-fixtures/streamable-http-server/README.md b/test-fixtures/streamable-http-server/README.md
deleted file mode 100644
index 09a952e..0000000
--- a/test-fixtures/streamable-http-server/README.md
+++ /dev/null
@@ -1,36 +0,0 @@
-# Streamable HTTP Fixture Server
-
-This in-repo TypeScript fixture is the conformance harness for the Java Streamable HTTP client transport.
-
-Current scope:
-
-- strict fixture behavior
-- HTTP-only
-- named startup-selected scenarios
-- canonical transcript serialization owned by the fixture
-
-Current scenarios:
-
-- `happy-path`
-- `permission-round-trip`
-- `session-load`
-- `two-sessions`
-- `wrong-stream-response`
-- `validation-failures`
-
-Fixture-wide validation already covers cookies and transport headers. Strict client-side
-routing rejection is tested in the Java unit tests because it should fail before the
-fixture ever sees a request.
-
-Manual use:
-
-```bash
-npm install
-npm run build
-node dist/server.js --scenario happy-path --port 8080
-```
-
-The server prints a single JSON `ready` line on startup. Golden transcripts live in
-`golden/` and are compared by the Java integration tests.
-
-The harness is intentionally small and local to this repository for now. It may later become a reusable ACP conformance fixture once the remote transport ecosystem settles.
diff --git a/test-fixtures/streamable-http-server/dist/protocol.js b/test-fixtures/streamable-http-server/dist/protocol.js
deleted file mode 100644
index 725eb81..0000000
--- a/test-fixtures/streamable-http-server/dist/protocol.js
+++ /dev/null
@@ -1,26 +0,0 @@
-export const ACP_PATH = "/acp";
-export const CONNECTION_HEADER = "acp-connection-id";
-export const SESSION_HEADER = "acp-session-id";
-export const FIXTURE_COOKIE = "fixture=streamable-http";
-export function sendJson(response, status, body, headers = {}) {
- response.writeHead(status, {
- "content-type": "application/json",
- ...headers,
- });
- if (body) {
- response.end(JSON.stringify(body));
- }
- else {
- response.end();
- }
-}
-export function openEventStream(response) {
- response.writeHead(200, {
- "content-type": "text/event-stream",
- "cache-control": "no-cache",
- });
- response.flushHeaders();
-}
-export function writeSse(response, message) {
- response.write(`data: ${JSON.stringify(message)}\n\n`);
-}
diff --git a/test-fixtures/streamable-http-server/dist/scenarios.js b/test-fixtures/streamable-http-server/dist/scenarios.js
deleted file mode 100644
index 8a00ec7..0000000
--- a/test-fixtures/streamable-http-server/dist/scenarios.js
+++ /dev/null
@@ -1,387 +0,0 @@
-import { CONNECTION_HEADER, FIXTURE_COOKIE, SESSION_HEADER, openEventStream, sendJson, writeSse, } from "./protocol.js";
-export function createScenario(name) {
- switch (name) {
- case "happy-path":
- return new HappyPathScenario();
- case "permission-round-trip":
- return new PermissionRoundTripScenario();
- case "session-load":
- return new SessionLoadScenario();
- case "two-sessions":
- return new TwoSessionsScenario();
- case "wrong-stream-response":
- return new WrongStreamResponseScenario();
- case "validation-failures":
- return new ValidationFailuresScenario();
- default:
- throw new Error(`Unknown scenario: ${name}`);
- }
-}
-class BaseScenario {
- connectionId = "conn-1";
- connectionStream = null;
- sessionStreams = new Map();
- handle(response, headers, method, body, recorder) {
- const request = this.recordRequest(method, headers, body, recorder);
- if (!this.validateRequest(response, request, body, recorder)) {
- return;
- }
- if (request.method === "POST" && body?.method === "initialize") {
- this.handleInitialize(response, body, recorder);
- return;
- }
- if (request.method === "GET" &&
- request.connectionId === this.connectionId &&
- !request.sessionId) {
- this.connectionStream = response;
- openEventStream(response);
- this.recordResponse(recorder, 200, "connection", null);
- return;
- }
- if (request.method === "GET" &&
- request.connectionId === this.connectionId &&
- request.sessionId) {
- this.sessionStreams.set(request.sessionId, response);
- openEventStream(response);
- this.recordResponse(recorder, 200, "session", request.sessionId);
- return;
- }
- if (request.method === "DELETE" && request.connectionId === this.connectionId) {
- sendJson(response, 202);
- this.recordResponse(recorder, 202, "connection", null);
- this.connectionStream?.end();
- this.sessionStreams.forEach((sessionStream) => sessionStream.end());
- return;
- }
- if (this.handleScenarioRequest(response, request, body, recorder)) {
- return;
- }
- this.reject(response, recorder, request.scope, request.sessionId, 400, "Unexpected fixture request", body?.id);
- }
- sendSessionNewResponse(responseStream, request, body, recorder, sessionId) {
- sendJson(responseStream, 202);
- this.recordResponse(recorder, 202, request.scope, request.sessionId);
- const response = {
- jsonrpc: "2.0",
- id: body.id,
- result: {
- sessionId,
- },
- };
- this.writeConnectionEvent(response, recorder);
- }
- sendPromptFlow(responseStream, request, body, recorder, sessionId) {
- sendJson(responseStream, 202);
- this.recordResponse(recorder, 202, request.scope, request.sessionId);
- const update = {
- jsonrpc: "2.0",
- method: "session/update",
- params: {
- sessionId,
- update: {
- sessionUpdate: "agent_message_chunk",
- content: { type: "text", text: "hello" },
- },
- },
- };
- const response = {
- jsonrpc: "2.0",
- id: body.id,
- result: {
- stopReason: "end_turn",
- },
- };
- this.writeSessionEvent(sessionId, update, recorder);
- this.writeSessionEvent(sessionId, response, recorder);
- }
- writeConnectionEvent(message, recorder) {
- if (!this.connectionStream) {
- throw new Error("connection stream must be open");
- }
- writeSse(this.connectionStream, message);
- recorder.record({
- kind: "sse_event",
- stream: "connection",
- sessionId: null,
- jsonRpc: recorder.summarizeJsonRpc(message),
- });
- }
- writeSessionEvent(sessionId, message, recorder) {
- const stream = this.sessionStreams.get(sessionId);
- if (!stream) {
- throw new Error(`session stream ${sessionId} must be open`);
- }
- writeSse(stream, message);
- recorder.record({
- kind: "sse_event",
- stream: "session",
- sessionId,
- jsonRpc: recorder.summarizeJsonRpc(message),
- });
- }
- handleInitialize(response, body, recorder) {
- sendJson(response, 200, {
- jsonrpc: "2.0",
- id: body.id,
- result: {
- protocolVersion: 1,
- agentCapabilities: {
- loadSession: true,
- },
- authMethods: [],
- },
- }, {
- "Acp-Connection-Id": this.connectionId,
- "set-cookie": FIXTURE_COOKIE,
- });
- this.recordResponse(recorder, 200, "bootstrap", null);
- }
- validateRequest(response, request, body, recorder) {
- if (request.scope === "bootstrap") {
- if (request.method !== "POST" || body?.method !== "initialize") {
- this.reject(response, recorder, request.scope, request.sessionId, 400, "Expected initialize bootstrap", body?.id);
- return false;
- }
- return true;
- }
- if (request.connectionId !== this.connectionId) {
- this.reject(response, recorder, request.scope, request.sessionId, 400, "Missing or invalid connection id", body?.id);
- return false;
- }
- if (!request.cookie?.includes(FIXTURE_COOKIE)) {
- this.reject(response, recorder, request.scope, request.sessionId, 401, "Missing fixture cookie", body?.id);
- return false;
- }
- if (request.method === "GET" && !request.accept?.includes("text/event-stream")) {
- this.reject(response, recorder, request.scope, request.sessionId, 406, "Expected text/event-stream", body?.id);
- return false;
- }
- if (request.method === "POST") {
- if (!request.accept?.includes("application/json")) {
- this.reject(response, recorder, request.scope, request.sessionId, 406, "Expected application/json accept", body?.id);
- return false;
- }
- if (!request.contentType?.includes("application/json")) {
- this.reject(response, recorder, request.scope, request.sessionId, 415, "Expected application/json body", body?.id);
- return false;
- }
- }
- return true;
- }
- recordRequest(method, headers, body, recorder) {
- const connectionId = header(headers, CONNECTION_HEADER);
- const sessionId = header(headers, SESSION_HEADER);
- const cookie = header(headers, "cookie");
- const accept = header(headers, "accept");
- const contentType = header(headers, "content-type");
- const scope = sessionId ? "session" : connectionId ? "connection" : "bootstrap";
- recorder.record({
- kind: "http_request",
- method,
- scope,
- connectionId,
- sessionId,
- cookie,
- jsonRpc: recorder.summarizeJsonRpc(body),
- });
- return { method, scope, connectionId, sessionId, cookie, accept, contentType };
- }
- recordResponse(recorder, status, scope, sessionId) {
- recorder.record({
- kind: "http_response",
- status,
- scope,
- connectionId: scope === "bootstrap" ? this.connectionId : this.connectionId,
- sessionId,
- });
- }
- reject(response, recorder, scope, sessionId, status, message, id) {
- sendJson(response, status, {
- jsonrpc: "2.0",
- id: typeof id === "string" || typeof id === "number" ? id : null,
- error: {
- code: -32600,
- message,
- },
- });
- this.recordResponse(recorder, status, scope, sessionId);
- }
-}
-class HappyPathScenario extends BaseScenario {
- name = "happy-path";
- sessionId = "sess-1";
- handleScenarioRequest(response, request, body, recorder) {
- if (request.method === "POST" && request.scope === "connection" && body?.method === "session/new") {
- this.sendSessionNewResponse(response, request, body, recorder, this.sessionId);
- return true;
- }
- if (request.method === "POST" &&
- request.scope === "session" &&
- request.sessionId === this.sessionId &&
- body?.method === "session/prompt") {
- this.sendPromptFlow(response, request, body, recorder, this.sessionId);
- return true;
- }
- return false;
- }
-}
-class PermissionRoundTripScenario extends BaseScenario {
- name = "permission-round-trip";
- sessionId = "sess-permission";
- pendingPromptId;
- handleScenarioRequest(response, request, body, recorder) {
- if (request.method === "POST" && request.scope === "connection" && body?.method === "session/new") {
- this.sendSessionNewResponse(response, request, body, recorder, this.sessionId);
- return true;
- }
- if (request.method === "POST" &&
- request.scope === "session" &&
- request.sessionId === this.sessionId &&
- body?.method === "session/prompt") {
- sendJson(response, 202);
- this.recordScenarioResponse(recorder, 202, request);
- this.pendingPromptId = body.id;
- this.writeSessionEvent(this.sessionId, {
- jsonrpc: "2.0",
- id: "perm-1",
- method: "session/request_permission",
- params: {
- sessionId: this.sessionId,
- toolCall: {
- toolCallId: "tool-1",
- title: "Write File",
- kind: "edit",
- status: "pending",
- },
- options: [
- { optionId: "allow", name: "Allow", kind: "allow_once" },
- { optionId: "deny", name: "Deny", kind: "reject_once" },
- ],
- },
- }, recorder);
- return true;
- }
- if (request.method === "POST" &&
- request.scope === "session" &&
- request.sessionId === this.sessionId &&
- body?.id === "perm-1" &&
- "result" in body) {
- sendJson(response, 202);
- this.recordScenarioResponse(recorder, 202, request);
- this.writeSessionEvent(this.sessionId, {
- jsonrpc: "2.0",
- id: this.pendingPromptId,
- result: {
- stopReason: "end_turn",
- },
- }, recorder);
- return true;
- }
- return false;
- }
- recordScenarioResponse(recorder, status, request) {
- recorder.record({
- kind: "http_response",
- status,
- scope: request.scope,
- connectionId: this.connectionId,
- sessionId: request.sessionId,
- });
- }
-}
-class SessionLoadScenario extends BaseScenario {
- name = "session-load";
- sessionId = "sess-load";
- handleScenarioRequest(response, request, body, recorder) {
- if (request.method === "POST" &&
- request.scope === "session" &&
- request.sessionId === this.sessionId &&
- body?.method === "session/load") {
- sendJson(response, 202);
- this.recordScenarioResponse(recorder, 202, request);
- this.writeConnectionEvent({
- jsonrpc: "2.0",
- id: body.id,
- result: {},
- }, recorder);
- return true;
- }
- return false;
- }
- recordScenarioResponse(recorder, status, request) {
- recorder.record({
- kind: "http_response",
- status,
- scope: request.scope,
- connectionId: this.connectionId,
- sessionId: request.sessionId,
- });
- }
-}
-class TwoSessionsScenario extends BaseScenario {
- name = "two-sessions";
- nextSessionNumber = 1;
- handleScenarioRequest(response, request, body, recorder) {
- if (request.method === "POST" && request.scope === "connection" && body?.method === "session/new") {
- const sessionId = `sess-${this.nextSessionNumber++}`;
- this.sendSessionNewResponse(response, request, body, recorder, sessionId);
- return true;
- }
- if (request.method === "POST" &&
- request.scope === "session" &&
- request.sessionId &&
- body?.method === "session/prompt") {
- this.sendPromptFlow(response, request, body, recorder, request.sessionId);
- return true;
- }
- return false;
- }
-}
-class WrongStreamResponseScenario extends BaseScenario {
- name = "wrong-stream-response";
- sessionId = "sess-wrong";
- handleScenarioRequest(response, request, body, recorder) {
- if (request.method === "POST" && request.scope === "connection" && body?.method === "session/new") {
- this.sendSessionNewResponse(response, request, body, recorder, this.sessionId);
- return true;
- }
- if (request.method === "POST" &&
- request.scope === "session" &&
- request.sessionId === this.sessionId &&
- body?.method === "session/prompt") {
- sendJson(response, 202);
- this.recordScenarioResponse(recorder, 202, request);
- this.writeConnectionEvent({
- jsonrpc: "2.0",
- id: body.id,
- result: {
- stopReason: "end_turn",
- },
- }, recorder);
- return true;
- }
- return false;
- }
- recordScenarioResponse(recorder, status, request) {
- recorder.record({
- kind: "http_response",
- status,
- scope: request.scope,
- connectionId: this.connectionId,
- sessionId: request.sessionId,
- });
- }
-}
-class ValidationFailuresScenario extends BaseScenario {
- name = "validation-failures";
- handleScenarioRequest(_response, _request, _body, _recorder) {
- return false;
- }
-}
-function header(headers, name) {
- const value = headers[name];
- if (Array.isArray(value)) {
- return value[0] ?? null;
- }
- return typeof value === "string" ? value : null;
-}
diff --git a/test-fixtures/streamable-http-server/dist/server.js b/test-fixtures/streamable-http-server/dist/server.js
deleted file mode 100644
index a1da7df..0000000
--- a/test-fixtures/streamable-http-server/dist/server.js
+++ /dev/null
@@ -1,46 +0,0 @@
-import { createServer } from "node:http";
-import { ACP_PATH } from "./protocol.js";
-import { createScenario } from "./scenarios.js";
-import { TranscriptRecorder } from "./transcript.js";
-const scenarioName = readArg("--scenario") ?? "happy-path";
-const port = Number(readArg("--port") ?? "0");
-const recorder = new TranscriptRecorder();
-const scenario = createScenario(scenarioName);
-const server = createServer();
-server.on("request", async (request, response) => {
- const path = request.url ?? "";
- if (path === "/__test/transcript") {
- response.writeHead(200, {
- "content-type": "application/json",
- });
- response.end(recorder.serialize());
- return;
- }
- if (path !== ACP_PATH) {
- response.writeHead(404);
- response.end();
- return;
- }
- const body = await readJsonBody(request);
- scenario.handle(response, request.headers, request.method ?? "", body, recorder);
-});
-server.listen(port, () => {
- const address = server.address();
- const actualPort = typeof address === "object" && address ? address.port : port;
- process.stdout.write(JSON.stringify({ status: "ready", port: actualPort, scenario: scenario.name }) + "\n");
-});
-function readArg(name) {
- const index = process.argv.indexOf(name);
- return index >= 0 ? process.argv[index + 1] : undefined;
-}
-async function readJsonBody(request) {
- const method = request.method ?? "";
- if (method === "GET" || method === "DELETE") {
- return null;
- }
- let raw = "";
- for await (const chunk of request) {
- raw += chunk.toString("utf8");
- }
- return raw ? JSON.parse(raw) : null;
-}
diff --git a/test-fixtures/streamable-http-server/dist/transcript.js b/test-fixtures/streamable-http-server/dist/transcript.js
deleted file mode 100644
index 9193e07..0000000
--- a/test-fixtures/streamable-http-server/dist/transcript.js
+++ /dev/null
@@ -1,52 +0,0 @@
-export class TranscriptRecorder {
- events = [];
- idAliases = new Map();
- record(event) {
- this.events.push(event);
- }
- summarizeJsonRpc(message) {
- if (!message || typeof message !== "object") {
- return null;
- }
- const candidate = message;
- if (typeof candidate.method === "string" && "id" in candidate) {
- return {
- type: "request",
- id: this.normalizeId(candidate.id),
- method: candidate.method,
- };
- }
- if (typeof candidate.method === "string") {
- return {
- type: "notification",
- method: candidate.method,
- };
- }
- if ("result" in candidate || "error" in candidate) {
- return {
- type: "response",
- id: this.normalizeId(candidate.id),
- hasError: candidate.error != null,
- };
- }
- return null;
- }
- toJSON() {
- return [...this.events];
- }
- serialize() {
- return JSON.stringify(this.events, null, 2);
- }
- normalizeId(id) {
- if (typeof id !== "string" && typeof id !== "number") {
- return null;
- }
- const existing = this.idAliases.get(id);
- if (existing) {
- return existing;
- }
- const next = `id-${this.idAliases.size + 1}`;
- this.idAliases.set(id, next);
- return next;
- }
-}
diff --git a/test-fixtures/streamable-http-server/golden/happy-path.json b/test-fixtures/streamable-http-server/golden/happy-path.json
deleted file mode 100644
index 171bf6c..0000000
--- a/test-fixtures/streamable-http-server/golden/happy-path.json
+++ /dev/null
@@ -1,139 +0,0 @@
-[
- {
- "kind": "http_request",
- "method": "POST",
- "scope": "bootstrap",
- "connectionId": null,
- "sessionId": null,
- "cookie": null,
- "jsonRpc": {
- "type": "request",
- "id": "id-1",
- "method": "initialize"
- }
- },
- {
- "kind": "http_response",
- "status": 200,
- "scope": "bootstrap",
- "connectionId": "conn-1",
- "sessionId": null
- },
- {
- "kind": "http_request",
- "method": "GET",
- "scope": "connection",
- "connectionId": "conn-1",
- "sessionId": null,
- "cookie": "fixture=streamable-http",
- "jsonRpc": null
- },
- {
- "kind": "http_response",
- "status": 200,
- "scope": "connection",
- "connectionId": "conn-1",
- "sessionId": null
- },
- {
- "kind": "http_request",
- "method": "POST",
- "scope": "connection",
- "connectionId": "conn-1",
- "sessionId": null,
- "cookie": "fixture=streamable-http",
- "jsonRpc": {
- "type": "request",
- "id": "id-2",
- "method": "session/new"
- }
- },
- {
- "kind": "http_response",
- "status": 202,
- "scope": "connection",
- "connectionId": "conn-1",
- "sessionId": null
- },
- {
- "kind": "sse_event",
- "stream": "connection",
- "sessionId": null,
- "jsonRpc": {
- "type": "response",
- "id": "id-2",
- "hasError": false
- }
- },
- {
- "kind": "http_request",
- "method": "GET",
- "scope": "session",
- "connectionId": "conn-1",
- "sessionId": "sess-1",
- "cookie": "fixture=streamable-http",
- "jsonRpc": null
- },
- {
- "kind": "http_response",
- "status": 200,
- "scope": "session",
- "connectionId": "conn-1",
- "sessionId": "sess-1"
- },
- {
- "kind": "http_request",
- "method": "POST",
- "scope": "session",
- "connectionId": "conn-1",
- "sessionId": "sess-1",
- "cookie": "fixture=streamable-http",
- "jsonRpc": {
- "type": "request",
- "id": "id-3",
- "method": "session/prompt"
- }
- },
- {
- "kind": "http_response",
- "status": 202,
- "scope": "session",
- "connectionId": "conn-1",
- "sessionId": "sess-1"
- },
- {
- "kind": "sse_event",
- "stream": "session",
- "sessionId": "sess-1",
- "jsonRpc": {
- "type": "notification",
- "method": "session/update"
- }
- },
- {
- "kind": "sse_event",
- "stream": "session",
- "sessionId": "sess-1",
- "jsonRpc": {
- "type": "response",
- "id": "id-3",
- "hasError": false
- }
- },
- {
- "kind": "http_request",
- "method": "DELETE",
- "scope": "connection",
- "connectionId": "conn-1",
- "sessionId": null,
- "cookie": "fixture=streamable-http",
- "jsonRpc": null
- },
- {
- "kind": "http_response",
- "status": 202,
- "scope": "connection",
- "connectionId": "conn-1",
- "sessionId": null
- }
-]
diff --git a/test-fixtures/streamable-http-server/golden/permission-round-trip.json b/test-fixtures/streamable-http-server/golden/permission-round-trip.json
deleted file mode 100644
index 0ba9132..0000000
--- a/test-fixtures/streamable-http-server/golden/permission-round-trip.json
+++ /dev/null
@@ -1,160 +0,0 @@
-[
- {
- "kind": "http_request",
- "method": "POST",
- "scope": "bootstrap",
- "connectionId": null,
- "sessionId": null,
- "cookie": null,
- "jsonRpc": {
- "type": "request",
- "id": "id-1",
- "method": "initialize"
- }
- },
- {
- "kind": "http_response",
- "status": 200,
- "scope": "bootstrap",
- "connectionId": "conn-1",
- "sessionId": null
- },
- {
- "kind": "http_request",
- "method": "GET",
- "scope": "connection",
- "connectionId": "conn-1",
- "sessionId": null,
- "cookie": "fixture=streamable-http",
- "jsonRpc": null
- },
- {
- "kind": "http_response",
- "status": 200,
- "scope": "connection",
- "connectionId": "conn-1",
- "sessionId": null
- },
- {
- "kind": "http_request",
- "method": "POST",
- "scope": "connection",
- "connectionId": "conn-1",
- "sessionId": null,
- "cookie": "fixture=streamable-http",
- "jsonRpc": {
- "type": "request",
- "id": "id-2",
- "method": "session/new"
- }
- },
- {
- "kind": "http_response",
- "status": 202,
- "scope": "connection",
- "connectionId": "conn-1",
- "sessionId": null
- },
- {
- "kind": "sse_event",
- "stream": "connection",
- "sessionId": null,
- "jsonRpc": {
- "type": "response",
- "id": "id-2",
- "hasError": false
- }
- },
- {
- "kind": "http_request",
- "method": "GET",
- "scope": "session",
- "connectionId": "conn-1",
- "sessionId": "sess-permission",
- "cookie": "fixture=streamable-http",
- "jsonRpc": null
- },
- {
- "kind": "http_response",
- "status": 200,
- "scope": "session",
- "connectionId": "conn-1",
- "sessionId": "sess-permission"
- },
- {
- "kind": "http_request",
- "method": "POST",
- "scope": "session",
- "connectionId": "conn-1",
- "sessionId": "sess-permission",
- "cookie": "fixture=streamable-http",
- "jsonRpc": {
- "type": "request",
- "id": "id-3",
- "method": "session/prompt"
- }
- },
- {
- "kind": "http_response",
- "status": 202,
- "scope": "session",
- "connectionId": "conn-1",
- "sessionId": "sess-permission"
- },
- {
- "kind": "sse_event",
- "stream": "session",
- "sessionId": "sess-permission",
- "jsonRpc": {
- "type": "request",
- "id": "id-4",
- "method": "session/request_permission"
- }
- },
- {
- "kind": "http_request",
- "method": "POST",
- "scope": "session",
- "connectionId": "conn-1",
- "sessionId": "sess-permission",
- "cookie": "fixture=streamable-http",
- "jsonRpc": {
- "type": "response",
- "id": "id-4",
- "hasError": false
- }
- },
- {
- "kind": "http_response",
- "status": 202,
- "scope": "session",
- "connectionId": "conn-1",
- "sessionId": "sess-permission"
- },
- {
- "kind": "sse_event",
- "stream": "session",
- "sessionId": "sess-permission",
- "jsonRpc": {
- "type": "response",
- "id": "id-3",
- "hasError": false
- }
- },
- {
- "kind": "http_request",
- "method": "DELETE",
- "scope": "connection",
- "connectionId": "conn-1",
- "sessionId": null,
- "cookie": "fixture=streamable-http",
- "jsonRpc": null
- },
- {
- "kind": "http_response",
- "status": 202,
- "scope": "connection",
- "connectionId": "conn-1",
- "sessionId": null
- }
-]
diff --git a/test-fixtures/streamable-http-server/golden/session-load.json b/test-fixtures/streamable-http-server/golden/session-load.json
deleted file mode 100644
index fd932fb..0000000
--- a/test-fixtures/streamable-http-server/golden/session-load.json
+++ /dev/null
@@ -1,100 +0,0 @@
-[
- {
- "kind": "http_request",
- "method": "POST",
- "scope": "bootstrap",
- "connectionId": null,
- "sessionId": null,
- "cookie": null,
- "jsonRpc": {
- "type": "request",
- "id": "id-1",
- "method": "initialize"
- }
- },
- {
- "kind": "http_response",
- "status": 200,
- "scope": "bootstrap",
- "connectionId": "conn-1",
- "sessionId": null
- },
- {
- "kind": "http_request",
- "method": "GET",
- "scope": "connection",
- "connectionId": "conn-1",
- "sessionId": null,
- "cookie": "fixture=streamable-http",
- "jsonRpc": null
- },
- {
- "kind": "http_response",
- "status": 200,
- "scope": "connection",
- "connectionId": "conn-1",
- "sessionId": null
- },
- {
- "kind": "http_request",
- "method": "GET",
- "scope": "session",
- "connectionId": "conn-1",
- "sessionId": "sess-load",
- "cookie": "fixture=streamable-http",
- "jsonRpc": null
- },
- {
- "kind": "http_response",
- "status": 200,
- "scope": "session",
- "connectionId": "conn-1",
- "sessionId": "sess-load"
- },
- {
- "kind": "http_request",
- "method": "POST",
- "scope": "session",
- "connectionId": "conn-1",
- "sessionId": "sess-load",
- "cookie": "fixture=streamable-http",
- "jsonRpc": {
- "type": "request",
- "id": "id-2",
- "method": "session/load"
- }
- },
- {
- "kind": "http_response",
- "status": 202,
- "scope": "session",
- "connectionId": "conn-1",
- "sessionId": "sess-load"
- },
- {
- "kind": "sse_event",
- "stream": "connection",
- "sessionId": null,
- "jsonRpc": {
- "type": "response",
- "id": "id-2",
- "hasError": false
- }
- },
- {
- "kind": "http_request",
- "method": "DELETE",
- "scope": "connection",
- "connectionId": "conn-1",
- "sessionId": null,
- "cookie": "fixture=streamable-http",
- "jsonRpc": null
- },
- {
- "kind": "http_response",
- "status": 202,
- "scope": "connection",
- "connectionId": "conn-1",
- "sessionId": null
- }
-]
diff --git a/test-fixtures/streamable-http-server/golden/two-sessions.json b/test-fixtures/streamable-http-server/golden/two-sessions.json
deleted file mode 100644
index 0d5eacb..0000000
--- a/test-fixtures/streamable-http-server/golden/two-sessions.json
+++ /dev/null
@@ -1,224 +0,0 @@
-[
- {
- "kind": "http_request",
- "method": "POST",
- "scope": "bootstrap",
- "connectionId": null,
- "sessionId": null,
- "cookie": null,
- "jsonRpc": {
- "type": "request",
- "id": "id-1",
- "method": "initialize"
- }
- },
- {
- "kind": "http_response",
- "status": 200,
- "scope": "bootstrap",
- "connectionId": "conn-1",
- "sessionId": null
- },
- {
- "kind": "http_request",
- "method": "GET",
- "scope": "connection",
- "connectionId": "conn-1",
- "sessionId": null,
- "cookie": "fixture=streamable-http",
- "jsonRpc": null
- },
- {
- "kind": "http_response",
- "status": 200,
- "scope": "connection",
- "connectionId": "conn-1",
- "sessionId": null
- },
- {
- "kind": "http_request",
- "method": "POST",
- "scope": "connection",
- "connectionId": "conn-1",
- "sessionId": null,
- "cookie": "fixture=streamable-http",
- "jsonRpc": {
- "type": "request",
- "id": "id-2",
- "method": "session/new"
- }
- },
- {
- "kind": "http_response",
- "status": 202,
- "scope": "connection",
- "connectionId": "conn-1",
- "sessionId": null
- },
- {
- "kind": "sse_event",
- "stream": "connection",
- "sessionId": null,
- "jsonRpc": {
- "type": "response",
- "id": "id-2",
- "hasError": false
- }
- },
- {
- "kind": "http_request",
- "method": "GET",
- "scope": "session",
- "connectionId": "conn-1",
- "sessionId": "sess-1",
- "cookie": "fixture=streamable-http",
- "jsonRpc": null
- },
- {
- "kind": "http_response",
- "status": 200,
- "scope": "session",
- "connectionId": "conn-1",
- "sessionId": "sess-1"
- },
- {
- "kind": "http_request",
- "method": "POST",
- "scope": "connection",
- "connectionId": "conn-1",
- "sessionId": null,
- "cookie": "fixture=streamable-http",
- "jsonRpc": {
- "type": "request",
- "id": "id-3",
- "method": "session/new"
- }
- },
- {
- "kind": "http_response",
- "status": 202,
- "scope": "connection",
- "connectionId": "conn-1",
- "sessionId": null
- },
- {
- "kind": "sse_event",
- "stream": "connection",
- "sessionId": null,
- "jsonRpc": {
- "type": "response",
- "id": "id-3",
- "hasError": false
- }
- },
- {
- "kind": "http_request",
- "method": "GET",
- "scope": "session",
- "connectionId": "conn-1",
- "sessionId": "sess-2",
- "cookie": "fixture=streamable-http",
- "jsonRpc": null
- },
- {
- "kind": "http_response",
- "status": 200,
- "scope": "session",
- "connectionId": "conn-1",
- "sessionId": "sess-2"
- },
- {
- "kind": "http_request",
- "method": "POST",
- "scope": "session",
- "connectionId": "conn-1",
- "sessionId": "sess-1",
- "cookie": "fixture=streamable-http",
- "jsonRpc": {
- "type": "request",
- "id": "id-4",
- "method": "session/prompt"
- }
- },
- {
- "kind": "http_response",
- "status": 202,
- "scope": "session",
- "connectionId": "conn-1",
- "sessionId": "sess-1"
- },
- {
- "kind": "sse_event",
- "stream": "session",
- "sessionId": "sess-1",
- "jsonRpc": {
- "type": "notification",
- "method": "session/update"
- }
- },
- {
- "kind": "sse_event",
- "stream": "session",
- "sessionId": "sess-1",
- "jsonRpc": {
- "type": "response",
- "id": "id-4",
- "hasError": false
- }
- },
- {
- "kind": "http_request",
- "method": "POST",
- "scope": "session",
- "connectionId": "conn-1",
- "sessionId": "sess-2",
- "cookie": "fixture=streamable-http",
- "jsonRpc": {
- "type": "request",
- "id": "id-5",
- "method": "session/prompt"
- }
- },
- {
- "kind": "http_response",
- "status": 202,
- "scope": "session",
- "connectionId": "conn-1",
- "sessionId": "sess-2"
- },
- {
- "kind": "sse_event",
- "stream": "session",
- "sessionId": "sess-2",
- "jsonRpc": {
- "type": "notification",
- "method": "session/update"
- }
- },
- {
- "kind": "sse_event",
- "stream": "session",
- "sessionId": "sess-2",
- "jsonRpc": {
- "type": "response",
- "id": "id-5",
- "hasError": false
- }
- },
- {
- "kind": "http_request",
- "method": "DELETE",
- "scope": "connection",
- "connectionId": "conn-1",
- "sessionId": null,
- "cookie": "fixture=streamable-http",
- "jsonRpc": null
- },
- {
- "kind": "http_response",
- "status": 202,
- "scope": "connection",
- "connectionId": "conn-1",
- "sessionId": null
- }
-]
diff --git a/test-fixtures/streamable-http-server/golden/validation-failures.json b/test-fixtures/streamable-http-server/golden/validation-failures.json
deleted file mode 100644
index 51cbd36..0000000
--- a/test-fixtures/streamable-http-server/golden/validation-failures.json
+++ /dev/null
@@ -1,86 +0,0 @@
-[
- {
- "kind": "http_request",
- "method": "POST",
- "scope": "bootstrap",
- "connectionId": null,
- "sessionId": null,
- "cookie": null,
- "jsonRpc": {
- "type": "request",
- "id": "id-1",
- "method": "initialize"
- }
- },
- {
- "kind": "http_response",
- "status": 200,
- "scope": "bootstrap",
- "connectionId": "conn-1",
- "sessionId": null
- },
- {
- "kind": "http_request",
- "method": "GET",
- "scope": "bootstrap",
- "connectionId": null,
- "sessionId": null,
- "cookie": "fixture=streamable-http",
- "jsonRpc": null
- },
- {
- "kind": "http_response",
- "status": 400,
- "scope": "bootstrap",
- "connectionId": "conn-1",
- "sessionId": null
- },
- {
- "kind": "http_request",
- "method": "GET",
- "scope": "connection",
- "connectionId": "conn-1",
- "sessionId": null,
- "cookie": null,
- "jsonRpc": null
- },
- {
- "kind": "http_response",
- "status": 401,
- "scope": "connection",
- "connectionId": "conn-1",
- "sessionId": null
- },
- {
- "kind": "http_request",
- "method": "GET",
- "scope": "connection",
- "connectionId": "conn-1",
- "sessionId": null,
- "cookie": "fixture=streamable-http",
- "jsonRpc": null
- },
- {
- "kind": "http_response",
- "status": 406,
- "scope": "connection",
- "connectionId": "conn-1",
- "sessionId": null
- },
- {
- "kind": "http_request",
- "method": "DELETE",
- "scope": "connection",
- "connectionId": "conn-1",
- "sessionId": null,
- "cookie": "fixture=streamable-http",
- "jsonRpc": null
- },
- {
- "kind": "http_response",
- "status": 202,
- "scope": "connection",
- "connectionId": "conn-1",
- "sessionId": null
- }
-]
diff --git a/test-fixtures/streamable-http-server/golden/wrong-stream-response.json b/test-fixtures/streamable-http-server/golden/wrong-stream-response.json
deleted file mode 100644
index 97e80ea..0000000
--- a/test-fixtures/streamable-http-server/golden/wrong-stream-response.json
+++ /dev/null
@@ -1,130 +0,0 @@
-[
- {
- "kind": "http_request",
- "method": "POST",
- "scope": "bootstrap",
- "connectionId": null,
- "sessionId": null,
- "cookie": null,
- "jsonRpc": {
- "type": "request",
- "id": "id-1",
- "method": "initialize"
- }
- },
- {
- "kind": "http_response",
- "status": 200,
- "scope": "bootstrap",
- "connectionId": "conn-1",
- "sessionId": null
- },
- {
- "kind": "http_request",
- "method": "GET",
- "scope": "connection",
- "connectionId": "conn-1",
- "sessionId": null,
- "cookie": "fixture=streamable-http",
- "jsonRpc": null
- },
- {
- "kind": "http_response",
- "status": 200,
- "scope": "connection",
- "connectionId": "conn-1",
- "sessionId": null
- },
- {
- "kind": "http_request",
- "method": "POST",
- "scope": "connection",
- "connectionId": "conn-1",
- "sessionId": null,
- "cookie": "fixture=streamable-http",
- "jsonRpc": {
- "type": "request",
- "id": "id-2",
- "method": "session/new"
- }
- },
- {
- "kind": "http_response",
- "status": 202,
- "scope": "connection",
- "connectionId": "conn-1",
- "sessionId": null
- },
- {
- "kind": "sse_event",
- "stream": "connection",
- "sessionId": null,
- "jsonRpc": {
- "type": "response",
- "id": "id-2",
- "hasError": false
- }
- },
- {
- "kind": "http_request",
- "method": "GET",
- "scope": "session",
- "connectionId": "conn-1",
- "sessionId": "sess-wrong",
- "cookie": "fixture=streamable-http",
- "jsonRpc": null
- },
- {
- "kind": "http_response",
- "status": 200,
- "scope": "session",
- "connectionId": "conn-1",
- "sessionId": "sess-wrong"
- },
- {
- "kind": "http_request",
- "method": "POST",
- "scope": "session",
- "connectionId": "conn-1",
- "sessionId": "sess-wrong",
- "cookie": "fixture=streamable-http",
- "jsonRpc": {
- "type": "request",
- "id": "id-3",
- "method": "session/prompt"
- }
- },
- {
- "kind": "http_response",
- "status": 202,
- "scope": "session",
- "connectionId": "conn-1",
- "sessionId": "sess-wrong"
- },
- {
- "kind": "sse_event",
- "stream": "connection",
- "sessionId": null,
- "jsonRpc": {
- "type": "response",
- "id": "id-3",
- "hasError": false
- }
- },
- {
- "kind": "http_request",
- "method": "DELETE",
- "scope": "connection",
- "connectionId": "conn-1",
- "sessionId": null,
- "cookie": "fixture=streamable-http",
- "jsonRpc": null
- },
- {
- "kind": "http_response",
- "status": 202,
- "scope": "connection",
- "connectionId": "conn-1",
- "sessionId": null
- }
-]
diff --git a/test-fixtures/streamable-http-server/package-lock.json b/test-fixtures/streamable-http-server/package-lock.json
deleted file mode 100644
index 1a89fc5..0000000
--- a/test-fixtures/streamable-http-server/package-lock.json
+++ /dev/null
@@ -1,566 +0,0 @@
-{
- "name": "acp-streamable-http-fixture",
- "version": "0.1.0",
- "lockfileVersion": 3,
- "requires": true,
- "packages": {
- "": {
- "name": "acp-streamable-http-fixture",
- "version": "0.1.0",
- "devDependencies": {
- "@types/node": "^22.15.0",
- "tsx": "^4.19.4",
- "typescript": "^5.8.3"
- }
- },
- "node_modules/@esbuild/aix-ppc64": {
- "version": "0.28.0",
- "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz",
- "integrity": "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==",
- "cpu": [
- "ppc64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "aix"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/android-arm": {
- "version": "0.28.0",
- "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.0.tgz",
- "integrity": "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==",
- "cpu": [
- "arm"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "android"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/android-arm64": {
- "version": "0.28.0",
- "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.0.tgz",
- "integrity": "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "android"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/android-x64": {
- "version": "0.28.0",
- "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.0.tgz",
- "integrity": "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "android"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/darwin-arm64": {
- "version": "0.28.0",
- "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.0.tgz",
- "integrity": "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "darwin"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/darwin-x64": {
- "version": "0.28.0",
- "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.0.tgz",
- "integrity": "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "darwin"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/freebsd-arm64": {
- "version": "0.28.0",
- "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.0.tgz",
- "integrity": "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "freebsd"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/freebsd-x64": {
- "version": "0.28.0",
- "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.0.tgz",
- "integrity": "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "freebsd"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/linux-arm": {
- "version": "0.28.0",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.0.tgz",
- "integrity": "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==",
- "cpu": [
- "arm"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/linux-arm64": {
- "version": "0.28.0",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.0.tgz",
- "integrity": "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/linux-ia32": {
- "version": "0.28.0",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.0.tgz",
- "integrity": "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==",
- "cpu": [
- "ia32"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/linux-loong64": {
- "version": "0.28.0",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.0.tgz",
- "integrity": "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==",
- "cpu": [
- "loong64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/linux-mips64el": {
- "version": "0.28.0",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.0.tgz",
- "integrity": "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==",
- "cpu": [
- "mips64el"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/linux-ppc64": {
- "version": "0.28.0",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.0.tgz",
- "integrity": "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==",
- "cpu": [
- "ppc64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/linux-riscv64": {
- "version": "0.28.0",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.0.tgz",
- "integrity": "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==",
- "cpu": [
- "riscv64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/linux-s390x": {
- "version": "0.28.0",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.0.tgz",
- "integrity": "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==",
- "cpu": [
- "s390x"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/linux-x64": {
- "version": "0.28.0",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.0.tgz",
- "integrity": "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/netbsd-arm64": {
- "version": "0.28.0",
- "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.0.tgz",
- "integrity": "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "netbsd"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/netbsd-x64": {
- "version": "0.28.0",
- "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.0.tgz",
- "integrity": "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "netbsd"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/openbsd-arm64": {
- "version": "0.28.0",
- "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.0.tgz",
- "integrity": "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "openbsd"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/openbsd-x64": {
- "version": "0.28.0",
- "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.0.tgz",
- "integrity": "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "openbsd"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/openharmony-arm64": {
- "version": "0.28.0",
- "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.0.tgz",
- "integrity": "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "openharmony"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/sunos-x64": {
- "version": "0.28.0",
- "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.0.tgz",
- "integrity": "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "sunos"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/win32-arm64": {
- "version": "0.28.0",
- "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.0.tgz",
- "integrity": "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "win32"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/win32-ia32": {
- "version": "0.28.0",
- "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.0.tgz",
- "integrity": "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==",
- "cpu": [
- "ia32"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "win32"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/win32-x64": {
- "version": "0.28.0",
- "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.0.tgz",
- "integrity": "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "win32"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@types/node": {
- "version": "22.19.19",
- "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.19.tgz",
- "integrity": "sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "undici-types": "~6.21.0"
- }
- },
- "node_modules/esbuild": {
- "version": "0.28.0",
- "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.0.tgz",
- "integrity": "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==",
- "dev": true,
- "hasInstallScript": true,
- "license": "MIT",
- "bin": {
- "esbuild": "bin/esbuild"
- },
- "engines": {
- "node": ">=18"
- },
- "optionalDependencies": {
- "@esbuild/aix-ppc64": "0.28.0",
- "@esbuild/android-arm": "0.28.0",
- "@esbuild/android-arm64": "0.28.0",
- "@esbuild/android-x64": "0.28.0",
- "@esbuild/darwin-arm64": "0.28.0",
- "@esbuild/darwin-x64": "0.28.0",
- "@esbuild/freebsd-arm64": "0.28.0",
- "@esbuild/freebsd-x64": "0.28.0",
- "@esbuild/linux-arm": "0.28.0",
- "@esbuild/linux-arm64": "0.28.0",
- "@esbuild/linux-ia32": "0.28.0",
- "@esbuild/linux-loong64": "0.28.0",
- "@esbuild/linux-mips64el": "0.28.0",
- "@esbuild/linux-ppc64": "0.28.0",
- "@esbuild/linux-riscv64": "0.28.0",
- "@esbuild/linux-s390x": "0.28.0",
- "@esbuild/linux-x64": "0.28.0",
- "@esbuild/netbsd-arm64": "0.28.0",
- "@esbuild/netbsd-x64": "0.28.0",
- "@esbuild/openbsd-arm64": "0.28.0",
- "@esbuild/openbsd-x64": "0.28.0",
- "@esbuild/openharmony-arm64": "0.28.0",
- "@esbuild/sunos-x64": "0.28.0",
- "@esbuild/win32-arm64": "0.28.0",
- "@esbuild/win32-ia32": "0.28.0",
- "@esbuild/win32-x64": "0.28.0"
- }
- },
- "node_modules/fsevents": {
- "version": "2.3.3",
- "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
- "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
- "dev": true,
- "hasInstallScript": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "darwin"
- ],
- "engines": {
- "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
- }
- },
- "node_modules/tsx": {
- "version": "4.22.1",
- "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.1.tgz",
- "integrity": "sha512-TvncJykhxAzFCk0VQZKBTClall4Pm7qXDSodb6uxi8QFa8X8mT6ABjxxsQ2opDRYxG7AzcRWXaFtruz5HJKuWg==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "esbuild": "~0.28.0"
- },
- "bin": {
- "tsx": "dist/cli.mjs"
- },
- "engines": {
- "node": ">=18.0.0"
- },
- "optionalDependencies": {
- "fsevents": "~2.3.3"
- }
- },
- "node_modules/typescript": {
- "version": "5.9.3",
- "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
- "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
- "dev": true,
- "license": "Apache-2.0",
- "bin": {
- "tsc": "bin/tsc",
- "tsserver": "bin/tsserver"
- },
- "engines": {
- "node": ">=14.17"
- }
- },
- "node_modules/undici-types": {
- "version": "6.21.0",
- "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
- "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
- "dev": true,
- "license": "MIT"
- }
- }
-}
diff --git a/test-fixtures/streamable-http-server/package.json b/test-fixtures/streamable-http-server/package.json
deleted file mode 100644
index 11a8163..0000000
--- a/test-fixtures/streamable-http-server/package.json
+++ /dev/null
@@ -1,16 +0,0 @@
-{
- "name": "acp-streamable-http-fixture",
- "private": true,
- "version": "0.1.0",
- "type": "module",
- "scripts": {
- "build": "tsc -p tsconfig.json",
- "start": "tsx src/server.ts",
- "start:happy-path": "tsx src/server.ts --scenario happy-path"
- },
- "devDependencies": {
- "@types/node": "^22.15.0",
- "tsx": "^4.19.4",
- "typescript": "^5.8.3"
- }
-}
diff --git a/test-fixtures/streamable-http-server/src/protocol.ts b/test-fixtures/streamable-http-server/src/protocol.ts
deleted file mode 100644
index df82cfd..0000000
--- a/test-fixtures/streamable-http-server/src/protocol.ts
+++ /dev/null
@@ -1,32 +0,0 @@
-import { ServerResponse } from "node:http";
-
-export const ACP_PATH = "/acp";
-export const CONNECTION_HEADER = "acp-connection-id";
-export const SESSION_HEADER = "acp-session-id";
-export const FIXTURE_COOKIE = "fixture=streamable-http";
-
-export type JsonRpcMessage = Record;
-
-export function sendJson(response: ServerResponse, status: number, body?: JsonRpcMessage, headers: Record = {}): void {
- response.writeHead(status, {
- "content-type": "application/json",
- ...headers,
- });
- if (body) {
- response.end(JSON.stringify(body));
- } else {
- response.end();
- }
-}
-
-export function openEventStream(response: ServerResponse): void {
- response.writeHead(200, {
- "content-type": "text/event-stream",
- "cache-control": "no-cache",
- });
- response.flushHeaders();
-}
-
-export function writeSse(response: ServerResponse, message: JsonRpcMessage): void {
- response.write(`data: ${JSON.stringify(message)}\n\n`);
-}
diff --git a/test-fixtures/streamable-http-server/src/scenarios.ts b/test-fixtures/streamable-http-server/src/scenarios.ts
deleted file mode 100644
index c4f49f3..0000000
--- a/test-fixtures/streamable-http-server/src/scenarios.ts
+++ /dev/null
@@ -1,574 +0,0 @@
-import { IncomingHttpHeaders, ServerResponse } from "node:http";
-import {
- CONNECTION_HEADER,
- FIXTURE_COOKIE,
- JsonRpcMessage,
- SESSION_HEADER,
- openEventStream,
- sendJson,
- writeSse,
-} from "./protocol.js";
-import { TranscriptRecorder } from "./transcript.js";
-
-export interface FixtureScenario {
- readonly name: string;
- handle(
- response: ServerResponse,
- headers: IncomingHttpHeaders,
- method: string,
- body: JsonRpcMessage | null,
- recorder: TranscriptRecorder,
- ): void;
-}
-
-export function createScenario(name: string): FixtureScenario {
- switch (name) {
- case "happy-path":
- return new HappyPathScenario();
- case "permission-round-trip":
- return new PermissionRoundTripScenario();
- case "session-load":
- return new SessionLoadScenario();
- case "two-sessions":
- return new TwoSessionsScenario();
- case "wrong-stream-response":
- return new WrongStreamResponseScenario();
- case "validation-failures":
- return new ValidationFailuresScenario();
- default:
- throw new Error(`Unknown scenario: ${name}`);
- }
-}
-
-abstract class BaseScenario implements FixtureScenario {
- abstract readonly name: string;
-
- protected readonly connectionId = "conn-1";
- protected connectionStream: ServerResponse | null = null;
- protected readonly sessionStreams = new Map();
-
- handle(
- response: ServerResponse,
- headers: IncomingHttpHeaders,
- method: string,
- body: JsonRpcMessage | null,
- recorder: TranscriptRecorder,
- ): void {
- const request = this.recordRequest(method, headers, body, recorder);
- if (!this.validateRequest(response, request, body, recorder)) {
- return;
- }
-
- if (request.method === "POST" && body?.method === "initialize") {
- this.handleInitialize(response, body, recorder);
- return;
- }
-
- if (
- request.method === "GET" &&
- request.connectionId === this.connectionId &&
- !request.sessionId
- ) {
- this.connectionStream = response;
- openEventStream(response);
- this.recordResponse(recorder, 200, "connection", null);
- return;
- }
-
- if (
- request.method === "GET" &&
- request.connectionId === this.connectionId &&
- request.sessionId
- ) {
- this.sessionStreams.set(request.sessionId, response);
- openEventStream(response);
- this.recordResponse(recorder, 200, "session", request.sessionId);
- return;
- }
-
- if (request.method === "DELETE" && request.connectionId === this.connectionId) {
- sendJson(response, 202);
- this.recordResponse(recorder, 202, "connection", null);
- this.connectionStream?.end();
- this.sessionStreams.forEach((sessionStream) => sessionStream.end());
- return;
- }
-
- if (this.handleScenarioRequest(response, request, body, recorder)) {
- return;
- }
-
- this.reject(response, recorder, request.scope, request.sessionId, 400, "Unexpected fixture request", body?.id);
- }
-
- protected abstract handleScenarioRequest(
- response: ServerResponse,
- request: RecordedRequest,
- body: JsonRpcMessage | null,
- recorder: TranscriptRecorder,
- ): boolean;
-
- protected sendSessionNewResponse(
- responseStream: ServerResponse,
- request: RecordedRequest,
- body: JsonRpcMessage,
- recorder: TranscriptRecorder,
- sessionId: string,
- ): void {
- sendJson(responseStream, 202);
- this.recordResponse(recorder, 202, request.scope, request.sessionId);
- const response = {
- jsonrpc: "2.0",
- id: body.id,
- result: {
- sessionId,
- },
- };
- this.writeConnectionEvent(response, recorder);
- }
-
- protected sendPromptFlow(
- responseStream: ServerResponse,
- request: RecordedRequest,
- body: JsonRpcMessage,
- recorder: TranscriptRecorder,
- sessionId: string,
- ): void {
- sendJson(responseStream, 202);
- this.recordResponse(recorder, 202, request.scope, request.sessionId);
- const update = {
- jsonrpc: "2.0",
- method: "session/update",
- params: {
- sessionId,
- update: {
- sessionUpdate: "agent_message_chunk",
- content: { type: "text", text: "hello" },
- },
- },
- };
- const response = {
- jsonrpc: "2.0",
- id: body.id,
- result: {
- stopReason: "end_turn",
- },
- };
- this.writeSessionEvent(sessionId, update, recorder);
- this.writeSessionEvent(sessionId, response, recorder);
- }
-
- protected writeConnectionEvent(message: JsonRpcMessage, recorder: TranscriptRecorder): void {
- if (!this.connectionStream) {
- throw new Error("connection stream must be open");
- }
- writeSse(this.connectionStream, message);
- recorder.record({
- kind: "sse_event",
- stream: "connection",
- sessionId: null,
- jsonRpc: recorder.summarizeJsonRpc(message)!,
- });
- }
-
- protected writeSessionEvent(sessionId: string, message: JsonRpcMessage, recorder: TranscriptRecorder): void {
- const stream = this.sessionStreams.get(sessionId);
- if (!stream) {
- throw new Error(`session stream ${sessionId} must be open`);
- }
- writeSse(stream, message);
- recorder.record({
- kind: "sse_event",
- stream: "session",
- sessionId,
- jsonRpc: recorder.summarizeJsonRpc(message)!,
- });
- }
-
- private handleInitialize(response: ServerResponse, body: JsonRpcMessage, recorder: TranscriptRecorder): void {
- sendJson(
- response,
- 200,
- {
- jsonrpc: "2.0",
- id: body.id,
- result: {
- protocolVersion: 1,
- agentCapabilities: {
- loadSession: true,
- },
- authMethods: [],
- },
- },
- {
- "Acp-Connection-Id": this.connectionId,
- "set-cookie": FIXTURE_COOKIE,
- },
- );
- this.recordResponse(recorder, 200, "bootstrap", null);
- }
-
- private validateRequest(
- response: ServerResponse,
- request: RecordedRequest,
- body: JsonRpcMessage | null,
- recorder: TranscriptRecorder,
- ): boolean {
- if (request.scope === "bootstrap") {
- if (request.method !== "POST" || body?.method !== "initialize") {
- this.reject(response, recorder, request.scope, request.sessionId, 400, "Expected initialize bootstrap", body?.id);
- return false;
- }
- return true;
- }
-
- if (request.connectionId !== this.connectionId) {
- this.reject(response, recorder, request.scope, request.sessionId, 400, "Missing or invalid connection id", body?.id);
- return false;
- }
-
- if (!request.cookie?.includes(FIXTURE_COOKIE)) {
- this.reject(response, recorder, request.scope, request.sessionId, 401, "Missing fixture cookie", body?.id);
- return false;
- }
-
- if (request.method === "GET" && !request.accept?.includes("text/event-stream")) {
- this.reject(response, recorder, request.scope, request.sessionId, 406, "Expected text/event-stream", body?.id);
- return false;
- }
-
- if (request.method === "POST") {
- if (!request.accept?.includes("application/json")) {
- this.reject(response, recorder, request.scope, request.sessionId, 406, "Expected application/json accept", body?.id);
- return false;
- }
- if (!request.contentType?.includes("application/json")) {
- this.reject(response, recorder, request.scope, request.sessionId, 415, "Expected application/json body", body?.id);
- return false;
- }
- }
-
- return true;
- }
-
- private recordRequest(
- method: string,
- headers: IncomingHttpHeaders,
- body: JsonRpcMessage | null,
- recorder: TranscriptRecorder,
- ): RecordedRequest {
- const connectionId = header(headers, CONNECTION_HEADER);
- const sessionId = header(headers, SESSION_HEADER);
- const cookie = header(headers, "cookie");
- const accept = header(headers, "accept");
- const contentType = header(headers, "content-type");
- const scope = sessionId ? "session" : connectionId ? "connection" : "bootstrap";
- recorder.record({
- kind: "http_request",
- method,
- scope,
- connectionId,
- sessionId,
- cookie,
- jsonRpc: recorder.summarizeJsonRpc(body),
- });
- return { method, scope, connectionId, sessionId, cookie, accept, contentType };
- }
-
- private recordResponse(
- recorder: TranscriptRecorder,
- status: number,
- scope: RequestScope,
- sessionId: string | null,
- ): void {
- recorder.record({
- kind: "http_response",
- status,
- scope,
- connectionId: scope === "bootstrap" ? this.connectionId : this.connectionId,
- sessionId,
- });
- }
-
- private reject(
- response: ServerResponse,
- recorder: TranscriptRecorder,
- scope: RequestScope,
- sessionId: string | null,
- status: number,
- message: string,
- id: unknown,
- ): void {
- sendJson(response, status, {
- jsonrpc: "2.0",
- id: typeof id === "string" || typeof id === "number" ? id : null,
- error: {
- code: -32600,
- message,
- },
- });
- this.recordResponse(recorder, status, scope, sessionId);
- }
-}
-
-class HappyPathScenario extends BaseScenario {
- readonly name = "happy-path";
- private readonly sessionId = "sess-1";
-
- protected handleScenarioRequest(
- response: ServerResponse,
- request: RecordedRequest,
- body: JsonRpcMessage | null,
- recorder: TranscriptRecorder,
- ): boolean {
- if (request.method === "POST" && request.scope === "connection" && body?.method === "session/new") {
- this.sendSessionNewResponse(response, request, body, recorder, this.sessionId);
- return true;
- }
- if (
- request.method === "POST" &&
- request.scope === "session" &&
- request.sessionId === this.sessionId &&
- body?.method === "session/prompt"
- ) {
- this.sendPromptFlow(response, request, body, recorder, this.sessionId);
- return true;
- }
- return false;
- }
-}
-
-class PermissionRoundTripScenario extends BaseScenario {
- readonly name = "permission-round-trip";
- private readonly sessionId = "sess-permission";
- private pendingPromptId: unknown;
-
- protected handleScenarioRequest(
- response: ServerResponse,
- request: RecordedRequest,
- body: JsonRpcMessage | null,
- recorder: TranscriptRecorder,
- ): boolean {
- if (request.method === "POST" && request.scope === "connection" && body?.method === "session/new") {
- this.sendSessionNewResponse(response, request, body, recorder, this.sessionId);
- return true;
- }
- if (
- request.method === "POST" &&
- request.scope === "session" &&
- request.sessionId === this.sessionId &&
- body?.method === "session/prompt"
- ) {
- sendJson(response, 202);
- this.recordScenarioResponse(recorder, 202, request);
- this.pendingPromptId = body.id;
- this.writeSessionEvent(
- this.sessionId,
- {
- jsonrpc: "2.0",
- id: "perm-1",
- method: "session/request_permission",
- params: {
- sessionId: this.sessionId,
- toolCall: {
- toolCallId: "tool-1",
- title: "Write File",
- kind: "edit",
- status: "pending",
- },
- options: [
- { optionId: "allow", name: "Allow", kind: "allow_once" },
- { optionId: "deny", name: "Deny", kind: "reject_once" },
- ],
- },
- },
- recorder,
- );
- return true;
- }
- if (
- request.method === "POST" &&
- request.scope === "session" &&
- request.sessionId === this.sessionId &&
- body?.id === "perm-1" &&
- "result" in body
- ) {
- sendJson(response, 202);
- this.recordScenarioResponse(recorder, 202, request);
- this.writeSessionEvent(
- this.sessionId,
- {
- jsonrpc: "2.0",
- id: this.pendingPromptId,
- result: {
- stopReason: "end_turn",
- },
- },
- recorder,
- );
- return true;
- }
- return false;
- }
-
- private recordScenarioResponse(recorder: TranscriptRecorder, status: number, request: RecordedRequest): void {
- recorder.record({
- kind: "http_response",
- status,
- scope: request.scope,
- connectionId: this.connectionId,
- sessionId: request.sessionId,
- });
- }
-}
-
-class SessionLoadScenario extends BaseScenario {
- readonly name = "session-load";
- private readonly sessionId = "sess-load";
-
- protected handleScenarioRequest(
- response: ServerResponse,
- request: RecordedRequest,
- body: JsonRpcMessage | null,
- recorder: TranscriptRecorder,
- ): boolean {
- if (
- request.method === "POST" &&
- request.scope === "session" &&
- request.sessionId === this.sessionId &&
- body?.method === "session/load"
- ) {
- sendJson(response, 202);
- this.recordScenarioResponse(recorder, 202, request);
- this.writeConnectionEvent(
- {
- jsonrpc: "2.0",
- id: body.id,
- result: {},
- },
- recorder,
- );
- return true;
- }
- return false;
- }
-
- private recordScenarioResponse(recorder: TranscriptRecorder, status: number, request: RecordedRequest): void {
- recorder.record({
- kind: "http_response",
- status,
- scope: request.scope,
- connectionId: this.connectionId,
- sessionId: request.sessionId,
- });
- }
-}
-
-class TwoSessionsScenario extends BaseScenario {
- readonly name = "two-sessions";
- private nextSessionNumber = 1;
-
- protected handleScenarioRequest(
- response: ServerResponse,
- request: RecordedRequest,
- body: JsonRpcMessage | null,
- recorder: TranscriptRecorder,
- ): boolean {
- if (request.method === "POST" && request.scope === "connection" && body?.method === "session/new") {
- const sessionId = `sess-${this.nextSessionNumber++}`;
- this.sendSessionNewResponse(response, request, body, recorder, sessionId);
- return true;
- }
- if (
- request.method === "POST" &&
- request.scope === "session" &&
- request.sessionId &&
- body?.method === "session/prompt"
- ) {
- this.sendPromptFlow(response, request, body, recorder, request.sessionId);
- return true;
- }
- return false;
- }
-}
-
-class WrongStreamResponseScenario extends BaseScenario {
- readonly name = "wrong-stream-response";
- private readonly sessionId = "sess-wrong";
-
- protected handleScenarioRequest(
- response: ServerResponse,
- request: RecordedRequest,
- body: JsonRpcMessage | null,
- recorder: TranscriptRecorder,
- ): boolean {
- if (request.method === "POST" && request.scope === "connection" && body?.method === "session/new") {
- this.sendSessionNewResponse(response, request, body, recorder, this.sessionId);
- return true;
- }
- if (
- request.method === "POST" &&
- request.scope === "session" &&
- request.sessionId === this.sessionId &&
- body?.method === "session/prompt"
- ) {
- sendJson(response, 202);
- this.recordScenarioResponse(recorder, 202, request);
- this.writeConnectionEvent(
- {
- jsonrpc: "2.0",
- id: body.id,
- result: {
- stopReason: "end_turn",
- },
- },
- recorder,
- );
- return true;
- }
- return false;
- }
-
- private recordScenarioResponse(recorder: TranscriptRecorder, status: number, request: RecordedRequest): void {
- recorder.record({
- kind: "http_response",
- status,
- scope: request.scope,
- connectionId: this.connectionId,
- sessionId: request.sessionId,
- });
- }
-}
-
-class ValidationFailuresScenario extends BaseScenario {
- readonly name = "validation-failures";
-
- protected handleScenarioRequest(
- _response: ServerResponse,
- _request: RecordedRequest,
- _body: JsonRpcMessage | null,
- _recorder: TranscriptRecorder,
- ): boolean {
- return false;
- }
-}
-
-type RequestScope = "bootstrap" | "connection" | "session";
-
-type RecordedRequest = {
- method: string;
- scope: RequestScope;
- connectionId: string | null;
- sessionId: string | null;
- cookie: string | null;
- accept: string | null;
- contentType: string | null;
-};
-
-function header(headers: IncomingHttpHeaders, name: string): string | null {
- const value = headers[name];
- if (Array.isArray(value)) {
- return value[0] ?? null;
- }
- return typeof value === "string" ? value : null;
-}
diff --git a/test-fixtures/streamable-http-server/src/server.ts b/test-fixtures/streamable-http-server/src/server.ts
deleted file mode 100644
index 57d9279..0000000
--- a/test-fixtures/streamable-http-server/src/server.ts
+++ /dev/null
@@ -1,55 +0,0 @@
-import { createServer, IncomingMessage } from "node:http";
-import { ACP_PATH, JsonRpcMessage } from "./protocol.js";
-import { createScenario } from "./scenarios.js";
-import { TranscriptRecorder } from "./transcript.js";
-
-const scenarioName = readArg("--scenario") ?? "happy-path";
-const port = Number(readArg("--port") ?? "0");
-const recorder = new TranscriptRecorder();
-const scenario = createScenario(scenarioName);
-
-const server = createServer();
-
-server.on("request", async (request, response) => {
- const path = request.url ?? "";
- if (path === "/__test/transcript") {
- response.writeHead(200, {
- "content-type": "application/json",
- });
- response.end(recorder.serialize());
- return;
- }
-
- if (path !== ACP_PATH) {
- response.writeHead(404);
- response.end();
- return;
- }
-
- const body = await readJsonBody(request);
- scenario.handle(response, request.headers, request.method ?? "", body, recorder);
-});
-
-server.listen(port, () => {
- const address = server.address();
- const actualPort = typeof address === "object" && address ? address.port : port;
- process.stdout.write(JSON.stringify({ status: "ready", port: actualPort, scenario: scenario.name }) + "\n");
-});
-
-function readArg(name: string): string | undefined {
- const index = process.argv.indexOf(name);
- return index >= 0 ? process.argv[index + 1] : undefined;
-}
-
-async function readJsonBody(request: IncomingMessage): Promise {
- const method = request.method ?? "";
- if (method === "GET" || method === "DELETE") {
- return null;
- }
-
- let raw = "";
- for await (const chunk of request) {
- raw += chunk.toString("utf8");
- }
- return raw ? (JSON.parse(raw) as JsonRpcMessage) : null;
-}
diff --git a/test-fixtures/streamable-http-server/src/transcript.ts b/test-fixtures/streamable-http-server/src/transcript.ts
deleted file mode 100644
index 19f3440..0000000
--- a/test-fixtures/streamable-http-server/src/transcript.ts
+++ /dev/null
@@ -1,101 +0,0 @@
-export type JsonRpcSummary =
- | {
- type: "request";
- id: string | number | null;
- method: string;
- }
- | {
- type: "notification";
- method: string;
- }
- | {
- type: "response";
- id: string | number | null;
- hasError: boolean;
- };
-
-export type TranscriptEvent =
- | {
- kind: "http_request";
- method: string;
- scope: "bootstrap" | "connection" | "session";
- connectionId: string | null;
- sessionId: string | null;
- cookie: string | null;
- jsonRpc: JsonRpcSummary | null;
- }
- | {
- kind: "http_response";
- status: number;
- scope: "bootstrap" | "connection" | "session";
- connectionId: string | null;
- sessionId: string | null;
- }
- | {
- kind: "sse_event";
- stream: "connection" | "session";
- sessionId: string | null;
- jsonRpc: JsonRpcSummary;
- };
-
-export class TranscriptRecorder {
- private readonly events: TranscriptEvent[] = [];
- private readonly idAliases = new Map();
-
- record(event: TranscriptEvent): void {
- this.events.push(event);
- }
-
- summarizeJsonRpc(message: unknown): JsonRpcSummary | null {
- if (!message || typeof message !== "object") {
- return null;
- }
-
- const candidate = message as Record;
- if (typeof candidate.method === "string" && "id" in candidate) {
- return {
- type: "request",
- id: this.normalizeId(candidate.id),
- method: candidate.method,
- };
- }
-
- if (typeof candidate.method === "string") {
- return {
- type: "notification",
- method: candidate.method,
- };
- }
-
- if ("result" in candidate || "error" in candidate) {
- return {
- type: "response",
- id: this.normalizeId(candidate.id),
- hasError: candidate.error != null,
- };
- }
-
- return null;
- }
-
- toJSON(): TranscriptEvent[] {
- return [...this.events];
- }
-
- serialize(): string {
- return JSON.stringify(this.events, null, 2);
- }
-
- private normalizeId(id: unknown): string | null {
- if (typeof id !== "string" && typeof id !== "number") {
- return null;
- }
- const existing = this.idAliases.get(id);
- if (existing) {
- return existing;
- }
- const next = `id-${this.idAliases.size + 1}`;
- this.idAliases.set(id, next);
- return next;
- }
-}
diff --git a/test-fixtures/streamable-http-server/tsconfig.json b/test-fixtures/streamable-http-server/tsconfig.json
deleted file mode 100644
index ce68458..0000000
--- a/test-fixtures/streamable-http-server/tsconfig.json
+++ /dev/null
@@ -1,13 +0,0 @@
-{
- "compilerOptions": {
- "target": "ES2022",
- "module": "NodeNext",
- "moduleResolution": "NodeNext",
- "strict": true,
- "outDir": "dist",
- "rootDir": "src",
- "esModuleInterop": true,
- "forceConsistentCasingInFileNames": true
- },
- "include": ["src/**/*.ts"]
-}
From ad1540786a2184b53ec01de5338d1f39785234c3 Mon Sep 17 00:00:00 2001
From: Kaiser Dandangi
Date: Sun, 24 May 2026 21:09:25 -0400
Subject: [PATCH 12/15] fix: serialize streamable HTTP sink emissions
---
.../sdk/agent/transport/RemoteAcpConnection.java | 16 +++++++++++++---
.../StreamableHttpAcpClientTransport.java | 15 ++++++++++++---
2 files changed, 25 insertions(+), 6 deletions(-)
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
index d2162dd..79f9cac 100644
--- 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
@@ -154,6 +154,14 @@ private final class ConnectionTransport implements AcpAgentTransport {
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);
@@ -190,9 +198,11 @@ void acceptInbound(JSONRPCMessage message) {
if (transportClosing.get()) {
throw new AcpConnectionException("Remote ACP connection is closing");
}
- Sinks.EmitResult result = inboundSink.tryEmitNext(message);
- if (result.isFailure()) {
- throw new AcpConnectionException("Failed to enqueue inbound message: " + result);
+ synchronized (inboundEmitMonitor) {
+ Sinks.EmitResult result = inboundSink.tryEmitNext(message);
+ if (result.isFailure()) {
+ throw new AcpConnectionException("Failed to enqueue inbound message: " + result);
+ }
}
}
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
index 7913a87..8f5a9db 100644
--- 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
@@ -146,6 +146,13 @@ private record HttpClientBundle(HttpClient httpClient, ExecutorService ownedExec
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);
@@ -581,9 +588,11 @@ private Mono processInbound(RouteScope actualScope, JSONRPCMessage message
private Mono emitInbound(JSONRPCMessage message) {
return Mono.fromRunnable(() -> {
- Sinks.EmitResult result = inboundSink.tryEmitNext(message);
- if (result.isFailure()) {
- throw new AcpConnectionException("Failed to enqueue inbound message: " + result);
+ synchronized (inboundEmitMonitor) {
+ Sinks.EmitResult result = inboundSink.tryEmitNext(message);
+ if (result.isFailure()) {
+ throw new AcpConnectionException("Failed to enqueue inbound message: " + result);
+ }
}
});
}
From da691f340984d5ee2a6f89072c2c1be38e1db37b Mon Sep 17 00:00:00 2001
From: Kaiser Dandangi
Date: Sun, 24 May 2026 22:43:08 -0400
Subject: [PATCH 13/15] chore: remove streamable HTTP fixtures and plans
---
README.md | 11 -
plans/DOCS-ROADMAP.md | 329 -----------------
plans/STREAMABLE-HTTP-AGENT-TRANSPORT.md | 136 -------
plans/STREAMABLE-HTTP-TRANSPORT.md | 176 ---------
pom.xml | 1 -
.../streamable-http-agent-server/README.md | 43 ---
.../streamable-http-agent-server/pom.xml | 81 -----
.../StreamableHttpAgentDemoServer.java | 342 ------------------
.../src/main/resources/logback.xml | 10 -
9 files changed, 1129 deletions(-)
delete mode 100644 plans/DOCS-ROADMAP.md
delete mode 100644 plans/STREAMABLE-HTTP-AGENT-TRANSPORT.md
delete mode 100644 plans/STREAMABLE-HTTP-TRANSPORT.md
delete mode 100644 test-fixtures/streamable-http-agent-server/README.md
delete mode 100644 test-fixtures/streamable-http-agent-server/pom.xml
delete mode 100644 test-fixtures/streamable-http-agent-server/src/main/java/com/agentclientprotocol/sdk/fixtures/StreamableHttpAgentDemoServer.java
delete mode 100644 test-fixtures/streamable-http-agent-server/src/main/resources/logback.xml
diff --git a/README.md b/README.md
index 0776ded..0d75e92 100644
--- a/README.md
+++ b/README.md
@@ -392,17 +392,6 @@ agent.start().block(); // Starts WebSocket server on port 8080
| WebSocket | `WebSocketAcpClientTransport` | `WebSocketAcpAgentTransport` | acp-core / acp-websocket-jetty |
| Streamable HTTP | `StreamableHttpAcpClientTransport` | `StreamableHttpAcpAgentTransport` | acp-core / acp-streamable-http-jetty |
-### Streamable HTTP Demo Server
-
-Build and run a local demo ACP agent over HTTP/SSE:
-
-```bash
-./mvnw -q -pl test-fixtures/streamable-http-agent-server -am -DskipTests package
-java -jar test-fixtures/streamable-http-agent-server/target/acp-streamable-http-agent-server.jar --port 8080
-```
-
-The endpoint will be available at `http://127.0.0.1:8080/acp`.
-
---
## Building
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/plans/STREAMABLE-HTTP-AGENT-TRANSPORT.md b/plans/STREAMABLE-HTTP-AGENT-TRANSPORT.md
deleted file mode 100644
index 26410fb..0000000
--- a/plans/STREAMABLE-HTTP-AGENT-TRANSPORT.md
+++ /dev/null
@@ -1,136 +0,0 @@
-# Plan: Streamable HTTP Agent Transport
-
-> **Status**: Milestone one implemented
-> **Created**: 2026-05-18
-> **Primary goal**: Java agents can be served from a running Java web server over the Streamable HTTP transport, including WebSocket upgrades on the same ACP endpoint.
-
-## Goal
-
-Add an agent-side Streamable HTTP transport backed by Jetty, with:
-
-- one fresh ACP agent runtime per accepted remote connection
-- a public `AcpAgentFactory` seam for listener-backed transports
-- RFD-oriented HTTP + SSE behavior
-- WebSocket upgrade handling on the same ACP path
-- strict and compatible routing modes
-- a fixture-driven conformance harness that exercises a real running Java listener
-
-## Public Shape
-
-- Add `AcpAgentFactory` in `acp-core`.
-- Make the async seam explicit:
- - `AcpAgentFactory.async(...)`
- - `AcpAgentFactory.sync(...)`
-- Add `StreamableHttpAcpAgentTransport` in a dedicated Jetty adapter module:
- - `acp-streamable-http-jetty`
-- Keep the current legacy WebSocket single-agent API intact in `acp-websocket-jetty`.
-- Serve the RFD-compatible remote WebSocket upgrade path from `StreamableHttpAcpAgentTransport`, matching the Rust SDK shape where one HTTP server owns POST/SSE and WebSocket upgrades.
-
-## Runtime Model
-
-```text
-StreamableHttpAcpAgentTransport
- accepts remote connection
- -> AcpAgentFactory creates fresh agent runtime
- -> per-connection AcpAgentTransport drives that runtime
- -> one ACP connection contains many logical ACP sessionIds
-```
-
-- `Acp-Connection-Id` identifies one remote peer relationship.
-- `Acp-Session-Id` identifies one logical ACP session inside that connection.
-- The transport owns routing; the agent owns protocol meaning.
-
-## Routing / Lifecycle Decisions
-
-- `initialize`
- - creates a provisional connection
- - starts a fresh agent runtime
- - returns `200 OK` with JSON-RPC response + `Acp-Connection-Id`
- - publishes the connection only after successful initialize
-- non-initialize POSTs require `Acp-Connection-Id`
-- connection-scoped SSE streams carry:
- - initialize follow-up traffic
- - responses to `session/new`
- - responses to `session/load`
-- session-scoped SSE streams carry:
- - responses to ordinary session-scoped requests
- - session updates
- - agent-originated session-scoped requests such as permission prompts
-- DELETE tears down the connection and releases transport state.
-- WebSocket upgrades:
- - create one connection during the upgrade handshake
- - return `Acp-Connection-Id` on the `101 Switching Protocols` response
- - require the first client-originated JSON-RPC message on the socket to be `initialize`
- - exchange JSON-RPC messages as text frames until the socket closes
-
-### Routing ledgers
-
-- client request id -> expected outbound response scope
-- agent request id -> expected client response scope
-
-Wrong-stream client replies are protocol errors. Unknown response ids preserve current SDK parity and are allowed through for the session layer to decide.
-
-### Strict vs compatible
-
-- `STRICT`
- - rejects unknown methods without explicit routing
- - rejects unknown session stream opens with `404`
-- `COMPATIBLE`
- - falls back to `params.sessionId` inference for unknown methods
- - permits provisional session streams before `session/load`
-
-## Known RFD Gap
-
-The RFD says unknown session-scoped GETs should return `404`, but the resume flow also asks clients to open the session SSE stream before sending `session/load`. This transport keeps that tension explicit:
-
-- strict mode preserves the literal 404 rule
-- compatible mode creates a provisional `PENDING_LOAD` session stream
-
-PLAN: revisit this once the protocol resolves the resume/session-load ordering contract more precisely.
-
-## Test Harness
-
-Use Java integration tests only for this branch so the PR stays focused on SDK
-transport behavior instead of adding a separate fixture harness.
-
-Covered scenarios:
-
-- happy path over Streamable HTTP POST + SSE
-- permission round-trip
-- session load / provisional pre-open
-- two logical sessions
-- wrong-stream response rejection
-- validation failures
-- strict unknown-session behavior
-- WebSocket upgrade behavior on the same Java listener
-
-## Demo Server
-
-Add a runnable Java demo server at:
-
-```text
-test-fixtures/streamable-http-agent-server/
-```
-
-It packages a small echo-style ACP agent into a runnable jar backed by the real
-Jetty `StreamableHttpAcpAgentTransport`, so manual testing can exercise a live
-HTTP/SSE endpoint and WebSocket upgrade endpoint instead of only the
-integration-test fixture lifecycle.
-
-## PLAN / Follow-Up Work
-
-- extract a shared remote-core layer only after HTTP parity is proven.
- Here, "remote-core" means the transport-independent runtime machinery that
- both remote listener transports need: per-connection agent factory creation,
- connection/session registries, lifecycle teardown, request/response routing
- ledgers, timeout/error propagation, and observability hooks. The actual wire
- adapters should remain transport-specific: legacy WebSocket framing stays in
- the WebSocket module, while the RFD Streamable HTTP endpoint owns HTTP
- methods, headers, SSE parsing, status codes, and its WebSocket upgrade branch.
- Deferring this extraction keeps the first implementation close to the RFD and
- avoids prematurely forcing the existing legacy WebSocket behavior through an
- abstraction before parity is proven.
-- add idle/provisional-session eviction and replay retention policies
-- revisit per-logical-session active-prompt tracking in `AcpAgentSession`
-- expose richer diagnostics / observability hooks
-- decide whether compatible provisional session streams remain necessary after the RFD is clarified
diff --git a/plans/STREAMABLE-HTTP-TRANSPORT.md b/plans/STREAMABLE-HTTP-TRANSPORT.md
deleted file mode 100644
index 188b3cc..0000000
--- a/plans/STREAMABLE-HTTP-TRANSPORT.md
+++ /dev/null
@@ -1,176 +0,0 @@
-# Plan: Streamable HTTP Client Transport
-
-> **Status**: Milestone one implemented
-> **Created**: 2026-05-17
-> **Primary goal**: Java clients can communicate with compliant remote ACP agents over the Streamable HTTP transport.
-
-## Goal
-
-Add a client-side Streamable HTTP transport to `acp-core` so applications can use the existing Java client API against compliant remote ACP agents without changing their own code.
-
-This first milestone is intentionally client-only:
-
-- implement `StreamableHttpAcpClientTransport`
-- preserve the existing ACP client API surface
-- prove the wire contract with focused Java integration coverage
-- defer remote transport negotiation, Java server support, and reconnect/resume behavior
-
-## Milestone-One Result
-
-Implemented in this branch:
-
-- `StreamableHttpAcpClientTransport`
-- preserved public ACP client API
-- compatibility note + isolated forwarding path for the existing client handler-emission ambiguity
-- Java integration coverage against an in-process Streamable HTTP fixture server
-- Java unit + integration coverage for:
- - initialize bootstrap
- - cookie persistence
- - connection SSE
- - `session/new`
- - prompt flow with session updates
- - `session/request_permission`
- - `session/load`
- - wrong-stream responses
- - strict-routing rejection
- - two logical sessions
- - validation failures for missing connection headers and invalid SSE `Accept`
-
-## Contract Decisions
-
-### Public API
-
-- Add `StreamableHttpAcpClientTransport` in `acp-core`.
-- Keep construction symmetrical with `WebSocketAcpClientTransport`:
- - `new StreamableHttpAcpClientTransport(endpointUri, jsonMapper)`
- - `new StreamableHttpAcpClientTransport(endpointUri, jsonMapper, httpClient)`
-- Use a transport-owned default `CookieManager`, while allowing advanced callers to inject a custom `HttpClient`.
-- Expose a public routing mode:
- - `COMPATIBLE`
- - `STRICT`
-
-### Client / Transport Boundary
-
-- `AcpClientSession` remains transport-agnostic and continues to own ACP request/response semantics.
-- Streamable HTTP owns HTTP-only concerns internally:
- - `Acp-Connection-Id`
- - `Acp-Session-Id`
- - SSE stream lifecycle
- - routing correlation
- - cookie propagation
-- Preserve current WebSocket-compatible client handler-emission forwarding for behavioral parity in the first implementation.
-- Isolate that compatibility behavior behind a small helper and flag it in code as an unresolved contract ambiguity.
-
-### Lifecycle
-
-- `connect(...)`
- - registers the inbound handler
- - prepares resources
- - performs no network I/O by itself
-- `initialize`
- - is the first real HTTP exchange
- - sends `POST /acp` without `Acp-Connection-Id`
- - captures `Acp-Connection-Id` and cookies from the `200 OK`
- - opens the connection-scoped SSE stream before delivering the initialize response upward
-- `session/new`
- - sends the POST first
- - receives its JSON-RPC response on the connection stream
- - opens the returned session’s SSE stream
- - completes only after that session stream is established
-- `session/load`
- - opens the session stream first
- - sends the POST second
- - receives its response on the connection stream
-- Session-scoped outbound messages require an already-open session stream.
-- No automatic reconnect / resume behavior in milestone one.
-- `closeGracefully()`
- 1. stop accepting new outbound work
- 2. cancel local SSE readers
- 3. send `DELETE /acp` with `Acp-Connection-Id`
- 4. clear local routing and stream state
-
-### Routing
-
-- Use an explicit routing table for known ACP methods.
-- Compatible-mode fallback for unknown outbound requests / notifications:
- - `params.sessionId` present → session-scoped
- - otherwise → connection-scoped
-- Strict mode rejects unknown outbound request / notification methods that lack explicit routing.
-- The transport owns a minimal routing ledger:
- - `outbound request id -> request kind + expected response scope`
- - `inbound request id -> scope required for the later outbound response`
- - `session id -> open session SSE stream`
-- Wrong-stream responses are protocol errors.
-- Unknown response ids retain current Java SDK parity and are left to the session layer’s existing behavior.
-
-### SSE Model
-
-- Treat each SSE `data:` payload as one JSON-RPC message.
-- Ignore comments / keep-alives.
-- Preserve order per SSE stream.
-- Do not impose a synthetic global order across different streams.
-- Treat SSE as the source of truth for server feedback and request completion, not as a receipt log for every POST envelope.
-
-## Test Harness
-
-Use an in-process Java Streamable HTTP fixture server for client transport tests.
-This keeps the PR focused on Java SDK transport behavior and avoids adding a
-separate conformance harness to the repository.
-
-### Milestone-One Scenarios
-
-- initialize bootstrap
-- connection SSE stream
-- `session/new`
-- prompt flow with session updates
-- agent → client `session/request_permission`
-- `session/load`
-- validation failures for wrong / missing headers
-- wrong-stream response
-- strict-routing rejection
-- light two-session coverage
-
-### Future Harness Scenarios
-
-- reconnect / resume behavior
-- concurrency / stress coverage
-- broader interop matrix
-- WebSocket scenarios for a future composite remote transport
-
-## Deferred Work
-
-- Composite `RemoteAcpClientTransport`
- - prefer WebSocket
- - fall back to Streamable HTTP
-- Java server-side Streamable HTTP transport
-- reconnect / resume behavior once the protocol defines it
-- richer debugging / observability hooks
-- broader interoperability testing against an official compliant server when one exists
-- deeper multi-session stress coverage
-
-## Known Ambiguity / Follow-Up Decision
-
-### Client handler-emission forwarding
-
-The existing WebSocket client transport forwards any message emitted by the registered handler back onto the transport. `AcpClientSession` also sends responses explicitly through `sendMessage(...)`, so the client-side contract is currently ambiguous.
-
-Decision for this milestone:
-
-- preserve WebSocket-compatible forwarding in the new HTTP transport for parity
-- isolate the forwarding path in a small helper
-- document the ambiguity in code and in this plan
-- have the default `AcpClientSession` consume inbound messages without re-emitting them,
- because it already sends legitimate outbound replies explicitly through `sendMessage(...)`
-
-Follow-up:
-
-- decide whether `AcpClientTransport` should become explicitly receive-only on the client side
-- if so, remove the compatibility forwarding path from both transports in a focused cleanup
-
-## Non-Goals for the First Milestone
-
-- WebSocket fallback orchestration
-- server-side Java transport
-- automatic reconnect / resume
-- transport-specific public debugging APIs
-- global ordering across streams
diff --git a/pom.xml b/pom.xml
index 51e7c93..22e5530 100644
--- a/pom.xml
+++ b/pom.xml
@@ -19,7 +19,6 @@
acp-agent-support
acp-test
acp-streamable-http-jetty
- test-fixtures/streamable-http-agent-server
acp-websocket-jetty
diff --git a/test-fixtures/streamable-http-agent-server/README.md b/test-fixtures/streamable-http-agent-server/README.md
deleted file mode 100644
index 066944c..0000000
--- a/test-fixtures/streamable-http-agent-server/README.md
+++ /dev/null
@@ -1,43 +0,0 @@
-# Streamable HTTP Agent Demo Server
-
-This is a small runnable Java ACP agent that serves the new Streamable HTTP
-agent transport from a real Jetty web server.
-
-Build the runnable jar:
-
-```bash
-./mvnw -q -pl test-fixtures/streamable-http-agent-server -am -DskipTests package
-```
-
-Run it:
-
-```bash
-java -jar test-fixtures/streamable-http-agent-server/target/acp-streamable-http-agent-server.jar --port 8080
-```
-
-Then connect any ACP client to either endpoint printed at startup:
-
-```text
-http://127.0.0.1:8080/acp # Streamable HTTP POST + SSE
-ws://127.0.0.1:8080/acp # WebSocket upgrade
-```
-
-The demo supports `initialize`, `session/new`, `session/load`, `session/prompt`,
-and `session/cancel`. Prompts containing the word `permission` also exercise the
-agent-to-client `session/request_permission` round trip.
-
-By default, the server uses a deterministic echo backend. To exercise the same
-ACP transport with a real OpenAI-backed agent through Spring AI:
-
-```bash
-export OPENAI_API_KEY=...
-# Optional; defaults to OPENAI_MODEL or gpt-4o-mini.
-export OPENAI_MODEL=gpt-4o-mini
-
-java -jar test-fixtures/streamable-http-agent-server/target/acp-streamable-http-agent-server.jar \
- --port 8080 \
- --backend spring-ai-openai
-```
-
-The Spring AI backend is intentionally scoped to this runnable fixture. It is not
-part of the core SDK transport implementation.
diff --git a/test-fixtures/streamable-http-agent-server/pom.xml b/test-fixtures/streamable-http-agent-server/pom.xml
deleted file mode 100644
index f054b91..0000000
--- a/test-fixtures/streamable-http-agent-server/pom.xml
+++ /dev/null
@@ -1,81 +0,0 @@
-
-
- 4.0.0
-
-
- com.agentclientprotocol
- acp-java-sdk
- 0.12.0-SNAPSHOT
- ../../pom.xml
-
-
- acp-streamable-http-agent-server
- jar
-
- ACP Streamable HTTP Agent Server Demo
- Runnable demo server for the Streamable HTTP agent transport
-
-
-
- true
- 1.1.6
-
-
-
-
-
- org.springframework.ai
- spring-ai-bom
- ${spring-ai.version}
- pom
- import
-
-
-
-
-
-
- com.agentclientprotocol
- acp-streamable-http-jetty
-
-
- org.springframework.ai
- spring-ai-openai
-
-
- ch.qos.logback
- logback-classic
- runtime
-
-
-
-
-
-
- org.apache.maven.plugins
- maven-shade-plugin
- 3.5.3
-
-
- package
-
- shade
-
-
- false
- acp-streamable-http-agent-server
-
-
- com.agentclientprotocol.sdk.fixtures.StreamableHttpAgentDemoServer
-
-
-
-
-
-
-
-
-
-
diff --git a/test-fixtures/streamable-http-agent-server/src/main/java/com/agentclientprotocol/sdk/fixtures/StreamableHttpAgentDemoServer.java b/test-fixtures/streamable-http-agent-server/src/main/java/com/agentclientprotocol/sdk/fixtures/StreamableHttpAgentDemoServer.java
deleted file mode 100644
index ab01011..0000000
--- a/test-fixtures/streamable-http-agent-server/src/main/java/com/agentclientprotocol/sdk/fixtures/StreamableHttpAgentDemoServer.java
+++ /dev/null
@@ -1,342 +0,0 @@
-/*
- * Copyright 2025-2026 the original author or authors.
- */
-
-package com.agentclientprotocol.sdk.fixtures;
-
-import java.time.Duration;
-import java.util.List;
-import java.util.Locale;
-import java.util.Map;
-import java.util.concurrent.ConcurrentHashMap;
-import java.util.concurrent.ExecutorService;
-import java.util.concurrent.Executors;
-import java.util.concurrent.atomic.AtomicInteger;
-
-import com.agentclientprotocol.sdk.agent.AcpAgent;
-import com.agentclientprotocol.sdk.agent.AcpAgentFactory;
-import com.agentclientprotocol.sdk.agent.transport.StreamableHttpAcpAgentTransport;
-import com.agentclientprotocol.sdk.json.AcpJsonMapper;
-import com.agentclientprotocol.sdk.spec.AcpSchema;
-import org.springframework.ai.chat.messages.SystemMessage;
-import org.springframework.ai.chat.messages.UserMessage;
-import org.springframework.ai.chat.model.ChatResponse;
-import org.springframework.ai.chat.model.Generation;
-import org.springframework.ai.chat.prompt.Prompt;
-import org.springframework.ai.openai.OpenAiChatModel;
-import org.springframework.ai.openai.OpenAiChatOptions;
-import org.springframework.ai.openai.api.OpenAiApi;
-import reactor.core.publisher.Mono;
-import reactor.core.scheduler.Scheduler;
-import reactor.core.scheduler.Schedulers;
-
-/**
- * Small runnable ACP agent server for manually exercising the Streamable HTTP transport.
- *
- * @author Kaiser Dandangi
- */
-public final class StreamableHttpAgentDemoServer {
-
- private static final Duration START_TIMEOUT = Duration.ofSeconds(30);
-
- private static final Duration STOP_TIMEOUT = Duration.ofSeconds(5);
-
- private static final String OPENAI_SYSTEM_PROMPT = """
- You are a small ACP demo agent running inside the Java SDK Streamable HTTP fixture.
- Answer concisely. If the user asks about implementation details, say that this
- fixture is exercising the ACP Streamable HTTP transport, not providing a full
- production agent runtime.
- """;
-
- private StreamableHttpAgentDemoServer() {
- }
-
- public static void main(String[] args) {
- Options options;
- try {
- options = Options.parse(args);
- }
- catch (IllegalArgumentException e) {
- System.err.println(e.getMessage());
- printUsage();
- System.exit(2);
- return;
- }
-
- if (options.help()) {
- printUsage();
- return;
- }
-
- Map sessionCwds = new ConcurrentHashMap<>();
- AtomicInteger sessionCounter = new AtomicInteger();
- PromptBackend promptBackend;
- try {
- promptBackend = options.backend().create();
- }
- catch (IllegalArgumentException e) {
- System.err.println(e.getMessage());
- System.exit(2);
- return;
- }
-
- AcpAgentFactory agentFactory = AcpAgentFactory.async(transport -> AcpAgent.async(transport)
- .requestTimeout(Duration.ofMinutes(2))
- .initializeHandler(request -> Mono.just(AcpSchema.InitializeResponse.ok(
- new AcpSchema.AgentCapabilities(true, new AcpSchema.McpCapabilities(),
- new AcpSchema.PromptCapabilities()))))
- .newSessionHandler(request -> {
- String sessionId = "demo-session-" + sessionCounter.incrementAndGet();
- sessionCwds.put(sessionId, request.cwd());
- return Mono.just(new AcpSchema.NewSessionResponse(sessionId, null, null));
- })
- .loadSessionHandler(request -> {
- sessionCwds.put(request.sessionId(), request.cwd());
- return Mono.just(new AcpSchema.LoadSessionResponse(null, null));
- })
- .promptHandler((request, context) -> {
- String text = request.text();
- String normalized = text == null || text.isBlank() ? "(empty prompt)" : text;
- String cwd = sessionCwds.getOrDefault(request.sessionId(), "(unknown cwd)");
- Mono response = normalized.toLowerCase(Locale.ROOT).contains("permission")
- ? context.askPermission("Demo agent permission check for session " + request.sessionId())
- .flatMap(allowed -> context.sendMessage(
- "Permission " + (allowed ? "granted" : "denied") + ". Prompt: " + normalized))
- .onErrorResume(error -> context.sendMessage(
- "Permission request failed in demo server: " + error.getMessage()))
- : promptBackend.generate(normalized, request.sessionId(), cwd).flatMap(context::sendMessage);
- return response.thenReturn(AcpSchema.PromptResponse.endTurn());
- })
- .cancelHandler(notification -> {
- System.out.println("Received cancel for session " + notification.sessionId());
- return Mono.empty();
- })
- .build());
-
- StreamableHttpAcpAgentTransport server = new StreamableHttpAcpAgentTransport(options.port(), options.path(),
- AcpJsonMapper.createDefault(), agentFactory)
- .routingMode(options.routingMode());
-
- Runtime.getRuntime().addShutdownHook(new Thread(() -> {
- try {
- server.closeGracefully().block(STOP_TIMEOUT);
- }
- finally {
- promptBackend.close();
- }
- }, "acp-demo-shutdown"));
-
- server.start().block(START_TIMEOUT);
- System.out.println("ACP Streamable HTTP demo agent listening at http://127.0.0.1:" + server.getPort()
- + options.path());
- System.out.println("ACP WebSocket upgrade endpoint available at ws://127.0.0.1:" + server.getPort()
- + options.path());
- System.out.println("Press Ctrl-C to stop.");
- server.awaitTermination().block();
- }
-
- private static void printUsage() {
- System.out.println("""
- Usage: java -jar acp-streamable-http-agent-server.jar [options]
-
- Options:
- --port Port to listen on. Defaults to 8080.
- --path ACP endpoint path. Defaults to /acp.
- --backend Agent backend: echo or spring-ai-openai. Defaults to echo.
- --openai-model OpenAI model for spring-ai-openai. Defaults to OPENAI_MODEL or gpt-4o-mini.
- --strict Use strict transport routing.
- --compatible Use compatible transport routing. This is the default.
- -h, --help Show this help.
-
- Environment:
- OPENAI_API_KEY Required when --backend spring-ai-openai is used.
- OPENAI_MODEL Optional default model for --backend spring-ai-openai.
- """);
- }
-
- private record Options(int port, String path, StreamableHttpAcpAgentTransport.RoutingMode routingMode,
- Backend backend, boolean help) {
-
- static Options parse(String[] args) {
- int port = 8080;
- String path = StreamableHttpAcpAgentTransport.DEFAULT_ACP_PATH;
- StreamableHttpAcpAgentTransport.RoutingMode routingMode =
- StreamableHttpAcpAgentTransport.RoutingMode.COMPATIBLE;
- String backendName = "echo";
- String openAiModel = null;
- boolean help = false;
-
- for (int i = 0; i < args.length; i++) {
- String arg = args[i];
- switch (arg) {
- case "--port" -> port = parsePort(requireValue(args, ++i, "--port"));
- case "--path" -> path = requireValue(args, ++i, "--path");
- case "--backend" -> backendName = requireValue(args, ++i, "--backend");
- case "--openai-model" -> openAiModel = requireValue(args, ++i, "--openai-model");
- case "--strict" -> routingMode = StreamableHttpAcpAgentTransport.RoutingMode.STRICT;
- case "--compatible" -> routingMode = StreamableHttpAcpAgentTransport.RoutingMode.COMPATIBLE;
- case "-h", "--help" -> help = true;
- default -> throw new IllegalArgumentException("Unknown option: " + arg);
- }
- }
-
- if (!path.startsWith("/")) {
- throw new IllegalArgumentException("--path must start with /");
- }
- Backend backend = Backend.parse(backendName, openAiModel);
- return new Options(port, path, routingMode, backend, help);
- }
-
- private static String requireValue(String[] args, int index, String option) {
- if (index >= args.length || args[index].startsWith("--")) {
- throw new IllegalArgumentException(option + " requires a value");
- }
- return args[index];
- }
-
- private static int parsePort(String value) {
- try {
- int port = Integer.parseInt(value);
- if (port <= 0) {
- throw new IllegalArgumentException("--port must be positive");
- }
- return port;
- }
- catch (NumberFormatException e) {
- throw new IllegalArgumentException("--port must be a number", e);
- }
- }
-
- }
-
- private sealed interface Backend permits EchoBackend, SpringAiOpenAiBackend {
-
- PromptBackend create();
-
- static Backend echo() {
- return new EchoBackend();
- }
-
- static Backend springAiOpenAi(String model) {
- return new SpringAiOpenAiBackend(model);
- }
-
- static Backend parse(String value, String openAiModel) {
- return switch (value) {
- case "echo" -> echo();
- case "spring-ai-openai" -> springAiOpenAi(openAiModel);
- default -> throw new IllegalArgumentException("Unknown backend: " + value);
- };
- }
-
- }
-
- @FunctionalInterface
- private interface PromptBackend {
-
- Mono generate(String prompt, String sessionId, String cwd);
-
- default void close() {
- }
-
- }
-
- private record EchoBackend() implements Backend {
-
- @Override
- public PromptBackend create() {
- return new EchoPromptBackend();
- }
-
- }
-
- private static final class EchoPromptBackend implements PromptBackend {
-
- @Override
- public Mono generate(String prompt, String sessionId, String cwd) {
- return Mono.just("Demo agent received: " + prompt + " [cwd=" + cwd + "]");
- }
-
- }
-
- private record SpringAiOpenAiBackend(String model) implements Backend {
-
- @Override
- public PromptBackend create() {
- String apiKey = System.getenv("OPENAI_API_KEY");
- if (apiKey == null || apiKey.isBlank()) {
- throw new IllegalArgumentException(
- "OPENAI_API_KEY is required when --backend spring-ai-openai is used");
- }
-
- OpenAiApi openAiApi = OpenAiApi.builder().apiKey(apiKey).build();
- OpenAiChatOptions chatOptions = OpenAiChatOptions.builder()
- .model(resolveOpenAiModel(this.model))
- .temperature(0.2)
- .maxTokens(800)
- .build();
- OpenAiChatModel chatModel = OpenAiChatModel.builder()
- .openAiApi(openAiApi)
- .defaultOptions(chatOptions)
- .build();
-
- return new SpringAiOpenAiPromptBackend(chatModel);
- }
-
- }
-
- private static final class SpringAiOpenAiPromptBackend implements PromptBackend {
-
- private final OpenAiChatModel chatModel;
-
- private final ExecutorService executorService;
-
- private final Scheduler scheduler;
-
- private SpringAiOpenAiPromptBackend(OpenAiChatModel chatModel) {
- this.chatModel = chatModel;
- AtomicInteger threadCounter = new AtomicInteger();
- this.executorService = Executors.newCachedThreadPool(task -> {
- Thread thread = new Thread(task, "acp-demo-openai-" + threadCounter.incrementAndGet());
- thread.setDaemon(true);
- return thread;
- });
- this.scheduler = Schedulers.fromExecutorService(this.executorService, "acp-demo-openai");
- }
-
- @Override
- public Mono generate(String prompt, String sessionId, String cwd) {
- return Mono.fromCallable(() -> generatePrompt(prompt, sessionId, cwd)).subscribeOn(this.scheduler);
- }
-
- @Override
- public void close() {
- this.scheduler.dispose();
- this.executorService.shutdownNow();
- }
-
- private String generatePrompt(String prompt, String sessionId, String cwd) {
- ChatResponse response = chatModel.call(new Prompt(List.of(new SystemMessage(OPENAI_SYSTEM_PROMPT),
- new UserMessage("Session: " + sessionId + "\nCWD: " + cwd + "\n\nUser prompt:\n" + prompt))));
- Generation generation = response.getResult();
- if (generation == null || generation.getOutput() == null || generation.getOutput().getText() == null
- || generation.getOutput().getText().isBlank()) {
- return "(OpenAI returned an empty response)";
- }
- return generation.getOutput().getText();
- }
-
- }
-
- private static String resolveOpenAiModel(String model) {
- if (model != null && !model.isBlank()) {
- return model;
- }
- String envModel = System.getenv("OPENAI_MODEL");
- if (envModel != null && !envModel.isBlank()) {
- return envModel;
- }
- return "gpt-4o-mini";
- }
-
-}
diff --git a/test-fixtures/streamable-http-agent-server/src/main/resources/logback.xml b/test-fixtures/streamable-http-agent-server/src/main/resources/logback.xml
deleted file mode 100644
index 2bcf49b..0000000
--- a/test-fixtures/streamable-http-agent-server/src/main/resources/logback.xml
+++ /dev/null
@@ -1,10 +0,0 @@
-
-
-
- %d{HH:mm:ss.SSS} %-5level %logger{36} - %msg%n
-
-
-
-
-
-
From e6b528f8adc4a9e25b8cd0c822dcbd5b5e1a9d47 Mon Sep 17 00:00:00 2001
From: Kaiser Dandangi
Date: Sun, 24 May 2026 22:54:55 -0400
Subject: [PATCH 14/15] fix: serialize streamable websocket writes
---
.../StreamableHttpAcpAgentTransport.java | 83 ++++++++++++++++---
...gentTransportWebSocketIntegrationTest.java | 55 ++++++++++++
2 files changed, 126 insertions(+), 12 deletions(-)
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
index 56ef14e..fed1a2d 100644
--- 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
@@ -935,6 +935,12 @@ private final class WebSocketConnectionState {
private final AtomicBoolean closed = new AtomicBoolean(false);
+ private final Object outboundLock = new Object();
+
+ private final ArrayDeque outboundQueue = new ArrayDeque<>();
+
+ private boolean outboundSendInProgress = false;
+
private volatile Session session;
WebSocketConnectionState(String id) {
@@ -970,20 +976,9 @@ void acceptFromClient(JSONRPCMessage message) {
void sendToClient(JSONRPCMessage message) {
try {
- Session currentSession = this.session;
- if (closed.get() || currentSession == null || !currentSession.isOpen()) {
- throw new AcpConnectionException("Streamable ACP WebSocket connection is closed");
- }
String payload = jsonMapper.writeValueAsString(message);
logger.debug("Sending streamable ACP WebSocket message: {}", payload);
- currentSession.sendText(payload, Callback.from(() -> {
- // Jetty requires an explicit success callback; there is no
- // follow-up work after the frame has been accepted for writing.
- }, error -> {
- if (!closed.get()) {
- remoteConnection.signalException(error);
- }
- }));
+ enqueueOutbound(payload);
}
catch (Exception e) {
remoteConnection.signalException(e);
@@ -991,6 +986,66 @@ void sendToClient(JSONRPCMessage message) {
}
}
+ private void enqueueOutbound(String payload) {
+ boolean shouldDrain;
+ synchronized (outboundLock) {
+ if (closed.get()) {
+ throw new AcpConnectionException("Streamable ACP WebSocket connection is closed");
+ }
+ outboundQueue.addLast(payload);
+ shouldDrain = !outboundSendInProgress;
+ if (shouldDrain) {
+ outboundSendInProgress = true;
+ }
+ }
+ if (shouldDrain) {
+ drainOutbound();
+ }
+ }
+
+ private void drainOutbound() {
+ String payload;
+ Session currentSession;
+ synchronized (outboundLock) {
+ if (closed.get()) {
+ outboundQueue.clear();
+ outboundSendInProgress = false;
+ return;
+ }
+ payload = outboundQueue.pollFirst();
+ if (payload == null) {
+ outboundSendInProgress = false;
+ return;
+ }
+ currentSession = this.session;
+ }
+
+ if (currentSession == null || !currentSession.isOpen()) {
+ failOutbound(new AcpConnectionException("Streamable ACP WebSocket connection is closed"));
+ return;
+ }
+
+ try {
+ /*
+ * 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.
+ */
+ currentSession.sendText(payload, Callback.from(this::drainOutbound, this::failOutbound));
+ }
+ catch (Exception e) {
+ failOutbound(e);
+ }
+ }
+
+ private void failOutbound(Throwable error) {
+ if (!closed.get()) {
+ remoteConnection.signalException(error);
+ close(StatusCode.SERVER_ERROR, "failed to send ACP message");
+ }
+ }
+
void close() {
close(StatusCode.NORMAL, "server closing");
}
@@ -999,6 +1054,10 @@ void close(int statusCode, String reason) {
if (!closed.compareAndSet(false, true)) {
return;
}
+ synchronized (outboundLock) {
+ outboundQueue.clear();
+ outboundSendInProgress = false;
+ }
webSocketConnections.remove(id, this);
Session currentSession = this.session;
if (currentSession != null && currentSession.isOpen()) {
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
index 60e4432..fc5ac86 100644
--- 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
@@ -37,7 +37,9 @@
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;
@@ -209,6 +211,59 @@ void supportsMultipleConcurrentWebSocketClients() throws Exception {
}
}
+ @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())) {
From 9c89c30fdbcad37be5f57292bfe0629fbba78781 Mon Sep 17 00:00:00 2001
From: Kaiser Dandangi
Date: Sun, 24 May 2026 23:14:05 -0400
Subject: [PATCH 15/15] refactor: encapsulate websocket send queue
---
.../StreamableHttpAcpAgentTransport.java | 149 ++++++++++--------
1 file changed, 81 insertions(+), 68 deletions(-)
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
index fed1a2d..6b40605 100644
--- 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
@@ -935,11 +935,7 @@ private final class WebSocketConnectionState {
private final AtomicBoolean closed = new AtomicBoolean(false);
- private final Object outboundLock = new Object();
-
- private final ArrayDeque outboundQueue = new ArrayDeque<>();
-
- private boolean outboundSendInProgress = false;
+ private final SerializedWebSocketSender outboundSender = new SerializedWebSocketSender();
private volatile Session session;
@@ -978,7 +974,7 @@ void sendToClient(JSONRPCMessage message) {
try {
String payload = jsonMapper.writeValueAsString(message);
logger.debug("Sending streamable ACP WebSocket message: {}", payload);
- enqueueOutbound(payload);
+ outboundSender.send(payload);
}
catch (Exception e) {
remoteConnection.signalException(e);
@@ -986,84 +982,101 @@ void sendToClient(JSONRPCMessage message) {
}
}
- private void enqueueOutbound(String payload) {
- boolean shouldDrain;
- synchronized (outboundLock) {
- if (closed.get()) {
- throw new AcpConnectionException("Streamable ACP WebSocket connection is closed");
+ 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;
+ }
}
- outboundQueue.addLast(payload);
- shouldDrain = !outboundSendInProgress;
if (shouldDrain) {
- outboundSendInProgress = true;
+ drain();
}
}
- if (shouldDrain) {
- drainOutbound();
- }
- }
- private void drainOutbound() {
- String payload;
- Session currentSession;
- synchronized (outboundLock) {
- if (closed.get()) {
- outboundQueue.clear();
- outboundSendInProgress = false;
- return;
+ /*
+ * 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;
}
- payload = outboundQueue.pollFirst();
- if (payload == null) {
- outboundSendInProgress = false;
+
+ if (currentSession == null || !currentSession.isOpen()) {
+ fail(new AcpConnectionException("Streamable ACP WebSocket connection is closed"));
return;
}
- currentSession = this.session;
- }
- if (currentSession == null || !currentSession.isOpen()) {
- failOutbound(new AcpConnectionException("Streamable ACP WebSocket connection is closed"));
- return;
+ try {
+ currentSession.sendText(payload, Callback.from(this::drain, this::fail));
+ }
+ catch (Exception e) {
+ fail(e);
+ }
}
- try {
- /*
- * 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.
- */
- currentSession.sendText(payload, Callback.from(this::drainOutbound, this::failOutbound));
- }
- catch (Exception e) {
- failOutbound(e);
+ private void fail(Throwable error) {
+ if (!closed.get()) {
+ remoteConnection.signalException(error);
+ WebSocketConnectionState.this.close(StatusCode.SERVER_ERROR, "failed to send ACP message");
+ }
}
- }
- private void failOutbound(Throwable error) {
- if (!closed.get()) {
- remoteConnection.signalException(error);
- close(StatusCode.SERVER_ERROR, "failed to send ACP message");
+ void close() {
+ clear();
}
- }
-
- void close() {
- close(StatusCode.NORMAL, "server closing");
- }
- void close(int statusCode, String reason) {
- if (!closed.compareAndSet(false, true)) {
- return;
- }
- synchronized (outboundLock) {
- outboundQueue.clear();
- outboundSendInProgress = false;
- }
- webSocketConnections.remove(id, this);
- Session currentSession = this.session;
- if (currentSession != null && currentSession.isOpen()) {
- currentSession.close(statusCode, reason, Callback.NOOP);
+ private void clear() {
+ synchronized (lock) {
+ queue.clear();
+ sendInProgress = false;
+ }
}
- remoteConnection.closeGracefully().subscribe();
+
}
}