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