From 0e50b35e998864d88c2bc86ea9c92b52ec719ad9 Mon Sep 17 00:00:00 2001
From: Kaiser Dandangi
Date: Sun, 17 May 2026 21:27:51 -0400
Subject: [PATCH 1/9] 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 2/9] 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 3/9] 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 4/9] 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 5/9] 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 6/9] 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 7/9] 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 8/9] 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 9/9] 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);
+ }
+
}