From 0e50b35e998864d88c2bc86ea9c92b52ec719ad9 Mon Sep 17 00:00:00 2001 From: Kaiser Dandangi Date: Sun, 17 May 2026 21:27:51 -0400 Subject: [PATCH 01/15] feat: add streamable http client transport --- .gitignore | 4 +- README.md | 3 +- .../StreamableHttpAcpClientTransport.java | 729 ++++++++++++++++++ .../WebSocketAcpClientTransport.java | 7 + .../sdk/spec/AcpClientSession.java | 18 +- ...HttpAcpClientTransportIntegrationTest.java | 290 +++++++ .../StreamableHttpAcpClientTransportTest.java | 94 +++ plans/STREAMABLE-HTTP-TRANSPORT.md | 194 +++++ .../streamable-http-server/README.md | 36 + .../streamable-http-server/dist/protocol.js | 26 + .../streamable-http-server/dist/scenarios.js | 387 ++++++++++ .../streamable-http-server/dist/server.js | 46 ++ .../streamable-http-server/dist/transcript.js | 52 ++ .../golden/happy-path.json | 139 ++++ .../golden/permission-round-trip.json | 160 ++++ .../golden/session-load.json | 100 +++ .../golden/two-sessions.json | 224 ++++++ .../golden/validation-failures.json | 86 +++ .../golden/wrong-stream-response.json | 130 ++++ .../streamable-http-server/package-lock.json | 566 ++++++++++++++ .../streamable-http-server/package.json | 16 + .../streamable-http-server/src/protocol.ts | 32 + .../streamable-http-server/src/scenarios.ts | 574 ++++++++++++++ .../streamable-http-server/src/server.ts | 55 ++ .../streamable-http-server/src/transcript.ts | 101 +++ .../streamable-http-server/tsconfig.json | 13 + 26 files changed, 4079 insertions(+), 3 deletions(-) create mode 100644 acp-core/src/main/java/com/agentclientprotocol/sdk/client/transport/StreamableHttpAcpClientTransport.java create mode 100644 acp-core/src/test/java/com/agentclientprotocol/sdk/client/transport/StreamableHttpAcpClientTransportIntegrationTest.java create mode 100644 acp-core/src/test/java/com/agentclientprotocol/sdk/client/transport/StreamableHttpAcpClientTransportTest.java create mode 100644 plans/STREAMABLE-HTTP-TRANSPORT.md create mode 100644 test-fixtures/streamable-http-server/README.md create mode 100644 test-fixtures/streamable-http-server/dist/protocol.js create mode 100644 test-fixtures/streamable-http-server/dist/scenarios.js create mode 100644 test-fixtures/streamable-http-server/dist/server.js create mode 100644 test-fixtures/streamable-http-server/dist/transcript.js create mode 100644 test-fixtures/streamable-http-server/golden/happy-path.json create mode 100644 test-fixtures/streamable-http-server/golden/permission-round-trip.json create mode 100644 test-fixtures/streamable-http-server/golden/session-load.json create mode 100644 test-fixtures/streamable-http-server/golden/two-sessions.json create mode 100644 test-fixtures/streamable-http-server/golden/validation-failures.json create mode 100644 test-fixtures/streamable-http-server/golden/wrong-stream-response.json create mode 100644 test-fixtures/streamable-http-server/package-lock.json create mode 100644 test-fixtures/streamable-http-server/package.json create mode 100644 test-fixtures/streamable-http-server/src/protocol.ts create mode 100644 test-fixtures/streamable-http-server/src/scenarios.ts create mode 100644 test-fixtures/streamable-http-server/src/server.ts create mode 100644 test-fixtures/streamable-http-server/src/transcript.ts create mode 100644 test-fixtures/streamable-http-server/tsconfig.json diff --git a/.gitignore b/.gitignore index a20568a..a7ae7a1 100644 --- a/.gitignore +++ b/.gitignore @@ -57,6 +57,7 @@ derby.log *.zip *.tar.gz *.rar +node_modules/ ### Claude Code ### .claude/ @@ -68,5 +69,6 @@ hs_err_pid* replay_pid* ### Planning and Internal Documentation ### -plans/ +plans/* +!plans/STREAMABLE-HTTP-TRANSPORT.md learnings/ diff --git a/README.md b/README.md index 486ee6a..4d51db7 100644 --- a/README.md +++ b/README.md @@ -368,7 +368,7 @@ agent.start().block(); // Starts WebSocket server on port 8080 | Artifact | Description | |----------|-------------| -| [`acp-core`](https://central.sonatype.com/artifact/com.agentclientprotocol/acp-core) | Client and Agent SDKs, stdio and WebSocket client transports | +| [`acp-core`](https://central.sonatype.com/artifact/com.agentclientprotocol/acp-core) | Client and Agent SDKs, stdio, WebSocket, and Streamable HTTP client transports | | [`acp-annotations`](https://central.sonatype.com/artifact/com.agentclientprotocol/acp-annotations) | `@AcpAgent`, `@Prompt`, and other annotations | | [`acp-agent-support`](https://central.sonatype.com/artifact/com.agentclientprotocol/acp-agent-support) | Annotation-based agent runtime | | [`acp-test`](https://central.sonatype.com/artifact/com.agentclientprotocol/acp-test) | In-memory transport and mock utilities for testing | @@ -380,6 +380,7 @@ agent.start().block(); // Starts WebSocket server on port 8080 |-----------|--------|-------|--------| | Stdio | `StdioAcpClientTransport` | `StdioAcpAgentTransport` | acp-core | | WebSocket | `WebSocketAcpClientTransport` | `WebSocketAcpAgentTransport` | acp-core / acp-websocket-jetty | +| Streamable HTTP | `StreamableHttpAcpClientTransport` | — | acp-core | --- diff --git a/acp-core/src/main/java/com/agentclientprotocol/sdk/client/transport/StreamableHttpAcpClientTransport.java b/acp-core/src/main/java/com/agentclientprotocol/sdk/client/transport/StreamableHttpAcpClientTransport.java new file mode 100644 index 0000000..1679775 --- /dev/null +++ b/acp-core/src/main/java/com/agentclientprotocol/sdk/client/transport/StreamableHttpAcpClientTransport.java @@ -0,0 +1,729 @@ +/* + * Copyright 2025-2025 the original author or authors. + */ + +package com.agentclientprotocol.sdk.client.transport; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.net.CookieManager; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.Consumer; +import java.util.function.Function; + +import com.agentclientprotocol.sdk.error.AcpConnectionException; +import com.agentclientprotocol.sdk.json.AcpJsonMapper; +import com.agentclientprotocol.sdk.json.TypeRef; +import com.agentclientprotocol.sdk.spec.AcpClientTransport; +import com.agentclientprotocol.sdk.spec.AcpSchema; +import com.agentclientprotocol.sdk.spec.AcpSchema.JSONRPCMessage; +import com.agentclientprotocol.sdk.util.Assert; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import reactor.core.publisher.Mono; +import reactor.core.publisher.Sinks; + +/** + * Client-side ACP transport for the Streamable HTTP profile. + * + *

+ * Streamable HTTP maps ACP's logical duplex conversation onto HTTP POST requests plus + * long-lived Server-Sent Event (SSE) streams. The transport keeps all HTTP-specific + * routing state internal so the higher-level ACP session can continue to operate only on + * JSON-RPC messages. + *

+ * + * @author Mark Pollack + */ +public class StreamableHttpAcpClientTransport implements AcpClientTransport { + + private static final Logger logger = LoggerFactory.getLogger(StreamableHttpAcpClientTransport.class); + + /** Default ACP path used by the remote transport RFD. */ + public static final String DEFAULT_ACP_PATH = "/acp"; + + private static final String HEADER_CONNECTION_ID = "Acp-Connection-Id"; + + private static final String HEADER_SESSION_ID = "Acp-Session-Id"; + + private static final String CONTENT_TYPE_JSON = "application/json"; + + private static final String CONTENT_TYPE_EVENT_STREAM = "text/event-stream"; + + /** + * Controls how unknown outbound request / notification methods are classified. + */ + public enum RoutingMode { + + /** + * Prefer explicit ACP routing, but fall back to session-id shape inference for + * unknown methods so clients can remain forward-compatible with extensions. + */ + COMPATIBLE, + + /** + * Require every outbound request / notification method to have an explicit routing + * rule. + */ + STRICT + + } + + private enum ScopeKind { + + BOOTSTRAP, + + CONNECTION, + + SESSION + + } + + private enum RequestKind { + + INITIALIZE, + + SESSION_NEW, + + SESSION_LOAD, + + GENERIC + + } + + private record RouteScope(ScopeKind kind, String sessionId) { + + static RouteScope bootstrap() { + return new RouteScope(ScopeKind.BOOTSTRAP, null); + } + + static RouteScope connection() { + return new RouteScope(ScopeKind.CONNECTION, null); + } + + static RouteScope session(String sessionId) { + return new RouteScope(ScopeKind.SESSION, sessionId); + } + + boolean isSession() { + return kind == ScopeKind.SESSION; + } + + } + + private record OutboundRequestRoute(RequestKind kind, RouteScope requestScope, RouteScope responseScope) { + } + + private record HttpClientBundle(HttpClient httpClient, ExecutorService ownedExecutor) { + } + + private final URI endpointUri; + + private final AcpJsonMapper jsonMapper; + + private final HttpClient httpClient; + + private final ExecutorService ownedHttpExecutor; + + private final ExecutorService httpSignalExecutor; + + private final ExecutorService sseExecutor; + + private final Sinks.Many inboundSink; + + private final AtomicBoolean connected = new AtomicBoolean(false); + + private final AtomicBoolean initialized = new AtomicBoolean(false); + + private final AtomicBoolean closing = new AtomicBoolean(false); + + private final Map outboundRequestRoutes = new ConcurrentHashMap<>(); + + private final Map inboundRequestRoutes = new ConcurrentHashMap<>(); + + private final Map sessionStreams = new ConcurrentHashMap<>(); + + private volatile SseStream connectionStream; + + private volatile String connectionId; + + private volatile RoutingMode routingMode = RoutingMode.COMPATIBLE; + + private volatile Consumer exceptionHandler = t -> logger.error("Transport error", t); + + /** + * Creates a new Streamable HTTP client transport using a default JDK {@link HttpClient} + * configured with an internal {@link CookieManager}. + * @param endpointUri the remote ACP endpoint URI + * @param jsonMapper JSON mapper used for message serialization + */ + public StreamableHttpAcpClientTransport(URI endpointUri, AcpJsonMapper jsonMapper) { + this(endpointUri, jsonMapper, createDefaultHttpClient()); + } + + /** + * Creates a new Streamable HTTP client transport using a caller-provided + * {@link HttpClient}. This allows advanced callers to customize cookies, TLS, + * executors, or proxy behavior. + * @param endpointUri the remote ACP endpoint URI + * @param jsonMapper JSON mapper used for message serialization + * @param httpClient HTTP client to use for requests + */ + public StreamableHttpAcpClientTransport(URI endpointUri, AcpJsonMapper jsonMapper, HttpClient httpClient) { + this(endpointUri, jsonMapper, new HttpClientBundle(httpClient, null)); + } + + private StreamableHttpAcpClientTransport(URI endpointUri, AcpJsonMapper jsonMapper, HttpClientBundle bundle) { + Assert.notNull(endpointUri, "The endpointUri can not be null"); + Assert.notNull(jsonMapper, "The JsonMapper can not be null"); + Assert.notNull(bundle, "The HttpClient bundle can not be null"); + Assert.notNull(bundle.httpClient(), "The HttpClient can not be null"); + Assert.isTrue("http".equalsIgnoreCase(endpointUri.getScheme()) + || "https".equalsIgnoreCase(endpointUri.getScheme()), + "The endpointUri must use http or https"); + + this.endpointUri = endpointUri; + this.jsonMapper = jsonMapper; + this.httpClient = bundle.httpClient(); + this.ownedHttpExecutor = bundle.ownedExecutor(); + this.httpSignalExecutor = Executors.newCachedThreadPool(r -> { + Thread t = new Thread(r, "acp-streamable-http-signal"); + t.setDaemon(true); + return t; + }); + this.sseExecutor = Executors.newCachedThreadPool(r -> { + Thread t = new Thread(r, "acp-streamable-http-sse"); + t.setDaemon(true); + return t; + }); + this.inboundSink = Sinks.many().unicast().onBackpressureBuffer(); + } + + private static HttpClientBundle createDefaultHttpClient() { + ExecutorService executor = Executors.newCachedThreadPool(r -> { + Thread t = new Thread(r, "acp-streamable-http-client"); + t.setDaemon(true); + return t; + }); + HttpClient client = HttpClient.newBuilder() + .version(HttpClient.Version.HTTP_2) + .cookieHandler(new CookieManager()) + .executor(executor) + .build(); + return new HttpClientBundle(client, executor); + } + + /** + * Sets the routing mode for outbound request / notification classification. + * @param routingMode routing mode to apply + * @return this transport + */ + public StreamableHttpAcpClientTransport routingMode(RoutingMode routingMode) { + Assert.notNull(routingMode, "The routingMode can not be null"); + this.routingMode = routingMode; + return this; + } + + @Override + public Mono connect(Function, Mono> handler) { + Assert.notNull(handler, "The handler can not be null"); + if (!connected.compareAndSet(false, true)) { + return Mono.error(new IllegalStateException("Already connected")); + } + + handleIncomingMessages(handler); + return Mono.empty(); + } + + private void handleIncomingMessages(Function, Mono> handler) { + this.inboundSink.asFlux() + .flatMap(message -> Mono.just(message).transform(handler)) + .doOnNext(this::forwardHandlerEmissionForCompatibility) + .subscribe(); + } + + private void forwardHandlerEmissionForCompatibility(JSONRPCMessage emittedMessage) { + /* + * Compatibility note: + * WebSocketAcpClientTransport currently forwards any message emitted by the + * registered client handler back onto the transport. AcpClientSession also sends + * client responses explicitly via sendMessage(...), so the client-side contract is + * still ambiguous. Preserve parity for now and keep this path isolated so it can be + * removed cheaply if the client transport contract is later made receive-only. + */ + if (emittedMessage != null && !closing.get()) { + routeAndPost(emittedMessage).subscribe(v -> { + }, exceptionHandler); + } + } + + @Override + public Mono sendMessage(JSONRPCMessage message) { + Assert.notNull(message, "The message can not be null"); + if (closing.get()) { + return Mono.error(new AcpConnectionException("Transport is closing")); + } + + if (message instanceof AcpSchema.JSONRPCRequest request + && AcpSchema.METHOD_INITIALIZE.equals(request.method())) { + return initialize(request); + } + + return routeAndPost(message); + } + + private Mono initialize(AcpSchema.JSONRPCRequest request) { + if (!initialized.compareAndSet(false, true)) { + return Mono.error(new IllegalStateException("Transport is already initialized")); + } + + HttpRequest httpRequest; + try { + httpRequest = jsonPostBuilder(RouteScope.bootstrap()) + .POST(HttpRequest.BodyPublishers.ofString(jsonMapper.writeValueAsString(request), StandardCharsets.UTF_8)) + .build(); + } + catch (IOException e) { + initialized.set(false); + return Mono.error(new AcpConnectionException("Failed to serialize initialize request", e)); + } + + return sendAsync(httpRequest, HttpResponse.BodyHandlers.ofString()) + .flatMap(response -> { + if (response.statusCode() != 200) { + return Mono.error(new AcpConnectionException( + "Expected 200 for initialize, got " + response.statusCode())); + } + String contentType = response.headers().firstValue("Content-Type").orElse(""); + if (!contentType.toLowerCase().contains(CONTENT_TYPE_JSON)) { + return Mono.error(new AcpConnectionException( + "Expected " + CONTENT_TYPE_JSON + " initialize response, got " + contentType)); + } + this.connectionId = response.headers() + .firstValue(HEADER_CONNECTION_ID) + .orElseThrow(() -> new AcpConnectionException( + "Initialize response missing " + HEADER_CONNECTION_ID)); + JSONRPCMessage responseMessage; + try { + responseMessage = AcpSchema.deserializeJsonRpcMessage(jsonMapper, response.body()); + } + catch (Exception e) { + return Mono.error(new AcpConnectionException("Failed to deserialize initialize response", e)); + } + return openConnectionStream().then(emitInbound(responseMessage)); + }) + .doOnError(error -> { + initialized.set(false); + exceptionHandler.accept(error); + }); + } + + private Mono routeAndPost(JSONRPCMessage message) { + return Mono.defer(() -> { + ResolvedOutboundRoute resolved = resolveOutboundRoute(message); + Mono preparation = prepareRoute(resolved); + return preparation.then(postAccepted(message, resolved.scope())) + .doOnSuccess(ignored -> { + if (message instanceof AcpSchema.JSONRPCResponse response) { + inboundRequestRoutes.remove(response.id()); + } + }) + .doOnError(error -> { + if (message instanceof AcpSchema.JSONRPCRequest request) { + outboundRequestRoutes.remove(request.id()); + } + }); + }); + } + + private Mono prepareRoute(ResolvedOutboundRoute resolved) { + if (resolved.message() instanceof AcpSchema.JSONRPCRequest request + && AcpSchema.METHOD_SESSION_LOAD.equals(request.method())) { + return openSessionStream(resolved.scope().sessionId()); + } + if (resolved.scope().isSession() && !sessionStreams.containsKey(resolved.scope().sessionId())) { + return Mono.error(new AcpConnectionException( + "No open session stream for session " + resolved.scope().sessionId())); + } + return Mono.empty(); + } + + private Mono postAccepted(JSONRPCMessage message, RouteScope scope) { + HttpRequest request; + try { + request = jsonPostBuilder(scope) + .POST(HttpRequest.BodyPublishers.ofString(jsonMapper.writeValueAsString(message), StandardCharsets.UTF_8)) + .build(); + } + catch (IOException e) { + return Mono.error(new AcpConnectionException("Failed to serialize outbound message", e)); + } + + return sendAsync(request, HttpResponse.BodyHandlers.discarding()) + .flatMap(response -> { + if (response.statusCode() != 202) { + return Mono.error(new AcpConnectionException( + "Expected 202 for POST, got " + response.statusCode())); + } + return Mono.empty(); + }); + } + + private HttpRequest.Builder jsonPostBuilder(RouteScope scope) { + HttpRequest.Builder builder = HttpRequest.newBuilder(endpointUri) + .header("Content-Type", CONTENT_TYPE_JSON) + .header("Accept", CONTENT_TYPE_JSON); + addScopeHeaders(builder, scope); + return builder; + } + + private Mono openConnectionStream() { + return openSseStream(RouteScope.connection()).doOnSuccess(stream -> this.connectionStream = stream).then(); + } + + private Mono openSessionStream(String sessionId) { + if (sessionStreams.containsKey(sessionId)) { + return Mono.empty(); + } + return openSseStream(RouteScope.session(sessionId)) + .doOnSuccess(stream -> sessionStreams.putIfAbsent(sessionId, stream)) + .then(); + } + + private Mono openSseStream(RouteScope scope) { + HttpRequest.Builder builder = HttpRequest.newBuilder(endpointUri).GET().header("Accept", CONTENT_TYPE_EVENT_STREAM); + addScopeHeaders(builder, scope); + HttpRequest request = builder.build(); + + return sendAsync(request, HttpResponse.BodyHandlers.ofInputStream()) + .flatMap(response -> { + if (response.statusCode() != 200) { + return Mono.error(new AcpConnectionException( + "Expected 200 when opening SSE stream, got " + response.statusCode())); + } + String contentType = response.headers().firstValue("Content-Type").orElse(""); + if (!contentType.toLowerCase().contains(CONTENT_TYPE_EVENT_STREAM)) { + return Mono.error(new AcpConnectionException( + "Expected " + CONTENT_TYPE_EVENT_STREAM + " response, got " + contentType)); + } + SseStream stream = new SseStream(scope, response.body()); + stream.start(); + return Mono.just(stream); + }); + } + + private void addScopeHeaders(HttpRequest.Builder builder, RouteScope scope) { + if (scope.kind() != ScopeKind.BOOTSTRAP) { + String currentConnectionId = requireConnectionId(); + builder.header(HEADER_CONNECTION_ID, currentConnectionId); + } + if (scope.isSession()) { + builder.header(HEADER_SESSION_ID, scope.sessionId()); + } + } + + private String requireConnectionId() { + String currentConnectionId = this.connectionId; + if (currentConnectionId == null || currentConnectionId.isBlank()) { + throw new AcpConnectionException("Missing " + HEADER_CONNECTION_ID); + } + return currentConnectionId; + } + + private ResolvedOutboundRoute resolveOutboundRoute(JSONRPCMessage message) { + if (message instanceof AcpSchema.JSONRPCResponse response) { + RouteScope scope = inboundRequestRoutes.get(response.id()); + if (scope == null) { + throw new AcpConnectionException("Cannot route outbound response with unknown id " + response.id()); + } + return new ResolvedOutboundRoute(message, scope, null); + } + + if (message instanceof AcpSchema.JSONRPCRequest request) { + ResolvedOutboundRoute resolved = resolveRequestOrNotificationRoute(message, request.method(), request.params()); + if (resolved.requestRoute() != null && request.id() != null) { + outboundRequestRoutes.put(request.id(), resolved.requestRoute()); + } + return resolved; + } + + if (message instanceof AcpSchema.JSONRPCNotification notification) { + return resolveRequestOrNotificationRoute(message, notification.method(), notification.params()); + } + + throw new AcpConnectionException("Unsupported outbound JSON-RPC message type: " + message); + } + + private ResolvedOutboundRoute resolveRequestOrNotificationRoute(JSONRPCMessage message, String method, Object params) { + RouteScope requestScope; + RequestKind requestKind = RequestKind.GENERIC; + RouteScope responseScope; + + switch (method) { + case AcpSchema.METHOD_INITIALIZE: + requestScope = RouteScope.bootstrap(); + requestKind = RequestKind.INITIALIZE; + responseScope = RouteScope.bootstrap(); + break; + case AcpSchema.METHOD_AUTHENTICATE: + case AcpSchema.METHOD_SESSION_NEW: + requestScope = RouteScope.connection(); + requestKind = AcpSchema.METHOD_SESSION_NEW.equals(method) ? RequestKind.SESSION_NEW : RequestKind.GENERIC; + responseScope = RouteScope.connection(); + break; + case AcpSchema.METHOD_SESSION_LOAD: + requestScope = RouteScope.session(requireSessionId(params, method)); + requestKind = RequestKind.SESSION_LOAD; + responseScope = RouteScope.connection(); + break; + case AcpSchema.METHOD_SESSION_PROMPT: + case AcpSchema.METHOD_SESSION_SET_MODE: + case AcpSchema.METHOD_SESSION_SET_MODEL: + case AcpSchema.METHOD_SESSION_CANCEL: + requestScope = RouteScope.session(requireSessionId(params, method)); + responseScope = requestScope; + break; + default: + Optional sessionId = extractSessionId(params); + if (routingMode == RoutingMode.STRICT) { + throw new AcpConnectionException("No explicit routing rule for outbound method " + method); + } + if (sessionId.isPresent()) { + logger.warn("Falling back to inferred session routing for unknown method '{}'", method); + requestScope = RouteScope.session(sessionId.get()); + } + else { + logger.warn("Falling back to inferred connection routing for unknown method '{}'", method); + requestScope = RouteScope.connection(); + } + responseScope = requestScope; + } + + OutboundRequestRoute requestRoute = null; + if (message instanceof AcpSchema.JSONRPCRequest) { + requestRoute = new OutboundRequestRoute(requestKind, requestScope, responseScope); + } + return new ResolvedOutboundRoute(message, requestScope, requestRoute); + } + + private Optional extractSessionId(Object params) { + if (params == null) { + return Optional.empty(); + } + Map paramsMap = jsonMapper.convertValue(params, Map.class); + Object sessionId = paramsMap.get("sessionId"); + return sessionId == null ? Optional.empty() : Optional.of(sessionId.toString()); + } + + private String requireSessionId(Object params, String method) { + return extractSessionId(params) + .filter(sessionId -> !sessionId.isBlank()) + .orElseThrow(() -> new AcpConnectionException("Missing sessionId for outbound method " + method)); + } + + private Mono processInbound(RouteScope actualScope, JSONRPCMessage message) { + if (message instanceof AcpSchema.JSONRPCResponse response) { + OutboundRequestRoute expectedRoute = outboundRequestRoutes.get(response.id()); + if (expectedRoute != null && !Objects.equals(expectedRoute.responseScope(), actualScope)) { + return Mono.error(new AcpConnectionException("Response id " + response.id() + " arrived on " + + actualScope + " but expected " + expectedRoute.responseScope())); + } + if (expectedRoute != null && expectedRoute.kind() == RequestKind.SESSION_NEW) { + AcpSchema.NewSessionResponse sessionResponse = jsonMapper.convertValue(response.result(), + new TypeRef() { + }); + String sessionId = sessionResponse.sessionId(); + if (sessionId == null || sessionId.isBlank()) { + return Mono.error(new AcpConnectionException("session/new response missing sessionId")); + } + return openSessionStream(sessionId) + .then(Mono.fromRunnable(() -> outboundRequestRoutes.remove(response.id()))) + .then(emitInbound(message)); + } + if (expectedRoute != null) { + outboundRequestRoutes.remove(response.id()); + } + return emitInbound(message); + } + + if (message instanceof AcpSchema.JSONRPCRequest request) { + if (request.id() != null) { + inboundRequestRoutes.put(request.id(), actualScope); + } + return emitInbound(message); + } + + return emitInbound(message); + } + + private Mono emitInbound(JSONRPCMessage message) { + return Mono.fromRunnable(() -> { + Sinks.EmitResult result = inboundSink.tryEmitNext(message); + if (result.isFailure()) { + throw new AcpConnectionException("Failed to enqueue inbound message: " + result); + } + }); + } + + @Override + public Mono closeGracefully() { + return Mono.defer(() -> { + closing.set(true); + Optional.ofNullable(connectionStream).ifPresent(SseStream::close); + sessionStreams.values().forEach(SseStream::close); + + Mono deleteRequest = Mono.empty(); + if (connectionId != null) { + HttpRequest request = HttpRequest.newBuilder(endpointUri) + .DELETE() + .header(HEADER_CONNECTION_ID, connectionId) + .build(); + deleteRequest = sendAsync(request, HttpResponse.BodyHandlers.discarding()) + .flatMap(response -> { + if (response.statusCode() != 202) { + return Mono.error(new AcpConnectionException( + "Expected 202 for DELETE, got " + response.statusCode())); + } + return Mono.empty(); + }); + } + + return deleteRequest.doFinally(signal -> clearState()); + }); + } + + private void clearState() { + connectionStream = null; + sessionStreams.clear(); + inboundRequestRoutes.clear(); + outboundRequestRoutes.clear(); + connectionId = null; + inboundSink.tryEmitComplete(); + sseExecutor.shutdownNow(); + httpSignalExecutor.shutdownNow(); + if (ownedHttpExecutor != null) { + ownedHttpExecutor.shutdownNow(); + } + } + + @Override + public void setExceptionHandler(Consumer handler) { + this.exceptionHandler = handler; + } + + @Override + public T unmarshalFrom(Object data, TypeRef typeRef) { + return jsonMapper.convertValue(data, typeRef); + } + + private record ResolvedOutboundRoute(JSONRPCMessage message, RouteScope scope, OutboundRequestRoute requestRoute) { + } + + private Mono> sendAsync(HttpRequest request, HttpResponse.BodyHandler bodyHandler) { + return Mono.create(sink -> httpClient.sendAsync(request, bodyHandler).whenCompleteAsync((response, error) -> { + if (error != null) { + sink.error(error); + } + else { + sink.success(response); + } + }, httpSignalExecutor)); + } + + private class SseStream { + + private final RouteScope scope; + + private final InputStream body; + + private final AtomicBoolean closed = new AtomicBoolean(false); + + private Future readerTask; + + SseStream(RouteScope scope, InputStream body) { + this.scope = scope; + this.body = body; + } + + void start() { + this.readerTask = sseExecutor.submit(this::readLoop); + } + + void close() { + if (closed.compareAndSet(false, true)) { + try { + body.close(); + } + catch (IOException ignored) { + } + if (readerTask != null) { + readerTask.cancel(true); + } + } + } + + private void readLoop() { + try (BufferedReader reader = new BufferedReader(new InputStreamReader(body, StandardCharsets.UTF_8))) { + StringBuilder dataBuffer = new StringBuilder(); + String line; + while (!closed.get() && (line = reader.readLine()) != null) { + if (line.isEmpty()) { + dispatchEvent(dataBuffer); + dataBuffer.setLength(0); + continue; + } + if (line.startsWith(":")) { + continue; + } + if (line.startsWith("data:")) { + if (!dataBuffer.isEmpty()) { + dataBuffer.append('\n'); + } + dataBuffer.append(line.substring(5).stripLeading()); + } + } + dispatchEvent(dataBuffer); + if (!closed.get() && !closing.get()) { + throw new AcpConnectionException("SSE stream closed unexpectedly: " + scope); + } + } + catch (Exception e) { + if (!closed.get() && !closing.get()) { + exceptionHandler.accept(e); + } + } + } + + private void dispatchEvent(StringBuilder dataBuffer) { + if (dataBuffer.isEmpty()) { + return; + } + try { + JSONRPCMessage message = AcpSchema.deserializeJsonRpcMessage(jsonMapper, dataBuffer.toString()); + processInbound(scope, message).block(Duration.ofSeconds(30)); + } + catch (Exception e) { + if (!closed.get() && !closing.get()) { + exceptionHandler.accept(e); + } + } + } + + } + +} diff --git a/acp-core/src/main/java/com/agentclientprotocol/sdk/client/transport/WebSocketAcpClientTransport.java b/acp-core/src/main/java/com/agentclientprotocol/sdk/client/transport/WebSocketAcpClientTransport.java index c5f6281..b0f8c41 100644 --- a/acp-core/src/main/java/com/agentclientprotocol/sdk/client/transport/WebSocketAcpClientTransport.java +++ b/acp-core/src/main/java/com/agentclientprotocol/sdk/client/transport/WebSocketAcpClientTransport.java @@ -166,6 +166,13 @@ private void handleIncomingMessages(Function, Mono Mono.just(message).transform(handler)) .doOnNext(response -> { + /* + * Compatibility note: + * AcpClientSession currently sends client responses explicitly through + * sendMessage(...), but this transport has also historically forwarded any + * message emitted by the registered handler. Keep the behavior for parity + * until the client-side transport contract is clarified. + */ if (response != null) { this.outboundSink.tryEmitNext(response); } diff --git a/acp-core/src/main/java/com/agentclientprotocol/sdk/spec/AcpClientSession.java b/acp-core/src/main/java/com/agentclientprotocol/sdk/spec/AcpClientSession.java index 8dd0f5e..6c30c5d 100644 --- a/acp-core/src/main/java/com/agentclientprotocol/sdk/spec/AcpClientSession.java +++ b/acp-core/src/main/java/com/agentclientprotocol/sdk/spec/AcpClientSession.java @@ -148,7 +148,23 @@ public AcpClientSession(Duration requestTimeout, AcpClientTransport transport, return t; }), "acp-timeout-" + sessionPrefix); - this.transport.connect(mono -> mono.doOnNext(this::handle)).transform(connectHook).subscribe(); + this.transport.setExceptionHandler(this::handleTransportException); + + /* + * Client transports currently retain a compatibility path that may forward any + * message emitted by this handler back onto the wire. The session handles outbound + * replies explicitly via transport.sendMessage(...), so the default session handler + * should consume inbound messages without re-emitting them. + */ + this.transport.connect(mono -> mono.doOnNext(this::handle).then(Mono.empty())).transform(connectHook).subscribe(); + } + + private void handleTransportException(Throwable error) { + this.pendingResponses.forEach((id, sink) -> { + logger.warn("Terminating exchange for request {} after transport error", id, error); + sink.error(error); + }); + this.pendingResponses.clear(); } private void dismissPendingResponses() { diff --git a/acp-core/src/test/java/com/agentclientprotocol/sdk/client/transport/StreamableHttpAcpClientTransportIntegrationTest.java b/acp-core/src/test/java/com/agentclientprotocol/sdk/client/transport/StreamableHttpAcpClientTransportIntegrationTest.java new file mode 100644 index 0000000..aed6658 --- /dev/null +++ b/acp-core/src/test/java/com/agentclientprotocol/sdk/client/transport/StreamableHttpAcpClientTransportIntegrationTest.java @@ -0,0 +1,290 @@ +/* + * Copyright 2025-2025 the original author or authors. + */ + +package com.agentclientprotocol.sdk.client.transport; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStreamReader; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Duration; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; + +import com.agentclientprotocol.sdk.AcpTestFixtures; +import com.agentclientprotocol.sdk.client.AcpAsyncClient; +import com.agentclientprotocol.sdk.client.AcpClient; +import com.agentclientprotocol.sdk.error.AcpConnectionException; +import com.agentclientprotocol.sdk.json.AcpJsonMapper; +import com.agentclientprotocol.sdk.spec.AcpSchema; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Mono; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * End-to-end tests against the in-repo TypeScript Streamable HTTP fixture. + */ +class StreamableHttpAcpClientTransportIntegrationTest { + + private static final Duration TIMEOUT = Duration.ofSeconds(5); + + private static final Path FIXTURE_DIR = Path.of("..", "test-fixtures", "streamable-http-server").normalize(); + + private static final Path GOLDEN_DIR = FIXTURE_DIR.resolve("golden"); + + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + + @Test + void happyPathMatchesFixtureTranscript() throws Exception { + try (FixtureServer fixture = FixtureServer.start("happy-path")) { + CopyOnWriteArrayList updates = new CopyOnWriteArrayList<>(); + AcpAsyncClient client = newClient(fixture.endpoint()) + .sessionUpdateConsumer(notification -> { + updates.add(notification); + return Mono.empty(); + }) + .build(); + + client.initialize().block(TIMEOUT); + AcpSchema.NewSessionResponse session = client + .newSession(AcpTestFixtures.createNewSessionRequest("/workspace")) + .block(TIMEOUT); + AcpSchema.PromptResponse prompt = client + .prompt(AcpTestFixtures.createPromptRequest(session.sessionId(), "hello")) + .block(TIMEOUT); + + assertThat(session.sessionId()).isEqualTo("sess-1"); + assertThat(prompt.stopReason()).isEqualTo(AcpSchema.StopReason.END_TURN); + assertThat(updates).hasSize(1); + + client.closeGracefully().block(TIMEOUT); + fixture.assertTranscriptMatches("happy-path.json"); + } + } + + @Test + void permissionRequestRoundTripsOnSessionStream() throws Exception { + try (FixtureServer fixture = FixtureServer.start("permission-round-trip")) { + AtomicInteger permissionRequests = new AtomicInteger(); + AcpAsyncClient client = newClient(fixture.endpoint()) + .requestPermissionHandler(request -> { + permissionRequests.incrementAndGet(); + return Mono.just(new AcpSchema.RequestPermissionResponse(new AcpSchema.PermissionSelected("allow"))); + }) + .build(); + + client.initialize().block(TIMEOUT); + AcpSchema.NewSessionResponse session = client + .newSession(AcpTestFixtures.createNewSessionRequest("/workspace")) + .block(TIMEOUT); + AcpSchema.PromptResponse prompt = client + .prompt(AcpTestFixtures.createPromptRequest(session.sessionId(), "needs permission")) + .block(TIMEOUT); + + assertThat(session.sessionId()).isEqualTo("sess-permission"); + assertThat(prompt.stopReason()).isEqualTo(AcpSchema.StopReason.END_TURN); + assertThat(permissionRequests).hasValue(1); + + client.closeGracefully().block(TIMEOUT); + fixture.assertTranscriptMatches("permission-round-trip.json"); + } + } + + @Test + void loadSessionOpensSessionStreamBeforePosting() throws Exception { + try (FixtureServer fixture = FixtureServer.start("session-load")) { + AcpAsyncClient client = newClient(fixture.endpoint()).build(); + + client.initialize().block(TIMEOUT); + AcpSchema.LoadSessionResponse response = client + .loadSession(new AcpSchema.LoadSessionRequest("sess-load", "/workspace", List.of())) + .block(TIMEOUT); + + assertThat(response).isNotNull(); + + client.closeGracefully().block(TIMEOUT); + fixture.assertTranscriptMatches("session-load.json"); + } + } + + @Test + void supportsTwoConcurrentLogicalSessions() throws Exception { + try (FixtureServer fixture = FixtureServer.start("two-sessions")) { + AcpAsyncClient client = newClient(fixture.endpoint()).build(); + + client.initialize().block(TIMEOUT); + AcpSchema.NewSessionResponse first = client + .newSession(AcpTestFixtures.createNewSessionRequest("/workspace/one")) + .block(TIMEOUT); + AcpSchema.NewSessionResponse second = client + .newSession(AcpTestFixtures.createNewSessionRequest("/workspace/two")) + .block(TIMEOUT); + AcpSchema.PromptResponse firstPrompt = client + .prompt(AcpTestFixtures.createPromptRequest(first.sessionId(), "one")) + .block(TIMEOUT); + AcpSchema.PromptResponse secondPrompt = client + .prompt(AcpTestFixtures.createPromptRequest(second.sessionId(), "two")) + .block(TIMEOUT); + + assertThat(first.sessionId()).isEqualTo("sess-1"); + assertThat(second.sessionId()).isEqualTo("sess-2"); + assertThat(firstPrompt.stopReason()).isEqualTo(AcpSchema.StopReason.END_TURN); + assertThat(secondPrompt.stopReason()).isEqualTo(AcpSchema.StopReason.END_TURN); + + client.closeGracefully().block(TIMEOUT); + fixture.assertTranscriptMatches("two-sessions.json"); + } + } + + @Test + void wrongStreamResponseFailsPendingExchange() throws Exception { + try (FixtureServer fixture = FixtureServer.start("wrong-stream-response")) { + AcpAsyncClient client = newClient(fixture.endpoint()).build(); + + client.initialize().block(TIMEOUT); + AcpSchema.NewSessionResponse session = client + .newSession(AcpTestFixtures.createNewSessionRequest("/workspace")) + .block(TIMEOUT); + + assertThatThrownBy(() -> client + .prompt(AcpTestFixtures.createPromptRequest(session.sessionId(), "wrong stream")) + .block(TIMEOUT)) + .isInstanceOf(AcpConnectionException.class) + .hasMessageContaining("arrived on RouteScope"); + + client.closeGracefully().block(TIMEOUT); + fixture.assertTranscriptMatches("wrong-stream-response.json"); + } + } + + @Test + void fixtureRejectsMissingConnectionHeadersCookiesAndSseAccept() throws Exception { + try (FixtureServer fixture = FixtureServer.start("validation-failures")) { + HttpClient rawClient = HttpClient.newHttpClient(); + HttpResponse initialize = rawClient.send(HttpRequest.newBuilder(fixture.endpoint()) + .header("Content-Type", "application/json") + .header("Accept", "application/json") + .POST(HttpRequest.BodyPublishers.ofString(""" + {"jsonrpc":"2.0","id":"init-1","method":"initialize","params":{"protocolVersion":1,"clientCapabilities":{}}} + """)) + .build(), HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); + + String connectionId = initialize.headers().firstValue("Acp-Connection-Id").orElseThrow(); + String cookie = initialize.headers().firstValue("set-cookie").orElseThrow(); + + HttpResponse missingConnection = rawClient.send(HttpRequest.newBuilder(fixture.endpoint()) + .header("Accept", "text/event-stream") + .header("Cookie", cookie) + .GET() + .build(), HttpResponse.BodyHandlers.discarding()); + HttpResponse missingCookie = rawClient.send(HttpRequest.newBuilder(fixture.endpoint()) + .header("Accept", "text/event-stream") + .header("Acp-Connection-Id", connectionId) + .GET() + .build(), HttpResponse.BodyHandlers.discarding()); + HttpResponse wrongAccept = rawClient.send(HttpRequest.newBuilder(fixture.endpoint()) + .header("Accept", "application/json") + .header("Cookie", cookie) + .header("Acp-Connection-Id", connectionId) + .GET() + .build(), HttpResponse.BodyHandlers.discarding()); + HttpResponse delete = rawClient.send(HttpRequest.newBuilder(fixture.endpoint()) + .header("Cookie", cookie) + .header("Acp-Connection-Id", connectionId) + .DELETE() + .build(), HttpResponse.BodyHandlers.discarding()); + + assertThat(initialize.statusCode()).isEqualTo(200); + assertThat(missingConnection.statusCode()).isEqualTo(400); + assertThat(missingCookie.statusCode()).isEqualTo(401); + assertThat(wrongAccept.statusCode()).isEqualTo(406); + assertThat(delete.statusCode()).isEqualTo(202); + + fixture.assertTranscriptMatches("validation-failures.json"); + } + } + + private AcpClient.AsyncSpec newClient(URI endpoint) { + StreamableHttpAcpClientTransport transport = new StreamableHttpAcpClientTransport(endpoint, + AcpJsonMapper.createDefault()); + return AcpClient.async(transport).requestTimeout(TIMEOUT); + } + + private static final class FixtureServer implements AutoCloseable { + + private final Process process; + + private final BufferedReader stdout; + + private final URI baseUri; + + private FixtureServer(Process process, BufferedReader stdout, URI baseUri) { + this.process = process; + this.stdout = stdout; + this.baseUri = baseUri; + } + + static FixtureServer start(String scenario) throws Exception { + Process process = new ProcessBuilder("node", "dist/server.js", "--scenario", scenario, "--port", "0") + .directory(FIXTURE_DIR.toFile()) + .redirectErrorStream(true) + .start(); + BufferedReader stdout = new BufferedReader( + new InputStreamReader(process.getInputStream(), StandardCharsets.UTF_8)); + String readyLine = stdout.readLine(); + if (readyLine == null) { + throw new IllegalStateException("Fixture server exited before becoming ready"); + } + JsonNode ready = OBJECT_MAPPER.readTree(readyLine); + if (!"ready".equals(ready.path("status").asText())) { + throw new IllegalStateException("Fixture server did not become ready: " + readyLine); + } + int port = ready.path("port").asInt(); + return new FixtureServer(process, stdout, URI.create("http://127.0.0.1:" + port)); + } + + URI endpoint() { + return baseUri.resolve("/acp"); + } + + void assertTranscriptMatches(String goldenName) throws Exception { + HttpRequest request = HttpRequest.newBuilder(baseUri.resolve("/__test/transcript")).GET().build(); + HttpResponse response = HttpClient.newHttpClient() + .send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); + assertThat(response.statusCode()).isEqualTo(200); + + JsonNode actual = OBJECT_MAPPER.readTree(response.body()); + JsonNode expected = OBJECT_MAPPER.readTree(Files.readString(GOLDEN_DIR.resolve(goldenName))); + assertThat(actual).isEqualTo(expected); + } + + @Override + public void close() throws Exception { + process.destroy(); + if (!process.waitFor(2, TimeUnit.SECONDS)) { + process.destroyForcibly(); + process.waitFor(2, TimeUnit.SECONDS); + } + try { + stdout.close(); + } + catch (IOException ignored) { + } + } + + } + +} diff --git a/acp-core/src/test/java/com/agentclientprotocol/sdk/client/transport/StreamableHttpAcpClientTransportTest.java b/acp-core/src/test/java/com/agentclientprotocol/sdk/client/transport/StreamableHttpAcpClientTransportTest.java new file mode 100644 index 0000000..33d75a1 --- /dev/null +++ b/acp-core/src/test/java/com/agentclientprotocol/sdk/client/transport/StreamableHttpAcpClientTransportTest.java @@ -0,0 +1,94 @@ +/* + * Copyright 2025-2025 the original author or authors. + */ + +package com.agentclientprotocol.sdk.client.transport; + +import java.net.URI; +import java.net.http.HttpClient; +import java.util.Map; + +import com.agentclientprotocol.sdk.json.AcpJsonMapper; +import com.agentclientprotocol.sdk.spec.AcpSchema; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Mono; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.mock; + +/** + * Unit tests for {@link StreamableHttpAcpClientTransport}. + */ +class StreamableHttpAcpClientTransportTest { + + private AcpJsonMapper jsonMapper; + + @BeforeEach + void setUp() { + jsonMapper = AcpJsonMapper.createDefault(); + } + + @Test + void constructorValidatesEndpointUri() { + assertThatThrownBy(() -> new StreamableHttpAcpClientTransport(null, jsonMapper)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("endpointUri"); + } + + @Test + void constructorValidatesJsonMapper() { + assertThatThrownBy( + () -> new StreamableHttpAcpClientTransport(URI.create("https://localhost:8443/acp"), null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("JsonMapper"); + } + + @Test + void constructorRejectsNonHttpSchemes() { + assertThatThrownBy(() -> new StreamableHttpAcpClientTransport(URI.create("ws://localhost:8080/acp"), jsonMapper)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("http or https"); + } + + @Test + void constructorAcceptsCustomHttpClient() { + HttpClient httpClient = mock(HttpClient.class); + + StreamableHttpAcpClientTransport transport = new StreamableHttpAcpClientTransport( + URI.create("https://localhost:8443/acp"), jsonMapper, httpClient); + + assertThat(transport).isNotNull(); + } + + @Test + void routingModeIsConfigurable() { + StreamableHttpAcpClientTransport transport = new StreamableHttpAcpClientTransport( + URI.create("https://localhost:8443/acp"), jsonMapper) + .routingMode(StreamableHttpAcpClientTransport.RoutingMode.STRICT); + + assertThat(transport).isNotNull(); + } + + @Test + void defaultAcpPathIsCorrect() { + assertThat(StreamableHttpAcpClientTransport.DEFAULT_ACP_PATH).isEqualTo("/acp"); + } + + @Test + void strictRoutingRejectsUnknownOutboundMethods() { + StreamableHttpAcpClientTransport transport = new StreamableHttpAcpClientTransport( + URI.create("https://localhost:8443/acp"), jsonMapper) + .routingMode(StreamableHttpAcpClientTransport.RoutingMode.STRICT); + + transport.connect(message -> Mono.empty()).block(); + + assertThatThrownBy(() -> transport + .sendMessage(new AcpSchema.JSONRPCNotification(AcpSchema.JSONRPC_VERSION, "extension/custom", + Map.of("sessionId", "session-1"))) + .block()) + .hasMessageContaining("No explicit routing rule for outbound method extension/custom"); + } + +} diff --git a/plans/STREAMABLE-HTTP-TRANSPORT.md b/plans/STREAMABLE-HTTP-TRANSPORT.md new file mode 100644 index 0000000..cc2248e --- /dev/null +++ b/plans/STREAMABLE-HTTP-TRANSPORT.md @@ -0,0 +1,194 @@ +# Plan: Streamable HTTP Client Transport + +> **Status**: Milestone one implemented +> **Created**: 2026-05-17 +> **Primary goal**: Java clients can communicate with compliant remote ACP agents over the Streamable HTTP transport. + +## Goal + +Add a client-side Streamable HTTP transport to `acp-core` so applications can use the existing Java client API against compliant remote ACP agents without changing their own code. + +This first milestone is intentionally client-only: + +- implement `StreamableHttpAcpClientTransport` +- preserve the existing ACP client API surface +- prove the wire contract with an in-repo TypeScript conformance fixture +- defer remote transport negotiation, Java server support, and reconnect/resume behavior + +## Milestone-One Result + +Implemented in this branch: + +- `StreamableHttpAcpClientTransport` +- preserved public ACP client API +- compatibility note + isolated forwarding path for the existing client handler-emission ambiguity +- in-repo TypeScript fixture with golden transcripts +- Java unit + integration coverage for: + - initialize bootstrap + - cookie persistence + - connection SSE + - `session/new` + - prompt flow with session updates + - `session/request_permission` + - `session/load` + - wrong-stream responses + - strict-routing rejection + - two logical sessions + - fixture validation failures for missing connection headers, missing cookies, and invalid SSE `Accept` + +## Contract Decisions + +### Public API + +- Add `StreamableHttpAcpClientTransport` in `acp-core`. +- Keep construction symmetrical with `WebSocketAcpClientTransport`: + - `new StreamableHttpAcpClientTransport(endpointUri, jsonMapper)` + - `new StreamableHttpAcpClientTransport(endpointUri, jsonMapper, httpClient)` +- Use a transport-owned default `CookieManager`, while allowing advanced callers to inject a custom `HttpClient`. +- Expose a public routing mode: + - `COMPATIBLE` + - `STRICT` + +### Client / Transport Boundary + +- `AcpClientSession` remains transport-agnostic and continues to own ACP request/response semantics. +- Streamable HTTP owns HTTP-only concerns internally: + - `Acp-Connection-Id` + - `Acp-Session-Id` + - SSE stream lifecycle + - routing correlation + - cookie propagation +- Preserve current WebSocket-compatible client handler-emission forwarding for behavioral parity in the first implementation. +- Isolate that compatibility behavior behind a small helper and flag it in code as an unresolved contract ambiguity. + +### Lifecycle + +- `connect(...)` + - registers the inbound handler + - prepares resources + - performs no network I/O by itself +- `initialize` + - is the first real HTTP exchange + - sends `POST /acp` without `Acp-Connection-Id` + - captures `Acp-Connection-Id` and cookies from the `200 OK` + - opens the connection-scoped SSE stream before delivering the initialize response upward +- `session/new` + - sends the POST first + - receives its JSON-RPC response on the connection stream + - opens the returned session’s SSE stream + - completes only after that session stream is established +- `session/load` + - opens the session stream first + - sends the POST second + - receives its response on the connection stream +- Session-scoped outbound messages require an already-open session stream. +- No automatic reconnect / resume behavior in milestone one. +- `closeGracefully()` + 1. stop accepting new outbound work + 2. cancel local SSE readers + 3. send `DELETE /acp` with `Acp-Connection-Id` + 4. clear local routing and stream state + +### Routing + +- Use an explicit routing table for known ACP methods. +- Compatible-mode fallback for unknown outbound requests / notifications: + - `params.sessionId` present → session-scoped + - otherwise → connection-scoped +- Strict mode rejects unknown outbound request / notification methods that lack explicit routing. +- The transport owns a minimal routing ledger: + - `outbound request id -> request kind + expected response scope` + - `inbound request id -> scope required for the later outbound response` + - `session id -> open session SSE stream` +- Wrong-stream responses are protocol errors. +- Unknown response ids retain current Java SDK parity and are left to the session layer’s existing behavior. + +### SSE Model + +- Treat each SSE `data:` payload as one JSON-RPC message. +- Ignore comments / keep-alives. +- Preserve order per SSE stream. +- Do not impose a synthetic global order across different streams. +- Treat SSE as the source of truth for server feedback and request completion, not as a receipt log for every POST envelope. + +## Test Harness + +Create an in-repo TypeScript fixture: + +```text +test-fixtures/streamable-http-server/ +``` + +The fixture will be: + +- HTTP-only in the first milestone +- strict by default +- scenario-driven with named startup-selected scenarios +- runnable manually and from Java integration tests +- the single owner of canonical transcript serialization + +Golden transcripts will live beside the fixture: + +```text +test-fixtures/streamable-http-server/golden/ +``` + +### Milestone-One Scenarios + +- initialize bootstrap +- cookie persistence +- connection SSE stream +- `session/new` +- prompt flow with session updates +- agent → client `session/request_permission` +- `session/load` +- validation failures for wrong / missing headers +- missing cookie +- wrong-stream response +- strict-routing rejection +- light two-session coverage + +### Future Harness Scenarios + +- reconnect / resume behavior +- concurrency / stress coverage +- broader interop matrix +- WebSocket scenarios for a future composite remote transport + +## Deferred Work + +- Composite `RemoteAcpClientTransport` + - prefer WebSocket + - fall back to Streamable HTTP +- Java server-side Streamable HTTP transport +- reconnect / resume behavior once the protocol defines it +- richer debugging / observability hooks +- broader interoperability testing against an official compliant server when one exists +- deeper multi-session stress coverage + +## Known Ambiguity / Follow-Up Decision + +### Client handler-emission forwarding + +The existing WebSocket client transport forwards any message emitted by the registered handler back onto the transport. `AcpClientSession` also sends responses explicitly through `sendMessage(...)`, so the client-side contract is currently ambiguous. + +Decision for this milestone: + +- preserve WebSocket-compatible forwarding in the new HTTP transport for parity +- isolate the forwarding path in a small helper +- document the ambiguity in code and in this plan +- have the default `AcpClientSession` consume inbound messages without re-emitting them, + because it already sends legitimate outbound replies explicitly through `sendMessage(...)` + +Follow-up: + +- decide whether `AcpClientTransport` should become explicitly receive-only on the client side +- if so, remove the compatibility forwarding path from both transports in a focused cleanup + +## Non-Goals for the First Milestone + +- WebSocket fallback orchestration +- server-side Java transport +- automatic reconnect / resume +- transport-specific public debugging APIs +- global ordering across streams diff --git a/test-fixtures/streamable-http-server/README.md b/test-fixtures/streamable-http-server/README.md new file mode 100644 index 0000000..09a952e --- /dev/null +++ b/test-fixtures/streamable-http-server/README.md @@ -0,0 +1,36 @@ +# Streamable HTTP Fixture Server + +This in-repo TypeScript fixture is the conformance harness for the Java Streamable HTTP client transport. + +Current scope: + +- strict fixture behavior +- HTTP-only +- named startup-selected scenarios +- canonical transcript serialization owned by the fixture + +Current scenarios: + +- `happy-path` +- `permission-round-trip` +- `session-load` +- `two-sessions` +- `wrong-stream-response` +- `validation-failures` + +Fixture-wide validation already covers cookies and transport headers. Strict client-side +routing rejection is tested in the Java unit tests because it should fail before the +fixture ever sees a request. + +Manual use: + +```bash +npm install +npm run build +node dist/server.js --scenario happy-path --port 8080 +``` + +The server prints a single JSON `ready` line on startup. Golden transcripts live in +`golden/` and are compared by the Java integration tests. + +The harness is intentionally small and local to this repository for now. It may later become a reusable ACP conformance fixture once the remote transport ecosystem settles. diff --git a/test-fixtures/streamable-http-server/dist/protocol.js b/test-fixtures/streamable-http-server/dist/protocol.js new file mode 100644 index 0000000..725eb81 --- /dev/null +++ b/test-fixtures/streamable-http-server/dist/protocol.js @@ -0,0 +1,26 @@ +export const ACP_PATH = "/acp"; +export const CONNECTION_HEADER = "acp-connection-id"; +export const SESSION_HEADER = "acp-session-id"; +export const FIXTURE_COOKIE = "fixture=streamable-http"; +export function sendJson(response, status, body, headers = {}) { + response.writeHead(status, { + "content-type": "application/json", + ...headers, + }); + if (body) { + response.end(JSON.stringify(body)); + } + else { + response.end(); + } +} +export function openEventStream(response) { + response.writeHead(200, { + "content-type": "text/event-stream", + "cache-control": "no-cache", + }); + response.flushHeaders(); +} +export function writeSse(response, message) { + response.write(`data: ${JSON.stringify(message)}\n\n`); +} diff --git a/test-fixtures/streamable-http-server/dist/scenarios.js b/test-fixtures/streamable-http-server/dist/scenarios.js new file mode 100644 index 0000000..8a00ec7 --- /dev/null +++ b/test-fixtures/streamable-http-server/dist/scenarios.js @@ -0,0 +1,387 @@ +import { CONNECTION_HEADER, FIXTURE_COOKIE, SESSION_HEADER, openEventStream, sendJson, writeSse, } from "./protocol.js"; +export function createScenario(name) { + switch (name) { + case "happy-path": + return new HappyPathScenario(); + case "permission-round-trip": + return new PermissionRoundTripScenario(); + case "session-load": + return new SessionLoadScenario(); + case "two-sessions": + return new TwoSessionsScenario(); + case "wrong-stream-response": + return new WrongStreamResponseScenario(); + case "validation-failures": + return new ValidationFailuresScenario(); + default: + throw new Error(`Unknown scenario: ${name}`); + } +} +class BaseScenario { + connectionId = "conn-1"; + connectionStream = null; + sessionStreams = new Map(); + handle(response, headers, method, body, recorder) { + const request = this.recordRequest(method, headers, body, recorder); + if (!this.validateRequest(response, request, body, recorder)) { + return; + } + if (request.method === "POST" && body?.method === "initialize") { + this.handleInitialize(response, body, recorder); + return; + } + if (request.method === "GET" && + request.connectionId === this.connectionId && + !request.sessionId) { + this.connectionStream = response; + openEventStream(response); + this.recordResponse(recorder, 200, "connection", null); + return; + } + if (request.method === "GET" && + request.connectionId === this.connectionId && + request.sessionId) { + this.sessionStreams.set(request.sessionId, response); + openEventStream(response); + this.recordResponse(recorder, 200, "session", request.sessionId); + return; + } + if (request.method === "DELETE" && request.connectionId === this.connectionId) { + sendJson(response, 202); + this.recordResponse(recorder, 202, "connection", null); + this.connectionStream?.end(); + this.sessionStreams.forEach((sessionStream) => sessionStream.end()); + return; + } + if (this.handleScenarioRequest(response, request, body, recorder)) { + return; + } + this.reject(response, recorder, request.scope, request.sessionId, 400, "Unexpected fixture request", body?.id); + } + sendSessionNewResponse(responseStream, request, body, recorder, sessionId) { + sendJson(responseStream, 202); + this.recordResponse(recorder, 202, request.scope, request.sessionId); + const response = { + jsonrpc: "2.0", + id: body.id, + result: { + sessionId, + }, + }; + this.writeConnectionEvent(response, recorder); + } + sendPromptFlow(responseStream, request, body, recorder, sessionId) { + sendJson(responseStream, 202); + this.recordResponse(recorder, 202, request.scope, request.sessionId); + const update = { + jsonrpc: "2.0", + method: "session/update", + params: { + sessionId, + update: { + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: "hello" }, + }, + }, + }; + const response = { + jsonrpc: "2.0", + id: body.id, + result: { + stopReason: "end_turn", + }, + }; + this.writeSessionEvent(sessionId, update, recorder); + this.writeSessionEvent(sessionId, response, recorder); + } + writeConnectionEvent(message, recorder) { + if (!this.connectionStream) { + throw new Error("connection stream must be open"); + } + writeSse(this.connectionStream, message); + recorder.record({ + kind: "sse_event", + stream: "connection", + sessionId: null, + jsonRpc: recorder.summarizeJsonRpc(message), + }); + } + writeSessionEvent(sessionId, message, recorder) { + const stream = this.sessionStreams.get(sessionId); + if (!stream) { + throw new Error(`session stream ${sessionId} must be open`); + } + writeSse(stream, message); + recorder.record({ + kind: "sse_event", + stream: "session", + sessionId, + jsonRpc: recorder.summarizeJsonRpc(message), + }); + } + handleInitialize(response, body, recorder) { + sendJson(response, 200, { + jsonrpc: "2.0", + id: body.id, + result: { + protocolVersion: 1, + agentCapabilities: { + loadSession: true, + }, + authMethods: [], + }, + }, { + "Acp-Connection-Id": this.connectionId, + "set-cookie": FIXTURE_COOKIE, + }); + this.recordResponse(recorder, 200, "bootstrap", null); + } + validateRequest(response, request, body, recorder) { + if (request.scope === "bootstrap") { + if (request.method !== "POST" || body?.method !== "initialize") { + this.reject(response, recorder, request.scope, request.sessionId, 400, "Expected initialize bootstrap", body?.id); + return false; + } + return true; + } + if (request.connectionId !== this.connectionId) { + this.reject(response, recorder, request.scope, request.sessionId, 400, "Missing or invalid connection id", body?.id); + return false; + } + if (!request.cookie?.includes(FIXTURE_COOKIE)) { + this.reject(response, recorder, request.scope, request.sessionId, 401, "Missing fixture cookie", body?.id); + return false; + } + if (request.method === "GET" && !request.accept?.includes("text/event-stream")) { + this.reject(response, recorder, request.scope, request.sessionId, 406, "Expected text/event-stream", body?.id); + return false; + } + if (request.method === "POST") { + if (!request.accept?.includes("application/json")) { + this.reject(response, recorder, request.scope, request.sessionId, 406, "Expected application/json accept", body?.id); + return false; + } + if (!request.contentType?.includes("application/json")) { + this.reject(response, recorder, request.scope, request.sessionId, 415, "Expected application/json body", body?.id); + return false; + } + } + return true; + } + recordRequest(method, headers, body, recorder) { + const connectionId = header(headers, CONNECTION_HEADER); + const sessionId = header(headers, SESSION_HEADER); + const cookie = header(headers, "cookie"); + const accept = header(headers, "accept"); + const contentType = header(headers, "content-type"); + const scope = sessionId ? "session" : connectionId ? "connection" : "bootstrap"; + recorder.record({ + kind: "http_request", + method, + scope, + connectionId, + sessionId, + cookie, + jsonRpc: recorder.summarizeJsonRpc(body), + }); + return { method, scope, connectionId, sessionId, cookie, accept, contentType }; + } + recordResponse(recorder, status, scope, sessionId) { + recorder.record({ + kind: "http_response", + status, + scope, + connectionId: scope === "bootstrap" ? this.connectionId : this.connectionId, + sessionId, + }); + } + reject(response, recorder, scope, sessionId, status, message, id) { + sendJson(response, status, { + jsonrpc: "2.0", + id: typeof id === "string" || typeof id === "number" ? id : null, + error: { + code: -32600, + message, + }, + }); + this.recordResponse(recorder, status, scope, sessionId); + } +} +class HappyPathScenario extends BaseScenario { + name = "happy-path"; + sessionId = "sess-1"; + handleScenarioRequest(response, request, body, recorder) { + if (request.method === "POST" && request.scope === "connection" && body?.method === "session/new") { + this.sendSessionNewResponse(response, request, body, recorder, this.sessionId); + return true; + } + if (request.method === "POST" && + request.scope === "session" && + request.sessionId === this.sessionId && + body?.method === "session/prompt") { + this.sendPromptFlow(response, request, body, recorder, this.sessionId); + return true; + } + return false; + } +} +class PermissionRoundTripScenario extends BaseScenario { + name = "permission-round-trip"; + sessionId = "sess-permission"; + pendingPromptId; + handleScenarioRequest(response, request, body, recorder) { + if (request.method === "POST" && request.scope === "connection" && body?.method === "session/new") { + this.sendSessionNewResponse(response, request, body, recorder, this.sessionId); + return true; + } + if (request.method === "POST" && + request.scope === "session" && + request.sessionId === this.sessionId && + body?.method === "session/prompt") { + sendJson(response, 202); + this.recordScenarioResponse(recorder, 202, request); + this.pendingPromptId = body.id; + this.writeSessionEvent(this.sessionId, { + jsonrpc: "2.0", + id: "perm-1", + method: "session/request_permission", + params: { + sessionId: this.sessionId, + toolCall: { + toolCallId: "tool-1", + title: "Write File", + kind: "edit", + status: "pending", + }, + options: [ + { optionId: "allow", name: "Allow", kind: "allow_once" }, + { optionId: "deny", name: "Deny", kind: "reject_once" }, + ], + }, + }, recorder); + return true; + } + if (request.method === "POST" && + request.scope === "session" && + request.sessionId === this.sessionId && + body?.id === "perm-1" && + "result" in body) { + sendJson(response, 202); + this.recordScenarioResponse(recorder, 202, request); + this.writeSessionEvent(this.sessionId, { + jsonrpc: "2.0", + id: this.pendingPromptId, + result: { + stopReason: "end_turn", + }, + }, recorder); + return true; + } + return false; + } + recordScenarioResponse(recorder, status, request) { + recorder.record({ + kind: "http_response", + status, + scope: request.scope, + connectionId: this.connectionId, + sessionId: request.sessionId, + }); + } +} +class SessionLoadScenario extends BaseScenario { + name = "session-load"; + sessionId = "sess-load"; + handleScenarioRequest(response, request, body, recorder) { + if (request.method === "POST" && + request.scope === "session" && + request.sessionId === this.sessionId && + body?.method === "session/load") { + sendJson(response, 202); + this.recordScenarioResponse(recorder, 202, request); + this.writeConnectionEvent({ + jsonrpc: "2.0", + id: body.id, + result: {}, + }, recorder); + return true; + } + return false; + } + recordScenarioResponse(recorder, status, request) { + recorder.record({ + kind: "http_response", + status, + scope: request.scope, + connectionId: this.connectionId, + sessionId: request.sessionId, + }); + } +} +class TwoSessionsScenario extends BaseScenario { + name = "two-sessions"; + nextSessionNumber = 1; + handleScenarioRequest(response, request, body, recorder) { + if (request.method === "POST" && request.scope === "connection" && body?.method === "session/new") { + const sessionId = `sess-${this.nextSessionNumber++}`; + this.sendSessionNewResponse(response, request, body, recorder, sessionId); + return true; + } + if (request.method === "POST" && + request.scope === "session" && + request.sessionId && + body?.method === "session/prompt") { + this.sendPromptFlow(response, request, body, recorder, request.sessionId); + return true; + } + return false; + } +} +class WrongStreamResponseScenario extends BaseScenario { + name = "wrong-stream-response"; + sessionId = "sess-wrong"; + handleScenarioRequest(response, request, body, recorder) { + if (request.method === "POST" && request.scope === "connection" && body?.method === "session/new") { + this.sendSessionNewResponse(response, request, body, recorder, this.sessionId); + return true; + } + if (request.method === "POST" && + request.scope === "session" && + request.sessionId === this.sessionId && + body?.method === "session/prompt") { + sendJson(response, 202); + this.recordScenarioResponse(recorder, 202, request); + this.writeConnectionEvent({ + jsonrpc: "2.0", + id: body.id, + result: { + stopReason: "end_turn", + }, + }, recorder); + return true; + } + return false; + } + recordScenarioResponse(recorder, status, request) { + recorder.record({ + kind: "http_response", + status, + scope: request.scope, + connectionId: this.connectionId, + sessionId: request.sessionId, + }); + } +} +class ValidationFailuresScenario extends BaseScenario { + name = "validation-failures"; + handleScenarioRequest(_response, _request, _body, _recorder) { + return false; + } +} +function header(headers, name) { + const value = headers[name]; + if (Array.isArray(value)) { + return value[0] ?? null; + } + return typeof value === "string" ? value : null; +} diff --git a/test-fixtures/streamable-http-server/dist/server.js b/test-fixtures/streamable-http-server/dist/server.js new file mode 100644 index 0000000..a1da7df --- /dev/null +++ b/test-fixtures/streamable-http-server/dist/server.js @@ -0,0 +1,46 @@ +import { createServer } from "node:http"; +import { ACP_PATH } from "./protocol.js"; +import { createScenario } from "./scenarios.js"; +import { TranscriptRecorder } from "./transcript.js"; +const scenarioName = readArg("--scenario") ?? "happy-path"; +const port = Number(readArg("--port") ?? "0"); +const recorder = new TranscriptRecorder(); +const scenario = createScenario(scenarioName); +const server = createServer(); +server.on("request", async (request, response) => { + const path = request.url ?? ""; + if (path === "/__test/transcript") { + response.writeHead(200, { + "content-type": "application/json", + }); + response.end(recorder.serialize()); + return; + } + if (path !== ACP_PATH) { + response.writeHead(404); + response.end(); + return; + } + const body = await readJsonBody(request); + scenario.handle(response, request.headers, request.method ?? "", body, recorder); +}); +server.listen(port, () => { + const address = server.address(); + const actualPort = typeof address === "object" && address ? address.port : port; + process.stdout.write(JSON.stringify({ status: "ready", port: actualPort, scenario: scenario.name }) + "\n"); +}); +function readArg(name) { + const index = process.argv.indexOf(name); + return index >= 0 ? process.argv[index + 1] : undefined; +} +async function readJsonBody(request) { + const method = request.method ?? ""; + if (method === "GET" || method === "DELETE") { + return null; + } + let raw = ""; + for await (const chunk of request) { + raw += chunk.toString("utf8"); + } + return raw ? JSON.parse(raw) : null; +} diff --git a/test-fixtures/streamable-http-server/dist/transcript.js b/test-fixtures/streamable-http-server/dist/transcript.js new file mode 100644 index 0000000..9193e07 --- /dev/null +++ b/test-fixtures/streamable-http-server/dist/transcript.js @@ -0,0 +1,52 @@ +export class TranscriptRecorder { + events = []; + idAliases = new Map(); + record(event) { + this.events.push(event); + } + summarizeJsonRpc(message) { + if (!message || typeof message !== "object") { + return null; + } + const candidate = message; + if (typeof candidate.method === "string" && "id" in candidate) { + return { + type: "request", + id: this.normalizeId(candidate.id), + method: candidate.method, + }; + } + if (typeof candidate.method === "string") { + return { + type: "notification", + method: candidate.method, + }; + } + if ("result" in candidate || "error" in candidate) { + return { + type: "response", + id: this.normalizeId(candidate.id), + hasError: candidate.error != null, + }; + } + return null; + } + toJSON() { + return [...this.events]; + } + serialize() { + return JSON.stringify(this.events, null, 2); + } + normalizeId(id) { + if (typeof id !== "string" && typeof id !== "number") { + return null; + } + const existing = this.idAliases.get(id); + if (existing) { + return existing; + } + const next = `id-${this.idAliases.size + 1}`; + this.idAliases.set(id, next); + return next; + } +} diff --git a/test-fixtures/streamable-http-server/golden/happy-path.json b/test-fixtures/streamable-http-server/golden/happy-path.json new file mode 100644 index 0000000..171bf6c --- /dev/null +++ b/test-fixtures/streamable-http-server/golden/happy-path.json @@ -0,0 +1,139 @@ +[ + { + "kind": "http_request", + "method": "POST", + "scope": "bootstrap", + "connectionId": null, + "sessionId": null, + "cookie": null, + "jsonRpc": { + "type": "request", + "id": "id-1", + "method": "initialize" + } + }, + { + "kind": "http_response", + "status": 200, + "scope": "bootstrap", + "connectionId": "conn-1", + "sessionId": null + }, + { + "kind": "http_request", + "method": "GET", + "scope": "connection", + "connectionId": "conn-1", + "sessionId": null, + "cookie": "fixture=streamable-http", + "jsonRpc": null + }, + { + "kind": "http_response", + "status": 200, + "scope": "connection", + "connectionId": "conn-1", + "sessionId": null + }, + { + "kind": "http_request", + "method": "POST", + "scope": "connection", + "connectionId": "conn-1", + "sessionId": null, + "cookie": "fixture=streamable-http", + "jsonRpc": { + "type": "request", + "id": "id-2", + "method": "session/new" + } + }, + { + "kind": "http_response", + "status": 202, + "scope": "connection", + "connectionId": "conn-1", + "sessionId": null + }, + { + "kind": "sse_event", + "stream": "connection", + "sessionId": null, + "jsonRpc": { + "type": "response", + "id": "id-2", + "hasError": false + } + }, + { + "kind": "http_request", + "method": "GET", + "scope": "session", + "connectionId": "conn-1", + "sessionId": "sess-1", + "cookie": "fixture=streamable-http", + "jsonRpc": null + }, + { + "kind": "http_response", + "status": 200, + "scope": "session", + "connectionId": "conn-1", + "sessionId": "sess-1" + }, + { + "kind": "http_request", + "method": "POST", + "scope": "session", + "connectionId": "conn-1", + "sessionId": "sess-1", + "cookie": "fixture=streamable-http", + "jsonRpc": { + "type": "request", + "id": "id-3", + "method": "session/prompt" + } + }, + { + "kind": "http_response", + "status": 202, + "scope": "session", + "connectionId": "conn-1", + "sessionId": "sess-1" + }, + { + "kind": "sse_event", + "stream": "session", + "sessionId": "sess-1", + "jsonRpc": { + "type": "notification", + "method": "session/update" + } + }, + { + "kind": "sse_event", + "stream": "session", + "sessionId": "sess-1", + "jsonRpc": { + "type": "response", + "id": "id-3", + "hasError": false + } + }, + { + "kind": "http_request", + "method": "DELETE", + "scope": "connection", + "connectionId": "conn-1", + "sessionId": null, + "cookie": "fixture=streamable-http", + "jsonRpc": null + }, + { + "kind": "http_response", + "status": 202, + "scope": "connection", + "connectionId": "conn-1", + "sessionId": null + } +] diff --git a/test-fixtures/streamable-http-server/golden/permission-round-trip.json b/test-fixtures/streamable-http-server/golden/permission-round-trip.json new file mode 100644 index 0000000..0ba9132 --- /dev/null +++ b/test-fixtures/streamable-http-server/golden/permission-round-trip.json @@ -0,0 +1,160 @@ +[ + { + "kind": "http_request", + "method": "POST", + "scope": "bootstrap", + "connectionId": null, + "sessionId": null, + "cookie": null, + "jsonRpc": { + "type": "request", + "id": "id-1", + "method": "initialize" + } + }, + { + "kind": "http_response", + "status": 200, + "scope": "bootstrap", + "connectionId": "conn-1", + "sessionId": null + }, + { + "kind": "http_request", + "method": "GET", + "scope": "connection", + "connectionId": "conn-1", + "sessionId": null, + "cookie": "fixture=streamable-http", + "jsonRpc": null + }, + { + "kind": "http_response", + "status": 200, + "scope": "connection", + "connectionId": "conn-1", + "sessionId": null + }, + { + "kind": "http_request", + "method": "POST", + "scope": "connection", + "connectionId": "conn-1", + "sessionId": null, + "cookie": "fixture=streamable-http", + "jsonRpc": { + "type": "request", + "id": "id-2", + "method": "session/new" + } + }, + { + "kind": "http_response", + "status": 202, + "scope": "connection", + "connectionId": "conn-1", + "sessionId": null + }, + { + "kind": "sse_event", + "stream": "connection", + "sessionId": null, + "jsonRpc": { + "type": "response", + "id": "id-2", + "hasError": false + } + }, + { + "kind": "http_request", + "method": "GET", + "scope": "session", + "connectionId": "conn-1", + "sessionId": "sess-permission", + "cookie": "fixture=streamable-http", + "jsonRpc": null + }, + { + "kind": "http_response", + "status": 200, + "scope": "session", + "connectionId": "conn-1", + "sessionId": "sess-permission" + }, + { + "kind": "http_request", + "method": "POST", + "scope": "session", + "connectionId": "conn-1", + "sessionId": "sess-permission", + "cookie": "fixture=streamable-http", + "jsonRpc": { + "type": "request", + "id": "id-3", + "method": "session/prompt" + } + }, + { + "kind": "http_response", + "status": 202, + "scope": "session", + "connectionId": "conn-1", + "sessionId": "sess-permission" + }, + { + "kind": "sse_event", + "stream": "session", + "sessionId": "sess-permission", + "jsonRpc": { + "type": "request", + "id": "id-4", + "method": "session/request_permission" + } + }, + { + "kind": "http_request", + "method": "POST", + "scope": "session", + "connectionId": "conn-1", + "sessionId": "sess-permission", + "cookie": "fixture=streamable-http", + "jsonRpc": { + "type": "response", + "id": "id-4", + "hasError": false + } + }, + { + "kind": "http_response", + "status": 202, + "scope": "session", + "connectionId": "conn-1", + "sessionId": "sess-permission" + }, + { + "kind": "sse_event", + "stream": "session", + "sessionId": "sess-permission", + "jsonRpc": { + "type": "response", + "id": "id-3", + "hasError": false + } + }, + { + "kind": "http_request", + "method": "DELETE", + "scope": "connection", + "connectionId": "conn-1", + "sessionId": null, + "cookie": "fixture=streamable-http", + "jsonRpc": null + }, + { + "kind": "http_response", + "status": 202, + "scope": "connection", + "connectionId": "conn-1", + "sessionId": null + } +] diff --git a/test-fixtures/streamable-http-server/golden/session-load.json b/test-fixtures/streamable-http-server/golden/session-load.json new file mode 100644 index 0000000..fd932fb --- /dev/null +++ b/test-fixtures/streamable-http-server/golden/session-load.json @@ -0,0 +1,100 @@ +[ + { + "kind": "http_request", + "method": "POST", + "scope": "bootstrap", + "connectionId": null, + "sessionId": null, + "cookie": null, + "jsonRpc": { + "type": "request", + "id": "id-1", + "method": "initialize" + } + }, + { + "kind": "http_response", + "status": 200, + "scope": "bootstrap", + "connectionId": "conn-1", + "sessionId": null + }, + { + "kind": "http_request", + "method": "GET", + "scope": "connection", + "connectionId": "conn-1", + "sessionId": null, + "cookie": "fixture=streamable-http", + "jsonRpc": null + }, + { + "kind": "http_response", + "status": 200, + "scope": "connection", + "connectionId": "conn-1", + "sessionId": null + }, + { + "kind": "http_request", + "method": "GET", + "scope": "session", + "connectionId": "conn-1", + "sessionId": "sess-load", + "cookie": "fixture=streamable-http", + "jsonRpc": null + }, + { + "kind": "http_response", + "status": 200, + "scope": "session", + "connectionId": "conn-1", + "sessionId": "sess-load" + }, + { + "kind": "http_request", + "method": "POST", + "scope": "session", + "connectionId": "conn-1", + "sessionId": "sess-load", + "cookie": "fixture=streamable-http", + "jsonRpc": { + "type": "request", + "id": "id-2", + "method": "session/load" + } + }, + { + "kind": "http_response", + "status": 202, + "scope": "session", + "connectionId": "conn-1", + "sessionId": "sess-load" + }, + { + "kind": "sse_event", + "stream": "connection", + "sessionId": null, + "jsonRpc": { + "type": "response", + "id": "id-2", + "hasError": false + } + }, + { + "kind": "http_request", + "method": "DELETE", + "scope": "connection", + "connectionId": "conn-1", + "sessionId": null, + "cookie": "fixture=streamable-http", + "jsonRpc": null + }, + { + "kind": "http_response", + "status": 202, + "scope": "connection", + "connectionId": "conn-1", + "sessionId": null + } +] diff --git a/test-fixtures/streamable-http-server/golden/two-sessions.json b/test-fixtures/streamable-http-server/golden/two-sessions.json new file mode 100644 index 0000000..0d5eacb --- /dev/null +++ b/test-fixtures/streamable-http-server/golden/two-sessions.json @@ -0,0 +1,224 @@ +[ + { + "kind": "http_request", + "method": "POST", + "scope": "bootstrap", + "connectionId": null, + "sessionId": null, + "cookie": null, + "jsonRpc": { + "type": "request", + "id": "id-1", + "method": "initialize" + } + }, + { + "kind": "http_response", + "status": 200, + "scope": "bootstrap", + "connectionId": "conn-1", + "sessionId": null + }, + { + "kind": "http_request", + "method": "GET", + "scope": "connection", + "connectionId": "conn-1", + "sessionId": null, + "cookie": "fixture=streamable-http", + "jsonRpc": null + }, + { + "kind": "http_response", + "status": 200, + "scope": "connection", + "connectionId": "conn-1", + "sessionId": null + }, + { + "kind": "http_request", + "method": "POST", + "scope": "connection", + "connectionId": "conn-1", + "sessionId": null, + "cookie": "fixture=streamable-http", + "jsonRpc": { + "type": "request", + "id": "id-2", + "method": "session/new" + } + }, + { + "kind": "http_response", + "status": 202, + "scope": "connection", + "connectionId": "conn-1", + "sessionId": null + }, + { + "kind": "sse_event", + "stream": "connection", + "sessionId": null, + "jsonRpc": { + "type": "response", + "id": "id-2", + "hasError": false + } + }, + { + "kind": "http_request", + "method": "GET", + "scope": "session", + "connectionId": "conn-1", + "sessionId": "sess-1", + "cookie": "fixture=streamable-http", + "jsonRpc": null + }, + { + "kind": "http_response", + "status": 200, + "scope": "session", + "connectionId": "conn-1", + "sessionId": "sess-1" + }, + { + "kind": "http_request", + "method": "POST", + "scope": "connection", + "connectionId": "conn-1", + "sessionId": null, + "cookie": "fixture=streamable-http", + "jsonRpc": { + "type": "request", + "id": "id-3", + "method": "session/new" + } + }, + { + "kind": "http_response", + "status": 202, + "scope": "connection", + "connectionId": "conn-1", + "sessionId": null + }, + { + "kind": "sse_event", + "stream": "connection", + "sessionId": null, + "jsonRpc": { + "type": "response", + "id": "id-3", + "hasError": false + } + }, + { + "kind": "http_request", + "method": "GET", + "scope": "session", + "connectionId": "conn-1", + "sessionId": "sess-2", + "cookie": "fixture=streamable-http", + "jsonRpc": null + }, + { + "kind": "http_response", + "status": 200, + "scope": "session", + "connectionId": "conn-1", + "sessionId": "sess-2" + }, + { + "kind": "http_request", + "method": "POST", + "scope": "session", + "connectionId": "conn-1", + "sessionId": "sess-1", + "cookie": "fixture=streamable-http", + "jsonRpc": { + "type": "request", + "id": "id-4", + "method": "session/prompt" + } + }, + { + "kind": "http_response", + "status": 202, + "scope": "session", + "connectionId": "conn-1", + "sessionId": "sess-1" + }, + { + "kind": "sse_event", + "stream": "session", + "sessionId": "sess-1", + "jsonRpc": { + "type": "notification", + "method": "session/update" + } + }, + { + "kind": "sse_event", + "stream": "session", + "sessionId": "sess-1", + "jsonRpc": { + "type": "response", + "id": "id-4", + "hasError": false + } + }, + { + "kind": "http_request", + "method": "POST", + "scope": "session", + "connectionId": "conn-1", + "sessionId": "sess-2", + "cookie": "fixture=streamable-http", + "jsonRpc": { + "type": "request", + "id": "id-5", + "method": "session/prompt" + } + }, + { + "kind": "http_response", + "status": 202, + "scope": "session", + "connectionId": "conn-1", + "sessionId": "sess-2" + }, + { + "kind": "sse_event", + "stream": "session", + "sessionId": "sess-2", + "jsonRpc": { + "type": "notification", + "method": "session/update" + } + }, + { + "kind": "sse_event", + "stream": "session", + "sessionId": "sess-2", + "jsonRpc": { + "type": "response", + "id": "id-5", + "hasError": false + } + }, + { + "kind": "http_request", + "method": "DELETE", + "scope": "connection", + "connectionId": "conn-1", + "sessionId": null, + "cookie": "fixture=streamable-http", + "jsonRpc": null + }, + { + "kind": "http_response", + "status": 202, + "scope": "connection", + "connectionId": "conn-1", + "sessionId": null + } +] diff --git a/test-fixtures/streamable-http-server/golden/validation-failures.json b/test-fixtures/streamable-http-server/golden/validation-failures.json new file mode 100644 index 0000000..51cbd36 --- /dev/null +++ b/test-fixtures/streamable-http-server/golden/validation-failures.json @@ -0,0 +1,86 @@ +[ + { + "kind": "http_request", + "method": "POST", + "scope": "bootstrap", + "connectionId": null, + "sessionId": null, + "cookie": null, + "jsonRpc": { + "type": "request", + "id": "id-1", + "method": "initialize" + } + }, + { + "kind": "http_response", + "status": 200, + "scope": "bootstrap", + "connectionId": "conn-1", + "sessionId": null + }, + { + "kind": "http_request", + "method": "GET", + "scope": "bootstrap", + "connectionId": null, + "sessionId": null, + "cookie": "fixture=streamable-http", + "jsonRpc": null + }, + { + "kind": "http_response", + "status": 400, + "scope": "bootstrap", + "connectionId": "conn-1", + "sessionId": null + }, + { + "kind": "http_request", + "method": "GET", + "scope": "connection", + "connectionId": "conn-1", + "sessionId": null, + "cookie": null, + "jsonRpc": null + }, + { + "kind": "http_response", + "status": 401, + "scope": "connection", + "connectionId": "conn-1", + "sessionId": null + }, + { + "kind": "http_request", + "method": "GET", + "scope": "connection", + "connectionId": "conn-1", + "sessionId": null, + "cookie": "fixture=streamable-http", + "jsonRpc": null + }, + { + "kind": "http_response", + "status": 406, + "scope": "connection", + "connectionId": "conn-1", + "sessionId": null + }, + { + "kind": "http_request", + "method": "DELETE", + "scope": "connection", + "connectionId": "conn-1", + "sessionId": null, + "cookie": "fixture=streamable-http", + "jsonRpc": null + }, + { + "kind": "http_response", + "status": 202, + "scope": "connection", + "connectionId": "conn-1", + "sessionId": null + } +] diff --git a/test-fixtures/streamable-http-server/golden/wrong-stream-response.json b/test-fixtures/streamable-http-server/golden/wrong-stream-response.json new file mode 100644 index 0000000..97e80ea --- /dev/null +++ b/test-fixtures/streamable-http-server/golden/wrong-stream-response.json @@ -0,0 +1,130 @@ +[ + { + "kind": "http_request", + "method": "POST", + "scope": "bootstrap", + "connectionId": null, + "sessionId": null, + "cookie": null, + "jsonRpc": { + "type": "request", + "id": "id-1", + "method": "initialize" + } + }, + { + "kind": "http_response", + "status": 200, + "scope": "bootstrap", + "connectionId": "conn-1", + "sessionId": null + }, + { + "kind": "http_request", + "method": "GET", + "scope": "connection", + "connectionId": "conn-1", + "sessionId": null, + "cookie": "fixture=streamable-http", + "jsonRpc": null + }, + { + "kind": "http_response", + "status": 200, + "scope": "connection", + "connectionId": "conn-1", + "sessionId": null + }, + { + "kind": "http_request", + "method": "POST", + "scope": "connection", + "connectionId": "conn-1", + "sessionId": null, + "cookie": "fixture=streamable-http", + "jsonRpc": { + "type": "request", + "id": "id-2", + "method": "session/new" + } + }, + { + "kind": "http_response", + "status": 202, + "scope": "connection", + "connectionId": "conn-1", + "sessionId": null + }, + { + "kind": "sse_event", + "stream": "connection", + "sessionId": null, + "jsonRpc": { + "type": "response", + "id": "id-2", + "hasError": false + } + }, + { + "kind": "http_request", + "method": "GET", + "scope": "session", + "connectionId": "conn-1", + "sessionId": "sess-wrong", + "cookie": "fixture=streamable-http", + "jsonRpc": null + }, + { + "kind": "http_response", + "status": 200, + "scope": "session", + "connectionId": "conn-1", + "sessionId": "sess-wrong" + }, + { + "kind": "http_request", + "method": "POST", + "scope": "session", + "connectionId": "conn-1", + "sessionId": "sess-wrong", + "cookie": "fixture=streamable-http", + "jsonRpc": { + "type": "request", + "id": "id-3", + "method": "session/prompt" + } + }, + { + "kind": "http_response", + "status": 202, + "scope": "session", + "connectionId": "conn-1", + "sessionId": "sess-wrong" + }, + { + "kind": "sse_event", + "stream": "connection", + "sessionId": null, + "jsonRpc": { + "type": "response", + "id": "id-3", + "hasError": false + } + }, + { + "kind": "http_request", + "method": "DELETE", + "scope": "connection", + "connectionId": "conn-1", + "sessionId": null, + "cookie": "fixture=streamable-http", + "jsonRpc": null + }, + { + "kind": "http_response", + "status": 202, + "scope": "connection", + "connectionId": "conn-1", + "sessionId": null + } +] diff --git a/test-fixtures/streamable-http-server/package-lock.json b/test-fixtures/streamable-http-server/package-lock.json new file mode 100644 index 0000000..1a89fc5 --- /dev/null +++ b/test-fixtures/streamable-http-server/package-lock.json @@ -0,0 +1,566 @@ +{ + "name": "acp-streamable-http-fixture", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "acp-streamable-http-fixture", + "version": "0.1.0", + "devDependencies": { + "@types/node": "^22.15.0", + "tsx": "^4.19.4", + "typescript": "^5.8.3" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz", + "integrity": "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.0.tgz", + "integrity": "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.0.tgz", + "integrity": "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.0.tgz", + "integrity": "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.0.tgz", + "integrity": "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.0.tgz", + "integrity": "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.0.tgz", + "integrity": "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.0.tgz", + "integrity": "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.0.tgz", + "integrity": "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.0.tgz", + "integrity": "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.0.tgz", + "integrity": "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.0.tgz", + "integrity": "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.0.tgz", + "integrity": "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.0.tgz", + "integrity": "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.0.tgz", + "integrity": "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.0.tgz", + "integrity": "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.0.tgz", + "integrity": "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.0.tgz", + "integrity": "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.0.tgz", + "integrity": "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.0.tgz", + "integrity": "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.0.tgz", + "integrity": "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.0.tgz", + "integrity": "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.0.tgz", + "integrity": "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.0.tgz", + "integrity": "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.0.tgz", + "integrity": "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.0.tgz", + "integrity": "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@types/node": { + "version": "22.19.19", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.19.tgz", + "integrity": "sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/esbuild": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.0.tgz", + "integrity": "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.0", + "@esbuild/android-arm": "0.28.0", + "@esbuild/android-arm64": "0.28.0", + "@esbuild/android-x64": "0.28.0", + "@esbuild/darwin-arm64": "0.28.0", + "@esbuild/darwin-x64": "0.28.0", + "@esbuild/freebsd-arm64": "0.28.0", + "@esbuild/freebsd-x64": "0.28.0", + "@esbuild/linux-arm": "0.28.0", + "@esbuild/linux-arm64": "0.28.0", + "@esbuild/linux-ia32": "0.28.0", + "@esbuild/linux-loong64": "0.28.0", + "@esbuild/linux-mips64el": "0.28.0", + "@esbuild/linux-ppc64": "0.28.0", + "@esbuild/linux-riscv64": "0.28.0", + "@esbuild/linux-s390x": "0.28.0", + "@esbuild/linux-x64": "0.28.0", + "@esbuild/netbsd-arm64": "0.28.0", + "@esbuild/netbsd-x64": "0.28.0", + "@esbuild/openbsd-arm64": "0.28.0", + "@esbuild/openbsd-x64": "0.28.0", + "@esbuild/openharmony-arm64": "0.28.0", + "@esbuild/sunos-x64": "0.28.0", + "@esbuild/win32-arm64": "0.28.0", + "@esbuild/win32-ia32": "0.28.0", + "@esbuild/win32-x64": "0.28.0" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/tsx": { + "version": "4.22.1", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.1.tgz", + "integrity": "sha512-TvncJykhxAzFCk0VQZKBTClall4Pm7qXDSodb6uxi8QFa8X8mT6ABjxxsQ2opDRYxG7AzcRWXaFtruz5HJKuWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/test-fixtures/streamable-http-server/package.json b/test-fixtures/streamable-http-server/package.json new file mode 100644 index 0000000..11a8163 --- /dev/null +++ b/test-fixtures/streamable-http-server/package.json @@ -0,0 +1,16 @@ +{ + "name": "acp-streamable-http-fixture", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "build": "tsc -p tsconfig.json", + "start": "tsx src/server.ts", + "start:happy-path": "tsx src/server.ts --scenario happy-path" + }, + "devDependencies": { + "@types/node": "^22.15.0", + "tsx": "^4.19.4", + "typescript": "^5.8.3" + } +} diff --git a/test-fixtures/streamable-http-server/src/protocol.ts b/test-fixtures/streamable-http-server/src/protocol.ts new file mode 100644 index 0000000..df82cfd --- /dev/null +++ b/test-fixtures/streamable-http-server/src/protocol.ts @@ -0,0 +1,32 @@ +import { ServerResponse } from "node:http"; + +export const ACP_PATH = "/acp"; +export const CONNECTION_HEADER = "acp-connection-id"; +export const SESSION_HEADER = "acp-session-id"; +export const FIXTURE_COOKIE = "fixture=streamable-http"; + +export type JsonRpcMessage = Record; + +export function sendJson(response: ServerResponse, status: number, body?: JsonRpcMessage, headers: Record = {}): void { + response.writeHead(status, { + "content-type": "application/json", + ...headers, + }); + if (body) { + response.end(JSON.stringify(body)); + } else { + response.end(); + } +} + +export function openEventStream(response: ServerResponse): void { + response.writeHead(200, { + "content-type": "text/event-stream", + "cache-control": "no-cache", + }); + response.flushHeaders(); +} + +export function writeSse(response: ServerResponse, message: JsonRpcMessage): void { + response.write(`data: ${JSON.stringify(message)}\n\n`); +} diff --git a/test-fixtures/streamable-http-server/src/scenarios.ts b/test-fixtures/streamable-http-server/src/scenarios.ts new file mode 100644 index 0000000..c4f49f3 --- /dev/null +++ b/test-fixtures/streamable-http-server/src/scenarios.ts @@ -0,0 +1,574 @@ +import { IncomingHttpHeaders, ServerResponse } from "node:http"; +import { + CONNECTION_HEADER, + FIXTURE_COOKIE, + JsonRpcMessage, + SESSION_HEADER, + openEventStream, + sendJson, + writeSse, +} from "./protocol.js"; +import { TranscriptRecorder } from "./transcript.js"; + +export interface FixtureScenario { + readonly name: string; + handle( + response: ServerResponse, + headers: IncomingHttpHeaders, + method: string, + body: JsonRpcMessage | null, + recorder: TranscriptRecorder, + ): void; +} + +export function createScenario(name: string): FixtureScenario { + switch (name) { + case "happy-path": + return new HappyPathScenario(); + case "permission-round-trip": + return new PermissionRoundTripScenario(); + case "session-load": + return new SessionLoadScenario(); + case "two-sessions": + return new TwoSessionsScenario(); + case "wrong-stream-response": + return new WrongStreamResponseScenario(); + case "validation-failures": + return new ValidationFailuresScenario(); + default: + throw new Error(`Unknown scenario: ${name}`); + } +} + +abstract class BaseScenario implements FixtureScenario { + abstract readonly name: string; + + protected readonly connectionId = "conn-1"; + protected connectionStream: ServerResponse | null = null; + protected readonly sessionStreams = new Map(); + + handle( + response: ServerResponse, + headers: IncomingHttpHeaders, + method: string, + body: JsonRpcMessage | null, + recorder: TranscriptRecorder, + ): void { + const request = this.recordRequest(method, headers, body, recorder); + if (!this.validateRequest(response, request, body, recorder)) { + return; + } + + if (request.method === "POST" && body?.method === "initialize") { + this.handleInitialize(response, body, recorder); + return; + } + + if ( + request.method === "GET" && + request.connectionId === this.connectionId && + !request.sessionId + ) { + this.connectionStream = response; + openEventStream(response); + this.recordResponse(recorder, 200, "connection", null); + return; + } + + if ( + request.method === "GET" && + request.connectionId === this.connectionId && + request.sessionId + ) { + this.sessionStreams.set(request.sessionId, response); + openEventStream(response); + this.recordResponse(recorder, 200, "session", request.sessionId); + return; + } + + if (request.method === "DELETE" && request.connectionId === this.connectionId) { + sendJson(response, 202); + this.recordResponse(recorder, 202, "connection", null); + this.connectionStream?.end(); + this.sessionStreams.forEach((sessionStream) => sessionStream.end()); + return; + } + + if (this.handleScenarioRequest(response, request, body, recorder)) { + return; + } + + this.reject(response, recorder, request.scope, request.sessionId, 400, "Unexpected fixture request", body?.id); + } + + protected abstract handleScenarioRequest( + response: ServerResponse, + request: RecordedRequest, + body: JsonRpcMessage | null, + recorder: TranscriptRecorder, + ): boolean; + + protected sendSessionNewResponse( + responseStream: ServerResponse, + request: RecordedRequest, + body: JsonRpcMessage, + recorder: TranscriptRecorder, + sessionId: string, + ): void { + sendJson(responseStream, 202); + this.recordResponse(recorder, 202, request.scope, request.sessionId); + const response = { + jsonrpc: "2.0", + id: body.id, + result: { + sessionId, + }, + }; + this.writeConnectionEvent(response, recorder); + } + + protected sendPromptFlow( + responseStream: ServerResponse, + request: RecordedRequest, + body: JsonRpcMessage, + recorder: TranscriptRecorder, + sessionId: string, + ): void { + sendJson(responseStream, 202); + this.recordResponse(recorder, 202, request.scope, request.sessionId); + const update = { + jsonrpc: "2.0", + method: "session/update", + params: { + sessionId, + update: { + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: "hello" }, + }, + }, + }; + const response = { + jsonrpc: "2.0", + id: body.id, + result: { + stopReason: "end_turn", + }, + }; + this.writeSessionEvent(sessionId, update, recorder); + this.writeSessionEvent(sessionId, response, recorder); + } + + protected writeConnectionEvent(message: JsonRpcMessage, recorder: TranscriptRecorder): void { + if (!this.connectionStream) { + throw new Error("connection stream must be open"); + } + writeSse(this.connectionStream, message); + recorder.record({ + kind: "sse_event", + stream: "connection", + sessionId: null, + jsonRpc: recorder.summarizeJsonRpc(message)!, + }); + } + + protected writeSessionEvent(sessionId: string, message: JsonRpcMessage, recorder: TranscriptRecorder): void { + const stream = this.sessionStreams.get(sessionId); + if (!stream) { + throw new Error(`session stream ${sessionId} must be open`); + } + writeSse(stream, message); + recorder.record({ + kind: "sse_event", + stream: "session", + sessionId, + jsonRpc: recorder.summarizeJsonRpc(message)!, + }); + } + + private handleInitialize(response: ServerResponse, body: JsonRpcMessage, recorder: TranscriptRecorder): void { + sendJson( + response, + 200, + { + jsonrpc: "2.0", + id: body.id, + result: { + protocolVersion: 1, + agentCapabilities: { + loadSession: true, + }, + authMethods: [], + }, + }, + { + "Acp-Connection-Id": this.connectionId, + "set-cookie": FIXTURE_COOKIE, + }, + ); + this.recordResponse(recorder, 200, "bootstrap", null); + } + + private validateRequest( + response: ServerResponse, + request: RecordedRequest, + body: JsonRpcMessage | null, + recorder: TranscriptRecorder, + ): boolean { + if (request.scope === "bootstrap") { + if (request.method !== "POST" || body?.method !== "initialize") { + this.reject(response, recorder, request.scope, request.sessionId, 400, "Expected initialize bootstrap", body?.id); + return false; + } + return true; + } + + if (request.connectionId !== this.connectionId) { + this.reject(response, recorder, request.scope, request.sessionId, 400, "Missing or invalid connection id", body?.id); + return false; + } + + if (!request.cookie?.includes(FIXTURE_COOKIE)) { + this.reject(response, recorder, request.scope, request.sessionId, 401, "Missing fixture cookie", body?.id); + return false; + } + + if (request.method === "GET" && !request.accept?.includes("text/event-stream")) { + this.reject(response, recorder, request.scope, request.sessionId, 406, "Expected text/event-stream", body?.id); + return false; + } + + if (request.method === "POST") { + if (!request.accept?.includes("application/json")) { + this.reject(response, recorder, request.scope, request.sessionId, 406, "Expected application/json accept", body?.id); + return false; + } + if (!request.contentType?.includes("application/json")) { + this.reject(response, recorder, request.scope, request.sessionId, 415, "Expected application/json body", body?.id); + return false; + } + } + + return true; + } + + private recordRequest( + method: string, + headers: IncomingHttpHeaders, + body: JsonRpcMessage | null, + recorder: TranscriptRecorder, + ): RecordedRequest { + const connectionId = header(headers, CONNECTION_HEADER); + const sessionId = header(headers, SESSION_HEADER); + const cookie = header(headers, "cookie"); + const accept = header(headers, "accept"); + const contentType = header(headers, "content-type"); + const scope = sessionId ? "session" : connectionId ? "connection" : "bootstrap"; + recorder.record({ + kind: "http_request", + method, + scope, + connectionId, + sessionId, + cookie, + jsonRpc: recorder.summarizeJsonRpc(body), + }); + return { method, scope, connectionId, sessionId, cookie, accept, contentType }; + } + + private recordResponse( + recorder: TranscriptRecorder, + status: number, + scope: RequestScope, + sessionId: string | null, + ): void { + recorder.record({ + kind: "http_response", + status, + scope, + connectionId: scope === "bootstrap" ? this.connectionId : this.connectionId, + sessionId, + }); + } + + private reject( + response: ServerResponse, + recorder: TranscriptRecorder, + scope: RequestScope, + sessionId: string | null, + status: number, + message: string, + id: unknown, + ): void { + sendJson(response, status, { + jsonrpc: "2.0", + id: typeof id === "string" || typeof id === "number" ? id : null, + error: { + code: -32600, + message, + }, + }); + this.recordResponse(recorder, status, scope, sessionId); + } +} + +class HappyPathScenario extends BaseScenario { + readonly name = "happy-path"; + private readonly sessionId = "sess-1"; + + protected handleScenarioRequest( + response: ServerResponse, + request: RecordedRequest, + body: JsonRpcMessage | null, + recorder: TranscriptRecorder, + ): boolean { + if (request.method === "POST" && request.scope === "connection" && body?.method === "session/new") { + this.sendSessionNewResponse(response, request, body, recorder, this.sessionId); + return true; + } + if ( + request.method === "POST" && + request.scope === "session" && + request.sessionId === this.sessionId && + body?.method === "session/prompt" + ) { + this.sendPromptFlow(response, request, body, recorder, this.sessionId); + return true; + } + return false; + } +} + +class PermissionRoundTripScenario extends BaseScenario { + readonly name = "permission-round-trip"; + private readonly sessionId = "sess-permission"; + private pendingPromptId: unknown; + + protected handleScenarioRequest( + response: ServerResponse, + request: RecordedRequest, + body: JsonRpcMessage | null, + recorder: TranscriptRecorder, + ): boolean { + if (request.method === "POST" && request.scope === "connection" && body?.method === "session/new") { + this.sendSessionNewResponse(response, request, body, recorder, this.sessionId); + return true; + } + if ( + request.method === "POST" && + request.scope === "session" && + request.sessionId === this.sessionId && + body?.method === "session/prompt" + ) { + sendJson(response, 202); + this.recordScenarioResponse(recorder, 202, request); + this.pendingPromptId = body.id; + this.writeSessionEvent( + this.sessionId, + { + jsonrpc: "2.0", + id: "perm-1", + method: "session/request_permission", + params: { + sessionId: this.sessionId, + toolCall: { + toolCallId: "tool-1", + title: "Write File", + kind: "edit", + status: "pending", + }, + options: [ + { optionId: "allow", name: "Allow", kind: "allow_once" }, + { optionId: "deny", name: "Deny", kind: "reject_once" }, + ], + }, + }, + recorder, + ); + return true; + } + if ( + request.method === "POST" && + request.scope === "session" && + request.sessionId === this.sessionId && + body?.id === "perm-1" && + "result" in body + ) { + sendJson(response, 202); + this.recordScenarioResponse(recorder, 202, request); + this.writeSessionEvent( + this.sessionId, + { + jsonrpc: "2.0", + id: this.pendingPromptId, + result: { + stopReason: "end_turn", + }, + }, + recorder, + ); + return true; + } + return false; + } + + private recordScenarioResponse(recorder: TranscriptRecorder, status: number, request: RecordedRequest): void { + recorder.record({ + kind: "http_response", + status, + scope: request.scope, + connectionId: this.connectionId, + sessionId: request.sessionId, + }); + } +} + +class SessionLoadScenario extends BaseScenario { + readonly name = "session-load"; + private readonly sessionId = "sess-load"; + + protected handleScenarioRequest( + response: ServerResponse, + request: RecordedRequest, + body: JsonRpcMessage | null, + recorder: TranscriptRecorder, + ): boolean { + if ( + request.method === "POST" && + request.scope === "session" && + request.sessionId === this.sessionId && + body?.method === "session/load" + ) { + sendJson(response, 202); + this.recordScenarioResponse(recorder, 202, request); + this.writeConnectionEvent( + { + jsonrpc: "2.0", + id: body.id, + result: {}, + }, + recorder, + ); + return true; + } + return false; + } + + private recordScenarioResponse(recorder: TranscriptRecorder, status: number, request: RecordedRequest): void { + recorder.record({ + kind: "http_response", + status, + scope: request.scope, + connectionId: this.connectionId, + sessionId: request.sessionId, + }); + } +} + +class TwoSessionsScenario extends BaseScenario { + readonly name = "two-sessions"; + private nextSessionNumber = 1; + + protected handleScenarioRequest( + response: ServerResponse, + request: RecordedRequest, + body: JsonRpcMessage | null, + recorder: TranscriptRecorder, + ): boolean { + if (request.method === "POST" && request.scope === "connection" && body?.method === "session/new") { + const sessionId = `sess-${this.nextSessionNumber++}`; + this.sendSessionNewResponse(response, request, body, recorder, sessionId); + return true; + } + if ( + request.method === "POST" && + request.scope === "session" && + request.sessionId && + body?.method === "session/prompt" + ) { + this.sendPromptFlow(response, request, body, recorder, request.sessionId); + return true; + } + return false; + } +} + +class WrongStreamResponseScenario extends BaseScenario { + readonly name = "wrong-stream-response"; + private readonly sessionId = "sess-wrong"; + + protected handleScenarioRequest( + response: ServerResponse, + request: RecordedRequest, + body: JsonRpcMessage | null, + recorder: TranscriptRecorder, + ): boolean { + if (request.method === "POST" && request.scope === "connection" && body?.method === "session/new") { + this.sendSessionNewResponse(response, request, body, recorder, this.sessionId); + return true; + } + if ( + request.method === "POST" && + request.scope === "session" && + request.sessionId === this.sessionId && + body?.method === "session/prompt" + ) { + sendJson(response, 202); + this.recordScenarioResponse(recorder, 202, request); + this.writeConnectionEvent( + { + jsonrpc: "2.0", + id: body.id, + result: { + stopReason: "end_turn", + }, + }, + recorder, + ); + return true; + } + return false; + } + + private recordScenarioResponse(recorder: TranscriptRecorder, status: number, request: RecordedRequest): void { + recorder.record({ + kind: "http_response", + status, + scope: request.scope, + connectionId: this.connectionId, + sessionId: request.sessionId, + }); + } +} + +class ValidationFailuresScenario extends BaseScenario { + readonly name = "validation-failures"; + + protected handleScenarioRequest( + _response: ServerResponse, + _request: RecordedRequest, + _body: JsonRpcMessage | null, + _recorder: TranscriptRecorder, + ): boolean { + return false; + } +} + +type RequestScope = "bootstrap" | "connection" | "session"; + +type RecordedRequest = { + method: string; + scope: RequestScope; + connectionId: string | null; + sessionId: string | null; + cookie: string | null; + accept: string | null; + contentType: string | null; +}; + +function header(headers: IncomingHttpHeaders, name: string): string | null { + const value = headers[name]; + if (Array.isArray(value)) { + return value[0] ?? null; + } + return typeof value === "string" ? value : null; +} diff --git a/test-fixtures/streamable-http-server/src/server.ts b/test-fixtures/streamable-http-server/src/server.ts new file mode 100644 index 0000000..57d9279 --- /dev/null +++ b/test-fixtures/streamable-http-server/src/server.ts @@ -0,0 +1,55 @@ +import { createServer, IncomingMessage } from "node:http"; +import { ACP_PATH, JsonRpcMessage } from "./protocol.js"; +import { createScenario } from "./scenarios.js"; +import { TranscriptRecorder } from "./transcript.js"; + +const scenarioName = readArg("--scenario") ?? "happy-path"; +const port = Number(readArg("--port") ?? "0"); +const recorder = new TranscriptRecorder(); +const scenario = createScenario(scenarioName); + +const server = createServer(); + +server.on("request", async (request, response) => { + const path = request.url ?? ""; + if (path === "/__test/transcript") { + response.writeHead(200, { + "content-type": "application/json", + }); + response.end(recorder.serialize()); + return; + } + + if (path !== ACP_PATH) { + response.writeHead(404); + response.end(); + return; + } + + const body = await readJsonBody(request); + scenario.handle(response, request.headers, request.method ?? "", body, recorder); +}); + +server.listen(port, () => { + const address = server.address(); + const actualPort = typeof address === "object" && address ? address.port : port; + process.stdout.write(JSON.stringify({ status: "ready", port: actualPort, scenario: scenario.name }) + "\n"); +}); + +function readArg(name: string): string | undefined { + const index = process.argv.indexOf(name); + return index >= 0 ? process.argv[index + 1] : undefined; +} + +async function readJsonBody(request: IncomingMessage): Promise { + const method = request.method ?? ""; + if (method === "GET" || method === "DELETE") { + return null; + } + + let raw = ""; + for await (const chunk of request) { + raw += chunk.toString("utf8"); + } + return raw ? (JSON.parse(raw) as JsonRpcMessage) : null; +} diff --git a/test-fixtures/streamable-http-server/src/transcript.ts b/test-fixtures/streamable-http-server/src/transcript.ts new file mode 100644 index 0000000..19f3440 --- /dev/null +++ b/test-fixtures/streamable-http-server/src/transcript.ts @@ -0,0 +1,101 @@ +export type JsonRpcSummary = + | { + type: "request"; + id: string | number | null; + method: string; + } + | { + type: "notification"; + method: string; + } + | { + type: "response"; + id: string | number | null; + hasError: boolean; + }; + +export type TranscriptEvent = + | { + kind: "http_request"; + method: string; + scope: "bootstrap" | "connection" | "session"; + connectionId: string | null; + sessionId: string | null; + cookie: string | null; + jsonRpc: JsonRpcSummary | null; + } + | { + kind: "http_response"; + status: number; + scope: "bootstrap" | "connection" | "session"; + connectionId: string | null; + sessionId: string | null; + } + | { + kind: "sse_event"; + stream: "connection" | "session"; + sessionId: string | null; + jsonRpc: JsonRpcSummary; + }; + +export class TranscriptRecorder { + private readonly events: TranscriptEvent[] = []; + private readonly idAliases = new Map(); + + record(event: TranscriptEvent): void { + this.events.push(event); + } + + summarizeJsonRpc(message: unknown): JsonRpcSummary | null { + if (!message || typeof message !== "object") { + return null; + } + + const candidate = message as Record; + if (typeof candidate.method === "string" && "id" in candidate) { + return { + type: "request", + id: this.normalizeId(candidate.id), + method: candidate.method, + }; + } + + if (typeof candidate.method === "string") { + return { + type: "notification", + method: candidate.method, + }; + } + + if ("result" in candidate || "error" in candidate) { + return { + type: "response", + id: this.normalizeId(candidate.id), + hasError: candidate.error != null, + }; + } + + return null; + } + + toJSON(): TranscriptEvent[] { + return [...this.events]; + } + + serialize(): string { + return JSON.stringify(this.events, null, 2); + } + + private normalizeId(id: unknown): string | null { + if (typeof id !== "string" && typeof id !== "number") { + return null; + } + const existing = this.idAliases.get(id); + if (existing) { + return existing; + } + const next = `id-${this.idAliases.size + 1}`; + this.idAliases.set(id, next); + return next; + } +} diff --git a/test-fixtures/streamable-http-server/tsconfig.json b/test-fixtures/streamable-http-server/tsconfig.json new file mode 100644 index 0000000..ce68458 --- /dev/null +++ b/test-fixtures/streamable-http-server/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "outDir": "dist", + "rootDir": "src", + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true + }, + "include": ["src/**/*.ts"] +} From e179831a16b0f1c79f0fbfcdbfb3e05bad38601f Mon Sep 17 00:00:00 2001 From: Kaiser Dandangi Date: Sun, 17 May 2026 23:12:41 -0400 Subject: [PATCH 02/15] docs: clarify streamable http routing state --- .../client/transport/StreamableHttpAcpClientTransport.java | 4 +++- .../com/agentclientprotocol/sdk/spec/AcpClientSession.java | 5 ++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/acp-core/src/main/java/com/agentclientprotocol/sdk/client/transport/StreamableHttpAcpClientTransport.java b/acp-core/src/main/java/com/agentclientprotocol/sdk/client/transport/StreamableHttpAcpClientTransport.java index 1679775..0cde983 100644 --- a/acp-core/src/main/java/com/agentclientprotocol/sdk/client/transport/StreamableHttpAcpClientTransport.java +++ b/acp-core/src/main/java/com/agentclientprotocol/sdk/client/transport/StreamableHttpAcpClientTransport.java @@ -48,7 +48,7 @@ * JSON-RPC messages. *

* - * @author Mark Pollack + * @author Kaiser Dandangi */ public class StreamableHttpAcpClientTransport implements AcpClientTransport { @@ -152,8 +152,10 @@ private record HttpClientBundle(HttpClient httpClient, ExecutorService ownedExec private final AtomicBoolean closing = new AtomicBoolean(false); + // Client-originated request id -> where the eventual SSE response is expected. private final Map outboundRequestRoutes = new ConcurrentHashMap<>(); + // Agent-originated request id -> HTTP scope required for the later client POST response. private final Map inboundRequestRoutes = new ConcurrentHashMap<>(); private final Map sessionStreams = new ConcurrentHashMap<>(); diff --git a/acp-core/src/main/java/com/agentclientprotocol/sdk/spec/AcpClientSession.java b/acp-core/src/main/java/com/agentclientprotocol/sdk/spec/AcpClientSession.java index 6c30c5d..ca14392 100644 --- a/acp-core/src/main/java/com/agentclientprotocol/sdk/spec/AcpClientSession.java +++ b/acp-core/src/main/java/com/agentclientprotocol/sdk/spec/AcpClientSession.java @@ -154,7 +154,10 @@ public AcpClientSession(Duration requestTimeout, AcpClientTransport transport, * Client transports currently retain a compatibility path that may forward any * message emitted by this handler back onto the wire. The session handles outbound * replies explicitly via transport.sendMessage(...), so the default session handler - * should consume inbound messages without re-emitting them. + * should consume inbound messages without re-emitting them. The transport-level + * handler type is Function, Mono>, so returning + * Mono.empty() is intentional here: the signature permits an emitted message, but + * the default client session has no message to return through that path. */ this.transport.connect(mono -> mono.doOnNext(this::handle).then(Mono.empty())).transform(connectHook).subscribe(); } From 08ef73963f2c4911e8f9e9ed3e835efad876c2b7 Mon Sep 17 00:00:00 2001 From: Kaiser Dandangi Date: Mon, 18 May 2026 11:39:21 -0400 Subject: [PATCH 03/15] fix: dedupe session stream opens --- .../StreamableHttpAcpClientTransport.java | 15 +++- .../StreamableHttpAcpClientTransportTest.java | 85 +++++++++++++++++++ 2 files changed, 96 insertions(+), 4 deletions(-) diff --git a/acp-core/src/main/java/com/agentclientprotocol/sdk/client/transport/StreamableHttpAcpClientTransport.java b/acp-core/src/main/java/com/agentclientprotocol/sdk/client/transport/StreamableHttpAcpClientTransport.java index 0cde983..7913a87 100644 --- a/acp-core/src/main/java/com/agentclientprotocol/sdk/client/transport/StreamableHttpAcpClientTransport.java +++ b/acp-core/src/main/java/com/agentclientprotocol/sdk/client/transport/StreamableHttpAcpClientTransport.java @@ -160,6 +160,9 @@ private record HttpClientBundle(HttpClient httpClient, ExecutorService ownedExec private final Map sessionStreams = new ConcurrentHashMap<>(); + // Session id -> shared open operation so callers reuse one GET while the stream lives. + private final Map> sessionStreamOpenOperations = new ConcurrentHashMap<>(); + private volatile SseStream connectionStream; private volatile String connectionId; @@ -399,12 +402,15 @@ private Mono openConnectionStream() { } private Mono openSessionStream(String sessionId) { - if (sessionStreams.containsKey(sessionId)) { - return Mono.empty(); - } + return sessionStreamOpenOperations.computeIfAbsent(sessionId, this::createSessionStreamOpenMono); + } + + private Mono createSessionStreamOpenMono(String sessionId) { return openSseStream(RouteScope.session(sessionId)) .doOnSuccess(stream -> sessionStreams.putIfAbsent(sessionId, stream)) - .then(); + .then() + .doOnError(error -> sessionStreamOpenOperations.remove(sessionId)) + .cache(); } private Mono openSseStream(RouteScope scope) { @@ -612,6 +618,7 @@ public Mono closeGracefully() { private void clearState() { connectionStream = null; sessionStreams.clear(); + sessionStreamOpenOperations.clear(); inboundRequestRoutes.clear(); outboundRequestRoutes.clear(); connectionId = null; diff --git a/acp-core/src/test/java/com/agentclientprotocol/sdk/client/transport/StreamableHttpAcpClientTransportTest.java b/acp-core/src/test/java/com/agentclientprotocol/sdk/client/transport/StreamableHttpAcpClientTransportTest.java index 33d75a1..5f9bec3 100644 --- a/acp-core/src/test/java/com/agentclientprotocol/sdk/client/transport/StreamableHttpAcpClientTransportTest.java +++ b/acp-core/src/test/java/com/agentclientprotocol/sdk/client/transport/StreamableHttpAcpClientTransportTest.java @@ -4,10 +4,22 @@ package com.agentclientprotocol.sdk.client.transport; +import java.io.ByteArrayInputStream; +import java.io.InputStream; import java.net.URI; import java.net.http.HttpClient; +import java.net.http.HttpHeaders; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.util.List; import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.stream.Collectors; +import com.agentclientprotocol.sdk.AcpTestFixtures; import com.agentclientprotocol.sdk.json.AcpJsonMapper; import com.agentclientprotocol.sdk.spec.AcpSchema; import org.junit.jupiter.api.BeforeEach; @@ -16,7 +28,9 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; /** * Unit tests for {@link StreamableHttpAcpClientTransport}. @@ -91,4 +105,75 @@ void strictRoutingRejectsUnknownOutboundMethods() { .hasMessageContaining("No explicit routing rule for outbound method extension/custom"); } + @Test + void concurrentSessionLoadsReuseInFlightSessionStreamOpen() throws Exception { + HttpClient httpClient = mock(HttpClient.class); + AtomicInteger sessionGetCount = new AtomicInteger(); + CountDownLatch sessionGetStarted = new CountDownLatch(1); + CompletableFuture> sessionStreamResponse = new CompletableFuture<>(); + + when(httpClient.sendAsync(any(), any())).thenAnswer(invocation -> { + HttpRequest request = invocation.getArgument(0); + if ("POST".equals(request.method()) + && request.headers().firstValue("Acp-Connection-Id").isEmpty()) { + String initializeResponse = jsonMapper.writeValueAsString(AcpTestFixtures + .createJsonRpcResponse("init-1", AcpTestFixtures.createInitializeResponse())); + return CompletableFuture.completedFuture(response(200, + Map.of("Content-Type", "application/json", "Acp-Connection-Id", "conn-1"), + initializeResponse)); + } + if ("GET".equals(request.method()) + && request.headers().firstValue("Acp-Session-Id").isEmpty()) { + return CompletableFuture.completedFuture( + response(200, Map.of("Content-Type", "text/event-stream"), emptyBody())); + } + if ("GET".equals(request.method())) { + sessionGetCount.incrementAndGet(); + sessionGetStarted.countDown(); + return sessionStreamResponse; + } + if ("POST".equals(request.method())) { + return CompletableFuture.completedFuture(response(202, Map.of(), null)); + } + return CompletableFuture.completedFuture(response(202, Map.of(), null)); + }); + + StreamableHttpAcpClientTransport transport = new StreamableHttpAcpClientTransport( + URI.create("https://localhost:8443/acp"), jsonMapper, httpClient); + transport.setExceptionHandler(error -> { + }); + transport.connect(message -> Mono.empty()).block(); + transport.sendMessage(AcpTestFixtures.createJsonRpcRequest(AcpSchema.METHOD_INITIALIZE, "init-1", + AcpTestFixtures.createInitializeRequest())) + .block(); + + CompletableFuture loads = Mono.when( + transport.sendMessage(AcpTestFixtures.createJsonRpcRequest(AcpSchema.METHOD_SESSION_LOAD, "load-1", + new AcpSchema.LoadSessionRequest("sess-1", "/workspace", List.of()))), + transport.sendMessage(AcpTestFixtures.createJsonRpcRequest(AcpSchema.METHOD_SESSION_LOAD, "load-2", + new AcpSchema.LoadSessionRequest("sess-1", "/workspace", List.of())))) + .toFuture(); + + assertThat(sessionGetStarted.await(1, TimeUnit.SECONDS)).isTrue(); + assertThat(sessionGetCount).hasValue(1); + + sessionStreamResponse.complete(response(200, Map.of("Content-Type", "text/event-stream"), emptyBody())); + loads.get(1, TimeUnit.SECONDS); + } + + private InputStream emptyBody() { + return new ByteArrayInputStream(new byte[0]); + } + + private HttpResponse response(int statusCode, Map headers, T body) { + HttpResponse response = mock(HttpResponse.class); + when(response.statusCode()).thenReturn(statusCode); + when(response.headers()).thenReturn(HttpHeaders.of(headers.entrySet() + .stream() + .collect(Collectors.toMap(Map.Entry::getKey, entry -> List.of(entry.getValue()))), + (name, value) -> true)); + when(response.body()).thenReturn(body); + return response; + } + } From 23858f1acb6d5f4d58ada0deacade7c96d71ec82 Mon Sep 17 00:00:00 2001 From: Kaiser Dandangi Date: Mon, 18 May 2026 16:08:41 -0400 Subject: [PATCH 04/15] feat: add streamable HTTP agent transport --- .gitignore | 2 + README.md | 12 +- .../sdk/agent/AcpAgentFactory.java | 60 ++ .../sdk/agent/AcpAgentFactoryTest.java | 41 + acp-streamable-http-jetty/pom.xml | 60 ++ .../StreamableHttpAcpAgentTransport.java | 988 ++++++++++++++++++ ...eHttpAcpAgentTransportIntegrationTest.java | 222 ++++ .../src/test/resources/logback-test.xml | 11 + plans/STREAMABLE-HTTP-AGENT-TRANSPORT.md | 119 +++ pom.xml | 16 + .../streamable-http-client/README.md | 27 + .../streamable-http-client/dist/client.js | 16 + .../streamable-http-client/dist/protocol.js | 245 +++++ .../streamable-http-client/dist/scenarios.js | 127 +++ .../streamable-http-client/dist/transcript.js | 69 ++ .../golden/happy-path.json | 127 +++ .../golden/permission-round-trip.json | 154 +++ .../golden/session-load.json | 92 ++ .../golden/two-sessions.json | 203 ++++ .../golden/validation-failures.json | 79 ++ .../golden/wrong-stream-response.json | 137 +++ .../streamable-http-client/package-lock.json | 45 + .../streamable-http-client/package.json | 12 + .../streamable-http-client/src/client.ts | 19 + .../streamable-http-client/src/protocol.ts | 278 +++++ .../streamable-http-client/src/scenarios.ts | 134 +++ .../streamable-http-client/src/transcript.ts | 115 ++ .../streamable-http-client/tsconfig.json | 12 + 28 files changed, 3421 insertions(+), 1 deletion(-) create mode 100644 acp-core/src/main/java/com/agentclientprotocol/sdk/agent/AcpAgentFactory.java create mode 100644 acp-core/src/test/java/com/agentclientprotocol/sdk/agent/AcpAgentFactoryTest.java create mode 100644 acp-streamable-http-jetty/pom.xml create mode 100644 acp-streamable-http-jetty/src/main/java/com/agentclientprotocol/sdk/agent/transport/StreamableHttpAcpAgentTransport.java create mode 100644 acp-streamable-http-jetty/src/test/java/com/agentclientprotocol/sdk/agent/transport/StreamableHttpAcpAgentTransportIntegrationTest.java create mode 100644 acp-streamable-http-jetty/src/test/resources/logback-test.xml create mode 100644 plans/STREAMABLE-HTTP-AGENT-TRANSPORT.md create mode 100644 test-fixtures/streamable-http-client/README.md create mode 100644 test-fixtures/streamable-http-client/dist/client.js create mode 100644 test-fixtures/streamable-http-client/dist/protocol.js create mode 100644 test-fixtures/streamable-http-client/dist/scenarios.js create mode 100644 test-fixtures/streamable-http-client/dist/transcript.js create mode 100644 test-fixtures/streamable-http-client/golden/happy-path.json create mode 100644 test-fixtures/streamable-http-client/golden/permission-round-trip.json create mode 100644 test-fixtures/streamable-http-client/golden/session-load.json create mode 100644 test-fixtures/streamable-http-client/golden/two-sessions.json create mode 100644 test-fixtures/streamable-http-client/golden/validation-failures.json create mode 100644 test-fixtures/streamable-http-client/golden/wrong-stream-response.json create mode 100644 test-fixtures/streamable-http-client/package-lock.json create mode 100644 test-fixtures/streamable-http-client/package.json create mode 100644 test-fixtures/streamable-http-client/src/client.ts create mode 100644 test-fixtures/streamable-http-client/src/protocol.ts create mode 100644 test-fixtures/streamable-http-client/src/scenarios.ts create mode 100644 test-fixtures/streamable-http-client/src/transcript.ts create mode 100644 test-fixtures/streamable-http-client/tsconfig.json diff --git a/.gitignore b/.gitignore index a7ae7a1..7aa4b83 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ ### Maven/Gradle Builds ### target/ +.m2repo/ !.mvn/wrapper/maven-wrapper.jar !**/src/main/**/target/ !**/src/test/**/target/ @@ -71,4 +72,5 @@ replay_pid* ### Planning and Internal Documentation ### plans/* !plans/STREAMABLE-HTTP-TRANSPORT.md +!plans/STREAMABLE-HTTP-AGENT-TRANSPORT.md learnings/ diff --git a/README.md b/README.md index 4d51db7..0d75e92 100644 --- a/README.md +++ b/README.md @@ -52,6 +52,15 @@ For WebSocket server support (agents accepting WebSocket connections): ``` +For Streamable HTTP server support (agents accepting remote HTTP/SSE connections): +```xml + + com.agentclientprotocol + acp-streamable-http-jetty + 0.11.0 + +``` + --- ## Getting Started @@ -369,6 +378,7 @@ agent.start().block(); // Starts WebSocket server on port 8080 | Artifact | Description | |----------|-------------| | [`acp-core`](https://central.sonatype.com/artifact/com.agentclientprotocol/acp-core) | Client and Agent SDKs, stdio, WebSocket, and Streamable HTTP client transports | +| `acp-streamable-http-jetty` | Jetty-backed Streamable HTTP agent transport for listener-backed remote agents | | [`acp-annotations`](https://central.sonatype.com/artifact/com.agentclientprotocol/acp-annotations) | `@AcpAgent`, `@Prompt`, and other annotations | | [`acp-agent-support`](https://central.sonatype.com/artifact/com.agentclientprotocol/acp-agent-support) | Annotation-based agent runtime | | [`acp-test`](https://central.sonatype.com/artifact/com.agentclientprotocol/acp-test) | In-memory transport and mock utilities for testing | @@ -380,7 +390,7 @@ agent.start().block(); // Starts WebSocket server on port 8080 |-----------|--------|-------|--------| | Stdio | `StdioAcpClientTransport` | `StdioAcpAgentTransport` | acp-core | | WebSocket | `WebSocketAcpClientTransport` | `WebSocketAcpAgentTransport` | acp-core / acp-websocket-jetty | -| Streamable HTTP | `StreamableHttpAcpClientTransport` | — | acp-core | +| Streamable HTTP | `StreamableHttpAcpClientTransport` | `StreamableHttpAcpAgentTransport` | acp-core / acp-streamable-http-jetty | --- diff --git a/acp-core/src/main/java/com/agentclientprotocol/sdk/agent/AcpAgentFactory.java b/acp-core/src/main/java/com/agentclientprotocol/sdk/agent/AcpAgentFactory.java new file mode 100644 index 0000000..ec05b06 --- /dev/null +++ b/acp-core/src/main/java/com/agentclientprotocol/sdk/agent/AcpAgentFactory.java @@ -0,0 +1,60 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.agent; + +import java.util.function.Function; + +import com.agentclientprotocol.sdk.spec.AcpAgentTransport; +import com.agentclientprotocol.sdk.util.Assert; + +/** + * Factory for creating one ACP agent runtime for one agent-side transport. + * + *

+ * Listener-backed transports such as remote HTTP transports accept multiple client + * connections over their lifetime. Each accepted connection needs its own + * connection-bound agent runtime while reusing the same agent definition. This factory + * is the explicit public seam for that relationship. + *

+ * + * @author Kaiser Dandangi + */ +@FunctionalInterface +public interface AcpAgentFactory { + + /** + * Creates a new asynchronous agent runtime for the supplied transport. + * @param transport per-connection transport + * @return a fresh asynchronous agent runtime + */ + AcpAsyncAgent create(AcpAgentTransport transport); + + /** + * Creates a factory from an asynchronous agent builder function. + * @param factory function that creates a fresh asynchronous agent per transport + * @return an agent factory + */ + static AcpAgentFactory async(Function factory) { + Assert.notNull(factory, "The async factory can not be null"); + return factory::apply; + } + + /** + * Creates a factory from a synchronous agent builder function. + * + *

+ * Synchronous agents are wrappers around asynchronous agents in this SDK, so the + * transport seam remains asynchronous underneath while callers may still author + * agents with the blocking API. + *

+ * @param factory function that creates a fresh synchronous agent per transport + * @return an agent factory + */ + static AcpAgentFactory sync(Function factory) { + Assert.notNull(factory, "The sync factory can not be null"); + return transport -> factory.apply(transport).async(); + } + +} diff --git a/acp-core/src/test/java/com/agentclientprotocol/sdk/agent/AcpAgentFactoryTest.java b/acp-core/src/test/java/com/agentclientprotocol/sdk/agent/AcpAgentFactoryTest.java new file mode 100644 index 0000000..fb91fc2 --- /dev/null +++ b/acp-core/src/test/java/com/agentclientprotocol/sdk/agent/AcpAgentFactoryTest.java @@ -0,0 +1,41 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.agent; + +import com.agentclientprotocol.sdk.spec.AcpSchema; +import com.agentclientprotocol.sdk.test.InMemoryTransportPair; +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Mono; + +import static org.assertj.core.api.Assertions.assertThat; + +class AcpAgentFactoryTest { + + @Test + void asyncFactoryReturnsFreshAgentRuntime() { + AcpAgentFactory factory = AcpAgentFactory.async(transport -> AcpAgent.async(transport) + .initializeHandler(request -> Mono.just(AcpSchema.InitializeResponse.ok())) + .newSessionHandler(request -> Mono.just(new AcpSchema.NewSessionResponse("session", null, null))) + .build()); + + AcpAsyncAgent first = factory.create(InMemoryTransportPair.create().agentTransport()); + AcpAsyncAgent second = factory.create(InMemoryTransportPair.create().agentTransport()); + + assertThat(first).isNotSameAs(second); + } + + @Test + void syncFactoryAdaptsToAsyncRuntime() { + AcpAgentFactory factory = AcpAgentFactory.sync(transport -> AcpAgent.sync(transport) + .initializeHandler(request -> AcpSchema.InitializeResponse.ok()) + .newSessionHandler(request -> new AcpSchema.NewSessionResponse("session", null, null)) + .build()); + + AcpAsyncAgent agent = factory.create(InMemoryTransportPair.create().agentTransport()); + + assertThat(agent).isNotNull(); + } + +} diff --git a/acp-streamable-http-jetty/pom.xml b/acp-streamable-http-jetty/pom.xml new file mode 100644 index 0000000..defe354 --- /dev/null +++ b/acp-streamable-http-jetty/pom.xml @@ -0,0 +1,60 @@ + + + 4.0.0 + + + com.agentclientprotocol + acp-java-sdk + 0.12.0-SNAPSHOT + + + acp-streamable-http-jetty + jar + + ACP Streamable HTTP Jetty + Streamable HTTP agent transport using Jetty for listener-backed remote agents + + + + com.agentclientprotocol + acp-core + + + + org.eclipse.jetty + jetty-server + + + org.eclipse.jetty.ee10 + jetty-ee10-servlet + + + org.eclipse.jetty.http2 + jetty-http2-server + + + + org.junit.jupiter + junit-jupiter + test + + + org.assertj + assertj-core + test + + + ch.qos.logback + logback-classic + test + + + io.projectreactor + reactor-test + test + + + + diff --git a/acp-streamable-http-jetty/src/main/java/com/agentclientprotocol/sdk/agent/transport/StreamableHttpAcpAgentTransport.java b/acp-streamable-http-jetty/src/main/java/com/agentclientprotocol/sdk/agent/transport/StreamableHttpAcpAgentTransport.java new file mode 100644 index 0000000..911b3a9 --- /dev/null +++ b/acp-streamable-http-jetty/src/main/java/com/agentclientprotocol/sdk/agent/transport/StreamableHttpAcpAgentTransport.java @@ -0,0 +1,988 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.agent.transport; + +import java.io.IOException; +import java.io.PrintWriter; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.Consumer; +import java.util.function.Function; + +import com.agentclientprotocol.sdk.agent.AcpAgentFactory; +import com.agentclientprotocol.sdk.agent.AcpAsyncAgent; +import com.agentclientprotocol.sdk.error.AcpConnectionException; +import com.agentclientprotocol.sdk.json.AcpJsonMapper; +import com.agentclientprotocol.sdk.json.TypeRef; +import com.agentclientprotocol.sdk.spec.AcpAgentTransport; +import com.agentclientprotocol.sdk.spec.AcpSchema; +import com.agentclientprotocol.sdk.spec.AcpSchema.JSONRPCMessage; +import com.agentclientprotocol.sdk.util.Assert; +import jakarta.servlet.AsyncContext; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServlet; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.eclipse.jetty.ee10.servlet.ServletContextHandler; +import org.eclipse.jetty.ee10.servlet.ServletHolder; +import org.eclipse.jetty.http2.server.HTTP2CServerConnectionFactory; +import org.eclipse.jetty.server.HttpConfiguration; +import org.eclipse.jetty.server.HttpConnectionFactory; +import org.eclipse.jetty.server.Server; +import org.eclipse.jetty.server.ServerConnector; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import reactor.core.publisher.Mono; +import reactor.core.publisher.Sinks; + +/** + * Listener-backed ACP Streamable HTTP transport for agents. + * + *

+ * This transport hosts a Jetty HTTP endpoint and creates one fresh agent runtime per + * remote ACP connection through {@link AcpAgentFactory}. The accepted connection then + * owns its own per-connection {@link AcpAgentTransport}, while the listener remains + * responsible only for HTTP concerns such as headers, SSE streams, and request routing. + *

+ * + *

+ * The current implementation is intentionally HTTP-only. The shared remote transport + * core that should eventually also back WebSocket remains a follow-up migration step so + * the existing WebSocket behavior can be preserved until parity is proven here first. + *

+ * + * @author Kaiser Dandangi + */ +public class StreamableHttpAcpAgentTransport { + + private static final Logger logger = LoggerFactory.getLogger(StreamableHttpAcpAgentTransport.class); + + public static final String DEFAULT_ACP_PATH = "/acp"; + + private static final String HEADER_CONNECTION_ID = "Acp-Connection-Id"; + + private static final String HEADER_SESSION_ID = "Acp-Session-Id"; + + private static final String CONTENT_TYPE_JSON = "application/json"; + + private static final String CONTENT_TYPE_EVENT_STREAM = "text/event-stream"; + + private static final int MAX_REPLAY_EVENTS = 1024; + + private static final Duration INITIALIZE_TIMEOUT = Duration.ofSeconds(30); + + /** + * Controls whether unknown message methods may fall back to shape-based routing. + */ + public enum RoutingMode { + + /** + * Prefer explicit ACP routing and fall back to session-id shape inference for + * extension methods. Also permits provisional session streams before + * {@code session/load} so the currently ambiguous resume flow can work. + */ + COMPATIBLE, + + /** + * Require explicit routing rules and reject unknown session streams. + */ + STRICT + + } + + private enum ScopeKind { + + CONNECTION, + + SESSION + + } + + private enum RequestKind { + + INITIALIZE, + + SESSION_NEW, + + SESSION_LOAD, + + GENERIC + + } + + private enum SessionState { + + PENDING_LOAD, + + KNOWN + + } + + private record RouteScope(ScopeKind kind, String sessionId) { + + static RouteScope connection() { + return new RouteScope(ScopeKind.CONNECTION, null); + } + + static RouteScope session(String sessionId) { + return new RouteScope(ScopeKind.SESSION, sessionId); + } + + boolean isSession() { + return kind == ScopeKind.SESSION; + } + + } + + private record ClientRequestRoute(RequestKind kind, RouteScope requestScope, RouteScope responseScope) { + } + + private record ResolvedInboundRoute(JSONRPCMessage message, RouteScope requestScope, + ClientRequestRoute requestRoute) { + } + + private final int configuredPort; + + private final String path; + + private final AcpJsonMapper jsonMapper; + + private final AcpAgentFactory agentFactory; + + private final ConcurrentMap connections = new ConcurrentHashMap<>(); + + private final AtomicBoolean started = new AtomicBoolean(false); + + private final AtomicBoolean closing = new AtomicBoolean(false); + + private final Sinks.One terminationSink = Sinks.one(); + + private volatile RoutingMode routingMode = RoutingMode.COMPATIBLE; + + private volatile Server server; + + private volatile ServerConnector connector; + + /** + * Creates a new Streamable HTTP listener on the default ACP path. + * @param port port to listen on + * @param jsonMapper JSON mapper used for serialization + * @param agentFactory factory used to create one agent runtime per connection + */ + public StreamableHttpAcpAgentTransport(int port, AcpJsonMapper jsonMapper, AcpAgentFactory agentFactory) { + this(port, DEFAULT_ACP_PATH, jsonMapper, agentFactory); + } + + /** + * Creates a new Streamable HTTP listener. + * @param port port to listen on + * @param path endpoint path + * @param jsonMapper JSON mapper used for serialization + * @param agentFactory factory used to create one agent runtime per connection + */ + public StreamableHttpAcpAgentTransport(int port, String path, AcpJsonMapper jsonMapper, + AcpAgentFactory agentFactory) { + Assert.isTrue(port > 0, "Port must be positive"); + Assert.hasText(path, "Path must not be empty"); + Assert.notNull(jsonMapper, "The JsonMapper can not be null"); + Assert.notNull(agentFactory, "The agentFactory can not be null"); + this.configuredPort = port; + this.path = path; + this.jsonMapper = jsonMapper; + this.agentFactory = agentFactory; + } + + /** + * Sets the routing mode used by the listener. + * @param routingMode routing mode to use + * @return this transport + */ + public StreamableHttpAcpAgentTransport routingMode(RoutingMode routingMode) { + Assert.notNull(routingMode, "The routingMode can not be null"); + this.routingMode = routingMode; + return this; + } + + /** + * Starts the embedded Jetty server. + * @return a mono that completes when the listener is ready + */ + public Mono start() { + if (!started.compareAndSet(false, true)) { + return Mono.error(new IllegalStateException("Already started")); + } + + return Mono.fromCallable(() -> { + Server jettyServer = new Server(); + HttpConfiguration httpConfig = new HttpConfiguration(); + ServerConnector jettyConnector = new ServerConnector(jettyServer, + new HttpConnectionFactory(httpConfig), new HTTP2CServerConnectionFactory(httpConfig)); + jettyConnector.setPort(configuredPort); + jettyServer.addConnector(jettyConnector); + + ServletContextHandler context = new ServletContextHandler(); + context.setContextPath("/"); + context.addServlet(new ServletHolder(new AcpServlet()), path); + jettyServer.setHandler(context); + + jettyServer.start(); + this.server = jettyServer; + this.connector = jettyConnector; + logger.info("Streamable HTTP agent listener started on port {} at path {}", getPort(), path); + return null; + }).then(); + } + + /** + * Returns the bound port. + * @return listener port + */ + public int getPort() { + ServerConnector currentConnector = this.connector; + return currentConnector != null ? currentConnector.getLocalPort() : configuredPort; + } + + /** + * Closes all active connections and stops the listener. + * @return a mono that completes when shutdown finishes + */ + public Mono closeGracefully() { + return Mono.fromRunnable(() -> { + if (!closing.compareAndSet(false, true)) { + return; + } + connections.values().forEach(ConnectionState::close); + connections.clear(); + Server currentServer = this.server; + if (currentServer != null) { + try { + currentServer.stop(); + } + catch (Exception e) { + throw new AcpConnectionException("Failed to stop Streamable HTTP listener", e); + } + } + terminationSink.tryEmitValue(null); + }); + } + + /** + * Returns a mono that completes once the listener terminates. + * @return termination mono + */ + public Mono awaitTermination() { + return terminationSink.asMono(); + } + + private ConnectionState createConnection() { + String connectionId = UUID.randomUUID().toString(); + ConnectionState connection = new ConnectionState(connectionId); + connection.start(); + return connection; + } + + private Optional connection(String connectionId) { + return Optional.ofNullable(connections.get(connectionId)); + } + + private final class AcpServlet extends HttpServlet { + + @Override + protected void doPost(HttpServletRequest request, HttpServletResponse response) + throws ServletException, IOException { + if (!hasContentType(request, CONTENT_TYPE_JSON)) { + writeText(response, HttpServletResponse.SC_UNSUPPORTED_MEDIA_TYPE, + "Content-Type must be application/json"); + return; + } + + String body = new String(request.getInputStream().readAllBytes(), StandardCharsets.UTF_8); + if (body.stripLeading().startsWith("[")) { + writeText(response, HttpServletResponse.SC_NOT_IMPLEMENTED, "JSON-RPC batches are not supported"); + return; + } + + JSONRPCMessage message; + try { + message = AcpSchema.deserializeJsonRpcMessage(jsonMapper, body); + } + catch (Exception e) { + writeText(response, HttpServletResponse.SC_BAD_REQUEST, "Invalid JSON-RPC"); + return; + } + + if (isInitialize(message)) { + handleInitialize(request, response, (AcpSchema.JSONRPCRequest) message); + return; + } + + String connectionId = header(request, HEADER_CONNECTION_ID).orElse(null); + if (connectionId == null) { + writeText(response, HttpServletResponse.SC_BAD_REQUEST, HEADER_CONNECTION_ID + " header required"); + return; + } + ConnectionState connection = connections.get(connectionId); + if (connection == null) { + response.setStatus(HttpServletResponse.SC_NOT_FOUND); + return; + } + + try { + connection.acceptClientPost(message, header(request, HEADER_SESSION_ID).orElse(null)); + response.setStatus(HttpServletResponse.SC_ACCEPTED); + } + catch (UnknownSessionException e) { + writeText(response, HttpServletResponse.SC_NOT_FOUND, e.getMessage()); + } + catch (AcpConnectionException | IllegalArgumentException e) { + writeText(response, HttpServletResponse.SC_BAD_REQUEST, e.getMessage()); + } + } + + @Override + protected void doGet(HttpServletRequest request, HttpServletResponse response) + throws ServletException, IOException { + if (!accepts(request, CONTENT_TYPE_EVENT_STREAM)) { + writeText(response, HttpServletResponse.SC_NOT_ACCEPTABLE, "client must accept text/event-stream"); + return; + } + + String connectionId = header(request, HEADER_CONNECTION_ID).orElse(null); + if (connectionId == null) { + writeText(response, HttpServletResponse.SC_BAD_REQUEST, HEADER_CONNECTION_ID + " header required"); + return; + } + ConnectionState connection = connections.get(connectionId); + if (connection == null) { + response.setStatus(HttpServletResponse.SC_NOT_FOUND); + return; + } + + try { + connection.openStream(request, response, header(request, HEADER_SESSION_ID).orElse(null)); + } + catch (UnknownSessionException e) { + writeText(response, HttpServletResponse.SC_NOT_FOUND, e.getMessage()); + } + } + + @Override + protected void doDelete(HttpServletRequest request, HttpServletResponse response) + throws ServletException, IOException { + String connectionId = header(request, HEADER_CONNECTION_ID).orElse(null); + if (connectionId == null) { + writeText(response, HttpServletResponse.SC_BAD_REQUEST, HEADER_CONNECTION_ID + " header required"); + return; + } + ConnectionState connection = connections.remove(connectionId); + if (connection == null) { + response.setStatus(HttpServletResponse.SC_NOT_FOUND); + return; + } + connection.close(); + response.setStatus(HttpServletResponse.SC_ACCEPTED); + } + + private void handleInitialize(HttpServletRequest request, HttpServletResponse response, + AcpSchema.JSONRPCRequest initializeRequest) throws IOException { + if (header(request, HEADER_CONNECTION_ID).isPresent()) { + writeText(response, HttpServletResponse.SC_BAD_REQUEST, + "initialize must not include " + HEADER_CONNECTION_ID); + return; + } + + ConnectionState connection = createConnection(); + try { + JSONRPCMessage initializeResponse = connection.initialize(initializeRequest) + .block(INITIALIZE_TIMEOUT); + if (!(initializeResponse instanceof AcpSchema.JSONRPCResponse)) { + throw new AcpConnectionException("initialize did not produce a JSON-RPC response"); + } + connections.put(connection.id(), connection); + response.setStatus(HttpServletResponse.SC_OK); + response.setContentType(CONTENT_TYPE_JSON); + response.setHeader(HEADER_CONNECTION_ID, connection.id()); + response.getWriter().write(jsonMapper.writeValueAsString(initializeResponse)); + } + catch (Exception e) { + connection.close(); + writeText(response, HttpServletResponse.SC_INTERNAL_SERVER_ERROR, "initialize failed"); + } + } + + } + + private boolean isInitialize(JSONRPCMessage message) { + return message instanceof AcpSchema.JSONRPCRequest request + && AcpSchema.METHOD_INITIALIZE.equals(request.method()); + } + + private boolean hasContentType(HttpServletRequest request, String expected) { + return Optional.ofNullable(request.getContentType()) + .map(String::toLowerCase) + .filter(contentType -> contentType.contains(expected)) + .isPresent(); + } + + private boolean accepts(HttpServletRequest request, String expected) { + return Optional.ofNullable(request.getHeader("Accept")) + .map(String::toLowerCase) + .filter(accept -> accept.contains(expected)) + .isPresent(); + } + + private Optional header(HttpServletRequest request, String name) { + return Optional.ofNullable(request.getHeader(name)).filter(value -> !value.isBlank()); + } + + private void writeText(HttpServletResponse response, int status, String body) throws IOException { + response.setStatus(status); + response.setContentType("text/plain"); + response.getWriter().write(body); + } + + private final class ConnectionState { + + private final String id; + + private final ConnectionTransport transport; + + private final OutboundStream connectionStream = new OutboundStream(); + + private final ConcurrentMap sessionStreams = new ConcurrentHashMap<>(); + + private final ConcurrentMap sessions = new ConcurrentHashMap<>(); + + // Client-originated request id -> route expected for the later agent response. + private final ConcurrentMap clientRequestRoutes = new ConcurrentHashMap<>(); + + // Agent-originated request id -> route required for the later client response. + private final ConcurrentMap agentRequestRoutes = new ConcurrentHashMap<>(); + + private final Sinks.One initializeResponse = Sinks.one(); + + private final AtomicBoolean initialized = new AtomicBoolean(false); + + private volatile Object initializeRequestId; + + private volatile AcpAsyncAgent agent; + + ConnectionState(String id) { + this.id = id; + this.transport = new ConnectionTransport(this::routeAgentMessage); + } + + String id() { + return id; + } + + void start() { + this.agent = agentFactory.create(transport); + this.agent.start().block(INITIALIZE_TIMEOUT); + } + + Mono initialize(AcpSchema.JSONRPCRequest request) { + this.initializeRequestId = request.id(); + transport.acceptInbound(request); + return initializeResponse.asMono().doOnSuccess(ignored -> initialized.set(true)); + } + + void acceptClientPost(JSONRPCMessage message, String sessionHeader) { + if (message instanceof AcpSchema.JSONRPCResponse response) { + validateClientResponseScope(response, sessionHeader); + transport.acceptInbound(message); + return; + } + + ResolvedInboundRoute resolved = resolveInboundRoute(message, sessionHeader); + if (resolved.requestScope().isSession()) { + prepareSessionForInbound(resolved.requestScope().sessionId(), resolved.requestRoute()); + } + if (message instanceof AcpSchema.JSONRPCRequest request && request.id() != null + && resolved.requestRoute() != null) { + clientRequestRoutes.put(request.id(), resolved.requestRoute()); + } + transport.acceptInbound(message); + } + + void openStream(HttpServletRequest request, HttpServletResponse response, String sessionId) + throws IOException { + RouteScope scope = sessionId == null ? RouteScope.connection() : RouteScope.session(sessionId); + OutboundStream stream; + if (scope.isSession()) { + stream = openSessionStream(scope.sessionId()); + } + else { + stream = connectionStream; + } + + response.setStatus(HttpServletResponse.SC_OK); + response.setContentType(CONTENT_TYPE_EVENT_STREAM); + response.setHeader("Cache-Control", "no-cache"); + response.setHeader(HEADER_CONNECTION_ID, id); + if (scope.isSession()) { + response.setHeader(HEADER_SESSION_ID, scope.sessionId()); + } + AsyncContext asyncContext = request.startAsync(); + asyncContext.setTimeout(0); + stream.subscribe(asyncContext, response); + } + + void close() { + connectionStream.close(); + sessionStreams.values().forEach(OutboundStream::close); + transport.closeGracefully().subscribe(); + AcpAsyncAgent currentAgent = this.agent; + if (currentAgent != null) { + currentAgent.closeGracefully().subscribe(); + } + } + + private void routeAgentMessage(JSONRPCMessage message) { + try { + if (message instanceof AcpSchema.JSONRPCResponse response + && Objects.equals(response.id(), initializeRequestId) && !initialized.get()) { + initializeResponse.tryEmitValue(message); + return; + } + + RouteScope scope = resolveAgentOutboundScope(message); + String payload = jsonMapper.writeValueAsString(message); + if (scope.isSession()) { + sessionStream(scope.sessionId()).push(payload); + } + else { + connectionStream.push(payload); + } + } + catch (Exception e) { + transport.signalException(e); + } + } + + private RouteScope resolveAgentOutboundScope(JSONRPCMessage message) { + if (message instanceof AcpSchema.JSONRPCResponse response) { + ClientRequestRoute route = clientRequestRoutes.remove(response.id()); + if (route == null) { + logger.warn("Agent emitted response for unknown client request id {}; routing to connection stream", + response.id()); + return RouteScope.connection(); + } + if (route.kind() == RequestKind.SESSION_NEW && response.error() == null) { + String sessionId = extractSessionIdFromNewSessionResponse(response); + markSessionKnown(sessionId); + } + if (route.kind() == RequestKind.SESSION_LOAD && response.error() == null) { + markSessionKnown(route.requestScope().sessionId()); + } + return route.responseScope(); + } + + String method; + Object params; + Object id = null; + if (message instanceof AcpSchema.JSONRPCRequest request) { + method = request.method(); + params = request.params(); + id = request.id(); + } + else if (message instanceof AcpSchema.JSONRPCNotification notification) { + method = notification.method(); + params = notification.params(); + } + else { + throw new AcpConnectionException("Unsupported outbound JSON-RPC message type: " + message); + } + + RouteScope scope = resolveAgentRequestOrNotificationScope(method, params); + if (id != null) { + agentRequestRoutes.put(id, scope); + } + return scope; + } + + private RouteScope resolveAgentRequestOrNotificationScope(String method, Object params) { + switch (method) { + case AcpSchema.METHOD_SESSION_REQUEST_PERMISSION: + case AcpSchema.METHOD_SESSION_UPDATE: + case AcpSchema.METHOD_FS_READ_TEXT_FILE: + case AcpSchema.METHOD_FS_WRITE_TEXT_FILE: + case AcpSchema.METHOD_TERMINAL_CREATE: + case AcpSchema.METHOD_TERMINAL_OUTPUT: + case AcpSchema.METHOD_TERMINAL_RELEASE: + case AcpSchema.METHOD_TERMINAL_WAIT_FOR_EXIT: + case AcpSchema.METHOD_TERMINAL_KILL: + return RouteScope.session(requireSessionId(params, method)); + default: + Optional sessionId = extractSessionId(params); + if (routingMode == RoutingMode.STRICT) { + throw new AcpConnectionException("No explicit routing rule for outbound method " + method); + } + return sessionId.map(RouteScope::session).orElseGet(RouteScope::connection); + } + } + + private ResolvedInboundRoute resolveInboundRoute(JSONRPCMessage message, String sessionHeader) { + String method; + Object params; + if (message instanceof AcpSchema.JSONRPCRequest request) { + method = request.method(); + params = request.params(); + } + else if (message instanceof AcpSchema.JSONRPCNotification notification) { + method = notification.method(); + params = notification.params(); + } + else { + throw new AcpConnectionException("Unsupported inbound JSON-RPC message type: " + message); + } + + RouteScope requestScope; + RequestKind kind = RequestKind.GENERIC; + RouteScope responseScope; + + switch (method) { + case AcpSchema.METHOD_AUTHENTICATE: + case AcpSchema.METHOD_SESSION_NEW: + requestScope = RouteScope.connection(); + kind = AcpSchema.METHOD_SESSION_NEW.equals(method) ? RequestKind.SESSION_NEW : RequestKind.GENERIC; + responseScope = RouteScope.connection(); + break; + case AcpSchema.METHOD_SESSION_LOAD: + requestScope = requireSessionScope(method, params, sessionHeader); + kind = RequestKind.SESSION_LOAD; + responseScope = RouteScope.connection(); + break; + case AcpSchema.METHOD_SESSION_PROMPT: + case AcpSchema.METHOD_SESSION_SET_MODE: + case AcpSchema.METHOD_SESSION_SET_MODEL: + case AcpSchema.METHOD_SESSION_CANCEL: + requestScope = requireSessionScope(method, params, sessionHeader); + responseScope = requestScope; + break; + default: + Optional sessionId = extractSessionId(params); + if (routingMode == RoutingMode.STRICT) { + throw new AcpConnectionException("No explicit routing rule for inbound method " + method); + } + if (sessionId.isPresent()) { + requestScope = requireSessionScope(method, params, sessionHeader); + } + else { + requestScope = RouteScope.connection(); + } + responseScope = requestScope; + } + + ClientRequestRoute requestRoute = message instanceof AcpSchema.JSONRPCRequest + ? new ClientRequestRoute(kind, requestScope, responseScope) : null; + return new ResolvedInboundRoute(message, requestScope, requestRoute); + } + + private RouteScope requireSessionScope(String method, Object params, String sessionHeader) { + String sessionId = requireSessionId(params, method); + if (sessionHeader == null) { + throw new AcpConnectionException(HEADER_SESSION_ID + " header required for " + method); + } + if (!sessionId.equals(sessionHeader)) { + throw new AcpConnectionException("Header " + HEADER_SESSION_ID + " does not match params.sessionId"); + } + return RouteScope.session(sessionId); + } + + private void prepareSessionForInbound(String sessionId, ClientRequestRoute route) { + SessionState current = sessions.get(sessionId); + if (route != null && route.kind() == RequestKind.SESSION_LOAD) { + if (current == null) { + if (routingMode == RoutingMode.STRICT) { + throw new UnknownSessionException("Unknown session " + sessionId); + } + sessions.putIfAbsent(sessionId, SessionState.PENDING_LOAD); + sessionStream(sessionId); + } + return; + } + if (current != SessionState.KNOWN) { + throw new UnknownSessionException("Unknown session " + sessionId); + } + } + + private void validateClientResponseScope(AcpSchema.JSONRPCResponse response, String sessionHeader) { + RouteScope expected = agentRequestRoutes.get(response.id()); + if (expected == null) { + logger.warn("Client posted response for unknown agent request id {}", response.id()); + return; + } + RouteScope actual = sessionHeader == null ? RouteScope.connection() : RouteScope.session(sessionHeader); + if (!Objects.equals(expected, actual)) { + throw new AcpConnectionException( + "Response id " + response.id() + " arrived on " + actual + " but expected " + expected); + } + agentRequestRoutes.remove(response.id(), expected); + } + + private OutboundStream openSessionStream(String sessionId) { + SessionState current = sessions.get(sessionId); + if (current == null) { + if (routingMode == RoutingMode.STRICT) { + throw new UnknownSessionException("Unknown session " + sessionId); + } + /* + * RFD gap: + * The current text says unknown session-scoped GET requests return 404, + * but its resume flow also asks clients to open a session stream before + * sending session/load. Compatible mode keeps a provisional stream so + * practical resume can work while strict mode preserves the literal rule. + */ + sessions.putIfAbsent(sessionId, SessionState.PENDING_LOAD); + } + return sessionStream(sessionId); + } + + private OutboundStream sessionStream(String sessionId) { + return sessionStreams.computeIfAbsent(sessionId, ignored -> new OutboundStream()); + } + + private void markSessionKnown(String sessionId) { + sessions.put(sessionId, SessionState.KNOWN); + sessionStream(sessionId); + } + + private String extractSessionIdFromNewSessionResponse(AcpSchema.JSONRPCResponse response) { + AcpSchema.NewSessionResponse sessionResponse = jsonMapper.convertValue(response.result(), + new TypeRef() { + }); + if (sessionResponse.sessionId() == null || sessionResponse.sessionId().isBlank()) { + throw new AcpConnectionException("session/new response missing sessionId"); + } + return sessionResponse.sessionId(); + } + + } + + private Optional extractSessionId(Object params) { + if (params == null) { + return Optional.empty(); + } + Map paramsMap = jsonMapper.convertValue(params, Map.class); + Object sessionId = paramsMap.get("sessionId"); + return sessionId == null ? Optional.empty() : Optional.of(sessionId.toString()); + } + + private String requireSessionId(Object params, String method) { + return extractSessionId(params) + .filter(sessionId -> !sessionId.isBlank()) + .orElseThrow(() -> new AcpConnectionException("Missing sessionId for method " + method)); + } + + private final class ConnectionTransport implements AcpAgentTransport { + + private final Consumer outboundConsumer; + + private final Sinks.Many inboundSink = Sinks.many().unicast().onBackpressureBuffer(); + + private final Sinks.One terminationSink = Sinks.one(); + + private final AtomicBoolean started = new AtomicBoolean(false); + + private final AtomicBoolean closing = new AtomicBoolean(false); + + private volatile Consumer exceptionHandler = t -> logger.error("Transport error", t); + + ConnectionTransport(Consumer outboundConsumer) { + this.outboundConsumer = outboundConsumer; + } + + @Override + public Mono start(Function, Mono> handler) { + Assert.notNull(handler, "The handler can not be null"); + if (!started.compareAndSet(false, true)) { + return Mono.error(new IllegalStateException("Already started")); + } + inboundSink.asFlux() + .flatMap(message -> Mono.just(message).transform(handler)) + .doOnNext(response -> { + if (response != null) { + outboundConsumer.accept(response); + } + }) + .doOnError(this::signalException) + .subscribe(); + return Mono.empty(); + } + + void acceptInbound(JSONRPCMessage message) { + if (closing.get()) { + throw new AcpConnectionException("Connection transport is closing"); + } + Sinks.EmitResult result = inboundSink.tryEmitNext(message); + if (result.isFailure()) { + throw new AcpConnectionException("Failed to enqueue inbound message: " + result); + } + } + + void signalException(Throwable error) { + exceptionHandler.accept(error); + } + + @Override + public Mono sendMessage(JSONRPCMessage message) { + return Mono.fromRunnable(() -> { + if (closing.get()) { + throw new AcpConnectionException("Connection transport is closing"); + } + outboundConsumer.accept(message); + }); + } + + @Override + public T unmarshalFrom(Object data, TypeRef typeRef) { + return jsonMapper.convertValue(data, typeRef); + } + + @Override + public Mono closeGracefully() { + return Mono.fromRunnable(() -> { + if (closing.compareAndSet(false, true)) { + inboundSink.tryEmitComplete(); + terminationSink.tryEmitValue(null); + } + }); + } + + @Override + public void setExceptionHandler(Consumer handler) { + this.exceptionHandler = handler; + } + + @Override + public Mono awaitTermination() { + return terminationSink.asMono(); + } + + } + + private final class OutboundStream { + + private final ArrayDeque replay = new ArrayDeque<>(); + + private final List subscribers = new CopyOnWriteArrayList<>(); + + private final AtomicBoolean closed = new AtomicBoolean(false); + + private boolean replayOpen = true; + + synchronized void push(String payload) { + if (closed.get()) { + return; + } + if (replayOpen) { + if (replay.size() == MAX_REPLAY_EVENTS) { + replay.removeFirst(); + } + replay.addLast(payload); + return; + } + subscribers.forEach(subscriber -> subscriber.send(payload)); + } + + synchronized void subscribe(AsyncContext asyncContext, HttpServletResponse response) throws IOException { + if (closed.get()) { + throw new IOException("SSE stream is closed"); + } + SseSubscriber subscriber = new SseSubscriber(this, asyncContext, response); + subscribers.add(subscriber); + if (replayOpen) { + for (String payload : new ArrayList<>(replay)) { + subscriber.send(payload); + } + replay.clear(); + replayOpen = false; + } + subscriber.flush(); + } + + void remove(SseSubscriber subscriber) { + subscribers.remove(subscriber); + } + + void close() { + if (closed.compareAndSet(false, true)) { + subscribers.forEach(SseSubscriber::close); + subscribers.clear(); + synchronized (this) { + replay.clear(); + } + } + } + + } + + private final class SseSubscriber { + + private final OutboundStream parent; + + private final AsyncContext asyncContext; + + private final PrintWriter writer; + + private final AtomicBoolean closed = new AtomicBoolean(false); + + SseSubscriber(OutboundStream parent, AsyncContext asyncContext, HttpServletResponse response) throws IOException { + this.parent = parent; + this.asyncContext = asyncContext; + this.writer = response.getWriter(); + } + + synchronized void send(String payload) { + if (closed.get()) { + return; + } + writer.write("data: "); + writer.write(payload); + writer.write("\n\n"); + writer.flush(); + if (writer.checkError()) { + close(); + } + } + + synchronized void flush() { + writer.flush(); + } + + void close() { + if (closed.compareAndSet(false, true)) { + parent.remove(this); + try { + asyncContext.complete(); + } + catch (IllegalStateException ignored) { + } + } + } + + } + + private static final class UnknownSessionException extends RuntimeException { + + UnknownSessionException(String message) { + super(message); + } + + } + +} diff --git a/acp-streamable-http-jetty/src/test/java/com/agentclientprotocol/sdk/agent/transport/StreamableHttpAcpAgentTransportIntegrationTest.java b/acp-streamable-http-jetty/src/test/java/com/agentclientprotocol/sdk/agent/transport/StreamableHttpAcpAgentTransportIntegrationTest.java new file mode 100644 index 0000000..cf34646 --- /dev/null +++ b/acp-streamable-http-jetty/src/test/java/com/agentclientprotocol/sdk/agent/transport/StreamableHttpAcpAgentTransportIntegrationTest.java @@ -0,0 +1,222 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.agent.transport; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStreamReader; +import java.net.ServerSocket; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Duration; +import java.util.List; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; + +import com.agentclientprotocol.sdk.agent.AcpAgent; +import com.agentclientprotocol.sdk.agent.AcpAgentFactory; +import com.agentclientprotocol.sdk.client.AcpAsyncClient; +import com.agentclientprotocol.sdk.client.AcpClient; +import com.agentclientprotocol.sdk.client.transport.StreamableHttpAcpClientTransport; +import com.agentclientprotocol.sdk.json.AcpJsonMapper; +import com.agentclientprotocol.sdk.spec.AcpSchema; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Mono; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * End-to-end tests against the in-repo TypeScript Streamable HTTP fixture client. + */ +class StreamableHttpAcpAgentTransportIntegrationTest { + + private static final Duration TIMEOUT = Duration.ofSeconds(5); + + private static final Path FIXTURE_DIR = Path.of("..", "test-fixtures", "streamable-http-client").normalize(); + + private static final Path GOLDEN_DIR = FIXTURE_DIR.resolve("golden"); + + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + + @Test + void happyPathMatchesFixtureTranscript() throws Exception { + try (FixtureServer server = FixtureServer.start(StreamableHttpAcpAgentTransport.RoutingMode.COMPATIBLE)) { + JsonNode transcript = FixtureClient.run(server.endpoint(), "happy-path"); + assertTranscriptMatches(transcript, "happy-path.json"); + } + } + + @Test + void permissionRoundTripMatchesFixtureTranscript() throws Exception { + try (FixtureServer server = FixtureServer.start(StreamableHttpAcpAgentTransport.RoutingMode.COMPATIBLE)) { + JsonNode transcript = FixtureClient.run(server.endpoint(), "permission-round-trip"); + assertTranscriptMatches(transcript, "permission-round-trip.json"); + } + } + + @Test + void compatibleModeAllowsSessionLoadPreopen() throws Exception { + try (FixtureServer server = FixtureServer.start(StreamableHttpAcpAgentTransport.RoutingMode.COMPATIBLE)) { + JsonNode transcript = FixtureClient.run(server.endpoint(), "session-load"); + assertTranscriptMatches(transcript, "session-load.json"); + } + } + + @Test + void supportsTwoLogicalSessions() throws Exception { + try (FixtureServer server = FixtureServer.start(StreamableHttpAcpAgentTransport.RoutingMode.COMPATIBLE)) { + JsonNode transcript = FixtureClient.run(server.endpoint(), "two-sessions"); + assertTranscriptMatches(transcript, "two-sessions.json"); + } + } + + @Test + void wrongStreamResponseIsRejected() throws Exception { + try (FixtureServer server = FixtureServer.start(StreamableHttpAcpAgentTransport.RoutingMode.COMPATIBLE)) { + JsonNode transcript = FixtureClient.run(server.endpoint(), "wrong-stream-response"); + assertTranscriptMatches(transcript, "wrong-stream-response.json"); + } + } + + @Test + void validationFailuresMatchFixtureTranscript() throws Exception { + try (FixtureServer server = FixtureServer.start(StreamableHttpAcpAgentTransport.RoutingMode.COMPATIBLE)) { + JsonNode transcript = FixtureClient.run(server.endpoint(), "validation-failures"); + assertTranscriptMatches(transcript, "validation-failures.json"); + } + } + + @Test + void javaClientCanTalkToRunningJavaServer() throws Exception { + try (FixtureServer server = FixtureServer.start(StreamableHttpAcpAgentTransport.RoutingMode.COMPATIBLE)) { + AcpAsyncClient client = AcpClient + .async(new StreamableHttpAcpClientTransport(server.endpoint(), AcpJsonMapper.createDefault())) + .build(); + + client.initialize().block(TIMEOUT); + AcpSchema.NewSessionResponse session = client + .newSession(new AcpSchema.NewSessionRequest("/workspace", List.of(), null)) + .block(TIMEOUT); + AcpSchema.PromptResponse prompt = client + .prompt(new AcpSchema.PromptRequest(session.sessionId(), + List.of(new AcpSchema.TextContent("hello")), null)) + .block(TIMEOUT); + + assertThat(session.sessionId()).isEqualTo("sess-1"); + assertThat(prompt.stopReason()).isEqualTo(AcpSchema.StopReason.END_TURN); + + client.closeGracefully().block(TIMEOUT); + } + } + + @Test + void strictModeRejectsUnknownSessionStream() throws Exception { + try (FixtureServer server = FixtureServer.start(StreamableHttpAcpAgentTransport.RoutingMode.STRICT)) { + HttpResponse response = HttpClient.newHttpClient() + .send(HttpRequest.newBuilder(server.endpoint()) + .header("Content-Type", "application/json") + .header("Accept", "application/json") + .POST(HttpRequest.BodyPublishers.ofString(""" + {"jsonrpc":"2.0","id":"init-1","method":"initialize","params":{"protocolVersion":1,"clientCapabilities":{}}} + """)) + .build(), HttpResponse.BodyHandlers.discarding()); + String connectionId = response.headers().firstValue("Acp-Connection-Id").orElseThrow(); + HttpResponse unknownSession = HttpClient.newHttpClient() + .send(HttpRequest.newBuilder(server.endpoint()) + .header("Accept", "text/event-stream") + .header("Acp-Connection-Id", connectionId) + .header("Acp-Session-Id", "unknown") + .GET() + .build(), HttpResponse.BodyHandlers.discarding()); + + assertThat(response.statusCode()).isEqualTo(200); + assertThat(unknownSession.statusCode()).isEqualTo(404); + } + } + + private static void assertTranscriptMatches(JsonNode actual, String goldenName) throws Exception { + JsonNode expected = OBJECT_MAPPER.readTree(Files.readString(GOLDEN_DIR.resolve(goldenName))); + assertThat(actual).isEqualTo(expected); + } + + private static final class FixtureServer implements AutoCloseable { + + private final StreamableHttpAcpAgentTransport transport; + + private FixtureServer(StreamableHttpAcpAgentTransport transport) { + this.transport = transport; + } + + static FixtureServer start(StreamableHttpAcpAgentTransport.RoutingMode routingMode) throws Exception { + AtomicInteger sessionCounter = new AtomicInteger(); + AcpAgentFactory agentFactory = AcpAgentFactory.async(transport -> AcpAgent.async(transport) + .initializeHandler(request -> Mono.just(new AcpSchema.InitializeResponse( + AcpSchema.LATEST_PROTOCOL_VERSION, new AcpSchema.AgentCapabilities(true, null, null), null))) + .newSessionHandler(request -> Mono.just(new AcpSchema.NewSessionResponse( + "sess-" + sessionCounter.incrementAndGet(), null, null))) + .loadSessionHandler(request -> Mono.just(new AcpSchema.LoadSessionResponse(null, null))) + .promptHandler((request, context) -> { + Mono work = request.text().contains("permission") + ? context.askPermission("fixture permission").then() + : Mono.empty(); + return work.then(context.sendMessage("hello")) + .thenReturn(AcpSchema.PromptResponse.endTurn()); + }) + .build()); + StreamableHttpAcpAgentTransport transport = new StreamableHttpAcpAgentTransport( + freePort(), AcpJsonMapper.createDefault(), agentFactory).routingMode(routingMode); + transport.start().block(TIMEOUT); + return new FixtureServer(transport); + } + + URI endpoint() { + return URI.create("http://127.0.0.1:" + transport.getPort() + "/acp"); + } + + @Override + public void close() { + transport.closeGracefully().block(TIMEOUT); + } + + private static int freePort() throws IOException { + try (ServerSocket socket = new ServerSocket(0)) { + return socket.getLocalPort(); + } + } + + } + + private static final class FixtureClient { + + static JsonNode run(URI endpoint, String scenario) throws Exception { + Process process = new ProcessBuilder("node", "dist/client.js", "--endpoint", endpoint.toString(), "--scenario", + scenario) + .directory(FIXTURE_DIR.toFile()) + .redirectErrorStream(true) + .start(); + try (BufferedReader stdout = new BufferedReader( + new InputStreamReader(process.getInputStream(), StandardCharsets.UTF_8))) { + String output = stdout.lines().reduce("", (left, right) -> left + right + System.lineSeparator()); + if (!process.waitFor(5, TimeUnit.SECONDS)) { + process.destroyForcibly(); + throw new IllegalStateException("Fixture client timed out"); + } + if (process.exitValue() != 0) { + throw new IllegalStateException("Fixture client failed: " + output); + } + return OBJECT_MAPPER.readTree(output); + } + } + + } + +} diff --git a/acp-streamable-http-jetty/src/test/resources/logback-test.xml b/acp-streamable-http-jetty/src/test/resources/logback-test.xml new file mode 100644 index 0000000..5243e19 --- /dev/null +++ b/acp-streamable-http-jetty/src/test/resources/logback-test.xml @@ -0,0 +1,11 @@ + + + + + + + + %d{HH:mm:ss.SSS} %-5level %logger{36} - %msg%n + + + diff --git a/plans/STREAMABLE-HTTP-AGENT-TRANSPORT.md b/plans/STREAMABLE-HTTP-AGENT-TRANSPORT.md new file mode 100644 index 0000000..a3c47e6 --- /dev/null +++ b/plans/STREAMABLE-HTTP-AGENT-TRANSPORT.md @@ -0,0 +1,119 @@ +# Plan: Streamable HTTP Agent Transport + +> **Status**: Milestone one implemented +> **Created**: 2026-05-18 +> **Primary goal**: Java agents can be served from a running Java web server over the Streamable HTTP transport while preserving current WebSocket behavior until parity is proven. + +## Goal + +Add an agent-side Streamable HTTP transport backed by Jetty, with: + +- one fresh ACP agent runtime per accepted remote connection +- a public `AcpAgentFactory` seam for listener-backed transports +- RFD-oriented HTTP + SSE behavior +- strict and compatible routing modes +- a fixture-driven conformance harness that exercises a real running Java listener + +## Public Shape + +- Add `AcpAgentFactory` in `acp-core`. +- Make the async seam explicit: + - `AcpAgentFactory.async(...)` + - `AcpAgentFactory.sync(...)` +- Add `StreamableHttpAcpAgentTransport` in a dedicated Jetty adapter module: + - `acp-streamable-http-jetty` +- Keep the current WebSocket single-agent API intact in this milestone. +- PLAN: once Streamable HTTP reaches behavioral parity, migrate remote WebSocket handling toward the same factory-backed listener model. + +## Runtime Model + +```text +StreamableHttpAcpAgentTransport + accepts remote connection + -> AcpAgentFactory creates fresh agent runtime + -> per-connection AcpAgentTransport drives that runtime + -> one ACP connection contains many logical ACP sessionIds +``` + +- `Acp-Connection-Id` identifies one remote peer relationship. +- `Acp-Session-Id` identifies one logical ACP session inside that connection. +- The transport owns routing; the agent owns protocol meaning. + +## Routing / Lifecycle Decisions + +- `initialize` + - creates a provisional connection + - starts a fresh agent runtime + - returns `200 OK` with JSON-RPC response + `Acp-Connection-Id` + - publishes the connection only after successful initialize +- non-initialize POSTs require `Acp-Connection-Id` +- connection-scoped SSE streams carry: + - initialize follow-up traffic + - responses to `session/new` + - responses to `session/load` +- session-scoped SSE streams carry: + - responses to ordinary session-scoped requests + - session updates + - agent-originated session-scoped requests such as permission prompts +- DELETE tears down the connection and releases transport state. + +### Routing ledgers + +- client request id -> expected outbound response scope +- agent request id -> expected client response scope + +Wrong-stream client replies are protocol errors. Unknown response ids preserve current SDK parity and are allowed through for the session layer to decide. + +### Strict vs compatible + +- `STRICT` + - rejects unknown methods without explicit routing + - rejects unknown session stream opens with `404` +- `COMPATIBLE` + - falls back to `params.sessionId` inference for unknown methods + - permits provisional session streams before `session/load` + +## Known RFD Gap + +The RFD says unknown session-scoped GETs should return `404`, but the resume flow also asks clients to open the session SSE stream before sending `session/load`. This transport keeps that tension explicit: + +- strict mode preserves the literal 404 rule +- compatible mode creates a provisional `PENDING_LOAD` session stream + +PLAN: revisit this once the protocol resolves the resume/session-load ordering contract more precisely. + +## Test Harness + +Create an in-repo fixture: + +```text +test-fixtures/streamable-http-client/ +``` + +The fixture is: + +- TypeScript +- HTTP-only +- scenario-driven +- the single owner of canonical transcript serialization +- run against a real Java Jetty listener + +Covered scenarios: + +- happy path +- permission round-trip +- session load / provisional pre-open +- two logical sessions +- wrong-stream response rejection +- validation failures + +The Java module also keeps focused integration coverage for strict unknown-session behavior. + +## PLAN / Follow-Up Work + +- extract a shared remote-core layer only after HTTP parity is proven +- migrate WebSocket toward the same factory-backed listener model +- add idle/provisional-session eviction and replay retention policies +- revisit per-logical-session active-prompt tracking in `AcpAgentSession` +- expose richer diagnostics / observability hooks +- decide whether compatible provisional session streams remain necessary after the RFD is clarified diff --git a/pom.xml b/pom.xml index aa98e65..3ccfd64 100644 --- a/pom.xml +++ b/pom.xml @@ -18,6 +18,7 @@ acp-core acp-agent-support acp-test + acp-streamable-http-jetty acp-websocket-jetty @@ -133,6 +134,21 @@ jetty-websocket-jetty-api ${jetty.version} + + org.eclipse.jetty + jetty-server + ${jetty.version} + + + org.eclipse.jetty.ee10 + jetty-ee10-servlet + ${jetty.version} + + + org.eclipse.jetty.http2 + jetty-http2-server + ${jetty.version} + diff --git a/test-fixtures/streamable-http-client/README.md b/test-fixtures/streamable-http-client/README.md new file mode 100644 index 0000000..37e1bb2 --- /dev/null +++ b/test-fixtures/streamable-http-client/README.md @@ -0,0 +1,27 @@ +# Streamable HTTP Fixture Client + +This in-repo TypeScript fixture drives the Java Streamable HTTP agent transport +through raw HTTP and SSE exchanges. It is intentionally small, strict, and scenario +driven so the wire contract stays visible while the Java server implementation evolves. + +Scenarios: + +- `happy-path` +- `permission-round-trip` +- `session-load` +- `two-sessions` +- `wrong-stream-response` +- `validation-failures` + +Build once: + +```bash +npm install +npm run build +``` + +Run against a local Java listener: + +```bash +node dist/client.js --endpoint http://127.0.0.1:8080/acp --scenario happy-path +``` diff --git a/test-fixtures/streamable-http-client/dist/client.js b/test-fixtures/streamable-http-client/dist/client.js new file mode 100644 index 0000000..26f4522 --- /dev/null +++ b/test-fixtures/streamable-http-client/dist/client.js @@ -0,0 +1,16 @@ +import { StreamableHttpFixtureClient } from "./protocol.js"; +import { runScenario } from "./scenarios.js"; +import { TranscriptRecorder } from "./transcript.js"; +const endpoint = readArg("--endpoint"); +if (!endpoint) { + throw new Error("--endpoint is required"); +} +const scenario = readArg("--scenario") ?? "happy-path"; +const recorder = new TranscriptRecorder(); +const client = new StreamableHttpFixtureClient(endpoint, recorder); +await runScenario(scenario, endpoint, client); +process.stdout.write(recorder.serialize()); +function readArg(name) { + const index = process.argv.indexOf(name); + return index >= 0 ? process.argv[index + 1] : undefined; +} diff --git a/test-fixtures/streamable-http-client/dist/protocol.js b/test-fixtures/streamable-http-client/dist/protocol.js new file mode 100644 index 0000000..e6df323 --- /dev/null +++ b/test-fixtures/streamable-http-client/dist/protocol.js @@ -0,0 +1,245 @@ +export const CONNECTION_HEADER = "Acp-Connection-Id"; +export const SESSION_HEADER = "Acp-Session-Id"; +export class StreamableHttpFixtureClient { + endpoint; + recorder; + connectionId = null; + nextId = 1; + constructor(endpoint, recorder) { + this.endpoint = endpoint; + this.recorder = recorder; + } + async initialize() { + const request = this.request("initialize", { + protocolVersion: 1, + clientCapabilities: {}, + }); + const response = await this.post(request, "bootstrap", null, true); + const connectionId = response.headers.get(CONNECTION_HEADER); + if (!connectionId) { + throw new Error("initialize response missing connection id"); + } + this.connectionId = connectionId; + return await response.json(); + } + async postMessage(message, sessionId = null) { + return this.post(message, sessionId ? "session" : "connection", sessionId, false); + } + async openConnectionStream() { + return SseStream.open(this.endpoint, this.recorder, "connection", this.requireConnectionId(), null); + } + async openSessionStream(sessionId) { + return SseStream.open(this.endpoint, this.recorder, "session", this.requireConnectionId(), sessionId); + } + async close() { + const headers = { + [CONNECTION_HEADER]: this.requireConnectionId(), + }; + this.recorder.record({ + kind: "http_request", + method: "DELETE", + scope: "connection", + connectionId: this.connectionId, + sessionId: null, + jsonRpc: null, + }); + const response = await fetch(this.endpoint, { + method: "DELETE", + headers, + }); + this.recorder.record({ + kind: "http_response", + status: response.status, + scope: "connection", + connectionId: this.connectionId, + sessionId: null, + jsonRpc: null, + }); + return response; + } + async rawRequest(method, scope, headers, body, sessionId = null) { + this.recorder.record({ + kind: "http_request", + method, + scope, + connectionId: headers[CONNECTION_HEADER] ?? null, + sessionId, + jsonRpc: this.recorder.summarizeJsonRpc(body), + }); + const response = await fetch(this.endpoint, { + method, + headers, + ...(body ? { body: JSON.stringify(body) } : {}), + }); + this.recorder.record({ + kind: "http_response", + status: response.status, + scope, + connectionId: response.headers.get(CONNECTION_HEADER) ?? headers[CONNECTION_HEADER] ?? null, + sessionId: response.headers.get(SESSION_HEADER) ?? sessionId, + jsonRpc: null, + }); + return response; + } + request(method, params) { + return { + jsonrpc: "2.0", + id: `req-${this.nextId++}`, + method, + params, + }; + } + response(id, result) { + return { + jsonrpc: "2.0", + id, + result, + }; + } + async post(message, scope, sessionId, expectJson) { + const headers = { + "content-type": "application/json", + accept: "application/json", + }; + if (scope !== "bootstrap") { + headers[CONNECTION_HEADER] = this.requireConnectionId(); + } + if (sessionId) { + headers[SESSION_HEADER] = sessionId; + } + this.recorder.record({ + kind: "http_request", + method: "POST", + scope, + connectionId: this.connectionId, + sessionId, + jsonRpc: this.recorder.summarizeJsonRpc(message), + }); + const response = await fetch(this.endpoint, { + method: "POST", + headers, + body: JSON.stringify(message), + }); + let jsonRpc = null; + if (expectJson) { + jsonRpc = this.recorder.summarizeJsonRpc(await response.clone().json()); + } + this.recorder.record({ + kind: "http_response", + status: response.status, + scope, + connectionId: response.headers.get(CONNECTION_HEADER) ?? this.connectionId, + sessionId, + jsonRpc, + }); + return response; + } + requireConnectionId() { + if (!this.connectionId) { + throw new Error("connection id not initialized"); + } + return this.connectionId; + } +} +export class SseStream { + recorder; + stream; + sessionId; + response; + messages = []; + waiters = []; + abortController = new AbortController(); + constructor(recorder, stream, sessionId, response) { + this.recorder = recorder; + this.stream = stream; + this.sessionId = sessionId; + this.response = response; + } + static async open(endpoint, recorder, stream, connectionId, sessionId) { + recorder.record({ + kind: "http_request", + method: "GET", + scope: stream, + connectionId, + sessionId, + jsonRpc: null, + }); + const response = await fetch(endpoint, { + method: "GET", + headers: { + accept: "text/event-stream", + [CONNECTION_HEADER]: connectionId, + ...(sessionId ? { [SESSION_HEADER]: sessionId } : {}), + }, + }); + recorder.record({ + kind: "http_response", + status: response.status, + scope: stream, + connectionId: response.headers.get(CONNECTION_HEADER), + sessionId: response.headers.get(SESSION_HEADER), + jsonRpc: null, + }); + const result = new SseStream(recorder, stream, sessionId, response); + void result.readLoop(); + return result; + } + async next() { + const existing = this.messages.shift(); + if (existing) { + return existing; + } + return new Promise((resolve) => this.waiters.push(resolve)); + } + close() { + this.abortController.abort(); + } + async readLoop() { + if (!this.response.body) { + return; + } + const reader = this.response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + for (;;) { + const { value, done } = await reader.read(); + if (done) { + break; + } + buffer += decoder.decode(value, { stream: true }); + for (;;) { + const boundary = buffer.indexOf("\n\n"); + if (boundary < 0) { + break; + } + const rawEvent = buffer.slice(0, boundary); + buffer = buffer.slice(boundary + 2); + const data = rawEvent + .split("\n") + .filter((line) => line.startsWith("data:")) + .map((line) => line.slice(5).trimStart()) + .join("\n"); + if (!data) { + continue; + } + const message = JSON.parse(data); + const summary = this.recorder.summarizeJsonRpc(message); + if (summary) { + this.recorder.record({ + kind: "sse_event", + stream: this.stream, + sessionId: this.sessionId, + jsonRpc: summary, + }); + } + const waiter = this.waiters.shift(); + if (waiter) { + waiter(message); + } + else { + this.messages.push(message); + } + } + } + } +} diff --git a/test-fixtures/streamable-http-client/dist/scenarios.js b/test-fixtures/streamable-http-client/dist/scenarios.js new file mode 100644 index 0000000..95f8c41 --- /dev/null +++ b/test-fixtures/streamable-http-client/dist/scenarios.js @@ -0,0 +1,127 @@ +import { CONNECTION_HEADER } from "./protocol.js"; +export async function runScenario(name, endpoint, client) { + switch (name) { + case "happy-path": + await happyPath(client); + return; + case "permission-round-trip": + await permissionRoundTrip(client); + return; + case "session-load": + await sessionLoad(client); + return; + case "two-sessions": + await twoSessions(client); + return; + case "wrong-stream-response": + await wrongStreamResponse(client); + return; + case "validation-failures": + await validationFailures(endpoint, client); + return; + default: + throw new Error(`Unknown scenario ${name}`); + } +} +async function happyPath(client) { + await client.initialize(); + const connection = await client.openConnectionStream(); + const newSession = client.request("session/new", { cwd: "/workspace", mcpServers: [] }); + await client.postMessage(newSession); + const sessionResponse = await connection.next(); + const sessionId = sessionResponse.result.sessionId; + const session = await client.openSessionStream(sessionId); + await client.postMessage(client.request("session/prompt", { + sessionId, + prompt: [{ type: "text", text: "hello" }], + }), sessionId); + await session.next(); + await session.next(); + await client.close(); +} +async function permissionRoundTrip(client) { + await client.initialize(); + const connection = await client.openConnectionStream(); + await client.postMessage(client.request("session/new", { cwd: "/workspace", mcpServers: [] })); + const sessionResponse = await connection.next(); + const sessionId = sessionResponse.result.sessionId; + const session = await client.openSessionStream(sessionId); + await client.postMessage(client.request("session/prompt", { + sessionId, + prompt: [{ type: "text", text: "needs permission" }], + }), sessionId); + const permissionRequest = await session.next(); + await client.postMessage(client.response(permissionRequest.id, { outcome: { outcome: "selected", optionId: "allow" } }), sessionId); + await session.next(); + await session.next(); + await client.close(); +} +async function sessionLoad(client) { + await client.initialize(); + const connection = await client.openConnectionStream(); + const session = await client.openSessionStream("sess-load"); + await client.postMessage(client.request("session/load", { + sessionId: "sess-load", + cwd: "/workspace", + mcpServers: [], + }), "sess-load"); + await connection.next(); + session.close(); + await client.close(); +} +async function twoSessions(client) { + await client.initialize(); + const connection = await client.openConnectionStream(); + await client.postMessage(client.request("session/new", { cwd: "/workspace/one", mcpServers: [] })); + const first = await connection.next(); + const firstId = first.result.sessionId; + await client.postMessage(client.request("session/new", { cwd: "/workspace/two", mcpServers: [] })); + const second = await connection.next(); + const secondId = second.result.sessionId; + const firstStream = await client.openSessionStream(firstId); + const secondStream = await client.openSessionStream(secondId); + await client.postMessage(client.request("session/prompt", { + sessionId: firstId, + prompt: [{ type: "text", text: "one" }], + }), firstId); + await firstStream.next(); + await firstStream.next(); + await client.postMessage(client.request("session/prompt", { + sessionId: secondId, + prompt: [{ type: "text", text: "two" }], + }), secondId); + await secondStream.next(); + await secondStream.next(); + await client.close(); +} +async function wrongStreamResponse(client) { + await client.initialize(); + const connection = await client.openConnectionStream(); + await client.postMessage(client.request("session/new", { cwd: "/workspace", mcpServers: [] })); + const sessionResponse = await connection.next(); + const sessionId = sessionResponse.result.sessionId; + const session = await client.openSessionStream(sessionId); + await client.postMessage(client.request("session/prompt", { + sessionId, + prompt: [{ type: "text", text: "needs permission" }], + }), sessionId); + const permissionRequest = await session.next(); + await client.postMessage(client.response(permissionRequest.id, { outcome: { outcome: "selected", optionId: "allow" } })); + await client.close(); +} +async function validationFailures(endpoint, client) { + await client.initialize(); + await client.rawRequest("POST", "connection", { + accept: "application/json", + "content-type": "text/plain", + [CONNECTION_HEADER]: "conn-invalid", + }, {}); + await client.rawRequest("GET", "connection", { + accept: "text/event-stream", + }, null); + await client.rawRequest("GET", "connection", { + accept: "application/json", + [CONNECTION_HEADER]: "missing", + }, null); + await client.close(); +} diff --git a/test-fixtures/streamable-http-client/dist/transcript.js b/test-fixtures/streamable-http-client/dist/transcript.js new file mode 100644 index 0000000..cb9391f --- /dev/null +++ b/test-fixtures/streamable-http-client/dist/transcript.js @@ -0,0 +1,69 @@ +export class TranscriptRecorder { + events = []; + idAliases = new Map(); + connectionAliases = new Map(); + record(event) { + if ("connectionId" in event) { + this.events.push({ + ...event, + connectionId: this.normalizeConnectionId(event.connectionId), + }); + return; + } + this.events.push(event); + } + summarizeJsonRpc(message) { + if (!message || typeof message !== "object") { + return null; + } + const candidate = message; + if (typeof candidate.method === "string" && "id" in candidate) { + return { + type: "request", + id: this.normalizeId(candidate.id), + method: candidate.method, + }; + } + if (typeof candidate.method === "string") { + return { + type: "notification", + method: candidate.method, + }; + } + if ("result" in candidate || "error" in candidate) { + return { + type: "response", + id: this.normalizeId(candidate.id), + hasError: candidate.error != null, + }; + } + return null; + } + serialize() { + return JSON.stringify(this.events, null, 2); + } + normalizeId(id) { + if (typeof id !== "string" && typeof id !== "number") { + return null; + } + const existing = this.idAliases.get(id); + if (existing) { + return existing; + } + const next = `id-${this.idAliases.size + 1}`; + this.idAliases.set(id, next); + return next; + } + normalizeConnectionId(connectionId) { + if (!connectionId) { + return null; + } + const existing = this.connectionAliases.get(connectionId); + if (existing) { + return existing; + } + const next = `conn-${this.connectionAliases.size + 1}`; + this.connectionAliases.set(connectionId, next); + return next; + } +} diff --git a/test-fixtures/streamable-http-client/golden/happy-path.json b/test-fixtures/streamable-http-client/golden/happy-path.json new file mode 100644 index 0000000..ae468a0 --- /dev/null +++ b/test-fixtures/streamable-http-client/golden/happy-path.json @@ -0,0 +1,127 @@ +[ { + "kind" : "http_request", + "method" : "POST", + "scope" : "bootstrap", + "connectionId" : null, + "sessionId" : null, + "jsonRpc" : { + "type" : "request", + "id" : "id-1", + "method" : "initialize" + } +}, { + "kind" : "http_response", + "status" : 200, + "scope" : "bootstrap", + "connectionId" : "conn-1", + "sessionId" : null, + "jsonRpc" : { + "type" : "response", + "id" : "id-1", + "hasError" : false + } +}, { + "kind" : "http_request", + "method" : "GET", + "scope" : "connection", + "connectionId" : "conn-1", + "sessionId" : null, + "jsonRpc" : null +}, { + "kind" : "http_response", + "status" : 200, + "scope" : "connection", + "connectionId" : "conn-1", + "sessionId" : null, + "jsonRpc" : null +}, { + "kind" : "http_request", + "method" : "POST", + "scope" : "connection", + "connectionId" : "conn-1", + "sessionId" : null, + "jsonRpc" : { + "type" : "request", + "id" : "id-2", + "method" : "session/new" + } +}, { + "kind" : "sse_event", + "stream" : "connection", + "sessionId" : null, + "jsonRpc" : { + "type" : "response", + "id" : "id-2", + "hasError" : false + } +}, { + "kind" : "http_response", + "status" : 202, + "scope" : "connection", + "connectionId" : "conn-1", + "sessionId" : null, + "jsonRpc" : null +}, { + "kind" : "http_request", + "method" : "GET", + "scope" : "session", + "connectionId" : "conn-1", + "sessionId" : "sess-1", + "jsonRpc" : null +}, { + "kind" : "http_response", + "status" : 200, + "scope" : "session", + "connectionId" : "conn-1", + "sessionId" : "sess-1", + "jsonRpc" : null +}, { + "kind" : "http_request", + "method" : "POST", + "scope" : "session", + "connectionId" : "conn-1", + "sessionId" : "sess-1", + "jsonRpc" : { + "type" : "request", + "id" : "id-3", + "method" : "session/prompt" + } +}, { + "kind" : "sse_event", + "stream" : "session", + "sessionId" : "sess-1", + "jsonRpc" : { + "type" : "notification", + "method" : "session/update" + } +}, { + "kind" : "sse_event", + "stream" : "session", + "sessionId" : "sess-1", + "jsonRpc" : { + "type" : "response", + "id" : "id-3", + "hasError" : false + } +}, { + "kind" : "http_response", + "status" : 202, + "scope" : "session", + "connectionId" : "conn-1", + "sessionId" : "sess-1", + "jsonRpc" : null +}, { + "kind" : "http_request", + "method" : "DELETE", + "scope" : "connection", + "connectionId" : "conn-1", + "sessionId" : null, + "jsonRpc" : null +}, { + "kind" : "http_response", + "status" : 202, + "scope" : "connection", + "connectionId" : "conn-1", + "sessionId" : null, + "jsonRpc" : null +} ] \ No newline at end of file diff --git a/test-fixtures/streamable-http-client/golden/permission-round-trip.json b/test-fixtures/streamable-http-client/golden/permission-round-trip.json new file mode 100644 index 0000000..c3af4a5 --- /dev/null +++ b/test-fixtures/streamable-http-client/golden/permission-round-trip.json @@ -0,0 +1,154 @@ +[ { + "kind" : "http_request", + "method" : "POST", + "scope" : "bootstrap", + "connectionId" : null, + "sessionId" : null, + "jsonRpc" : { + "type" : "request", + "id" : "id-1", + "method" : "initialize" + } +}, { + "kind" : "http_response", + "status" : 200, + "scope" : "bootstrap", + "connectionId" : "conn-1", + "sessionId" : null, + "jsonRpc" : { + "type" : "response", + "id" : "id-1", + "hasError" : false + } +}, { + "kind" : "http_request", + "method" : "GET", + "scope" : "connection", + "connectionId" : "conn-1", + "sessionId" : null, + "jsonRpc" : null +}, { + "kind" : "http_response", + "status" : 200, + "scope" : "connection", + "connectionId" : "conn-1", + "sessionId" : null, + "jsonRpc" : null +}, { + "kind" : "http_request", + "method" : "POST", + "scope" : "connection", + "connectionId" : "conn-1", + "sessionId" : null, + "jsonRpc" : { + "type" : "request", + "id" : "id-2", + "method" : "session/new" + } +}, { + "kind" : "sse_event", + "stream" : "connection", + "sessionId" : null, + "jsonRpc" : { + "type" : "response", + "id" : "id-2", + "hasError" : false + } +}, { + "kind" : "http_response", + "status" : 202, + "scope" : "connection", + "connectionId" : "conn-1", + "sessionId" : null, + "jsonRpc" : null +}, { + "kind" : "http_request", + "method" : "GET", + "scope" : "session", + "connectionId" : "conn-1", + "sessionId" : "sess-1", + "jsonRpc" : null +}, { + "kind" : "http_response", + "status" : 200, + "scope" : "session", + "connectionId" : "conn-1", + "sessionId" : "sess-1", + "jsonRpc" : null +}, { + "kind" : "http_request", + "method" : "POST", + "scope" : "session", + "connectionId" : "conn-1", + "sessionId" : "sess-1", + "jsonRpc" : { + "type" : "request", + "id" : "id-3", + "method" : "session/prompt" + } +}, { + "kind" : "sse_event", + "stream" : "session", + "sessionId" : "sess-1", + "jsonRpc" : { + "type" : "request", + "id" : "id-4", + "method" : "session/request_permission" + } +}, { + "kind" : "http_response", + "status" : 202, + "scope" : "session", + "connectionId" : "conn-1", + "sessionId" : "sess-1", + "jsonRpc" : null +}, { + "kind" : "http_request", + "method" : "POST", + "scope" : "session", + "connectionId" : "conn-1", + "sessionId" : "sess-1", + "jsonRpc" : { + "type" : "response", + "id" : "id-4", + "hasError" : false + } +}, { + "kind" : "sse_event", + "stream" : "session", + "sessionId" : "sess-1", + "jsonRpc" : { + "type" : "notification", + "method" : "session/update" + } +}, { + "kind" : "sse_event", + "stream" : "session", + "sessionId" : "sess-1", + "jsonRpc" : { + "type" : "response", + "id" : "id-3", + "hasError" : false + } +}, { + "kind" : "http_response", + "status" : 202, + "scope" : "session", + "connectionId" : "conn-1", + "sessionId" : "sess-1", + "jsonRpc" : null +}, { + "kind" : "http_request", + "method" : "DELETE", + "scope" : "connection", + "connectionId" : "conn-1", + "sessionId" : null, + "jsonRpc" : null +}, { + "kind" : "http_response", + "status" : 202, + "scope" : "connection", + "connectionId" : "conn-1", + "sessionId" : null, + "jsonRpc" : null +} ] \ No newline at end of file diff --git a/test-fixtures/streamable-http-client/golden/session-load.json b/test-fixtures/streamable-http-client/golden/session-load.json new file mode 100644 index 0000000..24086cb --- /dev/null +++ b/test-fixtures/streamable-http-client/golden/session-load.json @@ -0,0 +1,92 @@ +[ { + "kind" : "http_request", + "method" : "POST", + "scope" : "bootstrap", + "connectionId" : null, + "sessionId" : null, + "jsonRpc" : { + "type" : "request", + "id" : "id-1", + "method" : "initialize" + } +}, { + "kind" : "http_response", + "status" : 200, + "scope" : "bootstrap", + "connectionId" : "conn-1", + "sessionId" : null, + "jsonRpc" : { + "type" : "response", + "id" : "id-1", + "hasError" : false + } +}, { + "kind" : "http_request", + "method" : "GET", + "scope" : "connection", + "connectionId" : "conn-1", + "sessionId" : null, + "jsonRpc" : null +}, { + "kind" : "http_response", + "status" : 200, + "scope" : "connection", + "connectionId" : "conn-1", + "sessionId" : null, + "jsonRpc" : null +}, { + "kind" : "http_request", + "method" : "GET", + "scope" : "session", + "connectionId" : "conn-1", + "sessionId" : "sess-load", + "jsonRpc" : null +}, { + "kind" : "http_response", + "status" : 200, + "scope" : "session", + "connectionId" : "conn-1", + "sessionId" : "sess-load", + "jsonRpc" : null +}, { + "kind" : "http_request", + "method" : "POST", + "scope" : "session", + "connectionId" : "conn-1", + "sessionId" : "sess-load", + "jsonRpc" : { + "type" : "request", + "id" : "id-2", + "method" : "session/load" + } +}, { + "kind" : "sse_event", + "stream" : "connection", + "sessionId" : null, + "jsonRpc" : { + "type" : "response", + "id" : "id-2", + "hasError" : false + } +}, { + "kind" : "http_response", + "status" : 202, + "scope" : "session", + "connectionId" : "conn-1", + "sessionId" : "sess-load", + "jsonRpc" : null +}, { + "kind" : "http_request", + "method" : "DELETE", + "scope" : "connection", + "connectionId" : "conn-1", + "sessionId" : null, + "jsonRpc" : null +}, { + "kind" : "http_response", + "status" : 202, + "scope" : "connection", + "connectionId" : "conn-1", + "sessionId" : null, + "jsonRpc" : null +} ] \ No newline at end of file diff --git a/test-fixtures/streamable-http-client/golden/two-sessions.json b/test-fixtures/streamable-http-client/golden/two-sessions.json new file mode 100644 index 0000000..307c720 --- /dev/null +++ b/test-fixtures/streamable-http-client/golden/two-sessions.json @@ -0,0 +1,203 @@ +[ { + "kind" : "http_request", + "method" : "POST", + "scope" : "bootstrap", + "connectionId" : null, + "sessionId" : null, + "jsonRpc" : { + "type" : "request", + "id" : "id-1", + "method" : "initialize" + } +}, { + "kind" : "http_response", + "status" : 200, + "scope" : "bootstrap", + "connectionId" : "conn-1", + "sessionId" : null, + "jsonRpc" : { + "type" : "response", + "id" : "id-1", + "hasError" : false + } +}, { + "kind" : "http_request", + "method" : "GET", + "scope" : "connection", + "connectionId" : "conn-1", + "sessionId" : null, + "jsonRpc" : null +}, { + "kind" : "http_response", + "status" : 200, + "scope" : "connection", + "connectionId" : "conn-1", + "sessionId" : null, + "jsonRpc" : null +}, { + "kind" : "http_request", + "method" : "POST", + "scope" : "connection", + "connectionId" : "conn-1", + "sessionId" : null, + "jsonRpc" : { + "type" : "request", + "id" : "id-2", + "method" : "session/new" + } +}, { + "kind" : "sse_event", + "stream" : "connection", + "sessionId" : null, + "jsonRpc" : { + "type" : "response", + "id" : "id-2", + "hasError" : false + } +}, { + "kind" : "http_response", + "status" : 202, + "scope" : "connection", + "connectionId" : "conn-1", + "sessionId" : null, + "jsonRpc" : null +}, { + "kind" : "http_request", + "method" : "POST", + "scope" : "connection", + "connectionId" : "conn-1", + "sessionId" : null, + "jsonRpc" : { + "type" : "request", + "id" : "id-3", + "method" : "session/new" + } +}, { + "kind" : "sse_event", + "stream" : "connection", + "sessionId" : null, + "jsonRpc" : { + "type" : "response", + "id" : "id-3", + "hasError" : false + } +}, { + "kind" : "http_response", + "status" : 202, + "scope" : "connection", + "connectionId" : "conn-1", + "sessionId" : null, + "jsonRpc" : null +}, { + "kind" : "http_request", + "method" : "GET", + "scope" : "session", + "connectionId" : "conn-1", + "sessionId" : "sess-1", + "jsonRpc" : null +}, { + "kind" : "http_response", + "status" : 200, + "scope" : "session", + "connectionId" : "conn-1", + "sessionId" : "sess-1", + "jsonRpc" : null +}, { + "kind" : "http_request", + "method" : "GET", + "scope" : "session", + "connectionId" : "conn-1", + "sessionId" : "sess-2", + "jsonRpc" : null +}, { + "kind" : "http_response", + "status" : 200, + "scope" : "session", + "connectionId" : "conn-1", + "sessionId" : "sess-2", + "jsonRpc" : null +}, { + "kind" : "http_request", + "method" : "POST", + "scope" : "session", + "connectionId" : "conn-1", + "sessionId" : "sess-1", + "jsonRpc" : { + "type" : "request", + "id" : "id-4", + "method" : "session/prompt" + } +}, { + "kind" : "sse_event", + "stream" : "session", + "sessionId" : "sess-1", + "jsonRpc" : { + "type" : "notification", + "method" : "session/update" + } +}, { + "kind" : "sse_event", + "stream" : "session", + "sessionId" : "sess-1", + "jsonRpc" : { + "type" : "response", + "id" : "id-4", + "hasError" : false + } +}, { + "kind" : "http_response", + "status" : 202, + "scope" : "session", + "connectionId" : "conn-1", + "sessionId" : "sess-1", + "jsonRpc" : null +}, { + "kind" : "http_request", + "method" : "POST", + "scope" : "session", + "connectionId" : "conn-1", + "sessionId" : "sess-2", + "jsonRpc" : { + "type" : "request", + "id" : "id-5", + "method" : "session/prompt" + } +}, { + "kind" : "sse_event", + "stream" : "session", + "sessionId" : "sess-2", + "jsonRpc" : { + "type" : "notification", + "method" : "session/update" + } +}, { + "kind" : "sse_event", + "stream" : "session", + "sessionId" : "sess-2", + "jsonRpc" : { + "type" : "response", + "id" : "id-5", + "hasError" : false + } +}, { + "kind" : "http_response", + "status" : 202, + "scope" : "session", + "connectionId" : "conn-1", + "sessionId" : "sess-2", + "jsonRpc" : null +}, { + "kind" : "http_request", + "method" : "DELETE", + "scope" : "connection", + "connectionId" : "conn-1", + "sessionId" : null, + "jsonRpc" : null +}, { + "kind" : "http_response", + "status" : 202, + "scope" : "connection", + "connectionId" : "conn-1", + "sessionId" : null, + "jsonRpc" : null +} ] \ No newline at end of file diff --git a/test-fixtures/streamable-http-client/golden/validation-failures.json b/test-fixtures/streamable-http-client/golden/validation-failures.json new file mode 100644 index 0000000..3ae0b9b --- /dev/null +++ b/test-fixtures/streamable-http-client/golden/validation-failures.json @@ -0,0 +1,79 @@ +[ { + "kind" : "http_request", + "method" : "POST", + "scope" : "bootstrap", + "connectionId" : null, + "sessionId" : null, + "jsonRpc" : { + "type" : "request", + "id" : "id-1", + "method" : "initialize" + } +}, { + "kind" : "http_response", + "status" : 200, + "scope" : "bootstrap", + "connectionId" : "conn-1", + "sessionId" : null, + "jsonRpc" : { + "type" : "response", + "id" : "id-1", + "hasError" : false + } +}, { + "kind" : "http_request", + "method" : "POST", + "scope" : "connection", + "connectionId" : "conn-2", + "sessionId" : null, + "jsonRpc" : null +}, { + "kind" : "http_response", + "status" : 415, + "scope" : "connection", + "connectionId" : "conn-2", + "sessionId" : null, + "jsonRpc" : null +}, { + "kind" : "http_request", + "method" : "GET", + "scope" : "connection", + "connectionId" : null, + "sessionId" : null, + "jsonRpc" : null +}, { + "kind" : "http_response", + "status" : 400, + "scope" : "connection", + "connectionId" : null, + "sessionId" : null, + "jsonRpc" : null +}, { + "kind" : "http_request", + "method" : "GET", + "scope" : "connection", + "connectionId" : "conn-3", + "sessionId" : null, + "jsonRpc" : null +}, { + "kind" : "http_response", + "status" : 406, + "scope" : "connection", + "connectionId" : "conn-3", + "sessionId" : null, + "jsonRpc" : null +}, { + "kind" : "http_request", + "method" : "DELETE", + "scope" : "connection", + "connectionId" : "conn-1", + "sessionId" : null, + "jsonRpc" : null +}, { + "kind" : "http_response", + "status" : 202, + "scope" : "connection", + "connectionId" : "conn-1", + "sessionId" : null, + "jsonRpc" : null +} ] \ No newline at end of file diff --git a/test-fixtures/streamable-http-client/golden/wrong-stream-response.json b/test-fixtures/streamable-http-client/golden/wrong-stream-response.json new file mode 100644 index 0000000..83d292b --- /dev/null +++ b/test-fixtures/streamable-http-client/golden/wrong-stream-response.json @@ -0,0 +1,137 @@ +[ { + "kind" : "http_request", + "method" : "POST", + "scope" : "bootstrap", + "connectionId" : null, + "sessionId" : null, + "jsonRpc" : { + "type" : "request", + "id" : "id-1", + "method" : "initialize" + } +}, { + "kind" : "http_response", + "status" : 200, + "scope" : "bootstrap", + "connectionId" : "conn-1", + "sessionId" : null, + "jsonRpc" : { + "type" : "response", + "id" : "id-1", + "hasError" : false + } +}, { + "kind" : "http_request", + "method" : "GET", + "scope" : "connection", + "connectionId" : "conn-1", + "sessionId" : null, + "jsonRpc" : null +}, { + "kind" : "http_response", + "status" : 200, + "scope" : "connection", + "connectionId" : "conn-1", + "sessionId" : null, + "jsonRpc" : null +}, { + "kind" : "http_request", + "method" : "POST", + "scope" : "connection", + "connectionId" : "conn-1", + "sessionId" : null, + "jsonRpc" : { + "type" : "request", + "id" : "id-2", + "method" : "session/new" + } +}, { + "kind" : "sse_event", + "stream" : "connection", + "sessionId" : null, + "jsonRpc" : { + "type" : "response", + "id" : "id-2", + "hasError" : false + } +}, { + "kind" : "http_response", + "status" : 202, + "scope" : "connection", + "connectionId" : "conn-1", + "sessionId" : null, + "jsonRpc" : null +}, { + "kind" : "http_request", + "method" : "GET", + "scope" : "session", + "connectionId" : "conn-1", + "sessionId" : "sess-1", + "jsonRpc" : null +}, { + "kind" : "http_response", + "status" : 200, + "scope" : "session", + "connectionId" : "conn-1", + "sessionId" : "sess-1", + "jsonRpc" : null +}, { + "kind" : "http_request", + "method" : "POST", + "scope" : "session", + "connectionId" : "conn-1", + "sessionId" : "sess-1", + "jsonRpc" : { + "type" : "request", + "id" : "id-3", + "method" : "session/prompt" + } +}, { + "kind" : "sse_event", + "stream" : "session", + "sessionId" : "sess-1", + "jsonRpc" : { + "type" : "request", + "id" : "id-4", + "method" : "session/request_permission" + } +}, { + "kind" : "http_response", + "status" : 202, + "scope" : "session", + "connectionId" : "conn-1", + "sessionId" : "sess-1", + "jsonRpc" : null +}, { + "kind" : "http_request", + "method" : "POST", + "scope" : "connection", + "connectionId" : "conn-1", + "sessionId" : null, + "jsonRpc" : { + "type" : "response", + "id" : "id-4", + "hasError" : false + } +}, { + "kind" : "http_response", + "status" : 400, + "scope" : "connection", + "connectionId" : "conn-1", + "sessionId" : null, + "jsonRpc" : null +}, { + "kind" : "http_request", + "method" : "DELETE", + "scope" : "connection", + "connectionId" : "conn-1", + "sessionId" : null, + "jsonRpc" : null +}, { + "kind" : "http_response", + "status" : 202, + "scope" : "connection", + "connectionId" : "conn-1", + "sessionId" : null, + "jsonRpc" : null +} ] \ No newline at end of file diff --git a/test-fixtures/streamable-http-client/package-lock.json b/test-fixtures/streamable-http-client/package-lock.json new file mode 100644 index 0000000..c1fceaa --- /dev/null +++ b/test-fixtures/streamable-http-client/package-lock.json @@ -0,0 +1,45 @@ +{ + "name": "acp-streamable-http-client-fixture", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "acp-streamable-http-client-fixture", + "devDependencies": { + "@types/node": "^22.10.2", + "typescript": "^5.7.2" + } + }, + "node_modules/@types/node": { + "version": "22.19.19", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.19.tgz", + "integrity": "sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/test-fixtures/streamable-http-client/package.json b/test-fixtures/streamable-http-client/package.json new file mode 100644 index 0000000..75235c1 --- /dev/null +++ b/test-fixtures/streamable-http-client/package.json @@ -0,0 +1,12 @@ +{ + "name": "acp-streamable-http-client-fixture", + "private": true, + "type": "module", + "scripts": { + "build": "tsc" + }, + "devDependencies": { + "@types/node": "^22.10.2", + "typescript": "^5.7.2" + } +} diff --git a/test-fixtures/streamable-http-client/src/client.ts b/test-fixtures/streamable-http-client/src/client.ts new file mode 100644 index 0000000..eaf151d --- /dev/null +++ b/test-fixtures/streamable-http-client/src/client.ts @@ -0,0 +1,19 @@ +import { StreamableHttpFixtureClient } from "./protocol.js"; +import { runScenario } from "./scenarios.js"; +import { TranscriptRecorder } from "./transcript.js"; + +const endpoint = readArg("--endpoint"); +if (!endpoint) { + throw new Error("--endpoint is required"); +} +const scenario = readArg("--scenario") ?? "happy-path"; +const recorder = new TranscriptRecorder(); +const client = new StreamableHttpFixtureClient(endpoint, recorder); + +await runScenario(scenario, endpoint, client); +process.stdout.write(recorder.serialize()); + +function readArg(name: string): string | undefined { + const index = process.argv.indexOf(name); + return index >= 0 ? process.argv[index + 1] : undefined; +} diff --git a/test-fixtures/streamable-http-client/src/protocol.ts b/test-fixtures/streamable-http-client/src/protocol.ts new file mode 100644 index 0000000..7f44734 --- /dev/null +++ b/test-fixtures/streamable-http-client/src/protocol.ts @@ -0,0 +1,278 @@ +import { TranscriptRecorder } from "./transcript.js"; + +export const CONNECTION_HEADER = "Acp-Connection-Id"; +export const SESSION_HEADER = "Acp-Session-Id"; + +export type JsonRpcMessage = Record; +export type Scope = "bootstrap" | "connection" | "session"; + +export class StreamableHttpFixtureClient { + private connectionId: string | null = null; + private nextId = 1; + + constructor( + private readonly endpoint: string, + private readonly recorder: TranscriptRecorder, + ) {} + + async initialize(): Promise { + const request = this.request("initialize", { + protocolVersion: 1, + clientCapabilities: {}, + }); + const response = await this.post(request, "bootstrap", null, true); + const connectionId = response.headers.get(CONNECTION_HEADER); + if (!connectionId) { + throw new Error("initialize response missing connection id"); + } + this.connectionId = connectionId; + return await response.json(); + } + + async postMessage(message: JsonRpcMessage, sessionId: string | null = null): Promise { + return this.post(message, sessionId ? "session" : "connection", sessionId, false); + } + + async openConnectionStream(): Promise { + return SseStream.open(this.endpoint, this.recorder, "connection", this.requireConnectionId(), null); + } + + async openSessionStream(sessionId: string): Promise { + return SseStream.open(this.endpoint, this.recorder, "session", this.requireConnectionId(), sessionId); + } + + async close(): Promise { + const headers = { + [CONNECTION_HEADER]: this.requireConnectionId(), + }; + this.recorder.record({ + kind: "http_request", + method: "DELETE", + scope: "connection", + connectionId: this.connectionId, + sessionId: null, + jsonRpc: null, + }); + const response = await fetch(this.endpoint, { + method: "DELETE", + headers, + }); + this.recorder.record({ + kind: "http_response", + status: response.status, + scope: "connection", + connectionId: this.connectionId, + sessionId: null, + jsonRpc: null, + }); + return response; + } + + async rawRequest( + method: string, + scope: Scope, + headers: Record, + body: JsonRpcMessage | null, + sessionId: string | null = null, + ): Promise { + this.recorder.record({ + kind: "http_request", + method, + scope, + connectionId: headers[CONNECTION_HEADER] ?? null, + sessionId, + jsonRpc: this.recorder.summarizeJsonRpc(body), + }); + const response = await fetch(this.endpoint, { + method, + headers, + ...(body ? { body: JSON.stringify(body) } : {}), + }); + this.recorder.record({ + kind: "http_response", + status: response.status, + scope, + connectionId: response.headers.get(CONNECTION_HEADER) ?? headers[CONNECTION_HEADER] ?? null, + sessionId: response.headers.get(SESSION_HEADER) ?? sessionId, + jsonRpc: null, + }); + return response; + } + + request(method: string, params: Record): JsonRpcMessage { + return { + jsonrpc: "2.0", + id: `req-${this.nextId++}`, + method, + params, + }; + } + + response(id: unknown, result: Record): JsonRpcMessage { + return { + jsonrpc: "2.0", + id, + result, + }; + } + + private async post( + message: JsonRpcMessage, + scope: Scope, + sessionId: string | null, + expectJson: boolean, + ): Promise { + const headers: Record = { + "content-type": "application/json", + accept: "application/json", + }; + if (scope !== "bootstrap") { + headers[CONNECTION_HEADER] = this.requireConnectionId(); + } + if (sessionId) { + headers[SESSION_HEADER] = sessionId; + } + this.recorder.record({ + kind: "http_request", + method: "POST", + scope, + connectionId: this.connectionId, + sessionId, + jsonRpc: this.recorder.summarizeJsonRpc(message), + }); + const response = await fetch(this.endpoint, { + method: "POST", + headers, + body: JSON.stringify(message), + }); + let jsonRpc = null; + if (expectJson) { + jsonRpc = this.recorder.summarizeJsonRpc(await response.clone().json()); + } + this.recorder.record({ + kind: "http_response", + status: response.status, + scope, + connectionId: response.headers.get(CONNECTION_HEADER) ?? this.connectionId, + sessionId, + jsonRpc, + }); + return response; + } + + private requireConnectionId(): string { + if (!this.connectionId) { + throw new Error("connection id not initialized"); + } + return this.connectionId; + } +} + +export class SseStream { + private readonly messages: JsonRpcMessage[] = []; + private readonly waiters: Array<(message: JsonRpcMessage) => void> = []; + private readonly abortController = new AbortController(); + + private constructor( + private readonly recorder: TranscriptRecorder, + private readonly stream: "connection" | "session", + private readonly sessionId: string | null, + private readonly response: Response, + ) {} + + static async open( + endpoint: string, + recorder: TranscriptRecorder, + stream: "connection" | "session", + connectionId: string, + sessionId: string | null, + ): Promise { + recorder.record({ + kind: "http_request", + method: "GET", + scope: stream, + connectionId, + sessionId, + jsonRpc: null, + }); + const response = await fetch(endpoint, { + method: "GET", + headers: { + accept: "text/event-stream", + [CONNECTION_HEADER]: connectionId, + ...(sessionId ? { [SESSION_HEADER]: sessionId } : {}), + }, + }); + recorder.record({ + kind: "http_response", + status: response.status, + scope: stream, + connectionId: response.headers.get(CONNECTION_HEADER), + sessionId: response.headers.get(SESSION_HEADER), + jsonRpc: null, + }); + const result = new SseStream(recorder, stream, sessionId, response); + void result.readLoop(); + return result; + } + + async next(): Promise { + const existing = this.messages.shift(); + if (existing) { + return existing; + } + return new Promise((resolve) => this.waiters.push(resolve)); + } + + close(): void { + this.abortController.abort(); + } + + private async readLoop(): Promise { + if (!this.response.body) { + return; + } + const reader = this.response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + for (;;) { + const { value, done } = await reader.read(); + if (done) { + break; + } + buffer += decoder.decode(value, { stream: true }); + for (;;) { + const boundary = buffer.indexOf("\n\n"); + if (boundary < 0) { + break; + } + const rawEvent = buffer.slice(0, boundary); + buffer = buffer.slice(boundary + 2); + const data = rawEvent + .split("\n") + .filter((line) => line.startsWith("data:")) + .map((line) => line.slice(5).trimStart()) + .join("\n"); + if (!data) { + continue; + } + const message = JSON.parse(data) as JsonRpcMessage; + const summary = this.recorder.summarizeJsonRpc(message); + if (summary) { + this.recorder.record({ + kind: "sse_event", + stream: this.stream, + sessionId: this.sessionId, + jsonRpc: summary, + }); + } + const waiter = this.waiters.shift(); + if (waiter) { + waiter(message); + } else { + this.messages.push(message); + } + } + } + } +} diff --git a/test-fixtures/streamable-http-client/src/scenarios.ts b/test-fixtures/streamable-http-client/src/scenarios.ts new file mode 100644 index 0000000..ef5ac89 --- /dev/null +++ b/test-fixtures/streamable-http-client/src/scenarios.ts @@ -0,0 +1,134 @@ +import { CONNECTION_HEADER, StreamableHttpFixtureClient } from "./protocol.js"; + +export async function runScenario(name: string, endpoint: string, client: StreamableHttpFixtureClient): Promise { + switch (name) { + case "happy-path": + await happyPath(client); + return; + case "permission-round-trip": + await permissionRoundTrip(client); + return; + case "session-load": + await sessionLoad(client); + return; + case "two-sessions": + await twoSessions(client); + return; + case "wrong-stream-response": + await wrongStreamResponse(client); + return; + case "validation-failures": + await validationFailures(endpoint, client); + return; + default: + throw new Error(`Unknown scenario ${name}`); + } +} + +async function happyPath(client: StreamableHttpFixtureClient): Promise { + await client.initialize(); + const connection = await client.openConnectionStream(); + const newSession = client.request("session/new", { cwd: "/workspace", mcpServers: [] }); + await client.postMessage(newSession); + const sessionResponse = await connection.next(); + const sessionId = ((sessionResponse.result as Record).sessionId as string); + const session = await client.openSessionStream(sessionId); + await client.postMessage(client.request("session/prompt", { + sessionId, + prompt: [{ type: "text", text: "hello" }], + }), sessionId); + await session.next(); + await session.next(); + await client.close(); +} + +async function permissionRoundTrip(client: StreamableHttpFixtureClient): Promise { + await client.initialize(); + const connection = await client.openConnectionStream(); + await client.postMessage(client.request("session/new", { cwd: "/workspace", mcpServers: [] })); + const sessionResponse = await connection.next(); + const sessionId = ((sessionResponse.result as Record).sessionId as string); + const session = await client.openSessionStream(sessionId); + await client.postMessage(client.request("session/prompt", { + sessionId, + prompt: [{ type: "text", text: "needs permission" }], + }), sessionId); + const permissionRequest = await session.next(); + await client.postMessage(client.response(permissionRequest.id, { outcome: { outcome: "selected", optionId: "allow" } }), sessionId); + await session.next(); + await session.next(); + await client.close(); +} + +async function sessionLoad(client: StreamableHttpFixtureClient): Promise { + await client.initialize(); + const connection = await client.openConnectionStream(); + const session = await client.openSessionStream("sess-load"); + await client.postMessage(client.request("session/load", { + sessionId: "sess-load", + cwd: "/workspace", + mcpServers: [], + }), "sess-load"); + await connection.next(); + session.close(); + await client.close(); +} + +async function twoSessions(client: StreamableHttpFixtureClient): Promise { + await client.initialize(); + const connection = await client.openConnectionStream(); + await client.postMessage(client.request("session/new", { cwd: "/workspace/one", mcpServers: [] })); + const first = await connection.next(); + const firstId = ((first.result as Record).sessionId as string); + await client.postMessage(client.request("session/new", { cwd: "/workspace/two", mcpServers: [] })); + const second = await connection.next(); + const secondId = ((second.result as Record).sessionId as string); + const firstStream = await client.openSessionStream(firstId); + const secondStream = await client.openSessionStream(secondId); + await client.postMessage(client.request("session/prompt", { + sessionId: firstId, + prompt: [{ type: "text", text: "one" }], + }), firstId); + await firstStream.next(); + await firstStream.next(); + await client.postMessage(client.request("session/prompt", { + sessionId: secondId, + prompt: [{ type: "text", text: "two" }], + }), secondId); + await secondStream.next(); + await secondStream.next(); + await client.close(); +} + +async function wrongStreamResponse(client: StreamableHttpFixtureClient): Promise { + await client.initialize(); + const connection = await client.openConnectionStream(); + await client.postMessage(client.request("session/new", { cwd: "/workspace", mcpServers: [] })); + const sessionResponse = await connection.next(); + const sessionId = ((sessionResponse.result as Record).sessionId as string); + const session = await client.openSessionStream(sessionId); + await client.postMessage(client.request("session/prompt", { + sessionId, + prompt: [{ type: "text", text: "needs permission" }], + }), sessionId); + const permissionRequest = await session.next(); + await client.postMessage(client.response(permissionRequest.id, { outcome: { outcome: "selected", optionId: "allow" } })); + await client.close(); +} + +async function validationFailures(endpoint: string, client: StreamableHttpFixtureClient): Promise { + await client.initialize(); + await client.rawRequest("POST", "connection", { + accept: "application/json", + "content-type": "text/plain", + [CONNECTION_HEADER]: "conn-invalid", + }, {}); + await client.rawRequest("GET", "connection", { + accept: "text/event-stream", + }, null); + await client.rawRequest("GET", "connection", { + accept: "application/json", + [CONNECTION_HEADER]: "missing", + }, null); + await client.close(); +} diff --git a/test-fixtures/streamable-http-client/src/transcript.ts b/test-fixtures/streamable-http-client/src/transcript.ts new file mode 100644 index 0000000..ac8a8ff --- /dev/null +++ b/test-fixtures/streamable-http-client/src/transcript.ts @@ -0,0 +1,115 @@ +export type JsonRpcSummary = + | { + type: "request"; + id: string | null; + method: string; + } + | { + type: "notification"; + method: string; + } + | { + type: "response"; + id: string | null; + hasError: boolean; + }; + +export type TranscriptEvent = + | { + kind: "http_request"; + method: string; + scope: "bootstrap" | "connection" | "session"; + connectionId: string | null; + sessionId: string | null; + jsonRpc: JsonRpcSummary | null; + } + | { + kind: "http_response"; + status: number; + scope: "bootstrap" | "connection" | "session"; + connectionId: string | null; + sessionId: string | null; + jsonRpc: JsonRpcSummary | null; + } + | { + kind: "sse_event"; + stream: "connection" | "session"; + sessionId: string | null; + jsonRpc: JsonRpcSummary; + }; + +export class TranscriptRecorder { + private readonly events: TranscriptEvent[] = []; + private readonly idAliases = new Map(); + private readonly connectionAliases = new Map(); + + record(event: TranscriptEvent): void { + if ("connectionId" in event) { + this.events.push({ + ...event, + connectionId: this.normalizeConnectionId(event.connectionId), + }); + return; + } + this.events.push(event); + } + + summarizeJsonRpc(message: unknown): JsonRpcSummary | null { + if (!message || typeof message !== "object") { + return null; + } + + const candidate = message as Record; + if (typeof candidate.method === "string" && "id" in candidate) { + return { + type: "request", + id: this.normalizeId(candidate.id), + method: candidate.method, + }; + } + if (typeof candidate.method === "string") { + return { + type: "notification", + method: candidate.method, + }; + } + if ("result" in candidate || "error" in candidate) { + return { + type: "response", + id: this.normalizeId(candidate.id), + hasError: candidate.error != null, + }; + } + return null; + } + + serialize(): string { + return JSON.stringify(this.events, null, 2); + } + + private normalizeId(id: unknown): string | null { + if (typeof id !== "string" && typeof id !== "number") { + return null; + } + const existing = this.idAliases.get(id); + if (existing) { + return existing; + } + const next = `id-${this.idAliases.size + 1}`; + this.idAliases.set(id, next); + return next; + } + + private normalizeConnectionId(connectionId: string | null): string | null { + if (!connectionId) { + return null; + } + const existing = this.connectionAliases.get(connectionId); + if (existing) { + return existing; + } + const next = `conn-${this.connectionAliases.size + 1}`; + this.connectionAliases.set(connectionId, next); + return next; + } +} diff --git a/test-fixtures/streamable-http-client/tsconfig.json b/test-fixtures/streamable-http-client/tsconfig.json new file mode 100644 index 0000000..78c0292 --- /dev/null +++ b/test-fixtures/streamable-http-client/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "outDir": "dist", + "rootDir": "src", + "strict": true, + "esModuleInterop": true + }, + "include": ["src/**/*.ts"] +} From e2ba51c6d56a0110574713d5d2e347ea26084b06 Mon Sep 17 00:00:00 2001 From: Kaiser Dandangi Date: Mon, 18 May 2026 17:53:55 -0400 Subject: [PATCH 05/15] test: add streamable HTTP demo agent server --- README.md | 11 ++ plans/STREAMABLE-HTTP-AGENT-TRANSPORT.md | 12 ++ pom.xml | 6 + .../streamable-http-agent-server/README.md | 29 ++++ .../streamable-http-agent-server/pom.xml | 64 +++++++ .../StreamableHttpAgentDemoServer.java | 164 ++++++++++++++++++ .../src/main/resources/logback.xml | 10 ++ 7 files changed, 296 insertions(+) create mode 100644 test-fixtures/streamable-http-agent-server/README.md create mode 100644 test-fixtures/streamable-http-agent-server/pom.xml create mode 100644 test-fixtures/streamable-http-agent-server/src/main/java/com/agentclientprotocol/sdk/fixtures/StreamableHttpAgentDemoServer.java create mode 100644 test-fixtures/streamable-http-agent-server/src/main/resources/logback.xml diff --git a/README.md b/README.md index 0d75e92..0776ded 100644 --- a/README.md +++ b/README.md @@ -392,6 +392,17 @@ agent.start().block(); // Starts WebSocket server on port 8080 | WebSocket | `WebSocketAcpClientTransport` | `WebSocketAcpAgentTransport` | acp-core / acp-websocket-jetty | | Streamable HTTP | `StreamableHttpAcpClientTransport` | `StreamableHttpAcpAgentTransport` | acp-core / acp-streamable-http-jetty | +### Streamable HTTP Demo Server + +Build and run a local demo ACP agent over HTTP/SSE: + +```bash +./mvnw -q -pl test-fixtures/streamable-http-agent-server -am -DskipTests package +java -jar test-fixtures/streamable-http-agent-server/target/acp-streamable-http-agent-server.jar --port 8080 +``` + +The endpoint will be available at `http://127.0.0.1:8080/acp`. + --- ## Building diff --git a/plans/STREAMABLE-HTTP-AGENT-TRANSPORT.md b/plans/STREAMABLE-HTTP-AGENT-TRANSPORT.md index a3c47e6..ff0728b 100644 --- a/plans/STREAMABLE-HTTP-AGENT-TRANSPORT.md +++ b/plans/STREAMABLE-HTTP-AGENT-TRANSPORT.md @@ -109,6 +109,18 @@ Covered scenarios: The Java module also keeps focused integration coverage for strict unknown-session behavior. +## Demo Server + +Add a runnable Java demo server at: + +```text +test-fixtures/streamable-http-agent-server/ +``` + +It packages a small echo-style ACP agent into a runnable jar backed by the real +Jetty `StreamableHttpAcpAgentTransport`, so manual testing can exercise a live +HTTP/SSE endpoint instead of only the integration-test fixture lifecycle. + ## PLAN / Follow-Up Work - extract a shared remote-core layer only after HTTP parity is proven diff --git a/pom.xml b/pom.xml index 3ccfd64..51e7c93 100644 --- a/pom.xml +++ b/pom.xml @@ -19,6 +19,7 @@ acp-agent-support acp-test acp-streamable-http-jetty + test-fixtures/streamable-http-agent-server acp-websocket-jetty @@ -115,6 +116,11 @@ acp-test ${project.version} + + com.agentclientprotocol + acp-streamable-http-jetty + ${project.version} + diff --git a/test-fixtures/streamable-http-agent-server/README.md b/test-fixtures/streamable-http-agent-server/README.md new file mode 100644 index 0000000..fb39d6c --- /dev/null +++ b/test-fixtures/streamable-http-agent-server/README.md @@ -0,0 +1,29 @@ +# Streamable HTTP Agent Demo Server + +This is a small runnable Java ACP agent that serves the new Streamable HTTP +agent transport from a real Jetty web server. + +Build the runnable jar: + +```bash +./mvnw -q -pl test-fixtures/streamable-http-agent-server -am -DskipTests package +``` + +Run it: + +```bash +java -jar test-fixtures/streamable-http-agent-server/target/acp-streamable-http-agent-server.jar --port 8080 +``` + +Then drive it with the fixture client from another shell: + +```bash +cd test-fixtures/streamable-http-client +npm install +npm run build +node dist/client.js --endpoint http://127.0.0.1:8080/acp --scenario happy-path +``` + +The demo supports `initialize`, `session/new`, `session/load`, `session/prompt`, +and `session/cancel`. Prompts containing the word `permission` also exercise the +agent-to-client `session/request_permission` round trip. diff --git a/test-fixtures/streamable-http-agent-server/pom.xml b/test-fixtures/streamable-http-agent-server/pom.xml new file mode 100644 index 0000000..aeca87a --- /dev/null +++ b/test-fixtures/streamable-http-agent-server/pom.xml @@ -0,0 +1,64 @@ + + + 4.0.0 + + + com.agentclientprotocol + acp-java-sdk + 0.12.0-SNAPSHOT + ../../pom.xml + + + acp-streamable-http-agent-server + jar + + ACP Streamable HTTP Agent Server Demo + Runnable demo server for the Streamable HTTP agent transport + + + + true + + + + + com.agentclientprotocol + acp-streamable-http-jetty + + + ch.qos.logback + logback-classic + runtime + + + + + + + org.apache.maven.plugins + maven-shade-plugin + 3.5.3 + + + package + + shade + + + false + acp-streamable-http-agent-server + + + com.agentclientprotocol.sdk.fixtures.StreamableHttpAgentDemoServer + + + + + + + + + + diff --git a/test-fixtures/streamable-http-agent-server/src/main/java/com/agentclientprotocol/sdk/fixtures/StreamableHttpAgentDemoServer.java b/test-fixtures/streamable-http-agent-server/src/main/java/com/agentclientprotocol/sdk/fixtures/StreamableHttpAgentDemoServer.java new file mode 100644 index 0000000..4ba9967 --- /dev/null +++ b/test-fixtures/streamable-http-agent-server/src/main/java/com/agentclientprotocol/sdk/fixtures/StreamableHttpAgentDemoServer.java @@ -0,0 +1,164 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.fixtures; + +import java.time.Duration; +import java.util.Locale; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicInteger; + +import com.agentclientprotocol.sdk.agent.AcpAgent; +import com.agentclientprotocol.sdk.agent.AcpAgentFactory; +import com.agentclientprotocol.sdk.agent.transport.StreamableHttpAcpAgentTransport; +import com.agentclientprotocol.sdk.json.AcpJsonMapper; +import com.agentclientprotocol.sdk.spec.AcpSchema; +import reactor.core.publisher.Mono; + +/** + * Small runnable ACP agent server for manually exercising the Streamable HTTP transport. + * + * @author Kaiser Dandangi + */ +public final class StreamableHttpAgentDemoServer { + + private static final Duration START_TIMEOUT = Duration.ofSeconds(30); + + private static final Duration STOP_TIMEOUT = Duration.ofSeconds(5); + + private StreamableHttpAgentDemoServer() { + } + + public static void main(String[] args) { + Options options; + try { + options = Options.parse(args); + } + catch (IllegalArgumentException e) { + System.err.println(e.getMessage()); + printUsage(); + System.exit(2); + return; + } + + if (options.help()) { + printUsage(); + return; + } + + Map sessionCwds = new ConcurrentHashMap<>(); + AtomicInteger sessionCounter = new AtomicInteger(); + + AcpAgentFactory agentFactory = AcpAgentFactory.async(transport -> AcpAgent.async(transport) + .requestTimeout(Duration.ofMinutes(2)) + .initializeHandler(request -> Mono.just(AcpSchema.InitializeResponse.ok( + new AcpSchema.AgentCapabilities(true, new AcpSchema.McpCapabilities(), + new AcpSchema.PromptCapabilities())))) + .newSessionHandler(request -> { + String sessionId = "demo-session-" + sessionCounter.incrementAndGet(); + sessionCwds.put(sessionId, request.cwd()); + return Mono.just(new AcpSchema.NewSessionResponse(sessionId, null, null)); + }) + .loadSessionHandler(request -> { + sessionCwds.put(request.sessionId(), request.cwd()); + return Mono.just(new AcpSchema.LoadSessionResponse(null, null)); + }) + .promptHandler((request, context) -> { + String text = request.text(); + String normalized = text == null || text.isBlank() ? "(empty prompt)" : text; + String cwd = sessionCwds.getOrDefault(request.sessionId(), "(unknown cwd)"); + Mono response = normalized.toLowerCase(Locale.ROOT).contains("permission") + ? context.askPermission("Demo agent permission check for session " + request.sessionId()) + .flatMap(allowed -> context.sendMessage( + "Permission " + (allowed ? "granted" : "denied") + ". Prompt: " + normalized)) + .onErrorResume(error -> context.sendMessage( + "Permission request failed in demo server: " + error.getMessage())) + : context.sendMessage("Demo agent received: " + normalized + " [cwd=" + cwd + "]"); + return response.thenReturn(AcpSchema.PromptResponse.endTurn()); + }) + .cancelHandler(notification -> { + System.out.println("Received cancel for session " + notification.sessionId()); + return Mono.empty(); + }) + .build()); + + StreamableHttpAcpAgentTransport server = new StreamableHttpAcpAgentTransport(options.port(), options.path(), + AcpJsonMapper.createDefault(), agentFactory) + .routingMode(options.routingMode()); + + Runtime.getRuntime().addShutdownHook(new Thread(() -> server.closeGracefully().block(STOP_TIMEOUT), + "acp-demo-shutdown")); + + server.start().block(START_TIMEOUT); + System.out.println("ACP Streamable HTTP demo agent listening at http://127.0.0.1:" + server.getPort() + + options.path()); + System.out.println("Press Ctrl-C to stop."); + server.awaitTermination().block(); + } + + private static void printUsage() { + System.out.println(""" + Usage: java -jar acp-streamable-http-agent-server.jar [options] + + Options: + --port Port to listen on. Defaults to 8080. + --path ACP endpoint path. Defaults to /acp. + --strict Use strict transport routing. + --compatible Use compatible transport routing. This is the default. + -h, --help Show this help. + """); + } + + private record Options(int port, String path, StreamableHttpAcpAgentTransport.RoutingMode routingMode, + boolean help) { + + static Options parse(String[] args) { + int port = 8080; + String path = StreamableHttpAcpAgentTransport.DEFAULT_ACP_PATH; + StreamableHttpAcpAgentTransport.RoutingMode routingMode = + StreamableHttpAcpAgentTransport.RoutingMode.COMPATIBLE; + boolean help = false; + + for (int i = 0; i < args.length; i++) { + String arg = args[i]; + switch (arg) { + case "--port" -> port = parsePort(requireValue(args, ++i, "--port")); + case "--path" -> path = requireValue(args, ++i, "--path"); + case "--strict" -> routingMode = StreamableHttpAcpAgentTransport.RoutingMode.STRICT; + case "--compatible" -> routingMode = StreamableHttpAcpAgentTransport.RoutingMode.COMPATIBLE; + case "-h", "--help" -> help = true; + default -> throw new IllegalArgumentException("Unknown option: " + arg); + } + } + + if (!path.startsWith("/")) { + throw new IllegalArgumentException("--path must start with /"); + } + return new Options(port, path, routingMode, help); + } + + private static String requireValue(String[] args, int index, String option) { + if (index >= args.length || args[index].startsWith("--")) { + throw new IllegalArgumentException(option + " requires a value"); + } + return args[index]; + } + + private static int parsePort(String value) { + try { + int port = Integer.parseInt(value); + if (port <= 0) { + throw new IllegalArgumentException("--port must be positive"); + } + return port; + } + catch (NumberFormatException e) { + throw new IllegalArgumentException("--port must be a number", e); + } + } + + } + +} diff --git a/test-fixtures/streamable-http-agent-server/src/main/resources/logback.xml b/test-fixtures/streamable-http-agent-server/src/main/resources/logback.xml new file mode 100644 index 0000000..2bcf49b --- /dev/null +++ b/test-fixtures/streamable-http-agent-server/src/main/resources/logback.xml @@ -0,0 +1,10 @@ + + + + %d{HH:mm:ss.SSS} %-5level %logger{36} - %msg%n + + + + + + From 060be4d1d01f940b0bd5569aeeac7927c0ab3dd7 Mon Sep 17 00:00:00 2001 From: Kaiser Dandangi Date: Mon, 18 May 2026 21:09:08 -0400 Subject: [PATCH 06/15] test: add OpenAI backend to streamable HTTP demo --- .../streamable-http-agent-server/README.md | 16 ++ .../streamable-http-agent-server/pom.xml | 17 ++ .../StreamableHttpAgentDemoServer.java | 196 +++++++++++++++++- 3 files changed, 219 insertions(+), 10 deletions(-) diff --git a/test-fixtures/streamable-http-agent-server/README.md b/test-fixtures/streamable-http-agent-server/README.md index fb39d6c..3d5c753 100644 --- a/test-fixtures/streamable-http-agent-server/README.md +++ b/test-fixtures/streamable-http-agent-server/README.md @@ -27,3 +27,19 @@ node dist/client.js --endpoint http://127.0.0.1:8080/acp --scenario happy-path The demo supports `initialize`, `session/new`, `session/load`, `session/prompt`, and `session/cancel`. Prompts containing the word `permission` also exercise the agent-to-client `session/request_permission` round trip. + +By default, the server uses a deterministic echo backend. To exercise the same +ACP transport with a real OpenAI-backed agent through Spring AI: + +```bash +export OPENAI_API_KEY=... +# Optional; defaults to OPENAI_MODEL or gpt-4o-mini. +export OPENAI_MODEL=gpt-4o-mini + +java -jar test-fixtures/streamable-http-agent-server/target/acp-streamable-http-agent-server.jar \ + --port 8080 \ + --backend spring-ai-openai +``` + +The Spring AI backend is intentionally scoped to this runnable fixture. It is not +part of the core SDK transport implementation. diff --git a/test-fixtures/streamable-http-agent-server/pom.xml b/test-fixtures/streamable-http-agent-server/pom.xml index aeca87a..f054b91 100644 --- a/test-fixtures/streamable-http-agent-server/pom.xml +++ b/test-fixtures/streamable-http-agent-server/pom.xml @@ -20,13 +20,30 @@ true + 1.1.6 + + + + org.springframework.ai + spring-ai-bom + ${spring-ai.version} + pom + import + + + + com.agentclientprotocol acp-streamable-http-jetty + + org.springframework.ai + spring-ai-openai + ch.qos.logback logback-classic diff --git a/test-fixtures/streamable-http-agent-server/src/main/java/com/agentclientprotocol/sdk/fixtures/StreamableHttpAgentDemoServer.java b/test-fixtures/streamable-http-agent-server/src/main/java/com/agentclientprotocol/sdk/fixtures/StreamableHttpAgentDemoServer.java index 4ba9967..3061624 100644 --- a/test-fixtures/streamable-http-agent-server/src/main/java/com/agentclientprotocol/sdk/fixtures/StreamableHttpAgentDemoServer.java +++ b/test-fixtures/streamable-http-agent-server/src/main/java/com/agentclientprotocol/sdk/fixtures/StreamableHttpAgentDemoServer.java @@ -5,9 +5,12 @@ package com.agentclientprotocol.sdk.fixtures; import java.time.Duration; +import java.util.List; import java.util.Locale; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; import java.util.concurrent.atomic.AtomicInteger; import com.agentclientprotocol.sdk.agent.AcpAgent; @@ -15,7 +18,17 @@ import com.agentclientprotocol.sdk.agent.transport.StreamableHttpAcpAgentTransport; import com.agentclientprotocol.sdk.json.AcpJsonMapper; import com.agentclientprotocol.sdk.spec.AcpSchema; +import org.springframework.ai.chat.messages.SystemMessage; +import org.springframework.ai.chat.messages.UserMessage; +import org.springframework.ai.chat.model.ChatResponse; +import org.springframework.ai.chat.model.Generation; +import org.springframework.ai.chat.prompt.Prompt; +import org.springframework.ai.openai.OpenAiChatModel; +import org.springframework.ai.openai.OpenAiChatOptions; +import org.springframework.ai.openai.api.OpenAiApi; import reactor.core.publisher.Mono; +import reactor.core.scheduler.Scheduler; +import reactor.core.scheduler.Schedulers; /** * Small runnable ACP agent server for manually exercising the Streamable HTTP transport. @@ -28,6 +41,13 @@ public final class StreamableHttpAgentDemoServer { private static final Duration STOP_TIMEOUT = Duration.ofSeconds(5); + private static final String OPENAI_SYSTEM_PROMPT = """ + You are a small ACP demo agent running inside the Java SDK Streamable HTTP fixture. + Answer concisely. If the user asks about implementation details, say that this + fixture is exercising the ACP Streamable HTTP transport, not providing a full + production agent runtime. + """; + private StreamableHttpAgentDemoServer() { } @@ -50,6 +70,15 @@ public static void main(String[] args) { Map sessionCwds = new ConcurrentHashMap<>(); AtomicInteger sessionCounter = new AtomicInteger(); + PromptBackend promptBackend; + try { + promptBackend = options.backend().create(); + } + catch (IllegalArgumentException e) { + System.err.println(e.getMessage()); + System.exit(2); + return; + } AcpAgentFactory agentFactory = AcpAgentFactory.async(transport -> AcpAgent.async(transport) .requestTimeout(Duration.ofMinutes(2)) @@ -75,7 +104,7 @@ public static void main(String[] args) { "Permission " + (allowed ? "granted" : "denied") + ". Prompt: " + normalized)) .onErrorResume(error -> context.sendMessage( "Permission request failed in demo server: " + error.getMessage())) - : context.sendMessage("Demo agent received: " + normalized + " [cwd=" + cwd + "]"); + : promptBackend.generate(normalized, request.sessionId(), cwd).flatMap(context::sendMessage); return response.thenReturn(AcpSchema.PromptResponse.endTurn()); }) .cancelHandler(notification -> { @@ -88,8 +117,14 @@ public static void main(String[] args) { AcpJsonMapper.createDefault(), agentFactory) .routingMode(options.routingMode()); - Runtime.getRuntime().addShutdownHook(new Thread(() -> server.closeGracefully().block(STOP_TIMEOUT), - "acp-demo-shutdown")); + Runtime.getRuntime().addShutdownHook(new Thread(() -> { + try { + server.closeGracefully().block(STOP_TIMEOUT); + } + finally { + promptBackend.close(); + } + }, "acp-demo-shutdown")); server.start().block(START_TIMEOUT); System.out.println("ACP Streamable HTTP demo agent listening at http://127.0.0.1:" + server.getPort() @@ -103,22 +138,30 @@ private static void printUsage() { Usage: java -jar acp-streamable-http-agent-server.jar [options] Options: - --port Port to listen on. Defaults to 8080. - --path ACP endpoint path. Defaults to /acp. - --strict Use strict transport routing. - --compatible Use compatible transport routing. This is the default. - -h, --help Show this help. + --port Port to listen on. Defaults to 8080. + --path ACP endpoint path. Defaults to /acp. + --backend Agent backend: echo or spring-ai-openai. Defaults to echo. + --openai-model OpenAI model for spring-ai-openai. Defaults to OPENAI_MODEL or gpt-4o-mini. + --strict Use strict transport routing. + --compatible Use compatible transport routing. This is the default. + -h, --help Show this help. + + Environment: + OPENAI_API_KEY Required when --backend spring-ai-openai is used. + OPENAI_MODEL Optional default model for --backend spring-ai-openai. """); } private record Options(int port, String path, StreamableHttpAcpAgentTransport.RoutingMode routingMode, - boolean help) { + Backend backend, boolean help) { static Options parse(String[] args) { int port = 8080; String path = StreamableHttpAcpAgentTransport.DEFAULT_ACP_PATH; StreamableHttpAcpAgentTransport.RoutingMode routingMode = StreamableHttpAcpAgentTransport.RoutingMode.COMPATIBLE; + String backendName = "echo"; + String openAiModel = null; boolean help = false; for (int i = 0; i < args.length; i++) { @@ -126,6 +169,8 @@ static Options parse(String[] args) { switch (arg) { case "--port" -> port = parsePort(requireValue(args, ++i, "--port")); case "--path" -> path = requireValue(args, ++i, "--path"); + case "--backend" -> backendName = requireValue(args, ++i, "--backend"); + case "--openai-model" -> openAiModel = requireValue(args, ++i, "--openai-model"); case "--strict" -> routingMode = StreamableHttpAcpAgentTransport.RoutingMode.STRICT; case "--compatible" -> routingMode = StreamableHttpAcpAgentTransport.RoutingMode.COMPATIBLE; case "-h", "--help" -> help = true; @@ -136,7 +181,8 @@ static Options parse(String[] args) { if (!path.startsWith("/")) { throw new IllegalArgumentException("--path must start with /"); } - return new Options(port, path, routingMode, help); + Backend backend = Backend.parse(backendName, openAiModel); + return new Options(port, path, routingMode, backend, help); } private static String requireValue(String[] args, int index, String option) { @@ -161,4 +207,134 @@ private static int parsePort(String value) { } + private sealed interface Backend permits EchoBackend, SpringAiOpenAiBackend { + + PromptBackend create(); + + static Backend echo() { + return new EchoBackend(); + } + + static Backend springAiOpenAi(String model) { + return new SpringAiOpenAiBackend(model); + } + + static Backend parse(String value, String openAiModel) { + return switch (value) { + case "echo" -> echo(); + case "spring-ai-openai" -> springAiOpenAi(openAiModel); + default -> throw new IllegalArgumentException("Unknown backend: " + value); + }; + } + + } + + @FunctionalInterface + private interface PromptBackend { + + Mono generate(String prompt, String sessionId, String cwd); + + default void close() { + } + + } + + private record EchoBackend() implements Backend { + + @Override + public PromptBackend create() { + return new EchoPromptBackend(); + } + + } + + private static final class EchoPromptBackend implements PromptBackend { + + @Override + public Mono generate(String prompt, String sessionId, String cwd) { + return Mono.just("Demo agent received: " + prompt + " [cwd=" + cwd + "]"); + } + + } + + private record SpringAiOpenAiBackend(String model) implements Backend { + + @Override + public PromptBackend create() { + String apiKey = System.getenv("OPENAI_API_KEY"); + if (apiKey == null || apiKey.isBlank()) { + throw new IllegalArgumentException( + "OPENAI_API_KEY is required when --backend spring-ai-openai is used"); + } + + OpenAiApi openAiApi = OpenAiApi.builder().apiKey(apiKey).build(); + OpenAiChatOptions chatOptions = OpenAiChatOptions.builder() + .model(resolveOpenAiModel(this.model)) + .temperature(0.2) + .maxTokens(800) + .build(); + OpenAiChatModel chatModel = OpenAiChatModel.builder() + .openAiApi(openAiApi) + .defaultOptions(chatOptions) + .build(); + + return new SpringAiOpenAiPromptBackend(chatModel); + } + + } + + private static final class SpringAiOpenAiPromptBackend implements PromptBackend { + + private final OpenAiChatModel chatModel; + + private final ExecutorService executorService; + + private final Scheduler scheduler; + + private SpringAiOpenAiPromptBackend(OpenAiChatModel chatModel) { + this.chatModel = chatModel; + AtomicInteger threadCounter = new AtomicInteger(); + this.executorService = Executors.newCachedThreadPool(task -> { + Thread thread = new Thread(task, "acp-demo-openai-" + threadCounter.incrementAndGet()); + thread.setDaemon(true); + return thread; + }); + this.scheduler = Schedulers.fromExecutorService(this.executorService, "acp-demo-openai"); + } + + @Override + public Mono generate(String prompt, String sessionId, String cwd) { + return Mono.fromCallable(() -> generatePrompt(prompt, sessionId, cwd)).subscribeOn(this.scheduler); + } + + @Override + public void close() { + this.scheduler.dispose(); + this.executorService.shutdownNow(); + } + + private String generatePrompt(String prompt, String sessionId, String cwd) { + ChatResponse response = chatModel.call(new Prompt(List.of(new SystemMessage(OPENAI_SYSTEM_PROMPT), + new UserMessage("Session: " + sessionId + "\nCWD: " + cwd + "\n\nUser prompt:\n" + prompt)))); + Generation generation = response.getResult(); + if (generation == null || generation.getOutput() == null || generation.getOutput().getText() == null + || generation.getOutput().getText().isBlank()) { + return "(OpenAI returned an empty response)"; + } + return generation.getOutput().getText(); + } + + } + + private static String resolveOpenAiModel(String model) { + if (model != null && !model.isBlank()) { + return model; + } + String envModel = System.getenv("OPENAI_MODEL"); + if (envModel != null && !envModel.isBlank()) { + return envModel; + } + return "gpt-4o-mini"; + } + } From f1d91d8a173967bfbfb65424ad0c49c88f118a38 Mon Sep 17 00:00:00 2001 From: Kaiser Dandangi Date: Mon, 18 May 2026 21:18:17 -0400 Subject: [PATCH 07/15] chore: ignore environment files --- .gitignore | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.gitignore b/.gitignore index 7aa4b83..02608ab 100644 --- a/.gitignore +++ b/.gitignore @@ -47,6 +47,12 @@ build.log shell.log derby.log +### Environment Files ### +.env +.env.* +**/.env +**/.env.* + ### Compiled Files ### *.class From e486eff5b4520393c2b3726103135765d29a1649 Mon Sep 17 00:00:00 2001 From: Kaiser Dandangi Date: Mon, 18 May 2026 21:43:19 -0400 Subject: [PATCH 08/15] docs: clarify remote core follow-up --- plans/STREAMABLE-HTTP-AGENT-TRANSPORT.md | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/plans/STREAMABLE-HTTP-AGENT-TRANSPORT.md b/plans/STREAMABLE-HTTP-AGENT-TRANSPORT.md index ff0728b..a97f203 100644 --- a/plans/STREAMABLE-HTTP-AGENT-TRANSPORT.md +++ b/plans/STREAMABLE-HTTP-AGENT-TRANSPORT.md @@ -123,7 +123,16 @@ HTTP/SSE endpoint instead of only the integration-test fixture lifecycle. ## PLAN / Follow-Up Work -- extract a shared remote-core layer only after HTTP parity is proven +- extract a shared remote-core layer only after HTTP parity is proven. + Here, "remote-core" means the transport-independent runtime machinery that + both remote listener transports need: per-connection agent factory creation, + connection/session registries, lifecycle teardown, request/response routing + ledgers, timeout/error propagation, and observability hooks. The actual wire + adapters should remain transport-specific: WebSocket text frames stay in the + WebSocket module, and HTTP methods, headers, cookies, SSE parsing, and status + codes stay in the Streamable HTTP module. Deferring this extraction keeps the + first HTTP implementation close to the RFD and avoids prematurely forcing the + existing WebSocket behavior through an abstraction before parity is proven. - migrate WebSocket toward the same factory-backed listener model - add idle/provisional-session eviction and replay retention policies - revisit per-logical-session active-prompt tracking in `AcpAgentSession` From f706d170ebbb848bf85d8dcaf9ddec829d2a1fbe Mon Sep 17 00:00:00 2001 From: Kaiser Dandangi Date: Sat, 23 May 2026 22:26:27 -0400 Subject: [PATCH 09/15] Track active prompts per ACP session --- .../sdk/spec/AcpAgentSession.java | 80 +++++-- .../sdk/spec/AcpAgentSessionTest.java | 225 ++++++++++++------ 2 files changed, 204 insertions(+), 101 deletions(-) diff --git a/acp-core/src/main/java/com/agentclientprotocol/sdk/spec/AcpAgentSession.java b/acp-core/src/main/java/com/agentclientprotocol/sdk/spec/AcpAgentSession.java index a0168fd..d27a96a 100644 --- a/acp-core/src/main/java/com/agentclientprotocol/sdk/spec/AcpAgentSession.java +++ b/acp-core/src/main/java/com/agentclientprotocol/sdk/spec/AcpAgentSession.java @@ -6,11 +6,11 @@ import java.time.Duration; import java.util.Map; +import java.util.Set; import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.Executors; import java.util.concurrent.atomic.AtomicLong; -import java.util.concurrent.atomic.AtomicReference; import reactor.core.scheduler.Scheduler; import reactor.core.scheduler.Schedulers; @@ -78,10 +78,17 @@ public class AcpAgentSession implements AcpSession { private final AtomicLong requestCounter = new AtomicLong(0); /** - * Active prompt tracking for single-turn enforcement. - * Only ONE prompt can be active at a time per ACP session. + * Active prompt tracking for single-turn enforcement, keyed by logical ACP + * sessionId. + * + *

+ * Kotlin SDK precedent: its Agent.SessionWrapper owns a single active prompt guard + * per logical session wrapper. This Java session can multiplex multiple logical ACP + * sessionIds over one transport connection, so the same single-turn rule needs to + * be applied per sessionId instead of once for the whole connection. + *

*/ - private final AtomicReference activePrompt = new AtomicReference<>(null); + private final ConcurrentHashMap activePrompts = new ConcurrentHashMap<>(); /** * Represents an active prompt session for single-turn enforcement. @@ -235,12 +242,12 @@ private Mono handleIncomingRequest(AcpSchema.JSONRPCR String sessionId = extractSessionId(request.params()); ActivePrompt newPrompt = new ActivePrompt(sessionId, request.id()); - // Try to set as active prompt - fails if another prompt is active - if (!activePrompt.compareAndSet(null, newPrompt)) { - ActivePrompt current = activePrompt.get(); - logger.warn("Rejected concurrent prompt request. Active prompt: sessionId={}, requestId={}", - current != null ? current.sessionId() : "unknown", - current != null ? current.requestId() : "unknown"); + // Try to set as active prompt - fails if this logical session already has + // a prompt active. + ActivePrompt current = activePrompts.putIfAbsent(sessionId, newPrompt); + if (current != null) { + logger.warn("Rejected concurrent prompt request for sessionId={}. Active requestId={}", sessionId, + current.requestId()); return Mono.just(new AcpSchema.JSONRPCResponse(AcpSchema.JSONRPC_VERSION, request.id(), null, new AcpSchema.JSONRPCError(-32000, "There is already an active prompt execution", null))); } @@ -249,8 +256,8 @@ private Mono handleIncomingRequest(AcpSchema.JSONRPCR return handler.handle(request.params()) .map(result -> new AcpSchema.JSONRPCResponse(AcpSchema.JSONRPC_VERSION, request.id(), result, null)) .doFinally(signal -> { - activePrompt.compareAndSet(newPrompt, null); - logger.debug("Prompt completed with signal: {}", signal); + activePrompts.remove(sessionId, newPrompt); + logger.debug("Prompt completed for sessionId={} with signal: {}", sessionId, signal); }); } @@ -262,8 +269,13 @@ private Mono handleIncomingRequest(AcpSchema.JSONRPCR /** * Extracts the sessionId from request parameters. */ - @SuppressWarnings("unchecked") private String extractSessionId(Object params) { + if (params instanceof AcpSchema.PromptRequest promptRequest) { + return promptRequest.sessionId() != null ? promptRequest.sessionId() : "unknown"; + } + if (params instanceof AcpSchema.CancelNotification cancelNotification) { + return cancelNotification.sessionId() != null ? cancelNotification.sessionId() : "unknown"; + } if (params instanceof Map map) { Object sessionId = map.get("sessionId"); return sessionId != null ? sessionId.toString() : "unknown"; @@ -289,9 +301,8 @@ private Mono handleIncomingNotification(AcpSchema.JSONRPCNotification noti // Handle cancel notification specially if (AcpSchema.METHOD_SESSION_CANCEL.equals(notification.method())) { String sessionId = extractSessionId(notification.params()); - ActivePrompt current = activePrompt.get(); - if (current != null && sessionId.equals(current.sessionId())) { - activePrompt.compareAndSet(current, null); + ActivePrompt current = activePrompts.remove(sessionId); + if (current != null) { logger.debug("Cancelled active prompt for session: {}", sessionId); } } @@ -372,16 +383,39 @@ public Mono sendNotification(String method, Object params) { * @return true if a prompt is currently active */ public boolean hasActivePrompt() { - return activePrompt.get() != null; + return !activePrompts.isEmpty(); + } + + /** + * Checks if there is an active prompt being processed for the specified logical + * ACP session. + * @param sessionId the logical ACP session ID + * @return true if a prompt is currently active for the session + */ + public boolean hasActivePrompt(String sessionId) { + Assert.hasText(sessionId, "The sessionId can not be empty"); + return activePrompts.containsKey(sessionId); } /** - * Gets the session ID of the active prompt, if any. - * @return the session ID or null if no prompt is active + * Gets one active prompt session ID, if any. + * + *

+ * This is a legacy aggregate view. When multiple logical ACP sessions are active on + * the same transport connection, the returned session ID is arbitrary. + *

+ * @return one active session ID or null if no prompt is active */ public String getActivePromptSessionId() { - ActivePrompt current = activePrompt.get(); - return current != null ? current.sessionId() : null; + return activePrompts.keySet().stream().findFirst().orElse(null); + } + + /** + * Gets the logical ACP session IDs that currently have active prompts. + * @return an immutable snapshot of active prompt session IDs + */ + public Set getActivePromptSessionIds() { + return Set.copyOf(activePrompts.keySet()); } /** @@ -391,7 +425,7 @@ public String getActivePromptSessionId() { @Override public Mono closeGracefully() { return Mono.fromRunnable(() -> { - activePrompt.set(null); + activePrompts.clear(); dismissPendingResponses(); timeoutScheduler.dispose(); }).then(this.transport.closeGracefully()); @@ -402,7 +436,7 @@ public Mono closeGracefully() { */ @Override public void close() { - activePrompt.set(null); + activePrompts.clear(); dismissPendingResponses(); timeoutScheduler.dispose(); transport.close(); diff --git a/acp-core/src/test/java/com/agentclientprotocol/sdk/spec/AcpAgentSessionTest.java b/acp-core/src/test/java/com/agentclientprotocol/sdk/spec/AcpAgentSessionTest.java index 4ce255d..bc8934a 100644 --- a/acp-core/src/test/java/com/agentclientprotocol/sdk/spec/AcpAgentSessionTest.java +++ b/acp-core/src/test/java/com/agentclientprotocol/sdk/spec/AcpAgentSessionTest.java @@ -5,11 +5,12 @@ package com.agentclientprotocol.sdk.spec; import java.time.Duration; -import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.concurrent.CountDownLatch; +import java.util.concurrent.CopyOnWriteArrayList; import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicReference; import com.agentclientprotocol.sdk.test.InMemoryTransportPair; @@ -26,6 +27,18 @@ class AcpAgentSessionTest { private static final Duration TIMEOUT = Duration.ofSeconds(5); + private static final Duration PROMPT_RESPONSE_DELAY = Duration.ofMillis(250); + + private static final long AGENT_TRANSPORT_SUBSCRIPTION_DELAY_MILLIS = 100; + + private static final long CLIENT_TRANSPORT_SUBSCRIPTION_DELAY_MILLIS = 50; + + private static final int ACTIVE_PROMPT_ERROR_CODE = -32000; + + private static final String SESSION_1 = "session-1"; + + private static final String SESSION_2 = "session-2"; + @Test void constructorValidatesArguments() { var transportPair = InMemoryTransportPair.create(); @@ -59,8 +72,7 @@ void handlesIncomingRequest() throws Exception { new AcpAgentSession(TIMEOUT, transportPair.agentTransport(), requestHandlers, Map.of()); - // Allow transport to start - Thread.sleep(100); + allowAgentTransportSubscription(); // Send a request from the client side CountDownLatch latch = new CountDownLatch(1); @@ -74,7 +86,7 @@ void handlesIncomingRequest() throws Exception { latch.countDown(); }).then(Mono.empty())).subscribe(); - Thread.sleep(50); + allowClientTransportSubscription(); transportPair.clientTransport().sendMessage(request).block(TIMEOUT); assertThat(latch.await(5, TimeUnit.SECONDS)).isTrue(); @@ -96,7 +108,7 @@ void handlesMethodNotFound() throws Exception { // Create session with no handlers new AcpAgentSession(TIMEOUT, transportPair.agentTransport(), Map.of(), Map.of()); - Thread.sleep(100); + allowAgentTransportSubscription(); // Send a request for unknown method CountDownLatch latch = new CountDownLatch(1); @@ -110,7 +122,7 @@ void handlesMethodNotFound() throws Exception { latch.countDown(); }).then(Mono.empty())).subscribe(); - Thread.sleep(50); + allowClientTransportSubscription(); transportPair.clientTransport().sendMessage(request).block(TIMEOUT); assertThat(latch.await(5, TimeUnit.SECONDS)).isTrue(); @@ -140,14 +152,14 @@ void handlesNotification() throws Exception { new AcpAgentSession(TIMEOUT, transportPair.agentTransport(), Map.of(), notificationHandlers); - Thread.sleep(100); + allowAgentTransportSubscription(); // Send a notification from client AcpSchema.JSONRPCNotification notification = new AcpSchema.JSONRPCNotification(AcpSchema.JSONRPC_VERSION, - AcpSchema.METHOD_SESSION_CANCEL, new AcpSchema.CancelNotification("session-1")); + AcpSchema.METHOD_SESSION_CANCEL, new AcpSchema.CancelNotification(SESSION_1)); transportPair.clientTransport().connect(mono -> mono.then(Mono.empty())).subscribe(); - Thread.sleep(50); + allowClientTransportSubscription(); transportPair.clientTransport().sendMessage(notification).block(TIMEOUT); assertThat(notificationLatch.await(5, TimeUnit.SECONDS)).isTrue(); @@ -159,70 +171,104 @@ void handlesNotification() throws Exception { } @Test - void singleTurnEnforcementRejectsConcurrentPrompts() throws Exception { + void singleTurnEnforcementRejectsConcurrentPromptsForSameSession() throws Exception { var transportPair = InMemoryTransportPair.create(); try { - // Create a handler that uses a Mono.delay to simulate async processing - AtomicReference promptCanProceedRef = new AtomicReference<>(new CountDownLatch(1)); + CountDownLatch handlerStarted = new CountDownLatch(1); + AtomicInteger handlerInvocations = new AtomicInteger(); Map> requestHandlers = Map.of(AcpSchema.METHOD_SESSION_PROMPT, params -> Mono.defer(() -> { - // First call gets blocked, second call should be rejected before getting here - return Mono.delay(Duration.ofMillis(100)) + handlerInvocations.incrementAndGet(); + handlerStarted.countDown(); + return Mono.delay(PROMPT_RESPONSE_DELAY) .map(ignored -> new AcpSchema.PromptResponse(AcpSchema.StopReason.END_TURN)); })); AcpAgentSession session = new AcpAgentSession(TIMEOUT, transportPair.agentTransport(), requestHandlers, Map.of()); - Thread.sleep(100); - - // Manually set active prompt to simulate an in-progress prompt - // We use reflection to access the activePrompt field for testing - java.lang.reflect.Field activePromptField = AcpAgentSession.class.getDeclaredField("activePrompt"); - activePromptField.setAccessible(true); - @SuppressWarnings("unchecked") - AtomicReference activePromptRef = (AtomicReference) activePromptField.get(session); - - // Create an ActivePrompt instance using reflection - Class activePromptClass = Class.forName( - "com.agentclientprotocol.sdk.spec.AcpAgentSession$ActivePrompt"); - java.lang.reflect.Constructor constructor = activePromptClass.getDeclaredConstructor(String.class, - Object.class); - constructor.setAccessible(true); - Object activePrompt = constructor.newInstance("session-1", "existing-request-id"); - activePromptRef.set(activePrompt); - - // Verify active prompt is set - assertThat(session.hasActivePrompt()).isTrue(); + allowAgentTransportSubscription(); - // Set up client to receive response - CountDownLatch responseLatch = new CountDownLatch(1); - AtomicReference response = new AtomicReference<>(); + CountDownLatch responseLatch = new CountDownLatch(2); + List responses = new CopyOnWriteArrayList<>(); transportPair.clientTransport().connect(mono -> mono.doOnNext(msg -> { - response.set((AcpSchema.JSONRPCResponse) msg); + if (msg instanceof AcpSchema.JSONRPCResponse response) { + responses.add(response); + } responseLatch.countDown(); }).then(Mono.empty())).subscribe(); - Thread.sleep(50); + allowClientTransportSubscription(); - // Send prompt request while another is "active" - Map params = new HashMap<>(); - params.put("sessionId", "session-1"); - params.put("prompt", List.of(new AcpSchema.TextContent("Hello"))); - AcpSchema.JSONRPCRequest request = new AcpSchema.JSONRPCRequest(AcpSchema.JSONRPC_VERSION, "1", - AcpSchema.METHOD_SESSION_PROMPT, params); - transportPair.clientTransport().sendMessage(request).block(TIMEOUT); + transportPair.clientTransport().sendMessage(promptRequest("1", SESSION_1, "first")).block(TIMEOUT); + assertThat(handlerStarted.await(5, TimeUnit.SECONDS)).isTrue(); + assertThat(session.hasActivePrompt(SESSION_1)).isTrue(); + + transportPair.clientTransport().sendMessage(promptRequest("2", SESSION_1, "second")).block(TIMEOUT); + + assertThat(responseLatch.await(5, TimeUnit.SECONDS)).isTrue(); + + AcpSchema.JSONRPCResponse rejectedResponse = responseById(responses, "2"); + assertThat(rejectedResponse.error()).isNotNull(); + assertThat(rejectedResponse.error().code()).isEqualTo(ACTIVE_PROMPT_ERROR_CODE); + assertThat(rejectedResponse.error().message()).contains("already an active prompt"); + assertThat(handlerInvocations.get()).isEqualTo(1); + assertThat(session.hasActivePrompt()).isFalse(); + } + finally { + transportPair.closeGracefully().block(TIMEOUT); + } + } + + @Test + void singleTurnEnforcementAllowsConcurrentPromptsForDifferentSessions() throws Exception { + var transportPair = InMemoryTransportPair.create(); + try { + CountDownLatch handlersStarted = new CountDownLatch(2); + AtomicInteger handlerInvocations = new AtomicInteger(); + + Map> requestHandlers = Map.of(AcpSchema.METHOD_SESSION_PROMPT, + params -> Mono.defer(() -> { + handlerInvocations.incrementAndGet(); + handlersStarted.countDown(); + return Mono.delay(PROMPT_RESPONSE_DELAY) + .map(ignored -> new AcpSchema.PromptResponse(AcpSchema.StopReason.END_TURN)); + })); + + AcpAgentSession session = new AcpAgentSession(TIMEOUT, transportPair.agentTransport(), requestHandlers, + Map.of()); + + allowAgentTransportSubscription(); + + CountDownLatch responseLatch = new CountDownLatch(2); + List responses = new CopyOnWriteArrayList<>(); + + transportPair.clientTransport().connect(mono -> mono.doOnNext(msg -> { + if (msg instanceof AcpSchema.JSONRPCResponse response) { + responses.add(response); + } + responseLatch.countDown(); + }).then(Mono.empty())).subscribe(); + + allowClientTransportSubscription(); + + transportPair.clientTransport().sendMessage(promptRequest("1", SESSION_1, "first")).block(TIMEOUT); + transportPair.clientTransport().sendMessage(promptRequest("2", SESSION_2, "second")).block(TIMEOUT); + + assertThat(handlersStarted.await(5, TimeUnit.SECONDS)).isTrue(); + assertThat(session.hasActivePrompt(SESSION_1)).isTrue(); + assertThat(session.hasActivePrompt(SESSION_2)).isTrue(); + assertThat(session.getActivePromptSessionIds()).containsExactlyInAnyOrder(SESSION_1, SESSION_2); - // Wait for response assertThat(responseLatch.await(5, TimeUnit.SECONDS)).isTrue(); - // Should be rejected with error - assertThat(response.get()).isNotNull(); - assertThat(response.get().error()).isNotNull(); - assertThat(response.get().error().code()).isEqualTo(-32000); - assertThat(response.get().error().message()).contains("already an active prompt"); + assertThat(responseById(responses, "1").error()).isNull(); + assertThat(responseById(responses, "2").error()).isNull(); + assertThat(handlerInvocations.get()).isEqualTo(2); + assertThat(session.hasActivePrompt()).isFalse(); + assertThat(session.getActivePromptSessionIds()).isEmpty(); } finally { transportPair.closeGracefully().block(TIMEOUT); @@ -233,42 +279,43 @@ void singleTurnEnforcementRejectsConcurrentPrompts() throws Exception { void hasActivePromptReturnsCorrectState() throws Exception { var transportPair = InMemoryTransportPair.create(); try { + CountDownLatch handlerStarted = new CountDownLatch(1); + Map> requestHandlers = Map.of(AcpSchema.METHOD_SESSION_PROMPT, - params -> Mono.just(new AcpSchema.PromptResponse(AcpSchema.StopReason.END_TURN))); + params -> Mono.defer(() -> { + handlerStarted.countDown(); + return Mono.delay(PROMPT_RESPONSE_DELAY) + .map(ignored -> new AcpSchema.PromptResponse(AcpSchema.StopReason.END_TURN)); + })); AcpAgentSession session = new AcpAgentSession(TIMEOUT, transportPair.agentTransport(), requestHandlers, Map.of()); - Thread.sleep(100); + allowAgentTransportSubscription(); - // Initially no active prompt assertThat(session.hasActivePrompt()).isFalse(); + assertThat(session.hasActivePrompt(SESSION_1)).isFalse(); assertThat(session.getActivePromptSessionId()).isNull(); + assertThat(session.getActivePromptSessionIds()).isEmpty(); + + CountDownLatch responseLatch = new CountDownLatch(1); + transportPair.clientTransport().connect(mono -> mono.doOnNext(msg -> responseLatch.countDown()) + .then(Mono.empty())).subscribe(); - // Manually set active prompt using reflection to test the getter methods - java.lang.reflect.Field activePromptField = AcpAgentSession.class.getDeclaredField("activePrompt"); - activePromptField.setAccessible(true); - @SuppressWarnings("unchecked") - AtomicReference activePromptRef = (AtomicReference) activePromptField.get(session); - - // Create an ActivePrompt instance using reflection - Class activePromptClass = Class.forName( - "com.agentclientprotocol.sdk.spec.AcpAgentSession$ActivePrompt"); - java.lang.reflect.Constructor constructor = activePromptClass.getDeclaredConstructor(String.class, - Object.class); - constructor.setAccessible(true); - Object activePrompt = constructor.newInstance("session-1", "request-1"); - activePromptRef.set(activePrompt); - - // Now there should be an active prompt + allowClientTransportSubscription(); + transportPair.clientTransport().sendMessage(promptRequest("1", SESSION_1, "hello")).block(TIMEOUT); + + assertThat(handlerStarted.await(5, TimeUnit.SECONDS)).isTrue(); assertThat(session.hasActivePrompt()).isTrue(); - assertThat(session.getActivePromptSessionId()).isEqualTo("session-1"); + assertThat(session.hasActivePrompt(SESSION_1)).isTrue(); + assertThat(session.getActivePromptSessionIds()).containsExactly(SESSION_1); + assertThat(session.getActivePromptSessionId()).isEqualTo(SESSION_1); - // Clear active prompt - activePromptRef.set(null); + assertThat(responseLatch.await(5, TimeUnit.SECONDS)).isTrue(); - // Active prompt should be cleared assertThat(session.hasActivePrompt()).isFalse(); + assertThat(session.hasActivePrompt(SESSION_1)).isFalse(); + assertThat(session.getActivePromptSessionIds()).isEmpty(); assertThat(session.getActivePromptSessionId()).isNull(); } finally { @@ -282,7 +329,7 @@ void closeGracefullyCompletes() throws Exception { AcpAgentSession session = new AcpAgentSession(TIMEOUT, transportPair.agentTransport(), Map.of(), Map.of()); - Thread.sleep(100); + allowAgentTransportSubscription(); // Should complete without error session.closeGracefully().block(TIMEOUT); @@ -299,7 +346,7 @@ void handlerErrorReturnsJsonRpcError() throws Exception { new AcpAgentSession(TIMEOUT, transportPair.agentTransport(), requestHandlers, Map.of()); - Thread.sleep(100); + allowAgentTransportSubscription(); CountDownLatch latch = new CountDownLatch(1); AtomicReference response = new AtomicReference<>(); @@ -312,7 +359,7 @@ void handlerErrorReturnsJsonRpcError() throws Exception { latch.countDown(); }).then(Mono.empty())).subscribe(); - Thread.sleep(50); + allowClientTransportSubscription(); transportPair.clientTransport().sendMessage(request).block(TIMEOUT); assertThat(latch.await(5, TimeUnit.SECONDS)).isTrue(); @@ -327,4 +374,26 @@ void handlerErrorReturnsJsonRpcError() throws Exception { } } + private static AcpSchema.JSONRPCRequest promptRequest(String id, String sessionId, String text) { + return new AcpSchema.JSONRPCRequest(AcpSchema.JSONRPC_VERSION, id, AcpSchema.METHOD_SESSION_PROMPT, + new AcpSchema.PromptRequest(sessionId, List.of(new AcpSchema.TextContent(text)))); + } + + private static AcpSchema.JSONRPCResponse responseById(List responses, Object id) { + return responses.stream().filter(response -> id.equals(response.id())).findFirst().orElseThrow(); + } + + private static void allowAgentTransportSubscription() throws InterruptedException { + // AcpAgentSession subscribes to the in-memory transport in its constructor. + // subscribe() is asynchronous, so give the unicast sink subscriber a short + // window to attach before the test sends client messages. + Thread.sleep(AGENT_TRANSPORT_SUBSCRIPTION_DELAY_MILLIS); + } + + private static void allowClientTransportSubscription() throws InterruptedException { + // clientTransport.connect(...).subscribe() also attaches asynchronously. Without + // this small wait, an immediate agent response can race the test subscriber. + Thread.sleep(CLIENT_TRANSPORT_SUBSCRIPTION_DELAY_MILLIS); + } + } From ef9bb275c6df66203c74a93dd640b6f573d3161f Mon Sep 17 00:00:00 2001 From: Kaiser Dandangi Date: Sat, 23 May 2026 23:44:27 -0400 Subject: [PATCH 10/15] Add remote WebSocket agent transport --- .../agent/transport/RemoteAcpConnection.java | 244 ++++++++++++ .../sdk/spec/AcpAgentSessionTest.java | 33 +- .../StreamableHttpAcpAgentTransport.java | 122 +----- .../RemoteWebSocketAcpAgentTransport.java | 363 ++++++++++++++++++ ...ocketAcpAgentTransportIntegrationTest.java | 343 +++++++++++++++++ 5 files changed, 993 insertions(+), 112 deletions(-) create mode 100644 acp-core/src/main/java/com/agentclientprotocol/sdk/agent/transport/RemoteAcpConnection.java create mode 100644 acp-websocket-jetty/src/main/java/com/agentclientprotocol/sdk/agent/transport/RemoteWebSocketAcpAgentTransport.java create mode 100644 acp-websocket-jetty/src/test/java/com/agentclientprotocol/sdk/agent/transport/RemoteWebSocketAcpAgentTransportIntegrationTest.java diff --git a/acp-core/src/main/java/com/agentclientprotocol/sdk/agent/transport/RemoteAcpConnection.java b/acp-core/src/main/java/com/agentclientprotocol/sdk/agent/transport/RemoteAcpConnection.java new file mode 100644 index 0000000..d2162dd --- /dev/null +++ b/acp-core/src/main/java/com/agentclientprotocol/sdk/agent/transport/RemoteAcpConnection.java @@ -0,0 +1,244 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.agent.transport; + +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.Consumer; +import java.util.function.Function; + +import com.agentclientprotocol.sdk.agent.AcpAgentFactory; +import com.agentclientprotocol.sdk.agent.AcpAsyncAgent; +import com.agentclientprotocol.sdk.error.AcpConnectionException; +import com.agentclientprotocol.sdk.json.AcpJsonMapper; +import com.agentclientprotocol.sdk.json.TypeRef; +import com.agentclientprotocol.sdk.spec.AcpAgentTransport; +import com.agentclientprotocol.sdk.spec.AcpSchema; +import com.agentclientprotocol.sdk.spec.AcpSchema.JSONRPCMessage; +import com.agentclientprotocol.sdk.util.Assert; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import reactor.core.publisher.Mono; +import reactor.core.publisher.Sinks; + +/** + * Shared per-connection core for listener-backed remote ACP agent transports. + * + *

+ * Remote transports such as Streamable HTTP and WebSocket have different wire-level + * framing, but they both need the same agent-side shape once a remote ACP connection + * exists: one connection-bound {@link AcpAgentTransport}, one fresh agent runtime from + * {@link AcpAgentFactory}, inbound JSON-RPC delivery to the agent, and outbound JSON-RPC + * delivery back to the wire adapter. + *

+ * + *

+ * This class intentionally does not know about HTTP headers, SSE streams, WebSocket + * sessions, or route maps. Those remain transport-adapter concerns. + *

+ * + * @author Kaiser Dandangi + */ +public final class RemoteAcpConnection { + + private static final Logger logger = LoggerFactory.getLogger(RemoteAcpConnection.class); + + private final String id; + + private final AcpJsonMapper jsonMapper; + + private final ConnectionTransport transport; + + private final AtomicBoolean started = new AtomicBoolean(false); + + private final AtomicBoolean closing = new AtomicBoolean(false); + + private volatile AcpAsyncAgent agent; + + /** + * Creates a new remote ACP connection core. + * @param id stable transport connection id + * @param jsonMapper JSON mapper used by the connection transport + * @param outboundConsumer callback that receives agent-originated outbound messages + */ + public RemoteAcpConnection(String id, AcpJsonMapper jsonMapper, Consumer outboundConsumer) { + Assert.hasText(id, "The id can not be empty"); + Assert.notNull(jsonMapper, "The jsonMapper can not be null"); + Assert.notNull(outboundConsumer, "The outboundConsumer can not be null"); + this.id = id; + this.jsonMapper = jsonMapper; + this.transport = new ConnectionTransport(outboundConsumer); + } + + /** + * Returns the transport-level connection id. + * @return connection id + */ + public String id() { + return id; + } + + /** + * Starts a fresh agent runtime for this connection. + * @param agentFactory factory used to create the connection-bound agent runtime + * @return mono that completes when the agent runtime is started + */ + public Mono start(AcpAgentFactory agentFactory) { + Assert.notNull(agentFactory, "The agentFactory can not be null"); + if (!started.compareAndSet(false, true)) { + return Mono.error(new IllegalStateException("Already started")); + } + return Mono.defer(() -> { + this.agent = agentFactory.create(transport); + return this.agent.start(); + }).doOnError(this::signalException); + } + + /** + * Accepts one client-originated JSON-RPC message for delivery to the connection's + * agent runtime. + * @param message inbound message + */ + public void acceptInbound(JSONRPCMessage message) { + transport.acceptInbound(message); + } + + /** + * Reports a transport adapter exception to the agent transport exception handler. + * @param error exception to report + */ + public void signalException(Throwable error) { + transport.signalException(error); + } + + /** + * Closes the connection and its agent runtime gracefully. + * @return mono that completes when close work has been requested + */ + public Mono closeGracefully() { + return Mono.defer(() -> { + if (!closing.compareAndSet(false, true)) { + return Mono.empty(); + } + AcpAsyncAgent currentAgent = this.agent; + if (currentAgent != null) { + return currentAgent.closeGracefully() + .onErrorResume(error -> { + signalException(error); + return Mono.empty(); + }) + .then(transport.closeGracefully()); + } + return transport.closeGracefully(); + }); + } + + /** + * Closes the connection and its agent runtime immediately. + */ + public void close() { + if (!closing.compareAndSet(false, true)) { + return; + } + AcpAsyncAgent currentAgent = this.agent; + if (currentAgent != null) { + currentAgent.close(); + } + transport.close(); + } + + private final class ConnectionTransport implements AcpAgentTransport { + + private final Consumer outboundConsumer; + + private final Sinks.Many inboundSink = Sinks.many().unicast().onBackpressureBuffer(); + + private final Sinks.One terminationSink = Sinks.one(); + + private final AtomicBoolean transportStarted = new AtomicBoolean(false); + + private final AtomicBoolean transportClosing = new AtomicBoolean(false); + + private volatile Consumer exceptionHandler = t -> logger.error("Remote ACP transport error", t); + + ConnectionTransport(Consumer outboundConsumer) { + this.outboundConsumer = outboundConsumer; + } + + @Override + public Mono start(Function, Mono> handler) { + Assert.notNull(handler, "The handler can not be null"); + if (!transportStarted.compareAndSet(false, true)) { + return Mono.error(new IllegalStateException("Already started")); + } + inboundSink.asFlux() + .flatMap(message -> Mono.just(message).transform(handler)) + .doOnNext(response -> { + if (response != null) { + outboundConsumer.accept(response); + } + }) + .doOnError(this::signalException) + .doFinally(signal -> terminationSink.tryEmitValue(null)) + .subscribe(); + return Mono.empty(); + } + + void acceptInbound(JSONRPCMessage message) { + Assert.notNull(message, "The message can not be null"); + if (transportClosing.get()) { + throw new AcpConnectionException("Remote ACP connection is closing"); + } + Sinks.EmitResult result = inboundSink.tryEmitNext(message); + if (result.isFailure()) { + throw new AcpConnectionException("Failed to enqueue inbound message: " + result); + } + } + + void signalException(Throwable error) { + exceptionHandler.accept(error); + } + + @Override + public Mono sendMessage(JSONRPCMessage message) { + return Mono.fromRunnable(() -> { + if (transportClosing.get()) { + throw new AcpConnectionException("Remote ACP connection is closing"); + } + outboundConsumer.accept(message); + }); + } + + @Override + public T unmarshalFrom(Object data, TypeRef typeRef) { + return jsonMapper.convertValue(data, typeRef); + } + + @Override + public Mono closeGracefully() { + return Mono.fromRunnable(this::close); + } + + @Override + public void close() { + if (transportClosing.compareAndSet(false, true)) { + inboundSink.tryEmitComplete(); + terminationSink.tryEmitValue(null); + } + } + + @Override + public void setExceptionHandler(Consumer handler) { + Assert.notNull(handler, "The handler can not be null"); + this.exceptionHandler = handler; + } + + @Override + public Mono awaitTermination() { + return terminationSink.asMono(); + } + + } + +} diff --git a/acp-core/src/test/java/com/agentclientprotocol/sdk/spec/AcpAgentSessionTest.java b/acp-core/src/test/java/com/agentclientprotocol/sdk/spec/AcpAgentSessionTest.java index bc8934a..4d46cf5 100644 --- a/acp-core/src/test/java/com/agentclientprotocol/sdk/spec/AcpAgentSessionTest.java +++ b/acp-core/src/test/java/com/agentclientprotocol/sdk/spec/AcpAgentSessionTest.java @@ -16,6 +16,7 @@ import com.agentclientprotocol.sdk.test.InMemoryTransportPair; import org.junit.jupiter.api.Test; import reactor.core.publisher.Mono; +import reactor.core.publisher.Sinks; import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertThrows; @@ -228,13 +229,17 @@ void singleTurnEnforcementAllowsConcurrentPromptsForDifferentSessions() throws E try { CountDownLatch handlersStarted = new CountDownLatch(2); AtomicInteger handlerInvocations = new AtomicInteger(); + Sinks.One session1Release = Sinks.one(); + Sinks.One session2Release = Sinks.one(); Map> requestHandlers = Map.of(AcpSchema.METHOD_SESSION_PROMPT, params -> Mono.defer(() -> { handlerInvocations.incrementAndGet(); handlersStarted.countDown(); - return Mono.delay(PROMPT_RESPONSE_DELAY) - .map(ignored -> new AcpSchema.PromptResponse(AcpSchema.StopReason.END_TURN)); + String sessionId = sessionId(params); + Sinks.One release = SESSION_1.equals(sessionId) ? session1Release : session2Release; + return release.asMono() + .thenReturn(new AcpSchema.PromptResponse(AcpSchema.StopReason.END_TURN)); })); AcpAgentSession session = new AcpAgentSession(TIMEOUT, transportPair.agentTransport(), requestHandlers, @@ -262,6 +267,13 @@ void singleTurnEnforcementAllowsConcurrentPromptsForDifferentSessions() throws E assertThat(session.hasActivePrompt(SESSION_2)).isTrue(); assertThat(session.getActivePromptSessionIds()).containsExactlyInAnyOrder(SESSION_1, SESSION_2); + // Release the responses one at a time. The in-memory test transport uses a + // unicast sink, so simultaneous emissions from concurrent prompt handlers can + // fail with FAIL_NON_SERIALIZED and obscure the behavior under test. + session1Release.tryEmitValue(null); + awaitResponse(responses, "1"); + session2Release.tryEmitValue(null); + assertThat(responseLatch.await(5, TimeUnit.SECONDS)).isTrue(); assertThat(responseById(responses, "1").error()).isNull(); @@ -383,6 +395,23 @@ private static AcpSchema.JSONRPCResponse responseById(List id.equals(response.id())).findFirst().orElseThrow(); } + private static void awaitResponse(List responses, Object id) throws InterruptedException { + long deadline = System.nanoTime() + TIMEOUT.toNanos(); + while (System.nanoTime() < deadline) { + if (responses.stream().anyMatch(response -> id.equals(response.id()))) { + return; + } + Thread.sleep(10); + } + } + + private static String sessionId(Object params) { + if (params instanceof AcpSchema.PromptRequest promptRequest) { + return promptRequest.sessionId(); + } + throw new IllegalArgumentException("Expected PromptRequest params but received " + params); + } + private static void allowAgentTransportSubscription() throws InterruptedException { // AcpAgentSession subscribes to the in-memory transport in its constructor. // subscribe() is asynchronous, so give the unicast sink subscriber a short diff --git a/acp-streamable-http-jetty/src/main/java/com/agentclientprotocol/sdk/agent/transport/StreamableHttpAcpAgentTransport.java b/acp-streamable-http-jetty/src/main/java/com/agentclientprotocol/sdk/agent/transport/StreamableHttpAcpAgentTransport.java index 911b3a9..843860a 100644 --- a/acp-streamable-http-jetty/src/main/java/com/agentclientprotocol/sdk/agent/transport/StreamableHttpAcpAgentTransport.java +++ b/acp-streamable-http-jetty/src/main/java/com/agentclientprotocol/sdk/agent/transport/StreamableHttpAcpAgentTransport.java @@ -19,15 +19,11 @@ import java.util.concurrent.ConcurrentMap; import java.util.concurrent.CopyOnWriteArrayList; import java.util.concurrent.atomic.AtomicBoolean; -import java.util.function.Consumer; -import java.util.function.Function; import com.agentclientprotocol.sdk.agent.AcpAgentFactory; -import com.agentclientprotocol.sdk.agent.AcpAsyncAgent; import com.agentclientprotocol.sdk.error.AcpConnectionException; import com.agentclientprotocol.sdk.json.AcpJsonMapper; import com.agentclientprotocol.sdk.json.TypeRef; -import com.agentclientprotocol.sdk.spec.AcpAgentTransport; import com.agentclientprotocol.sdk.spec.AcpSchema; import com.agentclientprotocol.sdk.spec.AcpSchema.JSONRPCMessage; import com.agentclientprotocol.sdk.util.Assert; @@ -54,14 +50,14 @@ *

* This transport hosts a Jetty HTTP endpoint and creates one fresh agent runtime per * remote ACP connection through {@link AcpAgentFactory}. The accepted connection then - * owns its own per-connection {@link AcpAgentTransport}, while the listener remains + * owns its own per-connection {@link RemoteAcpConnection}, while the listener remains * responsible only for HTTP concerns such as headers, SSE streams, and request routing. *

* *

- * The current implementation is intentionally HTTP-only. The shared remote transport - * core that should eventually also back WebSocket remains a follow-up migration step so - * the existing WebSocket behavior can be preserved until parity is proven here first. + * Streamable HTTP and the RFD-compliant remote WebSocket listener share + * {@link RemoteAcpConnection}; this class keeps HTTP-specific routing, headers, SSE + * stream ownership, and replay behavior local to the HTTP adapter. *

* * @author Kaiser Dandangi @@ -458,7 +454,7 @@ private final class ConnectionState { private final String id; - private final ConnectionTransport transport; + private final RemoteAcpConnection connection; private final OutboundStream connectionStream = new OutboundStream(); @@ -478,11 +474,9 @@ private final class ConnectionState { private volatile Object initializeRequestId; - private volatile AcpAsyncAgent agent; - ConnectionState(String id) { this.id = id; - this.transport = new ConnectionTransport(this::routeAgentMessage); + this.connection = new RemoteAcpConnection(id, jsonMapper, this::routeAgentMessage); } String id() { @@ -490,20 +484,19 @@ String id() { } void start() { - this.agent = agentFactory.create(transport); - this.agent.start().block(INITIALIZE_TIMEOUT); + this.connection.start(agentFactory).block(INITIALIZE_TIMEOUT); } Mono initialize(AcpSchema.JSONRPCRequest request) { this.initializeRequestId = request.id(); - transport.acceptInbound(request); + connection.acceptInbound(request); return initializeResponse.asMono().doOnSuccess(ignored -> initialized.set(true)); } void acceptClientPost(JSONRPCMessage message, String sessionHeader) { if (message instanceof AcpSchema.JSONRPCResponse response) { validateClientResponseScope(response, sessionHeader); - transport.acceptInbound(message); + connection.acceptInbound(message); return; } @@ -515,7 +508,7 @@ void acceptClientPost(JSONRPCMessage message, String sessionHeader) { && resolved.requestRoute() != null) { clientRequestRoutes.put(request.id(), resolved.requestRoute()); } - transport.acceptInbound(message); + connection.acceptInbound(message); } void openStream(HttpServletRequest request, HttpServletResponse response, String sessionId) @@ -544,11 +537,7 @@ void openStream(HttpServletRequest request, HttpServletResponse response, String void close() { connectionStream.close(); sessionStreams.values().forEach(OutboundStream::close); - transport.closeGracefully().subscribe(); - AcpAsyncAgent currentAgent = this.agent; - if (currentAgent != null) { - currentAgent.closeGracefully().subscribe(); - } + connection.closeGracefully().subscribe(); } private void routeAgentMessage(JSONRPCMessage message) { @@ -569,7 +558,7 @@ private void routeAgentMessage(JSONRPCMessage message) { } } catch (Exception e) { - transport.signalException(e); + connection.signalException(e); } } @@ -788,93 +777,6 @@ private String requireSessionId(Object params, String method) { .orElseThrow(() -> new AcpConnectionException("Missing sessionId for method " + method)); } - private final class ConnectionTransport implements AcpAgentTransport { - - private final Consumer outboundConsumer; - - private final Sinks.Many inboundSink = Sinks.many().unicast().onBackpressureBuffer(); - - private final Sinks.One terminationSink = Sinks.one(); - - private final AtomicBoolean started = new AtomicBoolean(false); - - private final AtomicBoolean closing = new AtomicBoolean(false); - - private volatile Consumer exceptionHandler = t -> logger.error("Transport error", t); - - ConnectionTransport(Consumer outboundConsumer) { - this.outboundConsumer = outboundConsumer; - } - - @Override - public Mono start(Function, Mono> handler) { - Assert.notNull(handler, "The handler can not be null"); - if (!started.compareAndSet(false, true)) { - return Mono.error(new IllegalStateException("Already started")); - } - inboundSink.asFlux() - .flatMap(message -> Mono.just(message).transform(handler)) - .doOnNext(response -> { - if (response != null) { - outboundConsumer.accept(response); - } - }) - .doOnError(this::signalException) - .subscribe(); - return Mono.empty(); - } - - void acceptInbound(JSONRPCMessage message) { - if (closing.get()) { - throw new AcpConnectionException("Connection transport is closing"); - } - Sinks.EmitResult result = inboundSink.tryEmitNext(message); - if (result.isFailure()) { - throw new AcpConnectionException("Failed to enqueue inbound message: " + result); - } - } - - void signalException(Throwable error) { - exceptionHandler.accept(error); - } - - @Override - public Mono sendMessage(JSONRPCMessage message) { - return Mono.fromRunnable(() -> { - if (closing.get()) { - throw new AcpConnectionException("Connection transport is closing"); - } - outboundConsumer.accept(message); - }); - } - - @Override - public T unmarshalFrom(Object data, TypeRef typeRef) { - return jsonMapper.convertValue(data, typeRef); - } - - @Override - public Mono closeGracefully() { - return Mono.fromRunnable(() -> { - if (closing.compareAndSet(false, true)) { - inboundSink.tryEmitComplete(); - terminationSink.tryEmitValue(null); - } - }); - } - - @Override - public void setExceptionHandler(Consumer handler) { - this.exceptionHandler = handler; - } - - @Override - public Mono awaitTermination() { - return terminationSink.asMono(); - } - - } - private final class OutboundStream { private final ArrayDeque replay = new ArrayDeque<>(); diff --git a/acp-websocket-jetty/src/main/java/com/agentclientprotocol/sdk/agent/transport/RemoteWebSocketAcpAgentTransport.java b/acp-websocket-jetty/src/main/java/com/agentclientprotocol/sdk/agent/transport/RemoteWebSocketAcpAgentTransport.java new file mode 100644 index 0000000..6881ab1 --- /dev/null +++ b/acp-websocket-jetty/src/main/java/com/agentclientprotocol/sdk/agent/transport/RemoteWebSocketAcpAgentTransport.java @@ -0,0 +1,363 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.agent.transport; + +import java.time.Duration; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; +import java.util.concurrent.atomic.AtomicBoolean; + +import com.agentclientprotocol.sdk.agent.AcpAgentFactory; +import com.agentclientprotocol.sdk.error.AcpConnectionException; +import com.agentclientprotocol.sdk.json.AcpJsonMapper; +import com.agentclientprotocol.sdk.spec.AcpSchema; +import com.agentclientprotocol.sdk.spec.AcpSchema.JSONRPCMessage; +import com.agentclientprotocol.sdk.util.Assert; +import org.eclipse.jetty.server.Server; +import org.eclipse.jetty.server.ServerConnector; +import org.eclipse.jetty.websocket.api.Callback; +import org.eclipse.jetty.websocket.api.Session; +import org.eclipse.jetty.websocket.api.StatusCode; +import org.eclipse.jetty.websocket.api.annotations.OnWebSocketClose; +import org.eclipse.jetty.websocket.api.annotations.OnWebSocketError; +import org.eclipse.jetty.websocket.api.annotations.OnWebSocketMessage; +import org.eclipse.jetty.websocket.api.annotations.OnWebSocketOpen; +import org.eclipse.jetty.websocket.api.annotations.WebSocket; +import org.eclipse.jetty.websocket.server.WebSocketUpgradeHandler; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import reactor.core.publisher.Mono; +import reactor.core.publisher.Sinks; + +/** + * RFD-compliant listener-backed ACP WebSocket transport for agents. + * + *

+ * This transport accepts WebSocket upgrades on one ACP endpoint and creates one fresh + * agent runtime per accepted remote connection via {@link AcpAgentFactory}. The + * resulting per-connection {@link RemoteAcpConnection} owns ACP JSON-RPC semantics, + * while this class owns only Jetty listener setup, WebSocket framing, connection IDs, + * and close/error lifecycle. + *

+ * + *

+ * Unlike the legacy {@link WebSocketAcpAgentTransport}, this class is intentionally not + * an {@code AcpAgentTransport} itself. It is a listener that creates one + * connection-bound transport per WebSocket client. + *

+ * + * @author Kaiser Dandangi + */ +public class RemoteWebSocketAcpAgentTransport { + + private static final Logger logger = LoggerFactory.getLogger(RemoteWebSocketAcpAgentTransport.class); + + public static final String DEFAULT_ACP_PATH = "/acp"; + + public static final String HEADER_CONNECTION_ID = "Acp-Connection-Id"; + + private static final Duration INITIALIZE_TIMEOUT = Duration.ofSeconds(30); + + private final int configuredPort; + + private final String path; + + private final AcpJsonMapper jsonMapper; + + private final AcpAgentFactory agentFactory; + + private final ConcurrentMap connections = new ConcurrentHashMap<>(); + + private final AtomicBoolean started = new AtomicBoolean(false); + + private final AtomicBoolean closing = new AtomicBoolean(false); + + private final Sinks.One terminationSink = Sinks.one(); + + private volatile Duration idleTimeout = Duration.ofMinutes(30); + + private volatile Server server; + + private volatile ServerConnector connector; + + /** + * Creates a new RFD-compliant WebSocket listener on the default ACP path. + * @param port port to listen on + * @param jsonMapper JSON mapper used for serialization + * @param agentFactory factory used to create one agent runtime per connection + */ + public RemoteWebSocketAcpAgentTransport(int port, AcpJsonMapper jsonMapper, AcpAgentFactory agentFactory) { + this(port, DEFAULT_ACP_PATH, jsonMapper, agentFactory); + } + + /** + * Creates a new RFD-compliant WebSocket listener. + * @param port port to listen on + * @param path endpoint path + * @param jsonMapper JSON mapper used for serialization + * @param agentFactory factory used to create one agent runtime per connection + */ + public RemoteWebSocketAcpAgentTransport(int port, String path, AcpJsonMapper jsonMapper, + AcpAgentFactory agentFactory) { + Assert.isTrue(port > 0, "Port must be positive"); + Assert.hasText(path, "Path must not be empty"); + Assert.notNull(jsonMapper, "The JsonMapper can not be null"); + Assert.notNull(agentFactory, "The agentFactory can not be null"); + this.configuredPort = port; + this.path = path; + this.jsonMapper = jsonMapper; + this.agentFactory = agentFactory; + } + + /** + * Sets the WebSocket idle timeout. + * @param timeout idle timeout + * @return this transport + */ + public RemoteWebSocketAcpAgentTransport idleTimeout(Duration timeout) { + Assert.notNull(timeout, "The timeout can not be null"); + this.idleTimeout = timeout; + return this; + } + + /** + * Starts the embedded Jetty WebSocket listener. + * @return a mono that completes when the listener is ready + */ + public Mono start() { + if (!started.compareAndSet(false, true)) { + return Mono.error(new IllegalStateException("Already started")); + } + + return Mono.fromRunnable(() -> { + Server jettyServer = new Server(); + ServerConnector jettyConnector = new ServerConnector(jettyServer); + jettyConnector.setPort(configuredPort); + jettyServer.addConnector(jettyConnector); + + WebSocketUpgradeHandler wsHandler = WebSocketUpgradeHandler.from(jettyServer, container -> { + container.setIdleTimeout(idleTimeout); + container.addMapping(path, (request, response, callback) -> { + ConnectionState connection = createConnection(); + try { + connection.start(); + connections.put(connection.id(), connection); + response.getHeaders().put(HEADER_CONNECTION_ID, connection.id()); + return new AcpWebSocketEndpoint(connection); + } + catch (Exception e) { + connection.close(); + callback.failed(e); + return null; + } + }); + }); + jettyServer.setHandler(wsHandler); + + try { + jettyServer.start(); + } + catch (Exception e) { + throw new AcpConnectionException("Failed to start Remote WebSocket listener", e); + } + this.server = jettyServer; + this.connector = jettyConnector; + logger.info("Remote WebSocket ACP agent listener started on port {} at path {}", getPort(), path); + }); + } + + /** + * Returns the bound port. + * @return listener port + */ + public int getPort() { + ServerConnector currentConnector = this.connector; + return currentConnector != null ? currentConnector.getLocalPort() : configuredPort; + } + + /** + * Closes all active WebSocket connections and stops the listener. + * @return a mono that completes when shutdown finishes + */ + public Mono closeGracefully() { + return Mono.fromRunnable(() -> { + if (!closing.compareAndSet(false, true)) { + return; + } + connections.values().forEach(ConnectionState::close); + connections.clear(); + Server currentServer = this.server; + if (currentServer != null) { + try { + currentServer.stop(); + } + catch (Exception e) { + throw new AcpConnectionException("Failed to stop Remote WebSocket listener", e); + } + } + terminationSink.tryEmitValue(null); + }); + } + + /** + * Closes all active WebSocket connections and stops the listener immediately. + */ + public void close() { + closeGracefully().block(Duration.ofSeconds(5)); + } + + /** + * Returns a mono that completes once the listener terminates. + * @return termination mono + */ + public Mono awaitTermination() { + return terminationSink.asMono(); + } + + int activeConnectionCount() { + return connections.size(); + } + + private ConnectionState createConnection() { + String connectionId = UUID.randomUUID().toString(); + return new ConnectionState(connectionId); + } + + private boolean isInitializeRequest(JSONRPCMessage message) { + return message instanceof AcpSchema.JSONRPCRequest request + && AcpSchema.METHOD_INITIALIZE.equals(request.method()) && request.id() != null; + } + + private final class ConnectionState { + + private final String id; + + private final RemoteAcpConnection remoteConnection; + + private final AtomicBoolean initialized = new AtomicBoolean(false); + + private final AtomicBoolean closed = new AtomicBoolean(false); + + private volatile Session session; + + ConnectionState(String id) { + this.id = id; + this.remoteConnection = new RemoteAcpConnection(id, jsonMapper, this::sendToClient); + } + + String id() { + return id; + } + + void start() { + this.remoteConnection.start(agentFactory).block(INITIALIZE_TIMEOUT); + } + + void open(Session session) { + this.session = session; + } + + void acceptFromClient(JSONRPCMessage message) { + if (!initialized.get()) { + // The WebSocket RFD makes initialize the first client-originated + // JSON-RPC request. Enforce that at the wire adapter before the + // shared connection core sees application messages. + if (!isInitializeRequest(message)) { + close(StatusCode.PROTOCOL, "first ACP WebSocket message must be initialize"); + return; + } + initialized.set(true); + } + remoteConnection.acceptInbound(message); + } + + void sendToClient(JSONRPCMessage message) { + try { + Session currentSession = this.session; + if (closed.get() || currentSession == null || !currentSession.isOpen()) { + throw new AcpConnectionException("Remote WebSocket connection is closed"); + } + String payload = jsonMapper.writeValueAsString(message); + logger.debug("Sending remote WebSocket message: {}", payload); + currentSession.sendText(payload, Callback.from(() -> { + }, error -> { + if (!closed.get()) { + remoteConnection.signalException(error); + } + })); + } + catch (Exception e) { + remoteConnection.signalException(e); + close(StatusCode.SERVER_ERROR, "failed to send ACP message"); + } + } + + void close() { + close(StatusCode.NORMAL, "server closing"); + } + + void close(int statusCode, String reason) { + if (!closed.compareAndSet(false, true)) { + return; + } + connections.remove(id, this); + Session currentSession = this.session; + if (currentSession != null && currentSession.isOpen()) { + currentSession.close(statusCode, reason, Callback.NOOP); + } + remoteConnection.closeGracefully().subscribe(); + } + + } + + /** + * Jetty WebSocket endpoint for one accepted ACP connection. + */ + @WebSocket + public class AcpWebSocketEndpoint { + + private final ConnectionState connection; + + AcpWebSocketEndpoint(ConnectionState connection) { + this.connection = connection; + } + + @OnWebSocketOpen + public void onOpen(Session session) { + logger.info("Remote WebSocket ACP client connected from {}", session.getRemoteSocketAddress()); + connection.open(session); + } + + @OnWebSocketMessage + public void onMessage(Session session, String message) { + logger.debug("Received remote WebSocket message: {}", message); + + try { + JSONRPCMessage jsonRpcMessage = AcpSchema.deserializeJsonRpcMessage(jsonMapper, message); + connection.acceptFromClient(jsonRpcMessage); + } + catch (Exception e) { + logger.warn("Closing remote WebSocket ACP connection after invalid JSON-RPC frame", e); + connection.close(StatusCode.PROTOCOL, "invalid JSON-RPC frame"); + } + } + + @OnWebSocketClose + public void onClose(Session session, int statusCode, String reason) { + logger.info("Remote WebSocket ACP client disconnected: {} - {}", statusCode, reason); + connection.close(statusCode, reason); + } + + @OnWebSocketError + public void onError(Session session, Throwable error) { + logger.error("Remote WebSocket ACP error", error); + connection.remoteConnection.signalException(error); + connection.close(StatusCode.SERVER_ERROR, "WebSocket error"); + } + + } + +} diff --git a/acp-websocket-jetty/src/test/java/com/agentclientprotocol/sdk/agent/transport/RemoteWebSocketAcpAgentTransportIntegrationTest.java b/acp-websocket-jetty/src/test/java/com/agentclientprotocol/sdk/agent/transport/RemoteWebSocketAcpAgentTransportIntegrationTest.java new file mode 100644 index 0000000..2280e2c --- /dev/null +++ b/acp-websocket-jetty/src/test/java/com/agentclientprotocol/sdk/agent/transport/RemoteWebSocketAcpAgentTransportIntegrationTest.java @@ -0,0 +1,343 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.agent.transport; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStreamReader; +import java.io.OutputStreamWriter; +import java.io.PrintWriter; +import java.net.ServerSocket; +import java.net.Socket; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.WebSocket; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.ArrayList; +import java.util.Base64; +import java.util.List; +import java.util.Locale; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; + +import com.agentclientprotocol.sdk.agent.AcpAgent; +import com.agentclientprotocol.sdk.agent.AcpAgentFactory; +import com.agentclientprotocol.sdk.client.AcpAsyncClient; +import com.agentclientprotocol.sdk.client.AcpClient; +import com.agentclientprotocol.sdk.client.transport.WebSocketAcpClientTransport; +import com.agentclientprotocol.sdk.json.AcpJsonMapper; +import com.agentclientprotocol.sdk.spec.AcpSchema; +import org.eclipse.jetty.websocket.api.StatusCode; +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Mono; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * End-to-end tests for the RFD-compliant listener-backed WebSocket transport. + */ +class RemoteWebSocketAcpAgentTransportIntegrationTest { + + private static final Duration TIMEOUT = Duration.ofSeconds(5); + + @Test + void constructorValidatesRequiredArguments() { + AcpJsonMapper jsonMapper = AcpJsonMapper.createDefault(); + AcpAgentFactory agentFactory = simpleAgentFactory(); + + assertThatThrownBy(() -> new RemoteWebSocketAcpAgentTransport(0, jsonMapper, agentFactory)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Port"); + assertThatThrownBy(() -> new RemoteWebSocketAcpAgentTransport(8080, "", jsonMapper, agentFactory)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Path"); + assertThatThrownBy(() -> new RemoteWebSocketAcpAgentTransport(8080, null, agentFactory)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("JsonMapper"); + assertThatThrownBy(() -> new RemoteWebSocketAcpAgentTransport(8080, jsonMapper, null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("agentFactory"); + } + + @Test + void handshakeReturnsConnectionIdHeader() throws Exception { + try (FixtureServer server = FixtureServer.start(simpleAgentFactory()); + Socket socket = new Socket("127.0.0.1", server.port())) { + socket.setSoTimeout((int) TIMEOUT.toMillis()); + + String key = Base64.getEncoder() + .encodeToString(UUID.randomUUID().toString().substring(0, 16).getBytes(StandardCharsets.UTF_8)); + PrintWriter writer = new PrintWriter(new OutputStreamWriter(socket.getOutputStream(), StandardCharsets.UTF_8)); + writer.print("GET /acp HTTP/1.1\r\n"); + writer.print("Host: 127.0.0.1:" + server.port() + "\r\n"); + writer.print("Upgrade: websocket\r\n"); + writer.print("Connection: Upgrade\r\n"); + writer.print("Sec-WebSocket-Key: " + key + "\r\n"); + writer.print("Sec-WebSocket-Version: 13\r\n"); + writer.print("\r\n"); + writer.flush(); + + List responseLines = readHttpHeaders(socket); + + assertThat(responseLines.get(0)).contains("101"); + assertThat(responseLines.stream() + .map(line -> line.toLowerCase(Locale.ROOT)) + .anyMatch(line -> line.startsWith("acp-connection-id:"))).isTrue(); + } + } + + @Test + void javaClientCanTalkToRemoteWebSocketServer() throws Exception { + AtomicReference receivedUpdate = new AtomicReference<>(); + + try (FixtureServer server = FixtureServer.start(simpleAgentFactory())) { + AcpAsyncClient client = AcpClient + .async(new WebSocketAcpClientTransport(server.endpoint(), AcpJsonMapper.createDefault())) + .sessionUpdateConsumer(update -> { + receivedUpdate.set(update); + return Mono.empty(); + }) + .requestTimeout(TIMEOUT) + .build(); + try { + client.initialize(new AcpSchema.InitializeRequest( + AcpSchema.LATEST_PROTOCOL_VERSION, new AcpSchema.ClientCapabilities())) + .block(TIMEOUT); + AcpSchema.NewSessionResponse session = client + .newSession(new AcpSchema.NewSessionRequest("/workspace", List.of())) + .block(TIMEOUT); + AcpSchema.PromptResponse prompt = client + .prompt(new AcpSchema.PromptRequest(session.sessionId(), + List.of(new AcpSchema.TextContent("hello over ws")))) + .block(TIMEOUT); + + assertThat(session.sessionId()).startsWith("sess-"); + assertThat(prompt.stopReason()).isEqualTo(AcpSchema.StopReason.END_TURN); + assertThat(receivedUpdate.get()).isNotNull(); + } + finally { + client.closeGracefully().block(TIMEOUT); + } + } + } + + @Test + void permissionRequestRoundTripsOverRemoteWebSocket() throws Exception { + AtomicInteger permissionRequests = new AtomicInteger(); + AcpAgentFactory agentFactory = AcpAgentFactory.async(transport -> AcpAgent.async(transport) + .initializeHandler(request -> Mono.just(new AcpSchema.InitializeResponse( + AcpSchema.LATEST_PROTOCOL_VERSION, new AcpSchema.AgentCapabilities(true, null, null), List.of()))) + .newSessionHandler(request -> Mono.just(new AcpSchema.NewSessionResponse("permission-session", null, null))) + .promptHandler((request, context) -> context.askPermission("remote websocket edit") + .map(allowed -> { + assertThat(allowed).isTrue(); + return AcpSchema.PromptResponse.endTurn(); + })) + .build()); + + try (FixtureServer server = FixtureServer.start(agentFactory)) { + AcpAsyncClient client = AcpClient + .async(new WebSocketAcpClientTransport(server.endpoint(), AcpJsonMapper.createDefault())) + .requestPermissionHandler(request -> { + permissionRequests.incrementAndGet(); + return Mono.just(new AcpSchema.RequestPermissionResponse( + new AcpSchema.PermissionSelected("allow"))); + }) + .requestTimeout(TIMEOUT) + .build(); + try { + client.initialize(new AcpSchema.InitializeRequest( + AcpSchema.LATEST_PROTOCOL_VERSION, new AcpSchema.ClientCapabilities())) + .block(TIMEOUT); + client.newSession(new AcpSchema.NewSessionRequest("/workspace", List.of())).block(TIMEOUT); + AcpSchema.PromptResponse prompt = client + .prompt(new AcpSchema.PromptRequest("permission-session", + List.of(new AcpSchema.TextContent("please ask permission")))) + .block(TIMEOUT); + + assertThat(prompt.stopReason()).isEqualTo(AcpSchema.StopReason.END_TURN); + assertThat(permissionRequests.get()).isEqualTo(1); + } + finally { + client.closeGracefully().block(TIMEOUT); + } + } + } + + @Test + void supportsMultipleConcurrentWebSocketClients() throws Exception { + try (FixtureServer server = FixtureServer.start(simpleAgentFactory())) { + AcpAsyncClient firstClient = AcpClient + .async(new WebSocketAcpClientTransport(server.endpoint(), AcpJsonMapper.createDefault())) + .requestTimeout(TIMEOUT) + .build(); + AcpAsyncClient secondClient = AcpClient + .async(new WebSocketAcpClientTransport(server.endpoint(), AcpJsonMapper.createDefault())) + .requestTimeout(TIMEOUT) + .build(); + try { + firstClient.initialize(new AcpSchema.InitializeRequest( + AcpSchema.LATEST_PROTOCOL_VERSION, new AcpSchema.ClientCapabilities())) + .block(TIMEOUT); + secondClient.initialize(new AcpSchema.InitializeRequest( + AcpSchema.LATEST_PROTOCOL_VERSION, new AcpSchema.ClientCapabilities())) + .block(TIMEOUT); + + AcpSchema.NewSessionResponse firstSession = firstClient + .newSession(new AcpSchema.NewSessionRequest("/workspace/one", List.of())) + .block(TIMEOUT); + AcpSchema.NewSessionResponse secondSession = secondClient + .newSession(new AcpSchema.NewSessionRequest("/workspace/two", List.of())) + .block(TIMEOUT); + + assertThat(firstSession.sessionId()).isNotEqualTo(secondSession.sessionId()); + assertThat(server.transport().activeConnectionCount()).isEqualTo(2); + } + finally { + firstClient.closeGracefully().block(TIMEOUT); + secondClient.closeGracefully().block(TIMEOUT); + } + } + } + + @Test + void rejectsNonInitializeFirstMessage() throws Exception { + try (FixtureServer server = FixtureServer.start(simpleAgentFactory())) { + CloseRecordingListener listener = new CloseRecordingListener(); + WebSocket webSocket = HttpClient.newHttpClient() + .newWebSocketBuilder() + .connectTimeout(TIMEOUT) + .buildAsync(server.endpoint(), listener) + .get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS); + + assertThat(listener.openLatch.await(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS)).isTrue(); + webSocket.sendText(""" + {"jsonrpc":"2.0","id":"new-1","method":"session/new","params":{"cwd":"/workspace","mcpServers":[]}} + """, true).get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS); + + assertThat(listener.closeLatch.await(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS)).isTrue(); + assertThat(listener.closeCode.get()).isEqualTo(StatusCode.PROTOCOL); + assertEventuallyNoConnections(server.transport()); + } + } + + private static AcpAgentFactory simpleAgentFactory() { + AtomicInteger sessionCounter = new AtomicInteger(); + return AcpAgentFactory.async(transport -> AcpAgent.async(transport) + .initializeHandler(request -> Mono.just(new AcpSchema.InitializeResponse( + AcpSchema.LATEST_PROTOCOL_VERSION, new AcpSchema.AgentCapabilities(true, null, null), List.of()))) + .newSessionHandler(request -> Mono.just(new AcpSchema.NewSessionResponse( + "sess-" + sessionCounter.incrementAndGet(), null, null))) + .promptHandler((request, context) -> context.sendMessage("hello from remote websocket") + .thenReturn(AcpSchema.PromptResponse.endTurn())) + .build()); + } + + private static List readHttpHeaders(Socket socket) throws IOException { + BufferedReader reader = new BufferedReader(new InputStreamReader(socket.getInputStream(), StandardCharsets.UTF_8)); + List lines = new ArrayList<>(); + String line; + while ((line = reader.readLine()) != null && !line.isEmpty()) { + lines.add(line); + } + return lines; + } + + private static void assertEventuallyNoConnections(RemoteWebSocketAcpAgentTransport transport) throws InterruptedException { + long deadline = System.nanoTime() + TIMEOUT.toNanos(); + while (System.nanoTime() < deadline) { + if (transport.activeConnectionCount() == 0) { + return; + } + Thread.sleep(25); + } + assertThat(transport.activeConnectionCount()).isEqualTo(0); + } + + private static int freePort() { + try (ServerSocket socket = new ServerSocket(0)) { + return socket.getLocalPort(); + } + catch (IOException e) { + throw new IllegalStateException("Unable to allocate a free port", e); + } + } + + private static final class FixtureServer implements AutoCloseable { + + private final RemoteWebSocketAcpAgentTransport transport; + + private FixtureServer(RemoteWebSocketAcpAgentTransport transport) { + this.transport = transport; + } + + static FixtureServer start(AcpAgentFactory agentFactory) { + RemoteWebSocketAcpAgentTransport transport = new RemoteWebSocketAcpAgentTransport( + freePort(), AcpJsonMapper.createDefault(), agentFactory); + transport.start().block(TIMEOUT); + return new FixtureServer(transport); + } + + int port() { + return transport.getPort(); + } + + URI endpoint() { + return URI.create("ws://127.0.0.1:" + transport.getPort() + "/acp"); + } + + RemoteWebSocketAcpAgentTransport transport() { + return transport; + } + + @Override + public void close() { + transport.closeGracefully().block(TIMEOUT); + } + + } + + private static final class CloseRecordingListener implements WebSocket.Listener { + + private final CountDownLatch openLatch = new CountDownLatch(1); + + private final CountDownLatch closeLatch = new CountDownLatch(1); + + private final AtomicInteger closeCode = new AtomicInteger(); + + @Override + public void onOpen(WebSocket webSocket) { + openLatch.countDown(); + webSocket.request(1); + } + + @Override + public CompletionStage onText(WebSocket webSocket, CharSequence data, boolean last) { + webSocket.request(1); + return CompletableFuture.completedFuture(null); + } + + @Override + public CompletionStage onClose(WebSocket webSocket, int statusCode, String reason) { + closeCode.set(statusCode); + closeLatch.countDown(); + return CompletableFuture.completedFuture(null); + } + + @Override + public void onError(WebSocket webSocket, Throwable error) { + closeLatch.countDown(); + } + + } + +} From f0d8578a55ab07fa029f1ad5b6689b600f1d36a5 Mon Sep 17 00:00:00 2001 From: Kaiser Dandangi Date: Sun, 24 May 2026 18:02:39 -0400 Subject: [PATCH 11/15] feat: unify streamable agent transport --- ...HttpAcpClientTransportIntegrationTest.java | 450 ++++++++++---- acp-streamable-http-jetty/pom.xml | 8 + .../StreamableHttpAcpAgentTransport.java | 200 +++++- ...eHttpAcpAgentTransportIntegrationTest.java | 297 ++++++--- ...gentTransportWebSocketIntegrationTest.java | 30 +- .../RemoteWebSocketAcpAgentTransport.java | 363 ----------- plans/STREAMABLE-HTTP-AGENT-TRANSPORT.md | 48 +- plans/STREAMABLE-HTTP-TRANSPORT.md | 30 +- .../streamable-http-agent-server/README.md | 10 +- .../StreamableHttpAgentDemoServer.java | 2 + .../streamable-http-client/README.md | 27 - .../streamable-http-client/dist/client.js | 16 - .../streamable-http-client/dist/protocol.js | 245 -------- .../streamable-http-client/dist/scenarios.js | 127 ---- .../streamable-http-client/dist/transcript.js | 69 --- .../golden/happy-path.json | 127 ---- .../golden/permission-round-trip.json | 154 ----- .../golden/session-load.json | 92 --- .../golden/two-sessions.json | 203 ------- .../golden/validation-failures.json | 79 --- .../golden/wrong-stream-response.json | 137 ----- .../streamable-http-client/package-lock.json | 45 -- .../streamable-http-client/package.json | 12 - .../streamable-http-client/src/client.ts | 19 - .../streamable-http-client/src/protocol.ts | 278 --------- .../streamable-http-client/src/scenarios.ts | 134 ---- .../streamable-http-client/src/transcript.ts | 115 ---- .../streamable-http-client/tsconfig.json | 12 - .../streamable-http-server/README.md | 36 -- .../streamable-http-server/dist/protocol.js | 26 - .../streamable-http-server/dist/scenarios.js | 387 ------------ .../streamable-http-server/dist/server.js | 46 -- .../streamable-http-server/dist/transcript.js | 52 -- .../golden/happy-path.json | 139 ----- .../golden/permission-round-trip.json | 160 ----- .../golden/session-load.json | 100 --- .../golden/two-sessions.json | 224 ------- .../golden/validation-failures.json | 86 --- .../golden/wrong-stream-response.json | 130 ---- .../streamable-http-server/package-lock.json | 566 ----------------- .../streamable-http-server/package.json | 16 - .../streamable-http-server/src/protocol.ts | 32 - .../streamable-http-server/src/scenarios.ts | 574 ------------------ .../streamable-http-server/src/server.ts | 55 -- .../streamable-http-server/src/transcript.ts | 101 --- .../streamable-http-server/tsconfig.json | 13 - 46 files changed, 822 insertions(+), 5250 deletions(-) rename acp-websocket-jetty/src/test/java/com/agentclientprotocol/sdk/agent/transport/RemoteWebSocketAcpAgentTransportIntegrationTest.java => acp-streamable-http-jetty/src/test/java/com/agentclientprotocol/sdk/agent/transport/StreamableHttpAcpAgentTransportWebSocketIntegrationTest.java (90%) delete mode 100644 acp-websocket-jetty/src/main/java/com/agentclientprotocol/sdk/agent/transport/RemoteWebSocketAcpAgentTransport.java delete mode 100644 test-fixtures/streamable-http-client/README.md delete mode 100644 test-fixtures/streamable-http-client/dist/client.js delete mode 100644 test-fixtures/streamable-http-client/dist/protocol.js delete mode 100644 test-fixtures/streamable-http-client/dist/scenarios.js delete mode 100644 test-fixtures/streamable-http-client/dist/transcript.js delete mode 100644 test-fixtures/streamable-http-client/golden/happy-path.json delete mode 100644 test-fixtures/streamable-http-client/golden/permission-round-trip.json delete mode 100644 test-fixtures/streamable-http-client/golden/session-load.json delete mode 100644 test-fixtures/streamable-http-client/golden/two-sessions.json delete mode 100644 test-fixtures/streamable-http-client/golden/validation-failures.json delete mode 100644 test-fixtures/streamable-http-client/golden/wrong-stream-response.json delete mode 100644 test-fixtures/streamable-http-client/package-lock.json delete mode 100644 test-fixtures/streamable-http-client/package.json delete mode 100644 test-fixtures/streamable-http-client/src/client.ts delete mode 100644 test-fixtures/streamable-http-client/src/protocol.ts delete mode 100644 test-fixtures/streamable-http-client/src/scenarios.ts delete mode 100644 test-fixtures/streamable-http-client/src/transcript.ts delete mode 100644 test-fixtures/streamable-http-client/tsconfig.json delete mode 100644 test-fixtures/streamable-http-server/README.md delete mode 100644 test-fixtures/streamable-http-server/dist/protocol.js delete mode 100644 test-fixtures/streamable-http-server/dist/scenarios.js delete mode 100644 test-fixtures/streamable-http-server/dist/server.js delete mode 100644 test-fixtures/streamable-http-server/dist/transcript.js delete mode 100644 test-fixtures/streamable-http-server/golden/happy-path.json delete mode 100644 test-fixtures/streamable-http-server/golden/permission-round-trip.json delete mode 100644 test-fixtures/streamable-http-server/golden/session-load.json delete mode 100644 test-fixtures/streamable-http-server/golden/two-sessions.json delete mode 100644 test-fixtures/streamable-http-server/golden/validation-failures.json delete mode 100644 test-fixtures/streamable-http-server/golden/wrong-stream-response.json delete mode 100644 test-fixtures/streamable-http-server/package-lock.json delete mode 100644 test-fixtures/streamable-http-server/package.json delete mode 100644 test-fixtures/streamable-http-server/src/protocol.ts delete mode 100644 test-fixtures/streamable-http-server/src/scenarios.ts delete mode 100644 test-fixtures/streamable-http-server/src/server.ts delete mode 100644 test-fixtures/streamable-http-server/src/transcript.ts delete mode 100644 test-fixtures/streamable-http-server/tsconfig.json diff --git a/acp-core/src/test/java/com/agentclientprotocol/sdk/client/transport/StreamableHttpAcpClientTransportIntegrationTest.java b/acp-core/src/test/java/com/agentclientprotocol/sdk/client/transport/StreamableHttpAcpClientTransportIntegrationTest.java index aed6658..1cf2e51 100644 --- a/acp-core/src/test/java/com/agentclientprotocol/sdk/client/transport/StreamableHttpAcpClientTransportIntegrationTest.java +++ b/acp-core/src/test/java/com/agentclientprotocol/sdk/client/transport/StreamableHttpAcpClientTransportIntegrationTest.java @@ -4,20 +4,27 @@ package com.agentclientprotocol.sdk.client.transport; -import java.io.BufferedReader; import java.io.IOException; -import java.io.InputStreamReader; +import java.io.OutputStream; +import java.net.InetSocketAddress; +import java.net.ServerSocket; import java.net.URI; import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; import java.time.Duration; import java.util.List; +import java.util.Map; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.LinkedBlockingQueue; import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicInteger; import com.agentclientprotocol.sdk.AcpTestFixtures; @@ -26,8 +33,8 @@ import com.agentclientprotocol.sdk.error.AcpConnectionException; import com.agentclientprotocol.sdk.json.AcpJsonMapper; import com.agentclientprotocol.sdk.spec.AcpSchema; -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpServer; import org.junit.jupiter.api.Test; import reactor.core.publisher.Mono; @@ -35,21 +42,25 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy; /** - * End-to-end tests against the in-repo TypeScript Streamable HTTP fixture. + * End-to-end tests against an in-process Java Streamable HTTP fixture server. */ class StreamableHttpAcpClientTransportIntegrationTest { private static final Duration TIMEOUT = Duration.ofSeconds(5); - private static final Path FIXTURE_DIR = Path.of("..", "test-fixtures", "streamable-http-server").normalize(); + private static final String CONNECTION_ID = "conn-test"; - private static final Path GOLDEN_DIR = FIXTURE_DIR.resolve("golden"); + private static final String CONNECTION_STREAM = "connection"; - private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + private static final String CONTENT_TYPE_JSON = "application/json"; + + private static final String CONTENT_TYPE_EVENT_STREAM = "text/event-stream"; + + private static final AcpJsonMapper JSON_MAPPER = AcpJsonMapper.createDefault(); @Test - void happyPathMatchesFixtureTranscript() throws Exception { - try (FixtureServer fixture = FixtureServer.start("happy-path")) { + void happyPathUsesConnectionAndSessionStreams() throws Exception { + try (FixtureServer fixture = FixtureServer.start()) { CopyOnWriteArrayList updates = new CopyOnWriteArrayList<>(); AcpAsyncClient client = newClient(fixture.endpoint()) .sessionUpdateConsumer(notification -> { @@ -69,20 +80,23 @@ void happyPathMatchesFixtureTranscript() throws Exception { assertThat(session.sessionId()).isEqualTo("sess-1"); assertThat(prompt.stopReason()).isEqualTo(AcpSchema.StopReason.END_TURN); assertThat(updates).hasSize(1); + assertThat(fixture.connectionStreamOpened()).isTrue(); + assertThat(fixture.sessionStreamOpened("sess-1")).isTrue(); client.closeGracefully().block(TIMEOUT); - fixture.assertTranscriptMatches("happy-path.json"); + assertThat(fixture.deleteReceived()).isTrue(); } } @Test void permissionRequestRoundTripsOnSessionStream() throws Exception { - try (FixtureServer fixture = FixtureServer.start("permission-round-trip")) { + try (FixtureServer fixture = FixtureServer.start()) { AtomicInteger permissionRequests = new AtomicInteger(); AcpAsyncClient client = newClient(fixture.endpoint()) .requestPermissionHandler(request -> { permissionRequests.incrementAndGet(); - return Mono.just(new AcpSchema.RequestPermissionResponse(new AcpSchema.PermissionSelected("allow"))); + return Mono.just(new AcpSchema.RequestPermissionResponse( + new AcpSchema.PermissionSelected("allow"))); }) .build(); @@ -94,18 +108,17 @@ void permissionRequestRoundTripsOnSessionStream() throws Exception { .prompt(AcpTestFixtures.createPromptRequest(session.sessionId(), "needs permission")) .block(TIMEOUT); - assertThat(session.sessionId()).isEqualTo("sess-permission"); assertThat(prompt.stopReason()).isEqualTo(AcpSchema.StopReason.END_TURN); assertThat(permissionRequests).hasValue(1); + assertThat(fixture.permissionResponseReceived()).isTrue(); client.closeGracefully().block(TIMEOUT); - fixture.assertTranscriptMatches("permission-round-trip.json"); } } @Test void loadSessionOpensSessionStreamBeforePosting() throws Exception { - try (FixtureServer fixture = FixtureServer.start("session-load")) { + try (FixtureServer fixture = FixtureServer.start()) { AcpAsyncClient client = newClient(fixture.endpoint()).build(); client.initialize().block(TIMEOUT); @@ -114,15 +127,15 @@ void loadSessionOpensSessionStreamBeforePosting() throws Exception { .block(TIMEOUT); assertThat(response).isNotNull(); + assertThat(fixture.sessionLoadStreamWasOpenBeforePost()).isTrue(); client.closeGracefully().block(TIMEOUT); - fixture.assertTranscriptMatches("session-load.json"); } } @Test void supportsTwoConcurrentLogicalSessions() throws Exception { - try (FixtureServer fixture = FixtureServer.start("two-sessions")) { + try (FixtureServer fixture = FixtureServer.start()) { AcpAsyncClient client = newClient(fixture.endpoint()).build(); client.initialize().block(TIMEOUT); @@ -143,15 +156,17 @@ void supportsTwoConcurrentLogicalSessions() throws Exception { assertThat(second.sessionId()).isEqualTo("sess-2"); assertThat(firstPrompt.stopReason()).isEqualTo(AcpSchema.StopReason.END_TURN); assertThat(secondPrompt.stopReason()).isEqualTo(AcpSchema.StopReason.END_TURN); + assertThat(fixture.sessionStreamOpened("sess-1")).isTrue(); + assertThat(fixture.sessionStreamOpened("sess-2")).isTrue(); client.closeGracefully().block(TIMEOUT); - fixture.assertTranscriptMatches("two-sessions.json"); } } @Test void wrongStreamResponseFailsPendingExchange() throws Exception { - try (FixtureServer fixture = FixtureServer.start("wrong-stream-response")) { + try (FixtureServer fixture = FixtureServer.start()) { + fixture.routePromptResponsesOnConnectionStream(); AcpAsyncClient client = newClient(fixture.endpoint()).build(); client.initialize().block(TIMEOUT); @@ -166,54 +181,20 @@ void wrongStreamResponseFailsPendingExchange() throws Exception { .hasMessageContaining("arrived on RouteScope"); client.closeGracefully().block(TIMEOUT); - fixture.assertTranscriptMatches("wrong-stream-response.json"); } } @Test - void fixtureRejectsMissingConnectionHeadersCookiesAndSseAccept() throws Exception { - try (FixtureServer fixture = FixtureServer.start("validation-failures")) { - HttpClient rawClient = HttpClient.newHttpClient(); - HttpResponse initialize = rawClient.send(HttpRequest.newBuilder(fixture.endpoint()) - .header("Content-Type", "application/json") - .header("Accept", "application/json") - .POST(HttpRequest.BodyPublishers.ofString(""" - {"jsonrpc":"2.0","id":"init-1","method":"initialize","params":{"protocolVersion":1,"clientCapabilities":{}}} - """)) - .build(), HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); - - String connectionId = initialize.headers().firstValue("Acp-Connection-Id").orElseThrow(); - String cookie = initialize.headers().firstValue("set-cookie").orElseThrow(); - - HttpResponse missingConnection = rawClient.send(HttpRequest.newBuilder(fixture.endpoint()) - .header("Accept", "text/event-stream") - .header("Cookie", cookie) - .GET() - .build(), HttpResponse.BodyHandlers.discarding()); - HttpResponse missingCookie = rawClient.send(HttpRequest.newBuilder(fixture.endpoint()) - .header("Accept", "text/event-stream") - .header("Acp-Connection-Id", connectionId) - .GET() - .build(), HttpResponse.BodyHandlers.discarding()); - HttpResponse wrongAccept = rawClient.send(HttpRequest.newBuilder(fixture.endpoint()) - .header("Accept", "application/json") - .header("Cookie", cookie) - .header("Acp-Connection-Id", connectionId) - .GET() - .build(), HttpResponse.BodyHandlers.discarding()); - HttpResponse delete = rawClient.send(HttpRequest.newBuilder(fixture.endpoint()) - .header("Cookie", cookie) - .header("Acp-Connection-Id", connectionId) - .DELETE() - .build(), HttpResponse.BodyHandlers.discarding()); - - assertThat(initialize.statusCode()).isEqualTo(200); - assertThat(missingConnection.statusCode()).isEqualTo(400); - assertThat(missingCookie.statusCode()).isEqualTo(401); - assertThat(wrongAccept.statusCode()).isEqualTo(406); - assertThat(delete.statusCode()).isEqualTo(202); - - fixture.assertTranscriptMatches("validation-failures.json"); + void initializeRequiresConnectionIdHeader() throws Exception { + try (FixtureServer fixture = FixtureServer.start()) { + fixture.omitConnectionIdOnInitialize(); + AcpAsyncClient client = newClient(fixture.endpoint()).build(); + + assertThatThrownBy(() -> client.initialize().block(TIMEOUT)) + .isInstanceOf(AcpConnectionException.class) + .hasMessageContaining("Initialize response missing Acp-Connection-Id"); + + client.closeGracefully().onErrorResume(ignored -> Mono.empty()).block(TIMEOUT); } } @@ -225,63 +206,322 @@ private AcpClient.AsyncSpec newClient(URI endpoint) { private static final class FixtureServer implements AutoCloseable { - private final Process process; + private final HttpServer server; + + private final ExecutorService executor; + + private final Map streams = new ConcurrentHashMap<>(); + + private final AtomicInteger sessionCounter = new AtomicInteger(); + + private final AtomicBoolean deleteReceived = new AtomicBoolean(false); + + private final AtomicBoolean omitConnectionIdOnInitialize = new AtomicBoolean(false); + + private final AtomicBoolean routePromptResponsesOnConnectionStream = new AtomicBoolean(false); + + private final AtomicBoolean permissionResponseReceived = new AtomicBoolean(false); - private final BufferedReader stdout; + private final AtomicBoolean sessionLoadStreamWasOpenBeforePost = new AtomicBoolean(false); - private final URI baseUri; + private final CompletableFuture permissionResponse = new CompletableFuture<>(); - private FixtureServer(Process process, BufferedReader stdout, URI baseUri) { - this.process = process; - this.stdout = stdout; - this.baseUri = baseUri; + private FixtureServer(HttpServer server, ExecutorService executor) { + this.server = server; + this.executor = executor; } - static FixtureServer start(String scenario) throws Exception { - Process process = new ProcessBuilder("node", "dist/server.js", "--scenario", scenario, "--port", "0") - .directory(FIXTURE_DIR.toFile()) - .redirectErrorStream(true) - .start(); - BufferedReader stdout = new BufferedReader( - new InputStreamReader(process.getInputStream(), StandardCharsets.UTF_8)); - String readyLine = stdout.readLine(); - if (readyLine == null) { - throw new IllegalStateException("Fixture server exited before becoming ready"); + static FixtureServer start() throws Exception { + HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", freePort()), 0); + ExecutorService executor = Executors.newCachedThreadPool(); + FixtureServer fixture = new FixtureServer(server, executor); + server.createContext("/acp", fixture::handle); + server.setExecutor(executor); + server.start(); + return fixture; + } + + URI endpoint() { + return URI.create("http://127.0.0.1:" + server.getAddress().getPort() + "/acp"); + } + + boolean connectionStreamOpened() { + return streams.containsKey(CONNECTION_STREAM); + } + + boolean sessionStreamOpened(String sessionId) { + return streams.containsKey(sessionKey(sessionId)); + } + + boolean deleteReceived() { + return deleteReceived.get(); + } + + boolean permissionResponseReceived() { + return permissionResponseReceived.get(); + } + + boolean sessionLoadStreamWasOpenBeforePost() { + return sessionLoadStreamWasOpenBeforePost.get(); + } + + void omitConnectionIdOnInitialize() { + omitConnectionIdOnInitialize.set(true); + } + + void routePromptResponsesOnConnectionStream() { + routePromptResponsesOnConnectionStream.set(true); + } + + private void handle(HttpExchange exchange) throws IOException { + switch (exchange.getRequestMethod()) { + case "POST" -> handlePost(exchange); + case "GET" -> handleGet(exchange); + case "DELETE" -> handleDelete(exchange); + default -> writeText(exchange, 405, "method not allowed"); + } + } + + private void handlePost(HttpExchange exchange) throws IOException { + String body = new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8); + AcpSchema.JSONRPCMessage message; + try { + message = AcpSchema.deserializeJsonRpcMessage(JSON_MAPPER, body); + } + catch (Exception e) { + writeText(exchange, 400, "invalid json-rpc"); + return; } - JsonNode ready = OBJECT_MAPPER.readTree(readyLine); - if (!"ready".equals(ready.path("status").asText())) { - throw new IllegalStateException("Fixture server did not become ready: " + readyLine); + + if (message instanceof AcpSchema.JSONRPCRequest request + && AcpSchema.METHOD_INITIALIZE.equals(request.method())) { + if (!omitConnectionIdOnInitialize.get()) { + exchange.getResponseHeaders().add("Acp-Connection-Id", CONNECTION_ID); + } + writeJson(exchange, 200, response(request.id(), AcpSchema.InitializeResponse.ok())); + return; + } + + String connectionId = exchange.getRequestHeaders().getFirst("Acp-Connection-Id"); + if (!CONNECTION_ID.equals(connectionId)) { + writeText(exchange, 400, "Acp-Connection-Id header required"); + return; } - int port = ready.path("port").asInt(); - return new FixtureServer(process, stdout, URI.create("http://127.0.0.1:" + port)); + + String sessionId = exchange.getRequestHeaders().getFirst("Acp-Session-Id"); + exchange.sendResponseHeaders(202, -1); + exchange.close(); + handleAcceptedMessage(message, sessionId); } - URI endpoint() { - return baseUri.resolve("/acp"); + private void handleAcceptedMessage(AcpSchema.JSONRPCMessage message, String sessionHeader) { + if (message instanceof AcpSchema.JSONRPCResponse response) { + permissionResponseReceived.set(true); + permissionResponse.complete(response); + return; + } + if (!(message instanceof AcpSchema.JSONRPCRequest request)) { + return; + } + + switch (request.method()) { + case AcpSchema.METHOD_SESSION_NEW -> { + String sessionId = "sess-" + sessionCounter.incrementAndGet(); + send(CONNECTION_STREAM, response(request.id(), + new AcpSchema.NewSessionResponse(sessionId, null, null))); + } + case AcpSchema.METHOD_SESSION_LOAD -> { + String sessionId = sessionId(request.params()); + sessionLoadStreamWasOpenBeforePost.set(sessionStreamOpened(sessionId)); + send(CONNECTION_STREAM, response(request.id(), new AcpSchema.LoadSessionResponse(null, null))); + } + case AcpSchema.METHOD_SESSION_PROMPT -> handlePrompt(request, sessionHeader); + default -> send(CONNECTION_STREAM, response(request.id(), Map.of())); + } } - void assertTranscriptMatches(String goldenName) throws Exception { - HttpRequest request = HttpRequest.newBuilder(baseUri.resolve("/__test/transcript")).GET().build(); - HttpResponse response = HttpClient.newHttpClient() - .send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); - assertThat(response.statusCode()).isEqualTo(200); + private void handlePrompt(AcpSchema.JSONRPCRequest request, String sessionHeader) { + String sessionId = sessionId(request.params()); + if (routePromptResponsesOnConnectionStream.get()) { + send(CONNECTION_STREAM, response(request.id(), AcpSchema.PromptResponse.endTurn())); + return; + } - JsonNode actual = OBJECT_MAPPER.readTree(response.body()); - JsonNode expected = OBJECT_MAPPER.readTree(Files.readString(GOLDEN_DIR.resolve(goldenName))); - assertThat(actual).isEqualTo(expected); + if (requestText(request.params()).contains("permission")) { + String permissionId = "permission-1"; + send(sessionKey(sessionId), new AcpSchema.JSONRPCRequest(AcpSchema.JSONRPC_VERSION, permissionId, + AcpSchema.METHOD_SESSION_REQUEST_PERMISSION, new AcpSchema.RequestPermissionRequest(sessionId, + new AcpSchema.ToolCallUpdate("tool-1", "Edit file", AcpSchema.ToolKind.EDIT, + AcpSchema.ToolCallStatus.PENDING, null, null, null, null), + List.of(new AcpSchema.PermissionOption("allow", "Allow", AcpSchema.PermissionOptionKind.ALLOW_ONCE))))); + try { + permissionResponse.get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS); + } + catch (Exception e) { + throw new AssertionError("Timed out waiting for permission response", e); + } + } + else { + send(sessionKey(sessionId), new AcpSchema.JSONRPCNotification(AcpSchema.METHOD_SESSION_UPDATE, + new AcpSchema.SessionNotification(sessionId, + new AcpSchema.AgentMessageChunk("agent_message_chunk", new AcpSchema.TextContent("hello"))))); + } + send(sessionKey(sessionId), response(request.id(), AcpSchema.PromptResponse.endTurn())); + } + + private void handleGet(HttpExchange exchange) throws IOException { + if (!accepts(exchange, CONTENT_TYPE_EVENT_STREAM)) { + writeText(exchange, 406, "client must accept text/event-stream"); + return; + } + if (!CONNECTION_ID.equals(exchange.getRequestHeaders().getFirst("Acp-Connection-Id"))) { + writeText(exchange, 400, "Acp-Connection-Id header required"); + return; + } + + String sessionId = exchange.getRequestHeaders().getFirst("Acp-Session-Id"); + String key = sessionId == null ? CONNECTION_STREAM : sessionKey(sessionId); + exchange.getResponseHeaders().add("Content-Type", CONTENT_TYPE_EVENT_STREAM); + exchange.sendResponseHeaders(200, 0); + SseStream stream = new SseStream(exchange.getResponseBody()); + streams.put(key, stream); + stream.run(); + } + + private void handleDelete(HttpExchange exchange) throws IOException { + deleteReceived.set(true); + streams.values().forEach(SseStream::close); + writeText(exchange, 202, ""); + } + + private void send(String key, AcpSchema.JSONRPCMessage message) { + try { + SseStream stream = awaitStream(key); + stream.send(JSON_MAPPER.writeValueAsString(message)); + } + catch (Exception e) { + throw new AssertionError("Failed to send SSE message on " + key, e); + } + } + + private SseStream awaitStream(String key) throws InterruptedException { + long deadline = System.nanoTime() + TIMEOUT.toNanos(); + while (System.nanoTime() < deadline) { + SseStream stream = streams.get(key); + if (stream != null) { + return stream; + } + Thread.sleep(10); + } + throw new AssertionError("Timed out waiting for SSE stream " + key); + } + + private static AcpSchema.JSONRPCResponse response(Object id, Object result) { + return new AcpSchema.JSONRPCResponse(AcpSchema.JSONRPC_VERSION, id, result, null); + } + + private static boolean accepts(HttpExchange exchange, String expected) { + return exchange.getRequestHeaders() + .getOrDefault("Accept", List.of()) + .stream() + .map(String::toLowerCase) + .anyMatch(value -> value.contains(expected)); + } + + private static String sessionId(Object params) { + Object sessionId = JSON_MAPPER.convertValue(params, Map.class).get("sessionId"); + return sessionId == null ? null : sessionId.toString(); + } + + private static String requestText(Object params) { + Map map = JSON_MAPPER.convertValue(params, Map.class); + Object prompt = map.get("prompt"); + return prompt == null ? "" : prompt.toString(); + } + + private static String sessionKey(String sessionId) { + return "session:" + sessionId; + } + + private static void writeJson(HttpExchange exchange, int status, Object body) throws IOException { + byte[] bytes = JSON_MAPPER.writeValueAsString(body).getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().add("Content-Type", CONTENT_TYPE_JSON); + exchange.sendResponseHeaders(status, bytes.length); + exchange.getResponseBody().write(bytes); + exchange.close(); + } + + private static void writeText(HttpExchange exchange, int status, String body) throws IOException { + byte[] bytes = body.getBytes(StandardCharsets.UTF_8); + exchange.sendResponseHeaders(status, bytes.length); + exchange.getResponseBody().write(bytes); + exchange.close(); + } + + private static int freePort() throws IOException { + try (ServerSocket socket = new ServerSocket(0)) { + return socket.getLocalPort(); + } } @Override - public void close() throws Exception { - process.destroy(); - if (!process.waitFor(2, TimeUnit.SECONDS)) { - process.destroyForcibly(); - process.waitFor(2, TimeUnit.SECONDS); + public void close() { + streams.values().forEach(SseStream::close); + server.stop(0); + executor.shutdownNow(); + } + + } + + private static final class SseStream { + + private static final String CLOSE = "__close__"; + + private final OutputStream outputStream; + + private final BlockingQueue queue = new LinkedBlockingQueue<>(); + + private final AtomicBoolean closed = new AtomicBoolean(false); + + private SseStream(OutputStream outputStream) { + this.outputStream = outputStream; + } + + void send(String json) { + queue.add(json); + } + + void close() { + if (closed.compareAndSet(false, true)) { + queue.offer(CLOSE); + try { + outputStream.close(); + } + catch (IOException ignored) { + } } + } + + void run() { try { - stdout.close(); + while (true) { + String json = queue.take(); + if (CLOSE.equals(json)) { + return; + } + byte[] bytes = ("data: " + json + "\n\n").getBytes(StandardCharsets.UTF_8); + outputStream.write(bytes); + outputStream.flush(); + } + } + catch (Exception ignored) { } - catch (IOException ignored) { + finally { + try { + outputStream.close(); + } + catch (IOException ignored) { + } } } diff --git a/acp-streamable-http-jetty/pom.xml b/acp-streamable-http-jetty/pom.xml index defe354..c31405a 100644 --- a/acp-streamable-http-jetty/pom.xml +++ b/acp-streamable-http-jetty/pom.xml @@ -34,6 +34,14 @@ org.eclipse.jetty.http2 jetty-http2-server + + org.eclipse.jetty.websocket + jetty-websocket-jetty-server + + + org.eclipse.jetty.websocket + jetty-websocket-jetty-api + org.junit.jupiter diff --git a/acp-streamable-http-jetty/src/main/java/com/agentclientprotocol/sdk/agent/transport/StreamableHttpAcpAgentTransport.java b/acp-streamable-http-jetty/src/main/java/com/agentclientprotocol/sdk/agent/transport/StreamableHttpAcpAgentTransport.java index 843860a..56ef14e 100644 --- a/acp-streamable-http-jetty/src/main/java/com/agentclientprotocol/sdk/agent/transport/StreamableHttpAcpAgentTransport.java +++ b/acp-streamable-http-jetty/src/main/java/com/agentclientprotocol/sdk/agent/transport/StreamableHttpAcpAgentTransport.java @@ -6,6 +6,7 @@ import java.io.IOException; import java.io.PrintWriter; +import java.nio.channels.ClosedChannelException; import java.nio.charset.StandardCharsets; import java.time.Duration; import java.util.ArrayDeque; @@ -39,6 +40,15 @@ import org.eclipse.jetty.server.HttpConnectionFactory; import org.eclipse.jetty.server.Server; import org.eclipse.jetty.server.ServerConnector; +import org.eclipse.jetty.websocket.api.Callback; +import org.eclipse.jetty.websocket.api.Session; +import org.eclipse.jetty.websocket.api.StatusCode; +import org.eclipse.jetty.websocket.api.annotations.OnWebSocketClose; +import org.eclipse.jetty.websocket.api.annotations.OnWebSocketError; +import org.eclipse.jetty.websocket.api.annotations.OnWebSocketMessage; +import org.eclipse.jetty.websocket.api.annotations.OnWebSocketOpen; +import org.eclipse.jetty.websocket.api.annotations.WebSocket; +import org.eclipse.jetty.websocket.server.WebSocketUpgradeHandler; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import reactor.core.publisher.Mono; @@ -48,16 +58,19 @@ * Listener-backed ACP Streamable HTTP transport for agents. * *

- * This transport hosts a Jetty HTTP endpoint and creates one fresh agent runtime per - * remote ACP connection through {@link AcpAgentFactory}. The accepted connection then - * owns its own per-connection {@link RemoteAcpConnection}, while the listener remains - * responsible only for HTTP concerns such as headers, SSE streams, and request routing. + * This transport hosts the ACP Streamable HTTP endpoint on Jetty, including POST/SSE + * request handling and WebSocket upgrades on the same path. It creates one fresh agent + * runtime per remote ACP connection through {@link AcpAgentFactory}. The accepted + * connection then owns its own per-connection {@link RemoteAcpConnection}, while the + * listener remains responsible only for wire-level concerns such as headers, SSE + * streams, WebSocket frames, and request routing. *

* *

- * Streamable HTTP and the RFD-compliant remote WebSocket listener share - * {@link RemoteAcpConnection}; this class keeps HTTP-specific routing, headers, SSE - * stream ownership, and replay behavior local to the HTTP adapter. + * WebSocket support is intentionally hosted here instead of as a separate public + * listener so one {@code /acp} endpoint can behave like the RFD and the Rust + * {@code AcpHttpServer}: HTTP requests fall through to the servlet, while valid + * WebSocket upgrade requests are accepted by Jetty's {@link WebSocketUpgradeHandler}. *

* * @author Kaiser Dandangi @@ -160,6 +173,8 @@ private record ResolvedInboundRoute(JSONRPCMessage message, RouteScope requestSc private final ConcurrentMap connections = new ConcurrentHashMap<>(); + private final ConcurrentMap webSocketConnections = new ConcurrentHashMap<>(); + private final AtomicBoolean started = new AtomicBoolean(false); private final AtomicBoolean closing = new AtomicBoolean(false); @@ -232,6 +247,25 @@ public Mono start() { ServletContextHandler context = new ServletContextHandler(); context.setContextPath("/"); context.addServlet(new ServletHolder(new AcpServlet()), path); + + WebSocketUpgradeHandler webSocketHandler = WebSocketUpgradeHandler.from(jettyServer, context, container -> { + container.setIdleTimeout(Duration.ofMinutes(30)); + container.addMapping(path, (request, response, callback) -> { + WebSocketConnectionState connection = createWebSocketConnection(); + try { + connection.start(); + webSocketConnections.put(connection.id(), connection); + response.getHeaders().put(HEADER_CONNECTION_ID, connection.id()); + return new AcpWebSocketEndpoint(connection); + } + catch (Exception e) { + connection.close(); + callback.failed(e); + return null; + } + }); + }); + context.insertHandler(webSocketHandler); jettyServer.setHandler(context); jettyServer.start(); @@ -262,6 +296,8 @@ public Mono closeGracefully() { } connections.values().forEach(ConnectionState::close); connections.clear(); + webSocketConnections.values().forEach(WebSocketConnectionState::close); + webSocketConnections.clear(); Server currentServer = this.server; if (currentServer != null) { try { @@ -283,6 +319,10 @@ public Mono awaitTermination() { return terminationSink.asMono(); } + int activeConnectionCount() { + return connections.size() + webSocketConnections.size(); + } + private ConnectionState createConnection() { String connectionId = UUID.randomUUID().toString(); ConnectionState connection = new ConnectionState(connectionId); @@ -290,8 +330,14 @@ private ConnectionState createConnection() { return connection; } - private Optional connection(String connectionId) { - return Optional.ofNullable(connections.get(connectionId)); + private WebSocketConnectionState createWebSocketConnection() { + String connectionId = UUID.randomUUID().toString(); + return new WebSocketConnectionState(connectionId); + } + + private boolean isInitializeRequest(JSONRPCMessage message) { + return message instanceof AcpSchema.JSONRPCRequest request + && AcpSchema.METHOD_INITIALIZE.equals(request.method()) && request.id() != null; } private final class AcpServlet extends HttpServlet { @@ -879,6 +925,142 @@ void close() { } + private final class WebSocketConnectionState { + + private final String id; + + private final RemoteAcpConnection remoteConnection; + + private final AtomicBoolean initialized = new AtomicBoolean(false); + + private final AtomicBoolean closed = new AtomicBoolean(false); + + private volatile Session session; + + WebSocketConnectionState(String id) { + this.id = id; + this.remoteConnection = new RemoteAcpConnection(id, jsonMapper, this::sendToClient); + } + + String id() { + return id; + } + + void start() { + this.remoteConnection.start(agentFactory).block(INITIALIZE_TIMEOUT); + } + + void open(Session session) { + this.session = session; + } + + void acceptFromClient(JSONRPCMessage message) { + if (!initialized.get()) { + // The WebSocket branch of the streamable endpoint has no POST + // initialize response that can create the connection first, so the first + // client-originated JSON-RPC message on the socket must be initialize. + if (!isInitializeRequest(message)) { + close(StatusCode.PROTOCOL, "first ACP WebSocket message must be initialize"); + return; + } + initialized.set(true); + } + remoteConnection.acceptInbound(message); + } + + void sendToClient(JSONRPCMessage message) { + try { + Session currentSession = this.session; + if (closed.get() || currentSession == null || !currentSession.isOpen()) { + throw new AcpConnectionException("Streamable ACP WebSocket connection is closed"); + } + String payload = jsonMapper.writeValueAsString(message); + logger.debug("Sending streamable ACP WebSocket message: {}", payload); + currentSession.sendText(payload, Callback.from(() -> { + // Jetty requires an explicit success callback; there is no + // follow-up work after the frame has been accepted for writing. + }, error -> { + if (!closed.get()) { + remoteConnection.signalException(error); + } + })); + } + catch (Exception e) { + remoteConnection.signalException(e); + close(StatusCode.SERVER_ERROR, "failed to send ACP message"); + } + } + + void close() { + close(StatusCode.NORMAL, "server closing"); + } + + void close(int statusCode, String reason) { + if (!closed.compareAndSet(false, true)) { + return; + } + webSocketConnections.remove(id, this); + Session currentSession = this.session; + if (currentSession != null && currentSession.isOpen()) { + currentSession.close(statusCode, reason, Callback.NOOP); + } + remoteConnection.closeGracefully().subscribe(); + } + + } + + /** + * Jetty WebSocket endpoint for one WebSocket-upgraded ACP connection. + */ + @WebSocket + public class AcpWebSocketEndpoint { + + private final WebSocketConnectionState connection; + + AcpWebSocketEndpoint(WebSocketConnectionState connection) { + this.connection = connection; + } + + @OnWebSocketOpen + public void onOpen(Session session) { + logger.info("Streamable ACP WebSocket client connected from {}", session.getRemoteSocketAddress()); + connection.open(session); + } + + @OnWebSocketMessage + public void onMessage(Session session, String message) { + logger.debug("Received streamable ACP WebSocket message: {}", message); + + try { + JSONRPCMessage jsonRpcMessage = AcpSchema.deserializeJsonRpcMessage(jsonMapper, message); + connection.acceptFromClient(jsonRpcMessage); + } + catch (Exception e) { + logger.warn("Closing streamable ACP WebSocket connection after invalid JSON-RPC frame", e); + connection.close(StatusCode.PROTOCOL, "invalid JSON-RPC frame"); + } + } + + @OnWebSocketClose + public void onClose(Session session, int statusCode, String reason) { + logger.info("Streamable ACP WebSocket client disconnected: {} - {}", statusCode, reason); + connection.close(statusCode, reason); + } + + @OnWebSocketError + public void onError(Session session, Throwable error) { + if (error instanceof ClosedChannelException) { + logger.debug("Streamable ACP WebSocket channel closed"); + connection.close(StatusCode.NORMAL, "WebSocket channel closed"); + return; + } + logger.error("Streamable ACP WebSocket error", error); + connection.remoteConnection.signalException(error); + connection.close(StatusCode.SERVER_ERROR, "WebSocket error"); + } + + } + private static final class UnknownSessionException extends RuntimeException { UnknownSessionException(String message) { diff --git a/acp-streamable-http-jetty/src/test/java/com/agentclientprotocol/sdk/agent/transport/StreamableHttpAcpAgentTransportIntegrationTest.java b/acp-streamable-http-jetty/src/test/java/com/agentclientprotocol/sdk/agent/transport/StreamableHttpAcpAgentTransportIntegrationTest.java index cf34646..c42d2fa 100644 --- a/acp-streamable-http-jetty/src/test/java/com/agentclientprotocol/sdk/agent/transport/StreamableHttpAcpAgentTransportIntegrationTest.java +++ b/acp-streamable-http-jetty/src/test/java/com/agentclientprotocol/sdk/agent/transport/StreamableHttpAcpAgentTransportIntegrationTest.java @@ -6,6 +6,7 @@ import java.io.BufferedReader; import java.io.IOException; +import java.io.InputStream; import java.io.InputStreamReader; import java.net.ServerSocket; import java.net.URI; @@ -13,10 +14,12 @@ import java.net.http.HttpRequest; import java.net.http.HttpResponse; import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; import java.time.Duration; import java.util.List; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.LinkedBlockingQueue; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicInteger; @@ -26,98 +29,195 @@ import com.agentclientprotocol.sdk.client.AcpClient; import com.agentclientprotocol.sdk.client.transport.StreamableHttpAcpClientTransport; import com.agentclientprotocol.sdk.json.AcpJsonMapper; +import com.agentclientprotocol.sdk.json.TypeRef; import com.agentclientprotocol.sdk.spec.AcpSchema; -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.Test; import reactor.core.publisher.Mono; import static org.assertj.core.api.Assertions.assertThat; /** - * End-to-end tests against the in-repo TypeScript Streamable HTTP fixture client. + * End-to-end tests against the Java Streamable HTTP agent transport. */ class StreamableHttpAcpAgentTransportIntegrationTest { private static final Duration TIMEOUT = Duration.ofSeconds(5); - private static final Path FIXTURE_DIR = Path.of("..", "test-fixtures", "streamable-http-client").normalize(); - - private static final Path GOLDEN_DIR = FIXTURE_DIR.resolve("golden"); - - private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + private static final AcpJsonMapper JSON_MAPPER = AcpJsonMapper.createDefault(); @Test - void happyPathMatchesFixtureTranscript() throws Exception { + void javaClientCanTalkToRunningJavaServer() throws Exception { try (FixtureServer server = FixtureServer.start(StreamableHttpAcpAgentTransport.RoutingMode.COMPATIBLE)) { - JsonNode transcript = FixtureClient.run(server.endpoint(), "happy-path"); - assertTranscriptMatches(transcript, "happy-path.json"); + AcpAsyncClient client = AcpClient + .async(new StreamableHttpAcpClientTransport(server.endpoint(), AcpJsonMapper.createDefault())) + .requestTimeout(TIMEOUT) + .build(); + + client.initialize().block(TIMEOUT); + AcpSchema.NewSessionResponse session = client + .newSession(new AcpSchema.NewSessionRequest("/workspace", List.of(), null)) + .block(TIMEOUT); + AcpSchema.PromptResponse prompt = client + .prompt(new AcpSchema.PromptRequest(session.sessionId(), + List.of(new AcpSchema.TextContent("hello")), null)) + .block(TIMEOUT); + + assertThat(session.sessionId()).isEqualTo("sess-1"); + assertThat(prompt.stopReason()).isEqualTo(AcpSchema.StopReason.END_TURN); + + client.closeGracefully().block(TIMEOUT); } } @Test - void permissionRoundTripMatchesFixtureTranscript() throws Exception { + void permissionRequestRoundTripsOverSessionStream() throws Exception { try (FixtureServer server = FixtureServer.start(StreamableHttpAcpAgentTransport.RoutingMode.COMPATIBLE)) { - JsonNode transcript = FixtureClient.run(server.endpoint(), "permission-round-trip"); - assertTranscriptMatches(transcript, "permission-round-trip.json"); + AtomicInteger permissionRequests = new AtomicInteger(); + AcpAsyncClient client = AcpClient + .async(new StreamableHttpAcpClientTransport(server.endpoint(), AcpJsonMapper.createDefault())) + .requestPermissionHandler(request -> { + permissionRequests.incrementAndGet(); + return Mono.just(new AcpSchema.RequestPermissionResponse( + new AcpSchema.PermissionSelected("allow"))); + }) + .requestTimeout(TIMEOUT) + .build(); + + client.initialize().block(TIMEOUT); + AcpSchema.NewSessionResponse session = client + .newSession(new AcpSchema.NewSessionRequest("/workspace", List.of(), null)) + .block(TIMEOUT); + AcpSchema.PromptResponse prompt = client + .prompt(new AcpSchema.PromptRequest(session.sessionId(), + List.of(new AcpSchema.TextContent("permission please")), null)) + .block(TIMEOUT); + + assertThat(prompt.stopReason()).isEqualTo(AcpSchema.StopReason.END_TURN); + assertThat(permissionRequests).hasValue(1); + + client.closeGracefully().block(TIMEOUT); } } @Test void compatibleModeAllowsSessionLoadPreopen() throws Exception { try (FixtureServer server = FixtureServer.start(StreamableHttpAcpAgentTransport.RoutingMode.COMPATIBLE)) { - JsonNode transcript = FixtureClient.run(server.endpoint(), "session-load"); - assertTranscriptMatches(transcript, "session-load.json"); - } - } + AcpAsyncClient client = AcpClient + .async(new StreamableHttpAcpClientTransport(server.endpoint(), AcpJsonMapper.createDefault())) + .requestTimeout(TIMEOUT) + .build(); - @Test - void supportsTwoLogicalSessions() throws Exception { - try (FixtureServer server = FixtureServer.start(StreamableHttpAcpAgentTransport.RoutingMode.COMPATIBLE)) { - JsonNode transcript = FixtureClient.run(server.endpoint(), "two-sessions"); - assertTranscriptMatches(transcript, "two-sessions.json"); - } - } + client.initialize().block(TIMEOUT); + AcpSchema.LoadSessionResponse response = client + .loadSession(new AcpSchema.LoadSessionRequest("sess-load", "/workspace", List.of())) + .block(TIMEOUT); - @Test - void wrongStreamResponseIsRejected() throws Exception { - try (FixtureServer server = FixtureServer.start(StreamableHttpAcpAgentTransport.RoutingMode.COMPATIBLE)) { - JsonNode transcript = FixtureClient.run(server.endpoint(), "wrong-stream-response"); - assertTranscriptMatches(transcript, "wrong-stream-response.json"); - } - } + assertThat(response).isNotNull(); - @Test - void validationFailuresMatchFixtureTranscript() throws Exception { - try (FixtureServer server = FixtureServer.start(StreamableHttpAcpAgentTransport.RoutingMode.COMPATIBLE)) { - JsonNode transcript = FixtureClient.run(server.endpoint(), "validation-failures"); - assertTranscriptMatches(transcript, "validation-failures.json"); + client.closeGracefully().block(TIMEOUT); } } @Test - void javaClientCanTalkToRunningJavaServer() throws Exception { + void supportsTwoLogicalSessions() throws Exception { try (FixtureServer server = FixtureServer.start(StreamableHttpAcpAgentTransport.RoutingMode.COMPATIBLE)) { AcpAsyncClient client = AcpClient .async(new StreamableHttpAcpClientTransport(server.endpoint(), AcpJsonMapper.createDefault())) + .requestTimeout(TIMEOUT) .build(); client.initialize().block(TIMEOUT); - AcpSchema.NewSessionResponse session = client - .newSession(new AcpSchema.NewSessionRequest("/workspace", List.of(), null)) + AcpSchema.NewSessionResponse first = client + .newSession(new AcpSchema.NewSessionRequest("/workspace/one", List.of(), null)) .block(TIMEOUT); - AcpSchema.PromptResponse prompt = client - .prompt(new AcpSchema.PromptRequest(session.sessionId(), - List.of(new AcpSchema.TextContent("hello")), null)) + AcpSchema.NewSessionResponse second = client + .newSession(new AcpSchema.NewSessionRequest("/workspace/two", List.of(), null)) + .block(TIMEOUT); + AcpSchema.PromptResponse firstPrompt = client + .prompt(new AcpSchema.PromptRequest(first.sessionId(), List.of(new AcpSchema.TextContent("one")), null)) + .block(TIMEOUT); + AcpSchema.PromptResponse secondPrompt = client + .prompt(new AcpSchema.PromptRequest(second.sessionId(), List.of(new AcpSchema.TextContent("two")), null)) .block(TIMEOUT); - assertThat(session.sessionId()).isEqualTo("sess-1"); - assertThat(prompt.stopReason()).isEqualTo(AcpSchema.StopReason.END_TURN); + assertThat(first.sessionId()).isEqualTo("sess-1"); + assertThat(second.sessionId()).isEqualTo("sess-2"); + assertThat(firstPrompt.stopReason()).isEqualTo(AcpSchema.StopReason.END_TURN); + assertThat(secondPrompt.stopReason()).isEqualTo(AcpSchema.StopReason.END_TURN); client.closeGracefully().block(TIMEOUT); } } + @Test + void wrongStreamClientResponseIsRejected() throws Exception { + try (FixtureServer server = FixtureServer.start(StreamableHttpAcpAgentTransport.RoutingMode.COMPATIBLE)) { + HttpClient rawClient = HttpClient.newHttpClient(); + HttpResponse initialize = rawClient.send(HttpRequest.newBuilder(server.endpoint()) + .header("Content-Type", "application/json") + .header("Accept", "application/json") + .POST(HttpRequest.BodyPublishers.ofString(""" + {"jsonrpc":"2.0","id":"init-1","method":"initialize","params":{"protocolVersion":1,"clientCapabilities":{}}} + """)) + .build(), HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); + String connectionId = initialize.headers().firstValue("Acp-Connection-Id").orElseThrow(); + try (SseReader connectionStream = SseReader.open(rawClient, server.endpoint(), connectionId, null)) { + postJson(rawClient, server.endpoint(), connectionId, null, + """ + {"jsonrpc":"2.0","id":"new-1","method":"session/new","params":{"cwd":"/workspace","mcpServers":[]}} + """); + AcpSchema.JSONRPCResponse newSessionResponse = connectionStream.nextResponse(); + AcpSchema.NewSessionResponse session = JSON_MAPPER.convertValue(newSessionResponse.result(), + new TypeRef() { + }); + + try (SseReader sessionStream = SseReader.open(rawClient, server.endpoint(), connectionId, + session.sessionId())) { + postJson(rawClient, server.endpoint(), connectionId, session.sessionId(), + """ + {"jsonrpc":"2.0","id":"prompt-1","method":"session/prompt","params":{"sessionId":"%s","prompt":[{"type":"text","text":"permission please"}]}} + """.formatted(session.sessionId())); + AcpSchema.JSONRPCRequest permissionRequest = sessionStream.nextRequest(); + HttpResponse wrongStreamResponse = postJson(rawClient, server.endpoint(), connectionId, null, + """ + {"jsonrpc":"2.0","id":"%s","result":{"outcome":{"outcome":"selected","optionId":"allow"}}} + """.formatted(permissionRequest.id())); + + assertThat(wrongStreamResponse.statusCode()).isEqualTo(400); + assertThat(wrongStreamResponse.body()).contains("expected RouteScope"); + } + } + } + } + + @Test + void validationFailuresUseHttpStatusCodes() throws Exception { + try (FixtureServer server = FixtureServer.start(StreamableHttpAcpAgentTransport.RoutingMode.COMPATIBLE)) { + HttpClient rawClient = HttpClient.newHttpClient(); + HttpResponse unsupportedContentType = rawClient.send(HttpRequest.newBuilder(server.endpoint()) + .header("Content-Type", "text/plain") + .POST(HttpRequest.BodyPublishers.ofString("{}")) + .build(), HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); + HttpResponse batch = rawClient.send(HttpRequest.newBuilder(server.endpoint()) + .header("Content-Type", "application/json") + .POST(HttpRequest.BodyPublishers.ofString("[]")) + .build(), HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); + HttpResponse wrongAccept = rawClient.send(HttpRequest.newBuilder(server.endpoint()) + .header("Accept", "application/json") + .GET() + .build(), HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); + HttpResponse missingConnection = rawClient.send(HttpRequest.newBuilder(server.endpoint()) + .header("Accept", "text/event-stream") + .GET() + .build(), HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); + + assertThat(unsupportedContentType.statusCode()).isEqualTo(415); + assertThat(batch.statusCode()).isEqualTo(501); + assertThat(wrongAccept.statusCode()).isEqualTo(406); + assertThat(missingConnection.statusCode()).isEqualTo(400); + } + } + @Test void strictModeRejectsUnknownSessionStream() throws Exception { try (FixtureServer server = FixtureServer.start(StreamableHttpAcpAgentTransport.RoutingMode.STRICT)) { @@ -143,9 +243,17 @@ void strictModeRejectsUnknownSessionStream() throws Exception { } } - private static void assertTranscriptMatches(JsonNode actual, String goldenName) throws Exception { - JsonNode expected = OBJECT_MAPPER.readTree(Files.readString(GOLDEN_DIR.resolve(goldenName))); - assertThat(actual).isEqualTo(expected); + private static HttpResponse postJson(HttpClient client, URI endpoint, String connectionId, String sessionId, + String body) throws Exception { + HttpRequest.Builder builder = HttpRequest.newBuilder(endpoint) + .header("Content-Type", "application/json") + .header("Accept", "application/json") + .header("Acp-Connection-Id", connectionId) + .POST(HttpRequest.BodyPublishers.ofString(body)); + if (sessionId != null) { + builder.header("Acp-Session-Id", sessionId); + } + return client.send(builder.build(), HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); } private static final class FixtureServer implements AutoCloseable { @@ -195,26 +303,79 @@ private static int freePort() throws IOException { } - private static final class FixtureClient { - - static JsonNode run(URI endpoint, String scenario) throws Exception { - Process process = new ProcessBuilder("node", "dist/client.js", "--endpoint", endpoint.toString(), "--scenario", - scenario) - .directory(FIXTURE_DIR.toFile()) - .redirectErrorStream(true) - .start(); - try (BufferedReader stdout = new BufferedReader( - new InputStreamReader(process.getInputStream(), StandardCharsets.UTF_8))) { - String output = stdout.lines().reduce("", (left, right) -> left + right + System.lineSeparator()); - if (!process.waitFor(5, TimeUnit.SECONDS)) { - process.destroyForcibly(); - throw new IllegalStateException("Fixture client timed out"); - } - if (process.exitValue() != 0) { - throw new IllegalStateException("Fixture client failed: " + output); + private static final class SseReader implements AutoCloseable { + + private final BlockingQueue messages = new LinkedBlockingQueue<>(); + + private final InputStream body; + + private final ExecutorService executor; + + private SseReader(InputStream body) { + this.body = body; + this.executor = Executors.newSingleThreadExecutor(r -> { + Thread thread = new Thread(r, "streamable-http-test-sse-reader"); + thread.setDaemon(true); + return thread; + }); + this.executor.submit(this::readLoop); + } + + static SseReader open(HttpClient client, URI endpoint, String connectionId, String sessionId) throws Exception { + HttpRequest.Builder builder = HttpRequest.newBuilder(endpoint) + .header("Accept", "text/event-stream") + .header("Acp-Connection-Id", connectionId) + .GET(); + if (sessionId != null) { + builder.header("Acp-Session-Id", sessionId); + } + HttpResponse response = client.send(builder.build(), HttpResponse.BodyHandlers.ofInputStream()); + assertThat(response.statusCode()).isEqualTo(200); + return new SseReader(response.body()); + } + + AcpSchema.JSONRPCResponse nextResponse() throws Exception { + return (AcpSchema.JSONRPCResponse) nextMessage(); + } + + AcpSchema.JSONRPCRequest nextRequest() throws Exception { + return (AcpSchema.JSONRPCRequest) nextMessage(); + } + + private AcpSchema.JSONRPCMessage nextMessage() throws Exception { + AcpSchema.JSONRPCMessage message = messages.poll(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS); + assertThat(message).isNotNull(); + return message; + } + + private void readLoop() { + try (BufferedReader reader = new BufferedReader(new InputStreamReader(body, StandardCharsets.UTF_8))) { + StringBuilder data = new StringBuilder(); + String line; + while ((line = reader.readLine()) != null) { + if (line.isEmpty()) { + dispatch(data); + data.setLength(0); + } + else if (line.startsWith("data:")) { + data.append(line.substring(5).stripLeading()); + } } - return OBJECT_MAPPER.readTree(output); } + catch (Exception ignored) { + } + } + + private void dispatch(StringBuilder data) throws IOException { + if (!data.isEmpty()) { + messages.add(AcpSchema.deserializeJsonRpcMessage(JSON_MAPPER, data.toString())); + } + } + + @Override + public void close() throws IOException { + body.close(); + executor.shutdownNow(); } } diff --git a/acp-websocket-jetty/src/test/java/com/agentclientprotocol/sdk/agent/transport/RemoteWebSocketAcpAgentTransportIntegrationTest.java b/acp-streamable-http-jetty/src/test/java/com/agentclientprotocol/sdk/agent/transport/StreamableHttpAcpAgentTransportWebSocketIntegrationTest.java similarity index 90% rename from acp-websocket-jetty/src/test/java/com/agentclientprotocol/sdk/agent/transport/RemoteWebSocketAcpAgentTransportIntegrationTest.java rename to acp-streamable-http-jetty/src/test/java/com/agentclientprotocol/sdk/agent/transport/StreamableHttpAcpAgentTransportWebSocketIntegrationTest.java index 2280e2c..60e4432 100644 --- a/acp-websocket-jetty/src/test/java/com/agentclientprotocol/sdk/agent/transport/RemoteWebSocketAcpAgentTransportIntegrationTest.java +++ b/acp-streamable-http-jetty/src/test/java/com/agentclientprotocol/sdk/agent/transport/StreamableHttpAcpAgentTransportWebSocketIntegrationTest.java @@ -43,9 +43,9 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy; /** - * End-to-end tests for the RFD-compliant listener-backed WebSocket transport. + * End-to-end tests for the WebSocket upgrade path on the Streamable HTTP transport. */ -class RemoteWebSocketAcpAgentTransportIntegrationTest { +class StreamableHttpAcpAgentTransportWebSocketIntegrationTest { private static final Duration TIMEOUT = Duration.ofSeconds(5); @@ -54,16 +54,16 @@ void constructorValidatesRequiredArguments() { AcpJsonMapper jsonMapper = AcpJsonMapper.createDefault(); AcpAgentFactory agentFactory = simpleAgentFactory(); - assertThatThrownBy(() -> new RemoteWebSocketAcpAgentTransport(0, jsonMapper, agentFactory)) + assertThatThrownBy(() -> new StreamableHttpAcpAgentTransport(0, jsonMapper, agentFactory)) .isInstanceOf(IllegalArgumentException.class) .hasMessageContaining("Port"); - assertThatThrownBy(() -> new RemoteWebSocketAcpAgentTransport(8080, "", jsonMapper, agentFactory)) + assertThatThrownBy(() -> new StreamableHttpAcpAgentTransport(8080, "", jsonMapper, agentFactory)) .isInstanceOf(IllegalArgumentException.class) .hasMessageContaining("Path"); - assertThatThrownBy(() -> new RemoteWebSocketAcpAgentTransport(8080, null, agentFactory)) + assertThatThrownBy(() -> new StreamableHttpAcpAgentTransport(8080, null, agentFactory)) .isInstanceOf(IllegalArgumentException.class) .hasMessageContaining("JsonMapper"); - assertThatThrownBy(() -> new RemoteWebSocketAcpAgentTransport(8080, jsonMapper, null)) + assertThatThrownBy(() -> new StreamableHttpAcpAgentTransport(8080, jsonMapper, null)) .isInstanceOf(IllegalArgumentException.class) .hasMessageContaining("agentFactory"); } @@ -96,7 +96,7 @@ void handshakeReturnsConnectionIdHeader() throws Exception { } @Test - void javaClientCanTalkToRemoteWebSocketServer() throws Exception { + void javaClientCanTalkToStreamableWebSocketUpgrade() throws Exception { AtomicReference receivedUpdate = new AtomicReference<>(); try (FixtureServer server = FixtureServer.start(simpleAgentFactory())) { @@ -131,13 +131,13 @@ void javaClientCanTalkToRemoteWebSocketServer() throws Exception { } @Test - void permissionRequestRoundTripsOverRemoteWebSocket() throws Exception { + void permissionRequestRoundTripsOverStreamableWebSocketUpgrade() throws Exception { AtomicInteger permissionRequests = new AtomicInteger(); AcpAgentFactory agentFactory = AcpAgentFactory.async(transport -> AcpAgent.async(transport) .initializeHandler(request -> Mono.just(new AcpSchema.InitializeResponse( AcpSchema.LATEST_PROTOCOL_VERSION, new AcpSchema.AgentCapabilities(true, null, null), List.of()))) .newSessionHandler(request -> Mono.just(new AcpSchema.NewSessionResponse("permission-session", null, null))) - .promptHandler((request, context) -> context.askPermission("remote websocket edit") + .promptHandler((request, context) -> context.askPermission("streamable websocket edit") .map(allowed -> { assertThat(allowed).isTrue(); return AcpSchema.PromptResponse.endTurn(); @@ -237,7 +237,7 @@ private static AcpAgentFactory simpleAgentFactory() { AcpSchema.LATEST_PROTOCOL_VERSION, new AcpSchema.AgentCapabilities(true, null, null), List.of()))) .newSessionHandler(request -> Mono.just(new AcpSchema.NewSessionResponse( "sess-" + sessionCounter.incrementAndGet(), null, null))) - .promptHandler((request, context) -> context.sendMessage("hello from remote websocket") + .promptHandler((request, context) -> context.sendMessage("hello from streamable websocket") .thenReturn(AcpSchema.PromptResponse.endTurn())) .build()); } @@ -252,7 +252,7 @@ private static List readHttpHeaders(Socket socket) throws IOException { return lines; } - private static void assertEventuallyNoConnections(RemoteWebSocketAcpAgentTransport transport) throws InterruptedException { + private static void assertEventuallyNoConnections(StreamableHttpAcpAgentTransport transport) throws InterruptedException { long deadline = System.nanoTime() + TIMEOUT.toNanos(); while (System.nanoTime() < deadline) { if (transport.activeConnectionCount() == 0) { @@ -274,14 +274,14 @@ private static int freePort() { private static final class FixtureServer implements AutoCloseable { - private final RemoteWebSocketAcpAgentTransport transport; + private final StreamableHttpAcpAgentTransport transport; - private FixtureServer(RemoteWebSocketAcpAgentTransport transport) { + private FixtureServer(StreamableHttpAcpAgentTransport transport) { this.transport = transport; } static FixtureServer start(AcpAgentFactory agentFactory) { - RemoteWebSocketAcpAgentTransport transport = new RemoteWebSocketAcpAgentTransport( + StreamableHttpAcpAgentTransport transport = new StreamableHttpAcpAgentTransport( freePort(), AcpJsonMapper.createDefault(), agentFactory); transport.start().block(TIMEOUT); return new FixtureServer(transport); @@ -295,7 +295,7 @@ URI endpoint() { return URI.create("ws://127.0.0.1:" + transport.getPort() + "/acp"); } - RemoteWebSocketAcpAgentTransport transport() { + StreamableHttpAcpAgentTransport transport() { return transport; } diff --git a/acp-websocket-jetty/src/main/java/com/agentclientprotocol/sdk/agent/transport/RemoteWebSocketAcpAgentTransport.java b/acp-websocket-jetty/src/main/java/com/agentclientprotocol/sdk/agent/transport/RemoteWebSocketAcpAgentTransport.java deleted file mode 100644 index 6881ab1..0000000 --- a/acp-websocket-jetty/src/main/java/com/agentclientprotocol/sdk/agent/transport/RemoteWebSocketAcpAgentTransport.java +++ /dev/null @@ -1,363 +0,0 @@ -/* - * Copyright 2025-2026 the original author or authors. - */ - -package com.agentclientprotocol.sdk.agent.transport; - -import java.time.Duration; -import java.util.UUID; -import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.ConcurrentMap; -import java.util.concurrent.atomic.AtomicBoolean; - -import com.agentclientprotocol.sdk.agent.AcpAgentFactory; -import com.agentclientprotocol.sdk.error.AcpConnectionException; -import com.agentclientprotocol.sdk.json.AcpJsonMapper; -import com.agentclientprotocol.sdk.spec.AcpSchema; -import com.agentclientprotocol.sdk.spec.AcpSchema.JSONRPCMessage; -import com.agentclientprotocol.sdk.util.Assert; -import org.eclipse.jetty.server.Server; -import org.eclipse.jetty.server.ServerConnector; -import org.eclipse.jetty.websocket.api.Callback; -import org.eclipse.jetty.websocket.api.Session; -import org.eclipse.jetty.websocket.api.StatusCode; -import org.eclipse.jetty.websocket.api.annotations.OnWebSocketClose; -import org.eclipse.jetty.websocket.api.annotations.OnWebSocketError; -import org.eclipse.jetty.websocket.api.annotations.OnWebSocketMessage; -import org.eclipse.jetty.websocket.api.annotations.OnWebSocketOpen; -import org.eclipse.jetty.websocket.api.annotations.WebSocket; -import org.eclipse.jetty.websocket.server.WebSocketUpgradeHandler; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import reactor.core.publisher.Mono; -import reactor.core.publisher.Sinks; - -/** - * RFD-compliant listener-backed ACP WebSocket transport for agents. - * - *

- * This transport accepts WebSocket upgrades on one ACP endpoint and creates one fresh - * agent runtime per accepted remote connection via {@link AcpAgentFactory}. The - * resulting per-connection {@link RemoteAcpConnection} owns ACP JSON-RPC semantics, - * while this class owns only Jetty listener setup, WebSocket framing, connection IDs, - * and close/error lifecycle. - *

- * - *

- * Unlike the legacy {@link WebSocketAcpAgentTransport}, this class is intentionally not - * an {@code AcpAgentTransport} itself. It is a listener that creates one - * connection-bound transport per WebSocket client. - *

- * - * @author Kaiser Dandangi - */ -public class RemoteWebSocketAcpAgentTransport { - - private static final Logger logger = LoggerFactory.getLogger(RemoteWebSocketAcpAgentTransport.class); - - public static final String DEFAULT_ACP_PATH = "/acp"; - - public static final String HEADER_CONNECTION_ID = "Acp-Connection-Id"; - - private static final Duration INITIALIZE_TIMEOUT = Duration.ofSeconds(30); - - private final int configuredPort; - - private final String path; - - private final AcpJsonMapper jsonMapper; - - private final AcpAgentFactory agentFactory; - - private final ConcurrentMap connections = new ConcurrentHashMap<>(); - - private final AtomicBoolean started = new AtomicBoolean(false); - - private final AtomicBoolean closing = new AtomicBoolean(false); - - private final Sinks.One terminationSink = Sinks.one(); - - private volatile Duration idleTimeout = Duration.ofMinutes(30); - - private volatile Server server; - - private volatile ServerConnector connector; - - /** - * Creates a new RFD-compliant WebSocket listener on the default ACP path. - * @param port port to listen on - * @param jsonMapper JSON mapper used for serialization - * @param agentFactory factory used to create one agent runtime per connection - */ - public RemoteWebSocketAcpAgentTransport(int port, AcpJsonMapper jsonMapper, AcpAgentFactory agentFactory) { - this(port, DEFAULT_ACP_PATH, jsonMapper, agentFactory); - } - - /** - * Creates a new RFD-compliant WebSocket listener. - * @param port port to listen on - * @param path endpoint path - * @param jsonMapper JSON mapper used for serialization - * @param agentFactory factory used to create one agent runtime per connection - */ - public RemoteWebSocketAcpAgentTransport(int port, String path, AcpJsonMapper jsonMapper, - AcpAgentFactory agentFactory) { - Assert.isTrue(port > 0, "Port must be positive"); - Assert.hasText(path, "Path must not be empty"); - Assert.notNull(jsonMapper, "The JsonMapper can not be null"); - Assert.notNull(agentFactory, "The agentFactory can not be null"); - this.configuredPort = port; - this.path = path; - this.jsonMapper = jsonMapper; - this.agentFactory = agentFactory; - } - - /** - * Sets the WebSocket idle timeout. - * @param timeout idle timeout - * @return this transport - */ - public RemoteWebSocketAcpAgentTransport idleTimeout(Duration timeout) { - Assert.notNull(timeout, "The timeout can not be null"); - this.idleTimeout = timeout; - return this; - } - - /** - * Starts the embedded Jetty WebSocket listener. - * @return a mono that completes when the listener is ready - */ - public Mono start() { - if (!started.compareAndSet(false, true)) { - return Mono.error(new IllegalStateException("Already started")); - } - - return Mono.fromRunnable(() -> { - Server jettyServer = new Server(); - ServerConnector jettyConnector = new ServerConnector(jettyServer); - jettyConnector.setPort(configuredPort); - jettyServer.addConnector(jettyConnector); - - WebSocketUpgradeHandler wsHandler = WebSocketUpgradeHandler.from(jettyServer, container -> { - container.setIdleTimeout(idleTimeout); - container.addMapping(path, (request, response, callback) -> { - ConnectionState connection = createConnection(); - try { - connection.start(); - connections.put(connection.id(), connection); - response.getHeaders().put(HEADER_CONNECTION_ID, connection.id()); - return new AcpWebSocketEndpoint(connection); - } - catch (Exception e) { - connection.close(); - callback.failed(e); - return null; - } - }); - }); - jettyServer.setHandler(wsHandler); - - try { - jettyServer.start(); - } - catch (Exception e) { - throw new AcpConnectionException("Failed to start Remote WebSocket listener", e); - } - this.server = jettyServer; - this.connector = jettyConnector; - logger.info("Remote WebSocket ACP agent listener started on port {} at path {}", getPort(), path); - }); - } - - /** - * Returns the bound port. - * @return listener port - */ - public int getPort() { - ServerConnector currentConnector = this.connector; - return currentConnector != null ? currentConnector.getLocalPort() : configuredPort; - } - - /** - * Closes all active WebSocket connections and stops the listener. - * @return a mono that completes when shutdown finishes - */ - public Mono closeGracefully() { - return Mono.fromRunnable(() -> { - if (!closing.compareAndSet(false, true)) { - return; - } - connections.values().forEach(ConnectionState::close); - connections.clear(); - Server currentServer = this.server; - if (currentServer != null) { - try { - currentServer.stop(); - } - catch (Exception e) { - throw new AcpConnectionException("Failed to stop Remote WebSocket listener", e); - } - } - terminationSink.tryEmitValue(null); - }); - } - - /** - * Closes all active WebSocket connections and stops the listener immediately. - */ - public void close() { - closeGracefully().block(Duration.ofSeconds(5)); - } - - /** - * Returns a mono that completes once the listener terminates. - * @return termination mono - */ - public Mono awaitTermination() { - return terminationSink.asMono(); - } - - int activeConnectionCount() { - return connections.size(); - } - - private ConnectionState createConnection() { - String connectionId = UUID.randomUUID().toString(); - return new ConnectionState(connectionId); - } - - private boolean isInitializeRequest(JSONRPCMessage message) { - return message instanceof AcpSchema.JSONRPCRequest request - && AcpSchema.METHOD_INITIALIZE.equals(request.method()) && request.id() != null; - } - - private final class ConnectionState { - - private final String id; - - private final RemoteAcpConnection remoteConnection; - - private final AtomicBoolean initialized = new AtomicBoolean(false); - - private final AtomicBoolean closed = new AtomicBoolean(false); - - private volatile Session session; - - ConnectionState(String id) { - this.id = id; - this.remoteConnection = new RemoteAcpConnection(id, jsonMapper, this::sendToClient); - } - - String id() { - return id; - } - - void start() { - this.remoteConnection.start(agentFactory).block(INITIALIZE_TIMEOUT); - } - - void open(Session session) { - this.session = session; - } - - void acceptFromClient(JSONRPCMessage message) { - if (!initialized.get()) { - // The WebSocket RFD makes initialize the first client-originated - // JSON-RPC request. Enforce that at the wire adapter before the - // shared connection core sees application messages. - if (!isInitializeRequest(message)) { - close(StatusCode.PROTOCOL, "first ACP WebSocket message must be initialize"); - return; - } - initialized.set(true); - } - remoteConnection.acceptInbound(message); - } - - void sendToClient(JSONRPCMessage message) { - try { - Session currentSession = this.session; - if (closed.get() || currentSession == null || !currentSession.isOpen()) { - throw new AcpConnectionException("Remote WebSocket connection is closed"); - } - String payload = jsonMapper.writeValueAsString(message); - logger.debug("Sending remote WebSocket message: {}", payload); - currentSession.sendText(payload, Callback.from(() -> { - }, error -> { - if (!closed.get()) { - remoteConnection.signalException(error); - } - })); - } - catch (Exception e) { - remoteConnection.signalException(e); - close(StatusCode.SERVER_ERROR, "failed to send ACP message"); - } - } - - void close() { - close(StatusCode.NORMAL, "server closing"); - } - - void close(int statusCode, String reason) { - if (!closed.compareAndSet(false, true)) { - return; - } - connections.remove(id, this); - Session currentSession = this.session; - if (currentSession != null && currentSession.isOpen()) { - currentSession.close(statusCode, reason, Callback.NOOP); - } - remoteConnection.closeGracefully().subscribe(); - } - - } - - /** - * Jetty WebSocket endpoint for one accepted ACP connection. - */ - @WebSocket - public class AcpWebSocketEndpoint { - - private final ConnectionState connection; - - AcpWebSocketEndpoint(ConnectionState connection) { - this.connection = connection; - } - - @OnWebSocketOpen - public void onOpen(Session session) { - logger.info("Remote WebSocket ACP client connected from {}", session.getRemoteSocketAddress()); - connection.open(session); - } - - @OnWebSocketMessage - public void onMessage(Session session, String message) { - logger.debug("Received remote WebSocket message: {}", message); - - try { - JSONRPCMessage jsonRpcMessage = AcpSchema.deserializeJsonRpcMessage(jsonMapper, message); - connection.acceptFromClient(jsonRpcMessage); - } - catch (Exception e) { - logger.warn("Closing remote WebSocket ACP connection after invalid JSON-RPC frame", e); - connection.close(StatusCode.PROTOCOL, "invalid JSON-RPC frame"); - } - } - - @OnWebSocketClose - public void onClose(Session session, int statusCode, String reason) { - logger.info("Remote WebSocket ACP client disconnected: {} - {}", statusCode, reason); - connection.close(statusCode, reason); - } - - @OnWebSocketError - public void onError(Session session, Throwable error) { - logger.error("Remote WebSocket ACP error", error); - connection.remoteConnection.signalException(error); - connection.close(StatusCode.SERVER_ERROR, "WebSocket error"); - } - - } - -} diff --git a/plans/STREAMABLE-HTTP-AGENT-TRANSPORT.md b/plans/STREAMABLE-HTTP-AGENT-TRANSPORT.md index a97f203..26410fb 100644 --- a/plans/STREAMABLE-HTTP-AGENT-TRANSPORT.md +++ b/plans/STREAMABLE-HTTP-AGENT-TRANSPORT.md @@ -2,7 +2,7 @@ > **Status**: Milestone one implemented > **Created**: 2026-05-18 -> **Primary goal**: Java agents can be served from a running Java web server over the Streamable HTTP transport while preserving current WebSocket behavior until parity is proven. +> **Primary goal**: Java agents can be served from a running Java web server over the Streamable HTTP transport, including WebSocket upgrades on the same ACP endpoint. ## Goal @@ -11,6 +11,7 @@ Add an agent-side Streamable HTTP transport backed by Jetty, with: - one fresh ACP agent runtime per accepted remote connection - a public `AcpAgentFactory` seam for listener-backed transports - RFD-oriented HTTP + SSE behavior +- WebSocket upgrade handling on the same ACP path - strict and compatible routing modes - a fixture-driven conformance harness that exercises a real running Java listener @@ -22,8 +23,8 @@ Add an agent-side Streamable HTTP transport backed by Jetty, with: - `AcpAgentFactory.sync(...)` - Add `StreamableHttpAcpAgentTransport` in a dedicated Jetty adapter module: - `acp-streamable-http-jetty` -- Keep the current WebSocket single-agent API intact in this milestone. -- PLAN: once Streamable HTTP reaches behavioral parity, migrate remote WebSocket handling toward the same factory-backed listener model. +- Keep the current legacy WebSocket single-agent API intact in `acp-websocket-jetty`. +- Serve the RFD-compatible remote WebSocket upgrade path from `StreamableHttpAcpAgentTransport`, matching the Rust SDK shape where one HTTP server owns POST/SSE and WebSocket upgrades. ## Runtime Model @@ -56,6 +57,11 @@ StreamableHttpAcpAgentTransport - session updates - agent-originated session-scoped requests such as permission prompts - DELETE tears down the connection and releases transport state. +- WebSocket upgrades: + - create one connection during the upgrade handshake + - return `Acp-Connection-Id` on the `101 Switching Protocols` response + - require the first client-originated JSON-RPC message on the socket to be `initialize` + - exchange JSON-RPC messages as text frames until the socket closes ### Routing ledgers @@ -84,30 +90,19 @@ PLAN: revisit this once the protocol resolves the resume/session-load ordering c ## Test Harness -Create an in-repo fixture: - -```text -test-fixtures/streamable-http-client/ -``` - -The fixture is: - -- TypeScript -- HTTP-only -- scenario-driven -- the single owner of canonical transcript serialization -- run against a real Java Jetty listener +Use Java integration tests only for this branch so the PR stays focused on SDK +transport behavior instead of adding a separate fixture harness. Covered scenarios: -- happy path +- happy path over Streamable HTTP POST + SSE - permission round-trip - session load / provisional pre-open - two logical sessions - wrong-stream response rejection - validation failures - -The Java module also keeps focused integration coverage for strict unknown-session behavior. +- strict unknown-session behavior +- WebSocket upgrade behavior on the same Java listener ## Demo Server @@ -119,7 +114,8 @@ test-fixtures/streamable-http-agent-server/ It packages a small echo-style ACP agent into a runnable jar backed by the real Jetty `StreamableHttpAcpAgentTransport`, so manual testing can exercise a live -HTTP/SSE endpoint instead of only the integration-test fixture lifecycle. +HTTP/SSE endpoint and WebSocket upgrade endpoint instead of only the +integration-test fixture lifecycle. ## PLAN / Follow-Up Work @@ -128,12 +124,12 @@ HTTP/SSE endpoint instead of only the integration-test fixture lifecycle. both remote listener transports need: per-connection agent factory creation, connection/session registries, lifecycle teardown, request/response routing ledgers, timeout/error propagation, and observability hooks. The actual wire - adapters should remain transport-specific: WebSocket text frames stay in the - WebSocket module, and HTTP methods, headers, cookies, SSE parsing, and status - codes stay in the Streamable HTTP module. Deferring this extraction keeps the - first HTTP implementation close to the RFD and avoids prematurely forcing the - existing WebSocket behavior through an abstraction before parity is proven. -- migrate WebSocket toward the same factory-backed listener model + adapters should remain transport-specific: legacy WebSocket framing stays in + the WebSocket module, while the RFD Streamable HTTP endpoint owns HTTP + methods, headers, SSE parsing, status codes, and its WebSocket upgrade branch. + Deferring this extraction keeps the first implementation close to the RFD and + avoids prematurely forcing the existing legacy WebSocket behavior through an + abstraction before parity is proven. - add idle/provisional-session eviction and replay retention policies - revisit per-logical-session active-prompt tracking in `AcpAgentSession` - expose richer diagnostics / observability hooks diff --git a/plans/STREAMABLE-HTTP-TRANSPORT.md b/plans/STREAMABLE-HTTP-TRANSPORT.md index cc2248e..188b3cc 100644 --- a/plans/STREAMABLE-HTTP-TRANSPORT.md +++ b/plans/STREAMABLE-HTTP-TRANSPORT.md @@ -12,7 +12,7 @@ This first milestone is intentionally client-only: - implement `StreamableHttpAcpClientTransport` - preserve the existing ACP client API surface -- prove the wire contract with an in-repo TypeScript conformance fixture +- prove the wire contract with focused Java integration coverage - defer remote transport negotiation, Java server support, and reconnect/resume behavior ## Milestone-One Result @@ -22,7 +22,7 @@ Implemented in this branch: - `StreamableHttpAcpClientTransport` - preserved public ACP client API - compatibility note + isolated forwarding path for the existing client handler-emission ambiguity -- in-repo TypeScript fixture with golden transcripts +- Java integration coverage against an in-process Streamable HTTP fixture server - Java unit + integration coverage for: - initialize bootstrap - cookie persistence @@ -34,7 +34,7 @@ Implemented in this branch: - wrong-stream responses - strict-routing rejection - two logical sessions - - fixture validation failures for missing connection headers, missing cookies, and invalid SSE `Accept` + - validation failures for missing connection headers and invalid SSE `Accept` ## Contract Decisions @@ -113,37 +113,19 @@ Implemented in this branch: ## Test Harness -Create an in-repo TypeScript fixture: - -```text -test-fixtures/streamable-http-server/ -``` - -The fixture will be: - -- HTTP-only in the first milestone -- strict by default -- scenario-driven with named startup-selected scenarios -- runnable manually and from Java integration tests -- the single owner of canonical transcript serialization - -Golden transcripts will live beside the fixture: - -```text -test-fixtures/streamable-http-server/golden/ -``` +Use an in-process Java Streamable HTTP fixture server for client transport tests. +This keeps the PR focused on Java SDK transport behavior and avoids adding a +separate conformance harness to the repository. ### Milestone-One Scenarios - initialize bootstrap -- cookie persistence - connection SSE stream - `session/new` - prompt flow with session updates - agent → client `session/request_permission` - `session/load` - validation failures for wrong / missing headers -- missing cookie - wrong-stream response - strict-routing rejection - light two-session coverage diff --git a/test-fixtures/streamable-http-agent-server/README.md b/test-fixtures/streamable-http-agent-server/README.md index 3d5c753..066944c 100644 --- a/test-fixtures/streamable-http-agent-server/README.md +++ b/test-fixtures/streamable-http-agent-server/README.md @@ -15,13 +15,11 @@ Run it: java -jar test-fixtures/streamable-http-agent-server/target/acp-streamable-http-agent-server.jar --port 8080 ``` -Then drive it with the fixture client from another shell: +Then connect any ACP client to either endpoint printed at startup: -```bash -cd test-fixtures/streamable-http-client -npm install -npm run build -node dist/client.js --endpoint http://127.0.0.1:8080/acp --scenario happy-path +```text +http://127.0.0.1:8080/acp # Streamable HTTP POST + SSE +ws://127.0.0.1:8080/acp # WebSocket upgrade ``` The demo supports `initialize`, `session/new`, `session/load`, `session/prompt`, diff --git a/test-fixtures/streamable-http-agent-server/src/main/java/com/agentclientprotocol/sdk/fixtures/StreamableHttpAgentDemoServer.java b/test-fixtures/streamable-http-agent-server/src/main/java/com/agentclientprotocol/sdk/fixtures/StreamableHttpAgentDemoServer.java index 3061624..ab01011 100644 --- a/test-fixtures/streamable-http-agent-server/src/main/java/com/agentclientprotocol/sdk/fixtures/StreamableHttpAgentDemoServer.java +++ b/test-fixtures/streamable-http-agent-server/src/main/java/com/agentclientprotocol/sdk/fixtures/StreamableHttpAgentDemoServer.java @@ -129,6 +129,8 @@ public static void main(String[] args) { server.start().block(START_TIMEOUT); System.out.println("ACP Streamable HTTP demo agent listening at http://127.0.0.1:" + server.getPort() + options.path()); + System.out.println("ACP WebSocket upgrade endpoint available at ws://127.0.0.1:" + server.getPort() + + options.path()); System.out.println("Press Ctrl-C to stop."); server.awaitTermination().block(); } diff --git a/test-fixtures/streamable-http-client/README.md b/test-fixtures/streamable-http-client/README.md deleted file mode 100644 index 37e1bb2..0000000 --- a/test-fixtures/streamable-http-client/README.md +++ /dev/null @@ -1,27 +0,0 @@ -# Streamable HTTP Fixture Client - -This in-repo TypeScript fixture drives the Java Streamable HTTP agent transport -through raw HTTP and SSE exchanges. It is intentionally small, strict, and scenario -driven so the wire contract stays visible while the Java server implementation evolves. - -Scenarios: - -- `happy-path` -- `permission-round-trip` -- `session-load` -- `two-sessions` -- `wrong-stream-response` -- `validation-failures` - -Build once: - -```bash -npm install -npm run build -``` - -Run against a local Java listener: - -```bash -node dist/client.js --endpoint http://127.0.0.1:8080/acp --scenario happy-path -``` diff --git a/test-fixtures/streamable-http-client/dist/client.js b/test-fixtures/streamable-http-client/dist/client.js deleted file mode 100644 index 26f4522..0000000 --- a/test-fixtures/streamable-http-client/dist/client.js +++ /dev/null @@ -1,16 +0,0 @@ -import { StreamableHttpFixtureClient } from "./protocol.js"; -import { runScenario } from "./scenarios.js"; -import { TranscriptRecorder } from "./transcript.js"; -const endpoint = readArg("--endpoint"); -if (!endpoint) { - throw new Error("--endpoint is required"); -} -const scenario = readArg("--scenario") ?? "happy-path"; -const recorder = new TranscriptRecorder(); -const client = new StreamableHttpFixtureClient(endpoint, recorder); -await runScenario(scenario, endpoint, client); -process.stdout.write(recorder.serialize()); -function readArg(name) { - const index = process.argv.indexOf(name); - return index >= 0 ? process.argv[index + 1] : undefined; -} diff --git a/test-fixtures/streamable-http-client/dist/protocol.js b/test-fixtures/streamable-http-client/dist/protocol.js deleted file mode 100644 index e6df323..0000000 --- a/test-fixtures/streamable-http-client/dist/protocol.js +++ /dev/null @@ -1,245 +0,0 @@ -export const CONNECTION_HEADER = "Acp-Connection-Id"; -export const SESSION_HEADER = "Acp-Session-Id"; -export class StreamableHttpFixtureClient { - endpoint; - recorder; - connectionId = null; - nextId = 1; - constructor(endpoint, recorder) { - this.endpoint = endpoint; - this.recorder = recorder; - } - async initialize() { - const request = this.request("initialize", { - protocolVersion: 1, - clientCapabilities: {}, - }); - const response = await this.post(request, "bootstrap", null, true); - const connectionId = response.headers.get(CONNECTION_HEADER); - if (!connectionId) { - throw new Error("initialize response missing connection id"); - } - this.connectionId = connectionId; - return await response.json(); - } - async postMessage(message, sessionId = null) { - return this.post(message, sessionId ? "session" : "connection", sessionId, false); - } - async openConnectionStream() { - return SseStream.open(this.endpoint, this.recorder, "connection", this.requireConnectionId(), null); - } - async openSessionStream(sessionId) { - return SseStream.open(this.endpoint, this.recorder, "session", this.requireConnectionId(), sessionId); - } - async close() { - const headers = { - [CONNECTION_HEADER]: this.requireConnectionId(), - }; - this.recorder.record({ - kind: "http_request", - method: "DELETE", - scope: "connection", - connectionId: this.connectionId, - sessionId: null, - jsonRpc: null, - }); - const response = await fetch(this.endpoint, { - method: "DELETE", - headers, - }); - this.recorder.record({ - kind: "http_response", - status: response.status, - scope: "connection", - connectionId: this.connectionId, - sessionId: null, - jsonRpc: null, - }); - return response; - } - async rawRequest(method, scope, headers, body, sessionId = null) { - this.recorder.record({ - kind: "http_request", - method, - scope, - connectionId: headers[CONNECTION_HEADER] ?? null, - sessionId, - jsonRpc: this.recorder.summarizeJsonRpc(body), - }); - const response = await fetch(this.endpoint, { - method, - headers, - ...(body ? { body: JSON.stringify(body) } : {}), - }); - this.recorder.record({ - kind: "http_response", - status: response.status, - scope, - connectionId: response.headers.get(CONNECTION_HEADER) ?? headers[CONNECTION_HEADER] ?? null, - sessionId: response.headers.get(SESSION_HEADER) ?? sessionId, - jsonRpc: null, - }); - return response; - } - request(method, params) { - return { - jsonrpc: "2.0", - id: `req-${this.nextId++}`, - method, - params, - }; - } - response(id, result) { - return { - jsonrpc: "2.0", - id, - result, - }; - } - async post(message, scope, sessionId, expectJson) { - const headers = { - "content-type": "application/json", - accept: "application/json", - }; - if (scope !== "bootstrap") { - headers[CONNECTION_HEADER] = this.requireConnectionId(); - } - if (sessionId) { - headers[SESSION_HEADER] = sessionId; - } - this.recorder.record({ - kind: "http_request", - method: "POST", - scope, - connectionId: this.connectionId, - sessionId, - jsonRpc: this.recorder.summarizeJsonRpc(message), - }); - const response = await fetch(this.endpoint, { - method: "POST", - headers, - body: JSON.stringify(message), - }); - let jsonRpc = null; - if (expectJson) { - jsonRpc = this.recorder.summarizeJsonRpc(await response.clone().json()); - } - this.recorder.record({ - kind: "http_response", - status: response.status, - scope, - connectionId: response.headers.get(CONNECTION_HEADER) ?? this.connectionId, - sessionId, - jsonRpc, - }); - return response; - } - requireConnectionId() { - if (!this.connectionId) { - throw new Error("connection id not initialized"); - } - return this.connectionId; - } -} -export class SseStream { - recorder; - stream; - sessionId; - response; - messages = []; - waiters = []; - abortController = new AbortController(); - constructor(recorder, stream, sessionId, response) { - this.recorder = recorder; - this.stream = stream; - this.sessionId = sessionId; - this.response = response; - } - static async open(endpoint, recorder, stream, connectionId, sessionId) { - recorder.record({ - kind: "http_request", - method: "GET", - scope: stream, - connectionId, - sessionId, - jsonRpc: null, - }); - const response = await fetch(endpoint, { - method: "GET", - headers: { - accept: "text/event-stream", - [CONNECTION_HEADER]: connectionId, - ...(sessionId ? { [SESSION_HEADER]: sessionId } : {}), - }, - }); - recorder.record({ - kind: "http_response", - status: response.status, - scope: stream, - connectionId: response.headers.get(CONNECTION_HEADER), - sessionId: response.headers.get(SESSION_HEADER), - jsonRpc: null, - }); - const result = new SseStream(recorder, stream, sessionId, response); - void result.readLoop(); - return result; - } - async next() { - const existing = this.messages.shift(); - if (existing) { - return existing; - } - return new Promise((resolve) => this.waiters.push(resolve)); - } - close() { - this.abortController.abort(); - } - async readLoop() { - if (!this.response.body) { - return; - } - const reader = this.response.body.getReader(); - const decoder = new TextDecoder(); - let buffer = ""; - for (;;) { - const { value, done } = await reader.read(); - if (done) { - break; - } - buffer += decoder.decode(value, { stream: true }); - for (;;) { - const boundary = buffer.indexOf("\n\n"); - if (boundary < 0) { - break; - } - const rawEvent = buffer.slice(0, boundary); - buffer = buffer.slice(boundary + 2); - const data = rawEvent - .split("\n") - .filter((line) => line.startsWith("data:")) - .map((line) => line.slice(5).trimStart()) - .join("\n"); - if (!data) { - continue; - } - const message = JSON.parse(data); - const summary = this.recorder.summarizeJsonRpc(message); - if (summary) { - this.recorder.record({ - kind: "sse_event", - stream: this.stream, - sessionId: this.sessionId, - jsonRpc: summary, - }); - } - const waiter = this.waiters.shift(); - if (waiter) { - waiter(message); - } - else { - this.messages.push(message); - } - } - } - } -} diff --git a/test-fixtures/streamable-http-client/dist/scenarios.js b/test-fixtures/streamable-http-client/dist/scenarios.js deleted file mode 100644 index 95f8c41..0000000 --- a/test-fixtures/streamable-http-client/dist/scenarios.js +++ /dev/null @@ -1,127 +0,0 @@ -import { CONNECTION_HEADER } from "./protocol.js"; -export async function runScenario(name, endpoint, client) { - switch (name) { - case "happy-path": - await happyPath(client); - return; - case "permission-round-trip": - await permissionRoundTrip(client); - return; - case "session-load": - await sessionLoad(client); - return; - case "two-sessions": - await twoSessions(client); - return; - case "wrong-stream-response": - await wrongStreamResponse(client); - return; - case "validation-failures": - await validationFailures(endpoint, client); - return; - default: - throw new Error(`Unknown scenario ${name}`); - } -} -async function happyPath(client) { - await client.initialize(); - const connection = await client.openConnectionStream(); - const newSession = client.request("session/new", { cwd: "/workspace", mcpServers: [] }); - await client.postMessage(newSession); - const sessionResponse = await connection.next(); - const sessionId = sessionResponse.result.sessionId; - const session = await client.openSessionStream(sessionId); - await client.postMessage(client.request("session/prompt", { - sessionId, - prompt: [{ type: "text", text: "hello" }], - }), sessionId); - await session.next(); - await session.next(); - await client.close(); -} -async function permissionRoundTrip(client) { - await client.initialize(); - const connection = await client.openConnectionStream(); - await client.postMessage(client.request("session/new", { cwd: "/workspace", mcpServers: [] })); - const sessionResponse = await connection.next(); - const sessionId = sessionResponse.result.sessionId; - const session = await client.openSessionStream(sessionId); - await client.postMessage(client.request("session/prompt", { - sessionId, - prompt: [{ type: "text", text: "needs permission" }], - }), sessionId); - const permissionRequest = await session.next(); - await client.postMessage(client.response(permissionRequest.id, { outcome: { outcome: "selected", optionId: "allow" } }), sessionId); - await session.next(); - await session.next(); - await client.close(); -} -async function sessionLoad(client) { - await client.initialize(); - const connection = await client.openConnectionStream(); - const session = await client.openSessionStream("sess-load"); - await client.postMessage(client.request("session/load", { - sessionId: "sess-load", - cwd: "/workspace", - mcpServers: [], - }), "sess-load"); - await connection.next(); - session.close(); - await client.close(); -} -async function twoSessions(client) { - await client.initialize(); - const connection = await client.openConnectionStream(); - await client.postMessage(client.request("session/new", { cwd: "/workspace/one", mcpServers: [] })); - const first = await connection.next(); - const firstId = first.result.sessionId; - await client.postMessage(client.request("session/new", { cwd: "/workspace/two", mcpServers: [] })); - const second = await connection.next(); - const secondId = second.result.sessionId; - const firstStream = await client.openSessionStream(firstId); - const secondStream = await client.openSessionStream(secondId); - await client.postMessage(client.request("session/prompt", { - sessionId: firstId, - prompt: [{ type: "text", text: "one" }], - }), firstId); - await firstStream.next(); - await firstStream.next(); - await client.postMessage(client.request("session/prompt", { - sessionId: secondId, - prompt: [{ type: "text", text: "two" }], - }), secondId); - await secondStream.next(); - await secondStream.next(); - await client.close(); -} -async function wrongStreamResponse(client) { - await client.initialize(); - const connection = await client.openConnectionStream(); - await client.postMessage(client.request("session/new", { cwd: "/workspace", mcpServers: [] })); - const sessionResponse = await connection.next(); - const sessionId = sessionResponse.result.sessionId; - const session = await client.openSessionStream(sessionId); - await client.postMessage(client.request("session/prompt", { - sessionId, - prompt: [{ type: "text", text: "needs permission" }], - }), sessionId); - const permissionRequest = await session.next(); - await client.postMessage(client.response(permissionRequest.id, { outcome: { outcome: "selected", optionId: "allow" } })); - await client.close(); -} -async function validationFailures(endpoint, client) { - await client.initialize(); - await client.rawRequest("POST", "connection", { - accept: "application/json", - "content-type": "text/plain", - [CONNECTION_HEADER]: "conn-invalid", - }, {}); - await client.rawRequest("GET", "connection", { - accept: "text/event-stream", - }, null); - await client.rawRequest("GET", "connection", { - accept: "application/json", - [CONNECTION_HEADER]: "missing", - }, null); - await client.close(); -} diff --git a/test-fixtures/streamable-http-client/dist/transcript.js b/test-fixtures/streamable-http-client/dist/transcript.js deleted file mode 100644 index cb9391f..0000000 --- a/test-fixtures/streamable-http-client/dist/transcript.js +++ /dev/null @@ -1,69 +0,0 @@ -export class TranscriptRecorder { - events = []; - idAliases = new Map(); - connectionAliases = new Map(); - record(event) { - if ("connectionId" in event) { - this.events.push({ - ...event, - connectionId: this.normalizeConnectionId(event.connectionId), - }); - return; - } - this.events.push(event); - } - summarizeJsonRpc(message) { - if (!message || typeof message !== "object") { - return null; - } - const candidate = message; - if (typeof candidate.method === "string" && "id" in candidate) { - return { - type: "request", - id: this.normalizeId(candidate.id), - method: candidate.method, - }; - } - if (typeof candidate.method === "string") { - return { - type: "notification", - method: candidate.method, - }; - } - if ("result" in candidate || "error" in candidate) { - return { - type: "response", - id: this.normalizeId(candidate.id), - hasError: candidate.error != null, - }; - } - return null; - } - serialize() { - return JSON.stringify(this.events, null, 2); - } - normalizeId(id) { - if (typeof id !== "string" && typeof id !== "number") { - return null; - } - const existing = this.idAliases.get(id); - if (existing) { - return existing; - } - const next = `id-${this.idAliases.size + 1}`; - this.idAliases.set(id, next); - return next; - } - normalizeConnectionId(connectionId) { - if (!connectionId) { - return null; - } - const existing = this.connectionAliases.get(connectionId); - if (existing) { - return existing; - } - const next = `conn-${this.connectionAliases.size + 1}`; - this.connectionAliases.set(connectionId, next); - return next; - } -} diff --git a/test-fixtures/streamable-http-client/golden/happy-path.json b/test-fixtures/streamable-http-client/golden/happy-path.json deleted file mode 100644 index ae468a0..0000000 --- a/test-fixtures/streamable-http-client/golden/happy-path.json +++ /dev/null @@ -1,127 +0,0 @@ -[ { - "kind" : "http_request", - "method" : "POST", - "scope" : "bootstrap", - "connectionId" : null, - "sessionId" : null, - "jsonRpc" : { - "type" : "request", - "id" : "id-1", - "method" : "initialize" - } -}, { - "kind" : "http_response", - "status" : 200, - "scope" : "bootstrap", - "connectionId" : "conn-1", - "sessionId" : null, - "jsonRpc" : { - "type" : "response", - "id" : "id-1", - "hasError" : false - } -}, { - "kind" : "http_request", - "method" : "GET", - "scope" : "connection", - "connectionId" : "conn-1", - "sessionId" : null, - "jsonRpc" : null -}, { - "kind" : "http_response", - "status" : 200, - "scope" : "connection", - "connectionId" : "conn-1", - "sessionId" : null, - "jsonRpc" : null -}, { - "kind" : "http_request", - "method" : "POST", - "scope" : "connection", - "connectionId" : "conn-1", - "sessionId" : null, - "jsonRpc" : { - "type" : "request", - "id" : "id-2", - "method" : "session/new" - } -}, { - "kind" : "sse_event", - "stream" : "connection", - "sessionId" : null, - "jsonRpc" : { - "type" : "response", - "id" : "id-2", - "hasError" : false - } -}, { - "kind" : "http_response", - "status" : 202, - "scope" : "connection", - "connectionId" : "conn-1", - "sessionId" : null, - "jsonRpc" : null -}, { - "kind" : "http_request", - "method" : "GET", - "scope" : "session", - "connectionId" : "conn-1", - "sessionId" : "sess-1", - "jsonRpc" : null -}, { - "kind" : "http_response", - "status" : 200, - "scope" : "session", - "connectionId" : "conn-1", - "sessionId" : "sess-1", - "jsonRpc" : null -}, { - "kind" : "http_request", - "method" : "POST", - "scope" : "session", - "connectionId" : "conn-1", - "sessionId" : "sess-1", - "jsonRpc" : { - "type" : "request", - "id" : "id-3", - "method" : "session/prompt" - } -}, { - "kind" : "sse_event", - "stream" : "session", - "sessionId" : "sess-1", - "jsonRpc" : { - "type" : "notification", - "method" : "session/update" - } -}, { - "kind" : "sse_event", - "stream" : "session", - "sessionId" : "sess-1", - "jsonRpc" : { - "type" : "response", - "id" : "id-3", - "hasError" : false - } -}, { - "kind" : "http_response", - "status" : 202, - "scope" : "session", - "connectionId" : "conn-1", - "sessionId" : "sess-1", - "jsonRpc" : null -}, { - "kind" : "http_request", - "method" : "DELETE", - "scope" : "connection", - "connectionId" : "conn-1", - "sessionId" : null, - "jsonRpc" : null -}, { - "kind" : "http_response", - "status" : 202, - "scope" : "connection", - "connectionId" : "conn-1", - "sessionId" : null, - "jsonRpc" : null -} ] \ No newline at end of file diff --git a/test-fixtures/streamable-http-client/golden/permission-round-trip.json b/test-fixtures/streamable-http-client/golden/permission-round-trip.json deleted file mode 100644 index c3af4a5..0000000 --- a/test-fixtures/streamable-http-client/golden/permission-round-trip.json +++ /dev/null @@ -1,154 +0,0 @@ -[ { - "kind" : "http_request", - "method" : "POST", - "scope" : "bootstrap", - "connectionId" : null, - "sessionId" : null, - "jsonRpc" : { - "type" : "request", - "id" : "id-1", - "method" : "initialize" - } -}, { - "kind" : "http_response", - "status" : 200, - "scope" : "bootstrap", - "connectionId" : "conn-1", - "sessionId" : null, - "jsonRpc" : { - "type" : "response", - "id" : "id-1", - "hasError" : false - } -}, { - "kind" : "http_request", - "method" : "GET", - "scope" : "connection", - "connectionId" : "conn-1", - "sessionId" : null, - "jsonRpc" : null -}, { - "kind" : "http_response", - "status" : 200, - "scope" : "connection", - "connectionId" : "conn-1", - "sessionId" : null, - "jsonRpc" : null -}, { - "kind" : "http_request", - "method" : "POST", - "scope" : "connection", - "connectionId" : "conn-1", - "sessionId" : null, - "jsonRpc" : { - "type" : "request", - "id" : "id-2", - "method" : "session/new" - } -}, { - "kind" : "sse_event", - "stream" : "connection", - "sessionId" : null, - "jsonRpc" : { - "type" : "response", - "id" : "id-2", - "hasError" : false - } -}, { - "kind" : "http_response", - "status" : 202, - "scope" : "connection", - "connectionId" : "conn-1", - "sessionId" : null, - "jsonRpc" : null -}, { - "kind" : "http_request", - "method" : "GET", - "scope" : "session", - "connectionId" : "conn-1", - "sessionId" : "sess-1", - "jsonRpc" : null -}, { - "kind" : "http_response", - "status" : 200, - "scope" : "session", - "connectionId" : "conn-1", - "sessionId" : "sess-1", - "jsonRpc" : null -}, { - "kind" : "http_request", - "method" : "POST", - "scope" : "session", - "connectionId" : "conn-1", - "sessionId" : "sess-1", - "jsonRpc" : { - "type" : "request", - "id" : "id-3", - "method" : "session/prompt" - } -}, { - "kind" : "sse_event", - "stream" : "session", - "sessionId" : "sess-1", - "jsonRpc" : { - "type" : "request", - "id" : "id-4", - "method" : "session/request_permission" - } -}, { - "kind" : "http_response", - "status" : 202, - "scope" : "session", - "connectionId" : "conn-1", - "sessionId" : "sess-1", - "jsonRpc" : null -}, { - "kind" : "http_request", - "method" : "POST", - "scope" : "session", - "connectionId" : "conn-1", - "sessionId" : "sess-1", - "jsonRpc" : { - "type" : "response", - "id" : "id-4", - "hasError" : false - } -}, { - "kind" : "sse_event", - "stream" : "session", - "sessionId" : "sess-1", - "jsonRpc" : { - "type" : "notification", - "method" : "session/update" - } -}, { - "kind" : "sse_event", - "stream" : "session", - "sessionId" : "sess-1", - "jsonRpc" : { - "type" : "response", - "id" : "id-3", - "hasError" : false - } -}, { - "kind" : "http_response", - "status" : 202, - "scope" : "session", - "connectionId" : "conn-1", - "sessionId" : "sess-1", - "jsonRpc" : null -}, { - "kind" : "http_request", - "method" : "DELETE", - "scope" : "connection", - "connectionId" : "conn-1", - "sessionId" : null, - "jsonRpc" : null -}, { - "kind" : "http_response", - "status" : 202, - "scope" : "connection", - "connectionId" : "conn-1", - "sessionId" : null, - "jsonRpc" : null -} ] \ No newline at end of file diff --git a/test-fixtures/streamable-http-client/golden/session-load.json b/test-fixtures/streamable-http-client/golden/session-load.json deleted file mode 100644 index 24086cb..0000000 --- a/test-fixtures/streamable-http-client/golden/session-load.json +++ /dev/null @@ -1,92 +0,0 @@ -[ { - "kind" : "http_request", - "method" : "POST", - "scope" : "bootstrap", - "connectionId" : null, - "sessionId" : null, - "jsonRpc" : { - "type" : "request", - "id" : "id-1", - "method" : "initialize" - } -}, { - "kind" : "http_response", - "status" : 200, - "scope" : "bootstrap", - "connectionId" : "conn-1", - "sessionId" : null, - "jsonRpc" : { - "type" : "response", - "id" : "id-1", - "hasError" : false - } -}, { - "kind" : "http_request", - "method" : "GET", - "scope" : "connection", - "connectionId" : "conn-1", - "sessionId" : null, - "jsonRpc" : null -}, { - "kind" : "http_response", - "status" : 200, - "scope" : "connection", - "connectionId" : "conn-1", - "sessionId" : null, - "jsonRpc" : null -}, { - "kind" : "http_request", - "method" : "GET", - "scope" : "session", - "connectionId" : "conn-1", - "sessionId" : "sess-load", - "jsonRpc" : null -}, { - "kind" : "http_response", - "status" : 200, - "scope" : "session", - "connectionId" : "conn-1", - "sessionId" : "sess-load", - "jsonRpc" : null -}, { - "kind" : "http_request", - "method" : "POST", - "scope" : "session", - "connectionId" : "conn-1", - "sessionId" : "sess-load", - "jsonRpc" : { - "type" : "request", - "id" : "id-2", - "method" : "session/load" - } -}, { - "kind" : "sse_event", - "stream" : "connection", - "sessionId" : null, - "jsonRpc" : { - "type" : "response", - "id" : "id-2", - "hasError" : false - } -}, { - "kind" : "http_response", - "status" : 202, - "scope" : "session", - "connectionId" : "conn-1", - "sessionId" : "sess-load", - "jsonRpc" : null -}, { - "kind" : "http_request", - "method" : "DELETE", - "scope" : "connection", - "connectionId" : "conn-1", - "sessionId" : null, - "jsonRpc" : null -}, { - "kind" : "http_response", - "status" : 202, - "scope" : "connection", - "connectionId" : "conn-1", - "sessionId" : null, - "jsonRpc" : null -} ] \ No newline at end of file diff --git a/test-fixtures/streamable-http-client/golden/two-sessions.json b/test-fixtures/streamable-http-client/golden/two-sessions.json deleted file mode 100644 index 307c720..0000000 --- a/test-fixtures/streamable-http-client/golden/two-sessions.json +++ /dev/null @@ -1,203 +0,0 @@ -[ { - "kind" : "http_request", - "method" : "POST", - "scope" : "bootstrap", - "connectionId" : null, - "sessionId" : null, - "jsonRpc" : { - "type" : "request", - "id" : "id-1", - "method" : "initialize" - } -}, { - "kind" : "http_response", - "status" : 200, - "scope" : "bootstrap", - "connectionId" : "conn-1", - "sessionId" : null, - "jsonRpc" : { - "type" : "response", - "id" : "id-1", - "hasError" : false - } -}, { - "kind" : "http_request", - "method" : "GET", - "scope" : "connection", - "connectionId" : "conn-1", - "sessionId" : null, - "jsonRpc" : null -}, { - "kind" : "http_response", - "status" : 200, - "scope" : "connection", - "connectionId" : "conn-1", - "sessionId" : null, - "jsonRpc" : null -}, { - "kind" : "http_request", - "method" : "POST", - "scope" : "connection", - "connectionId" : "conn-1", - "sessionId" : null, - "jsonRpc" : { - "type" : "request", - "id" : "id-2", - "method" : "session/new" - } -}, { - "kind" : "sse_event", - "stream" : "connection", - "sessionId" : null, - "jsonRpc" : { - "type" : "response", - "id" : "id-2", - "hasError" : false - } -}, { - "kind" : "http_response", - "status" : 202, - "scope" : "connection", - "connectionId" : "conn-1", - "sessionId" : null, - "jsonRpc" : null -}, { - "kind" : "http_request", - "method" : "POST", - "scope" : "connection", - "connectionId" : "conn-1", - "sessionId" : null, - "jsonRpc" : { - "type" : "request", - "id" : "id-3", - "method" : "session/new" - } -}, { - "kind" : "sse_event", - "stream" : "connection", - "sessionId" : null, - "jsonRpc" : { - "type" : "response", - "id" : "id-3", - "hasError" : false - } -}, { - "kind" : "http_response", - "status" : 202, - "scope" : "connection", - "connectionId" : "conn-1", - "sessionId" : null, - "jsonRpc" : null -}, { - "kind" : "http_request", - "method" : "GET", - "scope" : "session", - "connectionId" : "conn-1", - "sessionId" : "sess-1", - "jsonRpc" : null -}, { - "kind" : "http_response", - "status" : 200, - "scope" : "session", - "connectionId" : "conn-1", - "sessionId" : "sess-1", - "jsonRpc" : null -}, { - "kind" : "http_request", - "method" : "GET", - "scope" : "session", - "connectionId" : "conn-1", - "sessionId" : "sess-2", - "jsonRpc" : null -}, { - "kind" : "http_response", - "status" : 200, - "scope" : "session", - "connectionId" : "conn-1", - "sessionId" : "sess-2", - "jsonRpc" : null -}, { - "kind" : "http_request", - "method" : "POST", - "scope" : "session", - "connectionId" : "conn-1", - "sessionId" : "sess-1", - "jsonRpc" : { - "type" : "request", - "id" : "id-4", - "method" : "session/prompt" - } -}, { - "kind" : "sse_event", - "stream" : "session", - "sessionId" : "sess-1", - "jsonRpc" : { - "type" : "notification", - "method" : "session/update" - } -}, { - "kind" : "sse_event", - "stream" : "session", - "sessionId" : "sess-1", - "jsonRpc" : { - "type" : "response", - "id" : "id-4", - "hasError" : false - } -}, { - "kind" : "http_response", - "status" : 202, - "scope" : "session", - "connectionId" : "conn-1", - "sessionId" : "sess-1", - "jsonRpc" : null -}, { - "kind" : "http_request", - "method" : "POST", - "scope" : "session", - "connectionId" : "conn-1", - "sessionId" : "sess-2", - "jsonRpc" : { - "type" : "request", - "id" : "id-5", - "method" : "session/prompt" - } -}, { - "kind" : "sse_event", - "stream" : "session", - "sessionId" : "sess-2", - "jsonRpc" : { - "type" : "notification", - "method" : "session/update" - } -}, { - "kind" : "sse_event", - "stream" : "session", - "sessionId" : "sess-2", - "jsonRpc" : { - "type" : "response", - "id" : "id-5", - "hasError" : false - } -}, { - "kind" : "http_response", - "status" : 202, - "scope" : "session", - "connectionId" : "conn-1", - "sessionId" : "sess-2", - "jsonRpc" : null -}, { - "kind" : "http_request", - "method" : "DELETE", - "scope" : "connection", - "connectionId" : "conn-1", - "sessionId" : null, - "jsonRpc" : null -}, { - "kind" : "http_response", - "status" : 202, - "scope" : "connection", - "connectionId" : "conn-1", - "sessionId" : null, - "jsonRpc" : null -} ] \ No newline at end of file diff --git a/test-fixtures/streamable-http-client/golden/validation-failures.json b/test-fixtures/streamable-http-client/golden/validation-failures.json deleted file mode 100644 index 3ae0b9b..0000000 --- a/test-fixtures/streamable-http-client/golden/validation-failures.json +++ /dev/null @@ -1,79 +0,0 @@ -[ { - "kind" : "http_request", - "method" : "POST", - "scope" : "bootstrap", - "connectionId" : null, - "sessionId" : null, - "jsonRpc" : { - "type" : "request", - "id" : "id-1", - "method" : "initialize" - } -}, { - "kind" : "http_response", - "status" : 200, - "scope" : "bootstrap", - "connectionId" : "conn-1", - "sessionId" : null, - "jsonRpc" : { - "type" : "response", - "id" : "id-1", - "hasError" : false - } -}, { - "kind" : "http_request", - "method" : "POST", - "scope" : "connection", - "connectionId" : "conn-2", - "sessionId" : null, - "jsonRpc" : null -}, { - "kind" : "http_response", - "status" : 415, - "scope" : "connection", - "connectionId" : "conn-2", - "sessionId" : null, - "jsonRpc" : null -}, { - "kind" : "http_request", - "method" : "GET", - "scope" : "connection", - "connectionId" : null, - "sessionId" : null, - "jsonRpc" : null -}, { - "kind" : "http_response", - "status" : 400, - "scope" : "connection", - "connectionId" : null, - "sessionId" : null, - "jsonRpc" : null -}, { - "kind" : "http_request", - "method" : "GET", - "scope" : "connection", - "connectionId" : "conn-3", - "sessionId" : null, - "jsonRpc" : null -}, { - "kind" : "http_response", - "status" : 406, - "scope" : "connection", - "connectionId" : "conn-3", - "sessionId" : null, - "jsonRpc" : null -}, { - "kind" : "http_request", - "method" : "DELETE", - "scope" : "connection", - "connectionId" : "conn-1", - "sessionId" : null, - "jsonRpc" : null -}, { - "kind" : "http_response", - "status" : 202, - "scope" : "connection", - "connectionId" : "conn-1", - "sessionId" : null, - "jsonRpc" : null -} ] \ No newline at end of file diff --git a/test-fixtures/streamable-http-client/golden/wrong-stream-response.json b/test-fixtures/streamable-http-client/golden/wrong-stream-response.json deleted file mode 100644 index 83d292b..0000000 --- a/test-fixtures/streamable-http-client/golden/wrong-stream-response.json +++ /dev/null @@ -1,137 +0,0 @@ -[ { - "kind" : "http_request", - "method" : "POST", - "scope" : "bootstrap", - "connectionId" : null, - "sessionId" : null, - "jsonRpc" : { - "type" : "request", - "id" : "id-1", - "method" : "initialize" - } -}, { - "kind" : "http_response", - "status" : 200, - "scope" : "bootstrap", - "connectionId" : "conn-1", - "sessionId" : null, - "jsonRpc" : { - "type" : "response", - "id" : "id-1", - "hasError" : false - } -}, { - "kind" : "http_request", - "method" : "GET", - "scope" : "connection", - "connectionId" : "conn-1", - "sessionId" : null, - "jsonRpc" : null -}, { - "kind" : "http_response", - "status" : 200, - "scope" : "connection", - "connectionId" : "conn-1", - "sessionId" : null, - "jsonRpc" : null -}, { - "kind" : "http_request", - "method" : "POST", - "scope" : "connection", - "connectionId" : "conn-1", - "sessionId" : null, - "jsonRpc" : { - "type" : "request", - "id" : "id-2", - "method" : "session/new" - } -}, { - "kind" : "sse_event", - "stream" : "connection", - "sessionId" : null, - "jsonRpc" : { - "type" : "response", - "id" : "id-2", - "hasError" : false - } -}, { - "kind" : "http_response", - "status" : 202, - "scope" : "connection", - "connectionId" : "conn-1", - "sessionId" : null, - "jsonRpc" : null -}, { - "kind" : "http_request", - "method" : "GET", - "scope" : "session", - "connectionId" : "conn-1", - "sessionId" : "sess-1", - "jsonRpc" : null -}, { - "kind" : "http_response", - "status" : 200, - "scope" : "session", - "connectionId" : "conn-1", - "sessionId" : "sess-1", - "jsonRpc" : null -}, { - "kind" : "http_request", - "method" : "POST", - "scope" : "session", - "connectionId" : "conn-1", - "sessionId" : "sess-1", - "jsonRpc" : { - "type" : "request", - "id" : "id-3", - "method" : "session/prompt" - } -}, { - "kind" : "sse_event", - "stream" : "session", - "sessionId" : "sess-1", - "jsonRpc" : { - "type" : "request", - "id" : "id-4", - "method" : "session/request_permission" - } -}, { - "kind" : "http_response", - "status" : 202, - "scope" : "session", - "connectionId" : "conn-1", - "sessionId" : "sess-1", - "jsonRpc" : null -}, { - "kind" : "http_request", - "method" : "POST", - "scope" : "connection", - "connectionId" : "conn-1", - "sessionId" : null, - "jsonRpc" : { - "type" : "response", - "id" : "id-4", - "hasError" : false - } -}, { - "kind" : "http_response", - "status" : 400, - "scope" : "connection", - "connectionId" : "conn-1", - "sessionId" : null, - "jsonRpc" : null -}, { - "kind" : "http_request", - "method" : "DELETE", - "scope" : "connection", - "connectionId" : "conn-1", - "sessionId" : null, - "jsonRpc" : null -}, { - "kind" : "http_response", - "status" : 202, - "scope" : "connection", - "connectionId" : "conn-1", - "sessionId" : null, - "jsonRpc" : null -} ] \ No newline at end of file diff --git a/test-fixtures/streamable-http-client/package-lock.json b/test-fixtures/streamable-http-client/package-lock.json deleted file mode 100644 index c1fceaa..0000000 --- a/test-fixtures/streamable-http-client/package-lock.json +++ /dev/null @@ -1,45 +0,0 @@ -{ - "name": "acp-streamable-http-client-fixture", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "acp-streamable-http-client-fixture", - "devDependencies": { - "@types/node": "^22.10.2", - "typescript": "^5.7.2" - } - }, - "node_modules/@types/node": { - "version": "22.19.19", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.19.tgz", - "integrity": "sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew==", - "dev": true, - "license": "MIT", - "dependencies": { - "undici-types": "~6.21.0" - } - }, - "node_modules/typescript": { - "version": "5.9.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", - "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, - "node_modules/undici-types": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", - "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "dev": true, - "license": "MIT" - } - } -} diff --git a/test-fixtures/streamable-http-client/package.json b/test-fixtures/streamable-http-client/package.json deleted file mode 100644 index 75235c1..0000000 --- a/test-fixtures/streamable-http-client/package.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "name": "acp-streamable-http-client-fixture", - "private": true, - "type": "module", - "scripts": { - "build": "tsc" - }, - "devDependencies": { - "@types/node": "^22.10.2", - "typescript": "^5.7.2" - } -} diff --git a/test-fixtures/streamable-http-client/src/client.ts b/test-fixtures/streamable-http-client/src/client.ts deleted file mode 100644 index eaf151d..0000000 --- a/test-fixtures/streamable-http-client/src/client.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { StreamableHttpFixtureClient } from "./protocol.js"; -import { runScenario } from "./scenarios.js"; -import { TranscriptRecorder } from "./transcript.js"; - -const endpoint = readArg("--endpoint"); -if (!endpoint) { - throw new Error("--endpoint is required"); -} -const scenario = readArg("--scenario") ?? "happy-path"; -const recorder = new TranscriptRecorder(); -const client = new StreamableHttpFixtureClient(endpoint, recorder); - -await runScenario(scenario, endpoint, client); -process.stdout.write(recorder.serialize()); - -function readArg(name: string): string | undefined { - const index = process.argv.indexOf(name); - return index >= 0 ? process.argv[index + 1] : undefined; -} diff --git a/test-fixtures/streamable-http-client/src/protocol.ts b/test-fixtures/streamable-http-client/src/protocol.ts deleted file mode 100644 index 7f44734..0000000 --- a/test-fixtures/streamable-http-client/src/protocol.ts +++ /dev/null @@ -1,278 +0,0 @@ -import { TranscriptRecorder } from "./transcript.js"; - -export const CONNECTION_HEADER = "Acp-Connection-Id"; -export const SESSION_HEADER = "Acp-Session-Id"; - -export type JsonRpcMessage = Record; -export type Scope = "bootstrap" | "connection" | "session"; - -export class StreamableHttpFixtureClient { - private connectionId: string | null = null; - private nextId = 1; - - constructor( - private readonly endpoint: string, - private readonly recorder: TranscriptRecorder, - ) {} - - async initialize(): Promise { - const request = this.request("initialize", { - protocolVersion: 1, - clientCapabilities: {}, - }); - const response = await this.post(request, "bootstrap", null, true); - const connectionId = response.headers.get(CONNECTION_HEADER); - if (!connectionId) { - throw new Error("initialize response missing connection id"); - } - this.connectionId = connectionId; - return await response.json(); - } - - async postMessage(message: JsonRpcMessage, sessionId: string | null = null): Promise { - return this.post(message, sessionId ? "session" : "connection", sessionId, false); - } - - async openConnectionStream(): Promise { - return SseStream.open(this.endpoint, this.recorder, "connection", this.requireConnectionId(), null); - } - - async openSessionStream(sessionId: string): Promise { - return SseStream.open(this.endpoint, this.recorder, "session", this.requireConnectionId(), sessionId); - } - - async close(): Promise { - const headers = { - [CONNECTION_HEADER]: this.requireConnectionId(), - }; - this.recorder.record({ - kind: "http_request", - method: "DELETE", - scope: "connection", - connectionId: this.connectionId, - sessionId: null, - jsonRpc: null, - }); - const response = await fetch(this.endpoint, { - method: "DELETE", - headers, - }); - this.recorder.record({ - kind: "http_response", - status: response.status, - scope: "connection", - connectionId: this.connectionId, - sessionId: null, - jsonRpc: null, - }); - return response; - } - - async rawRequest( - method: string, - scope: Scope, - headers: Record, - body: JsonRpcMessage | null, - sessionId: string | null = null, - ): Promise { - this.recorder.record({ - kind: "http_request", - method, - scope, - connectionId: headers[CONNECTION_HEADER] ?? null, - sessionId, - jsonRpc: this.recorder.summarizeJsonRpc(body), - }); - const response = await fetch(this.endpoint, { - method, - headers, - ...(body ? { body: JSON.stringify(body) } : {}), - }); - this.recorder.record({ - kind: "http_response", - status: response.status, - scope, - connectionId: response.headers.get(CONNECTION_HEADER) ?? headers[CONNECTION_HEADER] ?? null, - sessionId: response.headers.get(SESSION_HEADER) ?? sessionId, - jsonRpc: null, - }); - return response; - } - - request(method: string, params: Record): JsonRpcMessage { - return { - jsonrpc: "2.0", - id: `req-${this.nextId++}`, - method, - params, - }; - } - - response(id: unknown, result: Record): JsonRpcMessage { - return { - jsonrpc: "2.0", - id, - result, - }; - } - - private async post( - message: JsonRpcMessage, - scope: Scope, - sessionId: string | null, - expectJson: boolean, - ): Promise { - const headers: Record = { - "content-type": "application/json", - accept: "application/json", - }; - if (scope !== "bootstrap") { - headers[CONNECTION_HEADER] = this.requireConnectionId(); - } - if (sessionId) { - headers[SESSION_HEADER] = sessionId; - } - this.recorder.record({ - kind: "http_request", - method: "POST", - scope, - connectionId: this.connectionId, - sessionId, - jsonRpc: this.recorder.summarizeJsonRpc(message), - }); - const response = await fetch(this.endpoint, { - method: "POST", - headers, - body: JSON.stringify(message), - }); - let jsonRpc = null; - if (expectJson) { - jsonRpc = this.recorder.summarizeJsonRpc(await response.clone().json()); - } - this.recorder.record({ - kind: "http_response", - status: response.status, - scope, - connectionId: response.headers.get(CONNECTION_HEADER) ?? this.connectionId, - sessionId, - jsonRpc, - }); - return response; - } - - private requireConnectionId(): string { - if (!this.connectionId) { - throw new Error("connection id not initialized"); - } - return this.connectionId; - } -} - -export class SseStream { - private readonly messages: JsonRpcMessage[] = []; - private readonly waiters: Array<(message: JsonRpcMessage) => void> = []; - private readonly abortController = new AbortController(); - - private constructor( - private readonly recorder: TranscriptRecorder, - private readonly stream: "connection" | "session", - private readonly sessionId: string | null, - private readonly response: Response, - ) {} - - static async open( - endpoint: string, - recorder: TranscriptRecorder, - stream: "connection" | "session", - connectionId: string, - sessionId: string | null, - ): Promise { - recorder.record({ - kind: "http_request", - method: "GET", - scope: stream, - connectionId, - sessionId, - jsonRpc: null, - }); - const response = await fetch(endpoint, { - method: "GET", - headers: { - accept: "text/event-stream", - [CONNECTION_HEADER]: connectionId, - ...(sessionId ? { [SESSION_HEADER]: sessionId } : {}), - }, - }); - recorder.record({ - kind: "http_response", - status: response.status, - scope: stream, - connectionId: response.headers.get(CONNECTION_HEADER), - sessionId: response.headers.get(SESSION_HEADER), - jsonRpc: null, - }); - const result = new SseStream(recorder, stream, sessionId, response); - void result.readLoop(); - return result; - } - - async next(): Promise { - const existing = this.messages.shift(); - if (existing) { - return existing; - } - return new Promise((resolve) => this.waiters.push(resolve)); - } - - close(): void { - this.abortController.abort(); - } - - private async readLoop(): Promise { - if (!this.response.body) { - return; - } - const reader = this.response.body.getReader(); - const decoder = new TextDecoder(); - let buffer = ""; - for (;;) { - const { value, done } = await reader.read(); - if (done) { - break; - } - buffer += decoder.decode(value, { stream: true }); - for (;;) { - const boundary = buffer.indexOf("\n\n"); - if (boundary < 0) { - break; - } - const rawEvent = buffer.slice(0, boundary); - buffer = buffer.slice(boundary + 2); - const data = rawEvent - .split("\n") - .filter((line) => line.startsWith("data:")) - .map((line) => line.slice(5).trimStart()) - .join("\n"); - if (!data) { - continue; - } - const message = JSON.parse(data) as JsonRpcMessage; - const summary = this.recorder.summarizeJsonRpc(message); - if (summary) { - this.recorder.record({ - kind: "sse_event", - stream: this.stream, - sessionId: this.sessionId, - jsonRpc: summary, - }); - } - const waiter = this.waiters.shift(); - if (waiter) { - waiter(message); - } else { - this.messages.push(message); - } - } - } - } -} diff --git a/test-fixtures/streamable-http-client/src/scenarios.ts b/test-fixtures/streamable-http-client/src/scenarios.ts deleted file mode 100644 index ef5ac89..0000000 --- a/test-fixtures/streamable-http-client/src/scenarios.ts +++ /dev/null @@ -1,134 +0,0 @@ -import { CONNECTION_HEADER, StreamableHttpFixtureClient } from "./protocol.js"; - -export async function runScenario(name: string, endpoint: string, client: StreamableHttpFixtureClient): Promise { - switch (name) { - case "happy-path": - await happyPath(client); - return; - case "permission-round-trip": - await permissionRoundTrip(client); - return; - case "session-load": - await sessionLoad(client); - return; - case "two-sessions": - await twoSessions(client); - return; - case "wrong-stream-response": - await wrongStreamResponse(client); - return; - case "validation-failures": - await validationFailures(endpoint, client); - return; - default: - throw new Error(`Unknown scenario ${name}`); - } -} - -async function happyPath(client: StreamableHttpFixtureClient): Promise { - await client.initialize(); - const connection = await client.openConnectionStream(); - const newSession = client.request("session/new", { cwd: "/workspace", mcpServers: [] }); - await client.postMessage(newSession); - const sessionResponse = await connection.next(); - const sessionId = ((sessionResponse.result as Record).sessionId as string); - const session = await client.openSessionStream(sessionId); - await client.postMessage(client.request("session/prompt", { - sessionId, - prompt: [{ type: "text", text: "hello" }], - }), sessionId); - await session.next(); - await session.next(); - await client.close(); -} - -async function permissionRoundTrip(client: StreamableHttpFixtureClient): Promise { - await client.initialize(); - const connection = await client.openConnectionStream(); - await client.postMessage(client.request("session/new", { cwd: "/workspace", mcpServers: [] })); - const sessionResponse = await connection.next(); - const sessionId = ((sessionResponse.result as Record).sessionId as string); - const session = await client.openSessionStream(sessionId); - await client.postMessage(client.request("session/prompt", { - sessionId, - prompt: [{ type: "text", text: "needs permission" }], - }), sessionId); - const permissionRequest = await session.next(); - await client.postMessage(client.response(permissionRequest.id, { outcome: { outcome: "selected", optionId: "allow" } }), sessionId); - await session.next(); - await session.next(); - await client.close(); -} - -async function sessionLoad(client: StreamableHttpFixtureClient): Promise { - await client.initialize(); - const connection = await client.openConnectionStream(); - const session = await client.openSessionStream("sess-load"); - await client.postMessage(client.request("session/load", { - sessionId: "sess-load", - cwd: "/workspace", - mcpServers: [], - }), "sess-load"); - await connection.next(); - session.close(); - await client.close(); -} - -async function twoSessions(client: StreamableHttpFixtureClient): Promise { - await client.initialize(); - const connection = await client.openConnectionStream(); - await client.postMessage(client.request("session/new", { cwd: "/workspace/one", mcpServers: [] })); - const first = await connection.next(); - const firstId = ((first.result as Record).sessionId as string); - await client.postMessage(client.request("session/new", { cwd: "/workspace/two", mcpServers: [] })); - const second = await connection.next(); - const secondId = ((second.result as Record).sessionId as string); - const firstStream = await client.openSessionStream(firstId); - const secondStream = await client.openSessionStream(secondId); - await client.postMessage(client.request("session/prompt", { - sessionId: firstId, - prompt: [{ type: "text", text: "one" }], - }), firstId); - await firstStream.next(); - await firstStream.next(); - await client.postMessage(client.request("session/prompt", { - sessionId: secondId, - prompt: [{ type: "text", text: "two" }], - }), secondId); - await secondStream.next(); - await secondStream.next(); - await client.close(); -} - -async function wrongStreamResponse(client: StreamableHttpFixtureClient): Promise { - await client.initialize(); - const connection = await client.openConnectionStream(); - await client.postMessage(client.request("session/new", { cwd: "/workspace", mcpServers: [] })); - const sessionResponse = await connection.next(); - const sessionId = ((sessionResponse.result as Record).sessionId as string); - const session = await client.openSessionStream(sessionId); - await client.postMessage(client.request("session/prompt", { - sessionId, - prompt: [{ type: "text", text: "needs permission" }], - }), sessionId); - const permissionRequest = await session.next(); - await client.postMessage(client.response(permissionRequest.id, { outcome: { outcome: "selected", optionId: "allow" } })); - await client.close(); -} - -async function validationFailures(endpoint: string, client: StreamableHttpFixtureClient): Promise { - await client.initialize(); - await client.rawRequest("POST", "connection", { - accept: "application/json", - "content-type": "text/plain", - [CONNECTION_HEADER]: "conn-invalid", - }, {}); - await client.rawRequest("GET", "connection", { - accept: "text/event-stream", - }, null); - await client.rawRequest("GET", "connection", { - accept: "application/json", - [CONNECTION_HEADER]: "missing", - }, null); - await client.close(); -} diff --git a/test-fixtures/streamable-http-client/src/transcript.ts b/test-fixtures/streamable-http-client/src/transcript.ts deleted file mode 100644 index ac8a8ff..0000000 --- a/test-fixtures/streamable-http-client/src/transcript.ts +++ /dev/null @@ -1,115 +0,0 @@ -export type JsonRpcSummary = - | { - type: "request"; - id: string | null; - method: string; - } - | { - type: "notification"; - method: string; - } - | { - type: "response"; - id: string | null; - hasError: boolean; - }; - -export type TranscriptEvent = - | { - kind: "http_request"; - method: string; - scope: "bootstrap" | "connection" | "session"; - connectionId: string | null; - sessionId: string | null; - jsonRpc: JsonRpcSummary | null; - } - | { - kind: "http_response"; - status: number; - scope: "bootstrap" | "connection" | "session"; - connectionId: string | null; - sessionId: string | null; - jsonRpc: JsonRpcSummary | null; - } - | { - kind: "sse_event"; - stream: "connection" | "session"; - sessionId: string | null; - jsonRpc: JsonRpcSummary; - }; - -export class TranscriptRecorder { - private readonly events: TranscriptEvent[] = []; - private readonly idAliases = new Map(); - private readonly connectionAliases = new Map(); - - record(event: TranscriptEvent): void { - if ("connectionId" in event) { - this.events.push({ - ...event, - connectionId: this.normalizeConnectionId(event.connectionId), - }); - return; - } - this.events.push(event); - } - - summarizeJsonRpc(message: unknown): JsonRpcSummary | null { - if (!message || typeof message !== "object") { - return null; - } - - const candidate = message as Record; - if (typeof candidate.method === "string" && "id" in candidate) { - return { - type: "request", - id: this.normalizeId(candidate.id), - method: candidate.method, - }; - } - if (typeof candidate.method === "string") { - return { - type: "notification", - method: candidate.method, - }; - } - if ("result" in candidate || "error" in candidate) { - return { - type: "response", - id: this.normalizeId(candidate.id), - hasError: candidate.error != null, - }; - } - return null; - } - - serialize(): string { - return JSON.stringify(this.events, null, 2); - } - - private normalizeId(id: unknown): string | null { - if (typeof id !== "string" && typeof id !== "number") { - return null; - } - const existing = this.idAliases.get(id); - if (existing) { - return existing; - } - const next = `id-${this.idAliases.size + 1}`; - this.idAliases.set(id, next); - return next; - } - - private normalizeConnectionId(connectionId: string | null): string | null { - if (!connectionId) { - return null; - } - const existing = this.connectionAliases.get(connectionId); - if (existing) { - return existing; - } - const next = `conn-${this.connectionAliases.size + 1}`; - this.connectionAliases.set(connectionId, next); - return next; - } -} diff --git a/test-fixtures/streamable-http-client/tsconfig.json b/test-fixtures/streamable-http-client/tsconfig.json deleted file mode 100644 index 78c0292..0000000 --- a/test-fixtures/streamable-http-client/tsconfig.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2022", - "module": "NodeNext", - "moduleResolution": "NodeNext", - "outDir": "dist", - "rootDir": "src", - "strict": true, - "esModuleInterop": true - }, - "include": ["src/**/*.ts"] -} diff --git a/test-fixtures/streamable-http-server/README.md b/test-fixtures/streamable-http-server/README.md deleted file mode 100644 index 09a952e..0000000 --- a/test-fixtures/streamable-http-server/README.md +++ /dev/null @@ -1,36 +0,0 @@ -# Streamable HTTP Fixture Server - -This in-repo TypeScript fixture is the conformance harness for the Java Streamable HTTP client transport. - -Current scope: - -- strict fixture behavior -- HTTP-only -- named startup-selected scenarios -- canonical transcript serialization owned by the fixture - -Current scenarios: - -- `happy-path` -- `permission-round-trip` -- `session-load` -- `two-sessions` -- `wrong-stream-response` -- `validation-failures` - -Fixture-wide validation already covers cookies and transport headers. Strict client-side -routing rejection is tested in the Java unit tests because it should fail before the -fixture ever sees a request. - -Manual use: - -```bash -npm install -npm run build -node dist/server.js --scenario happy-path --port 8080 -``` - -The server prints a single JSON `ready` line on startup. Golden transcripts live in -`golden/` and are compared by the Java integration tests. - -The harness is intentionally small and local to this repository for now. It may later become a reusable ACP conformance fixture once the remote transport ecosystem settles. diff --git a/test-fixtures/streamable-http-server/dist/protocol.js b/test-fixtures/streamable-http-server/dist/protocol.js deleted file mode 100644 index 725eb81..0000000 --- a/test-fixtures/streamable-http-server/dist/protocol.js +++ /dev/null @@ -1,26 +0,0 @@ -export const ACP_PATH = "/acp"; -export const CONNECTION_HEADER = "acp-connection-id"; -export const SESSION_HEADER = "acp-session-id"; -export const FIXTURE_COOKIE = "fixture=streamable-http"; -export function sendJson(response, status, body, headers = {}) { - response.writeHead(status, { - "content-type": "application/json", - ...headers, - }); - if (body) { - response.end(JSON.stringify(body)); - } - else { - response.end(); - } -} -export function openEventStream(response) { - response.writeHead(200, { - "content-type": "text/event-stream", - "cache-control": "no-cache", - }); - response.flushHeaders(); -} -export function writeSse(response, message) { - response.write(`data: ${JSON.stringify(message)}\n\n`); -} diff --git a/test-fixtures/streamable-http-server/dist/scenarios.js b/test-fixtures/streamable-http-server/dist/scenarios.js deleted file mode 100644 index 8a00ec7..0000000 --- a/test-fixtures/streamable-http-server/dist/scenarios.js +++ /dev/null @@ -1,387 +0,0 @@ -import { CONNECTION_HEADER, FIXTURE_COOKIE, SESSION_HEADER, openEventStream, sendJson, writeSse, } from "./protocol.js"; -export function createScenario(name) { - switch (name) { - case "happy-path": - return new HappyPathScenario(); - case "permission-round-trip": - return new PermissionRoundTripScenario(); - case "session-load": - return new SessionLoadScenario(); - case "two-sessions": - return new TwoSessionsScenario(); - case "wrong-stream-response": - return new WrongStreamResponseScenario(); - case "validation-failures": - return new ValidationFailuresScenario(); - default: - throw new Error(`Unknown scenario: ${name}`); - } -} -class BaseScenario { - connectionId = "conn-1"; - connectionStream = null; - sessionStreams = new Map(); - handle(response, headers, method, body, recorder) { - const request = this.recordRequest(method, headers, body, recorder); - if (!this.validateRequest(response, request, body, recorder)) { - return; - } - if (request.method === "POST" && body?.method === "initialize") { - this.handleInitialize(response, body, recorder); - return; - } - if (request.method === "GET" && - request.connectionId === this.connectionId && - !request.sessionId) { - this.connectionStream = response; - openEventStream(response); - this.recordResponse(recorder, 200, "connection", null); - return; - } - if (request.method === "GET" && - request.connectionId === this.connectionId && - request.sessionId) { - this.sessionStreams.set(request.sessionId, response); - openEventStream(response); - this.recordResponse(recorder, 200, "session", request.sessionId); - return; - } - if (request.method === "DELETE" && request.connectionId === this.connectionId) { - sendJson(response, 202); - this.recordResponse(recorder, 202, "connection", null); - this.connectionStream?.end(); - this.sessionStreams.forEach((sessionStream) => sessionStream.end()); - return; - } - if (this.handleScenarioRequest(response, request, body, recorder)) { - return; - } - this.reject(response, recorder, request.scope, request.sessionId, 400, "Unexpected fixture request", body?.id); - } - sendSessionNewResponse(responseStream, request, body, recorder, sessionId) { - sendJson(responseStream, 202); - this.recordResponse(recorder, 202, request.scope, request.sessionId); - const response = { - jsonrpc: "2.0", - id: body.id, - result: { - sessionId, - }, - }; - this.writeConnectionEvent(response, recorder); - } - sendPromptFlow(responseStream, request, body, recorder, sessionId) { - sendJson(responseStream, 202); - this.recordResponse(recorder, 202, request.scope, request.sessionId); - const update = { - jsonrpc: "2.0", - method: "session/update", - params: { - sessionId, - update: { - sessionUpdate: "agent_message_chunk", - content: { type: "text", text: "hello" }, - }, - }, - }; - const response = { - jsonrpc: "2.0", - id: body.id, - result: { - stopReason: "end_turn", - }, - }; - this.writeSessionEvent(sessionId, update, recorder); - this.writeSessionEvent(sessionId, response, recorder); - } - writeConnectionEvent(message, recorder) { - if (!this.connectionStream) { - throw new Error("connection stream must be open"); - } - writeSse(this.connectionStream, message); - recorder.record({ - kind: "sse_event", - stream: "connection", - sessionId: null, - jsonRpc: recorder.summarizeJsonRpc(message), - }); - } - writeSessionEvent(sessionId, message, recorder) { - const stream = this.sessionStreams.get(sessionId); - if (!stream) { - throw new Error(`session stream ${sessionId} must be open`); - } - writeSse(stream, message); - recorder.record({ - kind: "sse_event", - stream: "session", - sessionId, - jsonRpc: recorder.summarizeJsonRpc(message), - }); - } - handleInitialize(response, body, recorder) { - sendJson(response, 200, { - jsonrpc: "2.0", - id: body.id, - result: { - protocolVersion: 1, - agentCapabilities: { - loadSession: true, - }, - authMethods: [], - }, - }, { - "Acp-Connection-Id": this.connectionId, - "set-cookie": FIXTURE_COOKIE, - }); - this.recordResponse(recorder, 200, "bootstrap", null); - } - validateRequest(response, request, body, recorder) { - if (request.scope === "bootstrap") { - if (request.method !== "POST" || body?.method !== "initialize") { - this.reject(response, recorder, request.scope, request.sessionId, 400, "Expected initialize bootstrap", body?.id); - return false; - } - return true; - } - if (request.connectionId !== this.connectionId) { - this.reject(response, recorder, request.scope, request.sessionId, 400, "Missing or invalid connection id", body?.id); - return false; - } - if (!request.cookie?.includes(FIXTURE_COOKIE)) { - this.reject(response, recorder, request.scope, request.sessionId, 401, "Missing fixture cookie", body?.id); - return false; - } - if (request.method === "GET" && !request.accept?.includes("text/event-stream")) { - this.reject(response, recorder, request.scope, request.sessionId, 406, "Expected text/event-stream", body?.id); - return false; - } - if (request.method === "POST") { - if (!request.accept?.includes("application/json")) { - this.reject(response, recorder, request.scope, request.sessionId, 406, "Expected application/json accept", body?.id); - return false; - } - if (!request.contentType?.includes("application/json")) { - this.reject(response, recorder, request.scope, request.sessionId, 415, "Expected application/json body", body?.id); - return false; - } - } - return true; - } - recordRequest(method, headers, body, recorder) { - const connectionId = header(headers, CONNECTION_HEADER); - const sessionId = header(headers, SESSION_HEADER); - const cookie = header(headers, "cookie"); - const accept = header(headers, "accept"); - const contentType = header(headers, "content-type"); - const scope = sessionId ? "session" : connectionId ? "connection" : "bootstrap"; - recorder.record({ - kind: "http_request", - method, - scope, - connectionId, - sessionId, - cookie, - jsonRpc: recorder.summarizeJsonRpc(body), - }); - return { method, scope, connectionId, sessionId, cookie, accept, contentType }; - } - recordResponse(recorder, status, scope, sessionId) { - recorder.record({ - kind: "http_response", - status, - scope, - connectionId: scope === "bootstrap" ? this.connectionId : this.connectionId, - sessionId, - }); - } - reject(response, recorder, scope, sessionId, status, message, id) { - sendJson(response, status, { - jsonrpc: "2.0", - id: typeof id === "string" || typeof id === "number" ? id : null, - error: { - code: -32600, - message, - }, - }); - this.recordResponse(recorder, status, scope, sessionId); - } -} -class HappyPathScenario extends BaseScenario { - name = "happy-path"; - sessionId = "sess-1"; - handleScenarioRequest(response, request, body, recorder) { - if (request.method === "POST" && request.scope === "connection" && body?.method === "session/new") { - this.sendSessionNewResponse(response, request, body, recorder, this.sessionId); - return true; - } - if (request.method === "POST" && - request.scope === "session" && - request.sessionId === this.sessionId && - body?.method === "session/prompt") { - this.sendPromptFlow(response, request, body, recorder, this.sessionId); - return true; - } - return false; - } -} -class PermissionRoundTripScenario extends BaseScenario { - name = "permission-round-trip"; - sessionId = "sess-permission"; - pendingPromptId; - handleScenarioRequest(response, request, body, recorder) { - if (request.method === "POST" && request.scope === "connection" && body?.method === "session/new") { - this.sendSessionNewResponse(response, request, body, recorder, this.sessionId); - return true; - } - if (request.method === "POST" && - request.scope === "session" && - request.sessionId === this.sessionId && - body?.method === "session/prompt") { - sendJson(response, 202); - this.recordScenarioResponse(recorder, 202, request); - this.pendingPromptId = body.id; - this.writeSessionEvent(this.sessionId, { - jsonrpc: "2.0", - id: "perm-1", - method: "session/request_permission", - params: { - sessionId: this.sessionId, - toolCall: { - toolCallId: "tool-1", - title: "Write File", - kind: "edit", - status: "pending", - }, - options: [ - { optionId: "allow", name: "Allow", kind: "allow_once" }, - { optionId: "deny", name: "Deny", kind: "reject_once" }, - ], - }, - }, recorder); - return true; - } - if (request.method === "POST" && - request.scope === "session" && - request.sessionId === this.sessionId && - body?.id === "perm-1" && - "result" in body) { - sendJson(response, 202); - this.recordScenarioResponse(recorder, 202, request); - this.writeSessionEvent(this.sessionId, { - jsonrpc: "2.0", - id: this.pendingPromptId, - result: { - stopReason: "end_turn", - }, - }, recorder); - return true; - } - return false; - } - recordScenarioResponse(recorder, status, request) { - recorder.record({ - kind: "http_response", - status, - scope: request.scope, - connectionId: this.connectionId, - sessionId: request.sessionId, - }); - } -} -class SessionLoadScenario extends BaseScenario { - name = "session-load"; - sessionId = "sess-load"; - handleScenarioRequest(response, request, body, recorder) { - if (request.method === "POST" && - request.scope === "session" && - request.sessionId === this.sessionId && - body?.method === "session/load") { - sendJson(response, 202); - this.recordScenarioResponse(recorder, 202, request); - this.writeConnectionEvent({ - jsonrpc: "2.0", - id: body.id, - result: {}, - }, recorder); - return true; - } - return false; - } - recordScenarioResponse(recorder, status, request) { - recorder.record({ - kind: "http_response", - status, - scope: request.scope, - connectionId: this.connectionId, - sessionId: request.sessionId, - }); - } -} -class TwoSessionsScenario extends BaseScenario { - name = "two-sessions"; - nextSessionNumber = 1; - handleScenarioRequest(response, request, body, recorder) { - if (request.method === "POST" && request.scope === "connection" && body?.method === "session/new") { - const sessionId = `sess-${this.nextSessionNumber++}`; - this.sendSessionNewResponse(response, request, body, recorder, sessionId); - return true; - } - if (request.method === "POST" && - request.scope === "session" && - request.sessionId && - body?.method === "session/prompt") { - this.sendPromptFlow(response, request, body, recorder, request.sessionId); - return true; - } - return false; - } -} -class WrongStreamResponseScenario extends BaseScenario { - name = "wrong-stream-response"; - sessionId = "sess-wrong"; - handleScenarioRequest(response, request, body, recorder) { - if (request.method === "POST" && request.scope === "connection" && body?.method === "session/new") { - this.sendSessionNewResponse(response, request, body, recorder, this.sessionId); - return true; - } - if (request.method === "POST" && - request.scope === "session" && - request.sessionId === this.sessionId && - body?.method === "session/prompt") { - sendJson(response, 202); - this.recordScenarioResponse(recorder, 202, request); - this.writeConnectionEvent({ - jsonrpc: "2.0", - id: body.id, - result: { - stopReason: "end_turn", - }, - }, recorder); - return true; - } - return false; - } - recordScenarioResponse(recorder, status, request) { - recorder.record({ - kind: "http_response", - status, - scope: request.scope, - connectionId: this.connectionId, - sessionId: request.sessionId, - }); - } -} -class ValidationFailuresScenario extends BaseScenario { - name = "validation-failures"; - handleScenarioRequest(_response, _request, _body, _recorder) { - return false; - } -} -function header(headers, name) { - const value = headers[name]; - if (Array.isArray(value)) { - return value[0] ?? null; - } - return typeof value === "string" ? value : null; -} diff --git a/test-fixtures/streamable-http-server/dist/server.js b/test-fixtures/streamable-http-server/dist/server.js deleted file mode 100644 index a1da7df..0000000 --- a/test-fixtures/streamable-http-server/dist/server.js +++ /dev/null @@ -1,46 +0,0 @@ -import { createServer } from "node:http"; -import { ACP_PATH } from "./protocol.js"; -import { createScenario } from "./scenarios.js"; -import { TranscriptRecorder } from "./transcript.js"; -const scenarioName = readArg("--scenario") ?? "happy-path"; -const port = Number(readArg("--port") ?? "0"); -const recorder = new TranscriptRecorder(); -const scenario = createScenario(scenarioName); -const server = createServer(); -server.on("request", async (request, response) => { - const path = request.url ?? ""; - if (path === "/__test/transcript") { - response.writeHead(200, { - "content-type": "application/json", - }); - response.end(recorder.serialize()); - return; - } - if (path !== ACP_PATH) { - response.writeHead(404); - response.end(); - return; - } - const body = await readJsonBody(request); - scenario.handle(response, request.headers, request.method ?? "", body, recorder); -}); -server.listen(port, () => { - const address = server.address(); - const actualPort = typeof address === "object" && address ? address.port : port; - process.stdout.write(JSON.stringify({ status: "ready", port: actualPort, scenario: scenario.name }) + "\n"); -}); -function readArg(name) { - const index = process.argv.indexOf(name); - return index >= 0 ? process.argv[index + 1] : undefined; -} -async function readJsonBody(request) { - const method = request.method ?? ""; - if (method === "GET" || method === "DELETE") { - return null; - } - let raw = ""; - for await (const chunk of request) { - raw += chunk.toString("utf8"); - } - return raw ? JSON.parse(raw) : null; -} diff --git a/test-fixtures/streamable-http-server/dist/transcript.js b/test-fixtures/streamable-http-server/dist/transcript.js deleted file mode 100644 index 9193e07..0000000 --- a/test-fixtures/streamable-http-server/dist/transcript.js +++ /dev/null @@ -1,52 +0,0 @@ -export class TranscriptRecorder { - events = []; - idAliases = new Map(); - record(event) { - this.events.push(event); - } - summarizeJsonRpc(message) { - if (!message || typeof message !== "object") { - return null; - } - const candidate = message; - if (typeof candidate.method === "string" && "id" in candidate) { - return { - type: "request", - id: this.normalizeId(candidate.id), - method: candidate.method, - }; - } - if (typeof candidate.method === "string") { - return { - type: "notification", - method: candidate.method, - }; - } - if ("result" in candidate || "error" in candidate) { - return { - type: "response", - id: this.normalizeId(candidate.id), - hasError: candidate.error != null, - }; - } - return null; - } - toJSON() { - return [...this.events]; - } - serialize() { - return JSON.stringify(this.events, null, 2); - } - normalizeId(id) { - if (typeof id !== "string" && typeof id !== "number") { - return null; - } - const existing = this.idAliases.get(id); - if (existing) { - return existing; - } - const next = `id-${this.idAliases.size + 1}`; - this.idAliases.set(id, next); - return next; - } -} diff --git a/test-fixtures/streamable-http-server/golden/happy-path.json b/test-fixtures/streamable-http-server/golden/happy-path.json deleted file mode 100644 index 171bf6c..0000000 --- a/test-fixtures/streamable-http-server/golden/happy-path.json +++ /dev/null @@ -1,139 +0,0 @@ -[ - { - "kind": "http_request", - "method": "POST", - "scope": "bootstrap", - "connectionId": null, - "sessionId": null, - "cookie": null, - "jsonRpc": { - "type": "request", - "id": "id-1", - "method": "initialize" - } - }, - { - "kind": "http_response", - "status": 200, - "scope": "bootstrap", - "connectionId": "conn-1", - "sessionId": null - }, - { - "kind": "http_request", - "method": "GET", - "scope": "connection", - "connectionId": "conn-1", - "sessionId": null, - "cookie": "fixture=streamable-http", - "jsonRpc": null - }, - { - "kind": "http_response", - "status": 200, - "scope": "connection", - "connectionId": "conn-1", - "sessionId": null - }, - { - "kind": "http_request", - "method": "POST", - "scope": "connection", - "connectionId": "conn-1", - "sessionId": null, - "cookie": "fixture=streamable-http", - "jsonRpc": { - "type": "request", - "id": "id-2", - "method": "session/new" - } - }, - { - "kind": "http_response", - "status": 202, - "scope": "connection", - "connectionId": "conn-1", - "sessionId": null - }, - { - "kind": "sse_event", - "stream": "connection", - "sessionId": null, - "jsonRpc": { - "type": "response", - "id": "id-2", - "hasError": false - } - }, - { - "kind": "http_request", - "method": "GET", - "scope": "session", - "connectionId": "conn-1", - "sessionId": "sess-1", - "cookie": "fixture=streamable-http", - "jsonRpc": null - }, - { - "kind": "http_response", - "status": 200, - "scope": "session", - "connectionId": "conn-1", - "sessionId": "sess-1" - }, - { - "kind": "http_request", - "method": "POST", - "scope": "session", - "connectionId": "conn-1", - "sessionId": "sess-1", - "cookie": "fixture=streamable-http", - "jsonRpc": { - "type": "request", - "id": "id-3", - "method": "session/prompt" - } - }, - { - "kind": "http_response", - "status": 202, - "scope": "session", - "connectionId": "conn-1", - "sessionId": "sess-1" - }, - { - "kind": "sse_event", - "stream": "session", - "sessionId": "sess-1", - "jsonRpc": { - "type": "notification", - "method": "session/update" - } - }, - { - "kind": "sse_event", - "stream": "session", - "sessionId": "sess-1", - "jsonRpc": { - "type": "response", - "id": "id-3", - "hasError": false - } - }, - { - "kind": "http_request", - "method": "DELETE", - "scope": "connection", - "connectionId": "conn-1", - "sessionId": null, - "cookie": "fixture=streamable-http", - "jsonRpc": null - }, - { - "kind": "http_response", - "status": 202, - "scope": "connection", - "connectionId": "conn-1", - "sessionId": null - } -] diff --git a/test-fixtures/streamable-http-server/golden/permission-round-trip.json b/test-fixtures/streamable-http-server/golden/permission-round-trip.json deleted file mode 100644 index 0ba9132..0000000 --- a/test-fixtures/streamable-http-server/golden/permission-round-trip.json +++ /dev/null @@ -1,160 +0,0 @@ -[ - { - "kind": "http_request", - "method": "POST", - "scope": "bootstrap", - "connectionId": null, - "sessionId": null, - "cookie": null, - "jsonRpc": { - "type": "request", - "id": "id-1", - "method": "initialize" - } - }, - { - "kind": "http_response", - "status": 200, - "scope": "bootstrap", - "connectionId": "conn-1", - "sessionId": null - }, - { - "kind": "http_request", - "method": "GET", - "scope": "connection", - "connectionId": "conn-1", - "sessionId": null, - "cookie": "fixture=streamable-http", - "jsonRpc": null - }, - { - "kind": "http_response", - "status": 200, - "scope": "connection", - "connectionId": "conn-1", - "sessionId": null - }, - { - "kind": "http_request", - "method": "POST", - "scope": "connection", - "connectionId": "conn-1", - "sessionId": null, - "cookie": "fixture=streamable-http", - "jsonRpc": { - "type": "request", - "id": "id-2", - "method": "session/new" - } - }, - { - "kind": "http_response", - "status": 202, - "scope": "connection", - "connectionId": "conn-1", - "sessionId": null - }, - { - "kind": "sse_event", - "stream": "connection", - "sessionId": null, - "jsonRpc": { - "type": "response", - "id": "id-2", - "hasError": false - } - }, - { - "kind": "http_request", - "method": "GET", - "scope": "session", - "connectionId": "conn-1", - "sessionId": "sess-permission", - "cookie": "fixture=streamable-http", - "jsonRpc": null - }, - { - "kind": "http_response", - "status": 200, - "scope": "session", - "connectionId": "conn-1", - "sessionId": "sess-permission" - }, - { - "kind": "http_request", - "method": "POST", - "scope": "session", - "connectionId": "conn-1", - "sessionId": "sess-permission", - "cookie": "fixture=streamable-http", - "jsonRpc": { - "type": "request", - "id": "id-3", - "method": "session/prompt" - } - }, - { - "kind": "http_response", - "status": 202, - "scope": "session", - "connectionId": "conn-1", - "sessionId": "sess-permission" - }, - { - "kind": "sse_event", - "stream": "session", - "sessionId": "sess-permission", - "jsonRpc": { - "type": "request", - "id": "id-4", - "method": "session/request_permission" - } - }, - { - "kind": "http_request", - "method": "POST", - "scope": "session", - "connectionId": "conn-1", - "sessionId": "sess-permission", - "cookie": "fixture=streamable-http", - "jsonRpc": { - "type": "response", - "id": "id-4", - "hasError": false - } - }, - { - "kind": "http_response", - "status": 202, - "scope": "session", - "connectionId": "conn-1", - "sessionId": "sess-permission" - }, - { - "kind": "sse_event", - "stream": "session", - "sessionId": "sess-permission", - "jsonRpc": { - "type": "response", - "id": "id-3", - "hasError": false - } - }, - { - "kind": "http_request", - "method": "DELETE", - "scope": "connection", - "connectionId": "conn-1", - "sessionId": null, - "cookie": "fixture=streamable-http", - "jsonRpc": null - }, - { - "kind": "http_response", - "status": 202, - "scope": "connection", - "connectionId": "conn-1", - "sessionId": null - } -] diff --git a/test-fixtures/streamable-http-server/golden/session-load.json b/test-fixtures/streamable-http-server/golden/session-load.json deleted file mode 100644 index fd932fb..0000000 --- a/test-fixtures/streamable-http-server/golden/session-load.json +++ /dev/null @@ -1,100 +0,0 @@ -[ - { - "kind": "http_request", - "method": "POST", - "scope": "bootstrap", - "connectionId": null, - "sessionId": null, - "cookie": null, - "jsonRpc": { - "type": "request", - "id": "id-1", - "method": "initialize" - } - }, - { - "kind": "http_response", - "status": 200, - "scope": "bootstrap", - "connectionId": "conn-1", - "sessionId": null - }, - { - "kind": "http_request", - "method": "GET", - "scope": "connection", - "connectionId": "conn-1", - "sessionId": null, - "cookie": "fixture=streamable-http", - "jsonRpc": null - }, - { - "kind": "http_response", - "status": 200, - "scope": "connection", - "connectionId": "conn-1", - "sessionId": null - }, - { - "kind": "http_request", - "method": "GET", - "scope": "session", - "connectionId": "conn-1", - "sessionId": "sess-load", - "cookie": "fixture=streamable-http", - "jsonRpc": null - }, - { - "kind": "http_response", - "status": 200, - "scope": "session", - "connectionId": "conn-1", - "sessionId": "sess-load" - }, - { - "kind": "http_request", - "method": "POST", - "scope": "session", - "connectionId": "conn-1", - "sessionId": "sess-load", - "cookie": "fixture=streamable-http", - "jsonRpc": { - "type": "request", - "id": "id-2", - "method": "session/load" - } - }, - { - "kind": "http_response", - "status": 202, - "scope": "session", - "connectionId": "conn-1", - "sessionId": "sess-load" - }, - { - "kind": "sse_event", - "stream": "connection", - "sessionId": null, - "jsonRpc": { - "type": "response", - "id": "id-2", - "hasError": false - } - }, - { - "kind": "http_request", - "method": "DELETE", - "scope": "connection", - "connectionId": "conn-1", - "sessionId": null, - "cookie": "fixture=streamable-http", - "jsonRpc": null - }, - { - "kind": "http_response", - "status": 202, - "scope": "connection", - "connectionId": "conn-1", - "sessionId": null - } -] diff --git a/test-fixtures/streamable-http-server/golden/two-sessions.json b/test-fixtures/streamable-http-server/golden/two-sessions.json deleted file mode 100644 index 0d5eacb..0000000 --- a/test-fixtures/streamable-http-server/golden/two-sessions.json +++ /dev/null @@ -1,224 +0,0 @@ -[ - { - "kind": "http_request", - "method": "POST", - "scope": "bootstrap", - "connectionId": null, - "sessionId": null, - "cookie": null, - "jsonRpc": { - "type": "request", - "id": "id-1", - "method": "initialize" - } - }, - { - "kind": "http_response", - "status": 200, - "scope": "bootstrap", - "connectionId": "conn-1", - "sessionId": null - }, - { - "kind": "http_request", - "method": "GET", - "scope": "connection", - "connectionId": "conn-1", - "sessionId": null, - "cookie": "fixture=streamable-http", - "jsonRpc": null - }, - { - "kind": "http_response", - "status": 200, - "scope": "connection", - "connectionId": "conn-1", - "sessionId": null - }, - { - "kind": "http_request", - "method": "POST", - "scope": "connection", - "connectionId": "conn-1", - "sessionId": null, - "cookie": "fixture=streamable-http", - "jsonRpc": { - "type": "request", - "id": "id-2", - "method": "session/new" - } - }, - { - "kind": "http_response", - "status": 202, - "scope": "connection", - "connectionId": "conn-1", - "sessionId": null - }, - { - "kind": "sse_event", - "stream": "connection", - "sessionId": null, - "jsonRpc": { - "type": "response", - "id": "id-2", - "hasError": false - } - }, - { - "kind": "http_request", - "method": "GET", - "scope": "session", - "connectionId": "conn-1", - "sessionId": "sess-1", - "cookie": "fixture=streamable-http", - "jsonRpc": null - }, - { - "kind": "http_response", - "status": 200, - "scope": "session", - "connectionId": "conn-1", - "sessionId": "sess-1" - }, - { - "kind": "http_request", - "method": "POST", - "scope": "connection", - "connectionId": "conn-1", - "sessionId": null, - "cookie": "fixture=streamable-http", - "jsonRpc": { - "type": "request", - "id": "id-3", - "method": "session/new" - } - }, - { - "kind": "http_response", - "status": 202, - "scope": "connection", - "connectionId": "conn-1", - "sessionId": null - }, - { - "kind": "sse_event", - "stream": "connection", - "sessionId": null, - "jsonRpc": { - "type": "response", - "id": "id-3", - "hasError": false - } - }, - { - "kind": "http_request", - "method": "GET", - "scope": "session", - "connectionId": "conn-1", - "sessionId": "sess-2", - "cookie": "fixture=streamable-http", - "jsonRpc": null - }, - { - "kind": "http_response", - "status": 200, - "scope": "session", - "connectionId": "conn-1", - "sessionId": "sess-2" - }, - { - "kind": "http_request", - "method": "POST", - "scope": "session", - "connectionId": "conn-1", - "sessionId": "sess-1", - "cookie": "fixture=streamable-http", - "jsonRpc": { - "type": "request", - "id": "id-4", - "method": "session/prompt" - } - }, - { - "kind": "http_response", - "status": 202, - "scope": "session", - "connectionId": "conn-1", - "sessionId": "sess-1" - }, - { - "kind": "sse_event", - "stream": "session", - "sessionId": "sess-1", - "jsonRpc": { - "type": "notification", - "method": "session/update" - } - }, - { - "kind": "sse_event", - "stream": "session", - "sessionId": "sess-1", - "jsonRpc": { - "type": "response", - "id": "id-4", - "hasError": false - } - }, - { - "kind": "http_request", - "method": "POST", - "scope": "session", - "connectionId": "conn-1", - "sessionId": "sess-2", - "cookie": "fixture=streamable-http", - "jsonRpc": { - "type": "request", - "id": "id-5", - "method": "session/prompt" - } - }, - { - "kind": "http_response", - "status": 202, - "scope": "session", - "connectionId": "conn-1", - "sessionId": "sess-2" - }, - { - "kind": "sse_event", - "stream": "session", - "sessionId": "sess-2", - "jsonRpc": { - "type": "notification", - "method": "session/update" - } - }, - { - "kind": "sse_event", - "stream": "session", - "sessionId": "sess-2", - "jsonRpc": { - "type": "response", - "id": "id-5", - "hasError": false - } - }, - { - "kind": "http_request", - "method": "DELETE", - "scope": "connection", - "connectionId": "conn-1", - "sessionId": null, - "cookie": "fixture=streamable-http", - "jsonRpc": null - }, - { - "kind": "http_response", - "status": 202, - "scope": "connection", - "connectionId": "conn-1", - "sessionId": null - } -] diff --git a/test-fixtures/streamable-http-server/golden/validation-failures.json b/test-fixtures/streamable-http-server/golden/validation-failures.json deleted file mode 100644 index 51cbd36..0000000 --- a/test-fixtures/streamable-http-server/golden/validation-failures.json +++ /dev/null @@ -1,86 +0,0 @@ -[ - { - "kind": "http_request", - "method": "POST", - "scope": "bootstrap", - "connectionId": null, - "sessionId": null, - "cookie": null, - "jsonRpc": { - "type": "request", - "id": "id-1", - "method": "initialize" - } - }, - { - "kind": "http_response", - "status": 200, - "scope": "bootstrap", - "connectionId": "conn-1", - "sessionId": null - }, - { - "kind": "http_request", - "method": "GET", - "scope": "bootstrap", - "connectionId": null, - "sessionId": null, - "cookie": "fixture=streamable-http", - "jsonRpc": null - }, - { - "kind": "http_response", - "status": 400, - "scope": "bootstrap", - "connectionId": "conn-1", - "sessionId": null - }, - { - "kind": "http_request", - "method": "GET", - "scope": "connection", - "connectionId": "conn-1", - "sessionId": null, - "cookie": null, - "jsonRpc": null - }, - { - "kind": "http_response", - "status": 401, - "scope": "connection", - "connectionId": "conn-1", - "sessionId": null - }, - { - "kind": "http_request", - "method": "GET", - "scope": "connection", - "connectionId": "conn-1", - "sessionId": null, - "cookie": "fixture=streamable-http", - "jsonRpc": null - }, - { - "kind": "http_response", - "status": 406, - "scope": "connection", - "connectionId": "conn-1", - "sessionId": null - }, - { - "kind": "http_request", - "method": "DELETE", - "scope": "connection", - "connectionId": "conn-1", - "sessionId": null, - "cookie": "fixture=streamable-http", - "jsonRpc": null - }, - { - "kind": "http_response", - "status": 202, - "scope": "connection", - "connectionId": "conn-1", - "sessionId": null - } -] diff --git a/test-fixtures/streamable-http-server/golden/wrong-stream-response.json b/test-fixtures/streamable-http-server/golden/wrong-stream-response.json deleted file mode 100644 index 97e80ea..0000000 --- a/test-fixtures/streamable-http-server/golden/wrong-stream-response.json +++ /dev/null @@ -1,130 +0,0 @@ -[ - { - "kind": "http_request", - "method": "POST", - "scope": "bootstrap", - "connectionId": null, - "sessionId": null, - "cookie": null, - "jsonRpc": { - "type": "request", - "id": "id-1", - "method": "initialize" - } - }, - { - "kind": "http_response", - "status": 200, - "scope": "bootstrap", - "connectionId": "conn-1", - "sessionId": null - }, - { - "kind": "http_request", - "method": "GET", - "scope": "connection", - "connectionId": "conn-1", - "sessionId": null, - "cookie": "fixture=streamable-http", - "jsonRpc": null - }, - { - "kind": "http_response", - "status": 200, - "scope": "connection", - "connectionId": "conn-1", - "sessionId": null - }, - { - "kind": "http_request", - "method": "POST", - "scope": "connection", - "connectionId": "conn-1", - "sessionId": null, - "cookie": "fixture=streamable-http", - "jsonRpc": { - "type": "request", - "id": "id-2", - "method": "session/new" - } - }, - { - "kind": "http_response", - "status": 202, - "scope": "connection", - "connectionId": "conn-1", - "sessionId": null - }, - { - "kind": "sse_event", - "stream": "connection", - "sessionId": null, - "jsonRpc": { - "type": "response", - "id": "id-2", - "hasError": false - } - }, - { - "kind": "http_request", - "method": "GET", - "scope": "session", - "connectionId": "conn-1", - "sessionId": "sess-wrong", - "cookie": "fixture=streamable-http", - "jsonRpc": null - }, - { - "kind": "http_response", - "status": 200, - "scope": "session", - "connectionId": "conn-1", - "sessionId": "sess-wrong" - }, - { - "kind": "http_request", - "method": "POST", - "scope": "session", - "connectionId": "conn-1", - "sessionId": "sess-wrong", - "cookie": "fixture=streamable-http", - "jsonRpc": { - "type": "request", - "id": "id-3", - "method": "session/prompt" - } - }, - { - "kind": "http_response", - "status": 202, - "scope": "session", - "connectionId": "conn-1", - "sessionId": "sess-wrong" - }, - { - "kind": "sse_event", - "stream": "connection", - "sessionId": null, - "jsonRpc": { - "type": "response", - "id": "id-3", - "hasError": false - } - }, - { - "kind": "http_request", - "method": "DELETE", - "scope": "connection", - "connectionId": "conn-1", - "sessionId": null, - "cookie": "fixture=streamable-http", - "jsonRpc": null - }, - { - "kind": "http_response", - "status": 202, - "scope": "connection", - "connectionId": "conn-1", - "sessionId": null - } -] diff --git a/test-fixtures/streamable-http-server/package-lock.json b/test-fixtures/streamable-http-server/package-lock.json deleted file mode 100644 index 1a89fc5..0000000 --- a/test-fixtures/streamable-http-server/package-lock.json +++ /dev/null @@ -1,566 +0,0 @@ -{ - "name": "acp-streamable-http-fixture", - "version": "0.1.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "acp-streamable-http-fixture", - "version": "0.1.0", - "devDependencies": { - "@types/node": "^22.15.0", - "tsx": "^4.19.4", - "typescript": "^5.8.3" - } - }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz", - "integrity": "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.0.tgz", - "integrity": "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.0.tgz", - "integrity": "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.0.tgz", - "integrity": "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.0.tgz", - "integrity": "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.0.tgz", - "integrity": "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.0.tgz", - "integrity": "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.0.tgz", - "integrity": "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.0.tgz", - "integrity": "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.0.tgz", - "integrity": "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.0.tgz", - "integrity": "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.0.tgz", - "integrity": "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.0.tgz", - "integrity": "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.0.tgz", - "integrity": "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.0.tgz", - "integrity": "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.0.tgz", - "integrity": "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.0.tgz", - "integrity": "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.0.tgz", - "integrity": "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.0.tgz", - "integrity": "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.0.tgz", - "integrity": "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.0.tgz", - "integrity": "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openharmony-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.0.tgz", - "integrity": "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.0.tgz", - "integrity": "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.0.tgz", - "integrity": "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.0.tgz", - "integrity": "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.0.tgz", - "integrity": "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@types/node": { - "version": "22.19.19", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.19.tgz", - "integrity": "sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew==", - "dev": true, - "license": "MIT", - "dependencies": { - "undici-types": "~6.21.0" - } - }, - "node_modules/esbuild": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.0.tgz", - "integrity": "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.28.0", - "@esbuild/android-arm": "0.28.0", - "@esbuild/android-arm64": "0.28.0", - "@esbuild/android-x64": "0.28.0", - "@esbuild/darwin-arm64": "0.28.0", - "@esbuild/darwin-x64": "0.28.0", - "@esbuild/freebsd-arm64": "0.28.0", - "@esbuild/freebsd-x64": "0.28.0", - "@esbuild/linux-arm": "0.28.0", - "@esbuild/linux-arm64": "0.28.0", - "@esbuild/linux-ia32": "0.28.0", - "@esbuild/linux-loong64": "0.28.0", - "@esbuild/linux-mips64el": "0.28.0", - "@esbuild/linux-ppc64": "0.28.0", - "@esbuild/linux-riscv64": "0.28.0", - "@esbuild/linux-s390x": "0.28.0", - "@esbuild/linux-x64": "0.28.0", - "@esbuild/netbsd-arm64": "0.28.0", - "@esbuild/netbsd-x64": "0.28.0", - "@esbuild/openbsd-arm64": "0.28.0", - "@esbuild/openbsd-x64": "0.28.0", - "@esbuild/openharmony-arm64": "0.28.0", - "@esbuild/sunos-x64": "0.28.0", - "@esbuild/win32-arm64": "0.28.0", - "@esbuild/win32-ia32": "0.28.0", - "@esbuild/win32-x64": "0.28.0" - } - }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/tsx": { - "version": "4.22.1", - "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.1.tgz", - "integrity": "sha512-TvncJykhxAzFCk0VQZKBTClall4Pm7qXDSodb6uxi8QFa8X8mT6ABjxxsQ2opDRYxG7AzcRWXaFtruz5HJKuWg==", - "dev": true, - "license": "MIT", - "dependencies": { - "esbuild": "~0.28.0" - }, - "bin": { - "tsx": "dist/cli.mjs" - }, - "engines": { - "node": ">=18.0.0" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" - } - }, - "node_modules/typescript": { - "version": "5.9.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", - "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, - "node_modules/undici-types": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", - "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "dev": true, - "license": "MIT" - } - } -} diff --git a/test-fixtures/streamable-http-server/package.json b/test-fixtures/streamable-http-server/package.json deleted file mode 100644 index 11a8163..0000000 --- a/test-fixtures/streamable-http-server/package.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "name": "acp-streamable-http-fixture", - "private": true, - "version": "0.1.0", - "type": "module", - "scripts": { - "build": "tsc -p tsconfig.json", - "start": "tsx src/server.ts", - "start:happy-path": "tsx src/server.ts --scenario happy-path" - }, - "devDependencies": { - "@types/node": "^22.15.0", - "tsx": "^4.19.4", - "typescript": "^5.8.3" - } -} diff --git a/test-fixtures/streamable-http-server/src/protocol.ts b/test-fixtures/streamable-http-server/src/protocol.ts deleted file mode 100644 index df82cfd..0000000 --- a/test-fixtures/streamable-http-server/src/protocol.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { ServerResponse } from "node:http"; - -export const ACP_PATH = "/acp"; -export const CONNECTION_HEADER = "acp-connection-id"; -export const SESSION_HEADER = "acp-session-id"; -export const FIXTURE_COOKIE = "fixture=streamable-http"; - -export type JsonRpcMessage = Record; - -export function sendJson(response: ServerResponse, status: number, body?: JsonRpcMessage, headers: Record = {}): void { - response.writeHead(status, { - "content-type": "application/json", - ...headers, - }); - if (body) { - response.end(JSON.stringify(body)); - } else { - response.end(); - } -} - -export function openEventStream(response: ServerResponse): void { - response.writeHead(200, { - "content-type": "text/event-stream", - "cache-control": "no-cache", - }); - response.flushHeaders(); -} - -export function writeSse(response: ServerResponse, message: JsonRpcMessage): void { - response.write(`data: ${JSON.stringify(message)}\n\n`); -} diff --git a/test-fixtures/streamable-http-server/src/scenarios.ts b/test-fixtures/streamable-http-server/src/scenarios.ts deleted file mode 100644 index c4f49f3..0000000 --- a/test-fixtures/streamable-http-server/src/scenarios.ts +++ /dev/null @@ -1,574 +0,0 @@ -import { IncomingHttpHeaders, ServerResponse } from "node:http"; -import { - CONNECTION_HEADER, - FIXTURE_COOKIE, - JsonRpcMessage, - SESSION_HEADER, - openEventStream, - sendJson, - writeSse, -} from "./protocol.js"; -import { TranscriptRecorder } from "./transcript.js"; - -export interface FixtureScenario { - readonly name: string; - handle( - response: ServerResponse, - headers: IncomingHttpHeaders, - method: string, - body: JsonRpcMessage | null, - recorder: TranscriptRecorder, - ): void; -} - -export function createScenario(name: string): FixtureScenario { - switch (name) { - case "happy-path": - return new HappyPathScenario(); - case "permission-round-trip": - return new PermissionRoundTripScenario(); - case "session-load": - return new SessionLoadScenario(); - case "two-sessions": - return new TwoSessionsScenario(); - case "wrong-stream-response": - return new WrongStreamResponseScenario(); - case "validation-failures": - return new ValidationFailuresScenario(); - default: - throw new Error(`Unknown scenario: ${name}`); - } -} - -abstract class BaseScenario implements FixtureScenario { - abstract readonly name: string; - - protected readonly connectionId = "conn-1"; - protected connectionStream: ServerResponse | null = null; - protected readonly sessionStreams = new Map(); - - handle( - response: ServerResponse, - headers: IncomingHttpHeaders, - method: string, - body: JsonRpcMessage | null, - recorder: TranscriptRecorder, - ): void { - const request = this.recordRequest(method, headers, body, recorder); - if (!this.validateRequest(response, request, body, recorder)) { - return; - } - - if (request.method === "POST" && body?.method === "initialize") { - this.handleInitialize(response, body, recorder); - return; - } - - if ( - request.method === "GET" && - request.connectionId === this.connectionId && - !request.sessionId - ) { - this.connectionStream = response; - openEventStream(response); - this.recordResponse(recorder, 200, "connection", null); - return; - } - - if ( - request.method === "GET" && - request.connectionId === this.connectionId && - request.sessionId - ) { - this.sessionStreams.set(request.sessionId, response); - openEventStream(response); - this.recordResponse(recorder, 200, "session", request.sessionId); - return; - } - - if (request.method === "DELETE" && request.connectionId === this.connectionId) { - sendJson(response, 202); - this.recordResponse(recorder, 202, "connection", null); - this.connectionStream?.end(); - this.sessionStreams.forEach((sessionStream) => sessionStream.end()); - return; - } - - if (this.handleScenarioRequest(response, request, body, recorder)) { - return; - } - - this.reject(response, recorder, request.scope, request.sessionId, 400, "Unexpected fixture request", body?.id); - } - - protected abstract handleScenarioRequest( - response: ServerResponse, - request: RecordedRequest, - body: JsonRpcMessage | null, - recorder: TranscriptRecorder, - ): boolean; - - protected sendSessionNewResponse( - responseStream: ServerResponse, - request: RecordedRequest, - body: JsonRpcMessage, - recorder: TranscriptRecorder, - sessionId: string, - ): void { - sendJson(responseStream, 202); - this.recordResponse(recorder, 202, request.scope, request.sessionId); - const response = { - jsonrpc: "2.0", - id: body.id, - result: { - sessionId, - }, - }; - this.writeConnectionEvent(response, recorder); - } - - protected sendPromptFlow( - responseStream: ServerResponse, - request: RecordedRequest, - body: JsonRpcMessage, - recorder: TranscriptRecorder, - sessionId: string, - ): void { - sendJson(responseStream, 202); - this.recordResponse(recorder, 202, request.scope, request.sessionId); - const update = { - jsonrpc: "2.0", - method: "session/update", - params: { - sessionId, - update: { - sessionUpdate: "agent_message_chunk", - content: { type: "text", text: "hello" }, - }, - }, - }; - const response = { - jsonrpc: "2.0", - id: body.id, - result: { - stopReason: "end_turn", - }, - }; - this.writeSessionEvent(sessionId, update, recorder); - this.writeSessionEvent(sessionId, response, recorder); - } - - protected writeConnectionEvent(message: JsonRpcMessage, recorder: TranscriptRecorder): void { - if (!this.connectionStream) { - throw new Error("connection stream must be open"); - } - writeSse(this.connectionStream, message); - recorder.record({ - kind: "sse_event", - stream: "connection", - sessionId: null, - jsonRpc: recorder.summarizeJsonRpc(message)!, - }); - } - - protected writeSessionEvent(sessionId: string, message: JsonRpcMessage, recorder: TranscriptRecorder): void { - const stream = this.sessionStreams.get(sessionId); - if (!stream) { - throw new Error(`session stream ${sessionId} must be open`); - } - writeSse(stream, message); - recorder.record({ - kind: "sse_event", - stream: "session", - sessionId, - jsonRpc: recorder.summarizeJsonRpc(message)!, - }); - } - - private handleInitialize(response: ServerResponse, body: JsonRpcMessage, recorder: TranscriptRecorder): void { - sendJson( - response, - 200, - { - jsonrpc: "2.0", - id: body.id, - result: { - protocolVersion: 1, - agentCapabilities: { - loadSession: true, - }, - authMethods: [], - }, - }, - { - "Acp-Connection-Id": this.connectionId, - "set-cookie": FIXTURE_COOKIE, - }, - ); - this.recordResponse(recorder, 200, "bootstrap", null); - } - - private validateRequest( - response: ServerResponse, - request: RecordedRequest, - body: JsonRpcMessage | null, - recorder: TranscriptRecorder, - ): boolean { - if (request.scope === "bootstrap") { - if (request.method !== "POST" || body?.method !== "initialize") { - this.reject(response, recorder, request.scope, request.sessionId, 400, "Expected initialize bootstrap", body?.id); - return false; - } - return true; - } - - if (request.connectionId !== this.connectionId) { - this.reject(response, recorder, request.scope, request.sessionId, 400, "Missing or invalid connection id", body?.id); - return false; - } - - if (!request.cookie?.includes(FIXTURE_COOKIE)) { - this.reject(response, recorder, request.scope, request.sessionId, 401, "Missing fixture cookie", body?.id); - return false; - } - - if (request.method === "GET" && !request.accept?.includes("text/event-stream")) { - this.reject(response, recorder, request.scope, request.sessionId, 406, "Expected text/event-stream", body?.id); - return false; - } - - if (request.method === "POST") { - if (!request.accept?.includes("application/json")) { - this.reject(response, recorder, request.scope, request.sessionId, 406, "Expected application/json accept", body?.id); - return false; - } - if (!request.contentType?.includes("application/json")) { - this.reject(response, recorder, request.scope, request.sessionId, 415, "Expected application/json body", body?.id); - return false; - } - } - - return true; - } - - private recordRequest( - method: string, - headers: IncomingHttpHeaders, - body: JsonRpcMessage | null, - recorder: TranscriptRecorder, - ): RecordedRequest { - const connectionId = header(headers, CONNECTION_HEADER); - const sessionId = header(headers, SESSION_HEADER); - const cookie = header(headers, "cookie"); - const accept = header(headers, "accept"); - const contentType = header(headers, "content-type"); - const scope = sessionId ? "session" : connectionId ? "connection" : "bootstrap"; - recorder.record({ - kind: "http_request", - method, - scope, - connectionId, - sessionId, - cookie, - jsonRpc: recorder.summarizeJsonRpc(body), - }); - return { method, scope, connectionId, sessionId, cookie, accept, contentType }; - } - - private recordResponse( - recorder: TranscriptRecorder, - status: number, - scope: RequestScope, - sessionId: string | null, - ): void { - recorder.record({ - kind: "http_response", - status, - scope, - connectionId: scope === "bootstrap" ? this.connectionId : this.connectionId, - sessionId, - }); - } - - private reject( - response: ServerResponse, - recorder: TranscriptRecorder, - scope: RequestScope, - sessionId: string | null, - status: number, - message: string, - id: unknown, - ): void { - sendJson(response, status, { - jsonrpc: "2.0", - id: typeof id === "string" || typeof id === "number" ? id : null, - error: { - code: -32600, - message, - }, - }); - this.recordResponse(recorder, status, scope, sessionId); - } -} - -class HappyPathScenario extends BaseScenario { - readonly name = "happy-path"; - private readonly sessionId = "sess-1"; - - protected handleScenarioRequest( - response: ServerResponse, - request: RecordedRequest, - body: JsonRpcMessage | null, - recorder: TranscriptRecorder, - ): boolean { - if (request.method === "POST" && request.scope === "connection" && body?.method === "session/new") { - this.sendSessionNewResponse(response, request, body, recorder, this.sessionId); - return true; - } - if ( - request.method === "POST" && - request.scope === "session" && - request.sessionId === this.sessionId && - body?.method === "session/prompt" - ) { - this.sendPromptFlow(response, request, body, recorder, this.sessionId); - return true; - } - return false; - } -} - -class PermissionRoundTripScenario extends BaseScenario { - readonly name = "permission-round-trip"; - private readonly sessionId = "sess-permission"; - private pendingPromptId: unknown; - - protected handleScenarioRequest( - response: ServerResponse, - request: RecordedRequest, - body: JsonRpcMessage | null, - recorder: TranscriptRecorder, - ): boolean { - if (request.method === "POST" && request.scope === "connection" && body?.method === "session/new") { - this.sendSessionNewResponse(response, request, body, recorder, this.sessionId); - return true; - } - if ( - request.method === "POST" && - request.scope === "session" && - request.sessionId === this.sessionId && - body?.method === "session/prompt" - ) { - sendJson(response, 202); - this.recordScenarioResponse(recorder, 202, request); - this.pendingPromptId = body.id; - this.writeSessionEvent( - this.sessionId, - { - jsonrpc: "2.0", - id: "perm-1", - method: "session/request_permission", - params: { - sessionId: this.sessionId, - toolCall: { - toolCallId: "tool-1", - title: "Write File", - kind: "edit", - status: "pending", - }, - options: [ - { optionId: "allow", name: "Allow", kind: "allow_once" }, - { optionId: "deny", name: "Deny", kind: "reject_once" }, - ], - }, - }, - recorder, - ); - return true; - } - if ( - request.method === "POST" && - request.scope === "session" && - request.sessionId === this.sessionId && - body?.id === "perm-1" && - "result" in body - ) { - sendJson(response, 202); - this.recordScenarioResponse(recorder, 202, request); - this.writeSessionEvent( - this.sessionId, - { - jsonrpc: "2.0", - id: this.pendingPromptId, - result: { - stopReason: "end_turn", - }, - }, - recorder, - ); - return true; - } - return false; - } - - private recordScenarioResponse(recorder: TranscriptRecorder, status: number, request: RecordedRequest): void { - recorder.record({ - kind: "http_response", - status, - scope: request.scope, - connectionId: this.connectionId, - sessionId: request.sessionId, - }); - } -} - -class SessionLoadScenario extends BaseScenario { - readonly name = "session-load"; - private readonly sessionId = "sess-load"; - - protected handleScenarioRequest( - response: ServerResponse, - request: RecordedRequest, - body: JsonRpcMessage | null, - recorder: TranscriptRecorder, - ): boolean { - if ( - request.method === "POST" && - request.scope === "session" && - request.sessionId === this.sessionId && - body?.method === "session/load" - ) { - sendJson(response, 202); - this.recordScenarioResponse(recorder, 202, request); - this.writeConnectionEvent( - { - jsonrpc: "2.0", - id: body.id, - result: {}, - }, - recorder, - ); - return true; - } - return false; - } - - private recordScenarioResponse(recorder: TranscriptRecorder, status: number, request: RecordedRequest): void { - recorder.record({ - kind: "http_response", - status, - scope: request.scope, - connectionId: this.connectionId, - sessionId: request.sessionId, - }); - } -} - -class TwoSessionsScenario extends BaseScenario { - readonly name = "two-sessions"; - private nextSessionNumber = 1; - - protected handleScenarioRequest( - response: ServerResponse, - request: RecordedRequest, - body: JsonRpcMessage | null, - recorder: TranscriptRecorder, - ): boolean { - if (request.method === "POST" && request.scope === "connection" && body?.method === "session/new") { - const sessionId = `sess-${this.nextSessionNumber++}`; - this.sendSessionNewResponse(response, request, body, recorder, sessionId); - return true; - } - if ( - request.method === "POST" && - request.scope === "session" && - request.sessionId && - body?.method === "session/prompt" - ) { - this.sendPromptFlow(response, request, body, recorder, request.sessionId); - return true; - } - return false; - } -} - -class WrongStreamResponseScenario extends BaseScenario { - readonly name = "wrong-stream-response"; - private readonly sessionId = "sess-wrong"; - - protected handleScenarioRequest( - response: ServerResponse, - request: RecordedRequest, - body: JsonRpcMessage | null, - recorder: TranscriptRecorder, - ): boolean { - if (request.method === "POST" && request.scope === "connection" && body?.method === "session/new") { - this.sendSessionNewResponse(response, request, body, recorder, this.sessionId); - return true; - } - if ( - request.method === "POST" && - request.scope === "session" && - request.sessionId === this.sessionId && - body?.method === "session/prompt" - ) { - sendJson(response, 202); - this.recordScenarioResponse(recorder, 202, request); - this.writeConnectionEvent( - { - jsonrpc: "2.0", - id: body.id, - result: { - stopReason: "end_turn", - }, - }, - recorder, - ); - return true; - } - return false; - } - - private recordScenarioResponse(recorder: TranscriptRecorder, status: number, request: RecordedRequest): void { - recorder.record({ - kind: "http_response", - status, - scope: request.scope, - connectionId: this.connectionId, - sessionId: request.sessionId, - }); - } -} - -class ValidationFailuresScenario extends BaseScenario { - readonly name = "validation-failures"; - - protected handleScenarioRequest( - _response: ServerResponse, - _request: RecordedRequest, - _body: JsonRpcMessage | null, - _recorder: TranscriptRecorder, - ): boolean { - return false; - } -} - -type RequestScope = "bootstrap" | "connection" | "session"; - -type RecordedRequest = { - method: string; - scope: RequestScope; - connectionId: string | null; - sessionId: string | null; - cookie: string | null; - accept: string | null; - contentType: string | null; -}; - -function header(headers: IncomingHttpHeaders, name: string): string | null { - const value = headers[name]; - if (Array.isArray(value)) { - return value[0] ?? null; - } - return typeof value === "string" ? value : null; -} diff --git a/test-fixtures/streamable-http-server/src/server.ts b/test-fixtures/streamable-http-server/src/server.ts deleted file mode 100644 index 57d9279..0000000 --- a/test-fixtures/streamable-http-server/src/server.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { createServer, IncomingMessage } from "node:http"; -import { ACP_PATH, JsonRpcMessage } from "./protocol.js"; -import { createScenario } from "./scenarios.js"; -import { TranscriptRecorder } from "./transcript.js"; - -const scenarioName = readArg("--scenario") ?? "happy-path"; -const port = Number(readArg("--port") ?? "0"); -const recorder = new TranscriptRecorder(); -const scenario = createScenario(scenarioName); - -const server = createServer(); - -server.on("request", async (request, response) => { - const path = request.url ?? ""; - if (path === "/__test/transcript") { - response.writeHead(200, { - "content-type": "application/json", - }); - response.end(recorder.serialize()); - return; - } - - if (path !== ACP_PATH) { - response.writeHead(404); - response.end(); - return; - } - - const body = await readJsonBody(request); - scenario.handle(response, request.headers, request.method ?? "", body, recorder); -}); - -server.listen(port, () => { - const address = server.address(); - const actualPort = typeof address === "object" && address ? address.port : port; - process.stdout.write(JSON.stringify({ status: "ready", port: actualPort, scenario: scenario.name }) + "\n"); -}); - -function readArg(name: string): string | undefined { - const index = process.argv.indexOf(name); - return index >= 0 ? process.argv[index + 1] : undefined; -} - -async function readJsonBody(request: IncomingMessage): Promise { - const method = request.method ?? ""; - if (method === "GET" || method === "DELETE") { - return null; - } - - let raw = ""; - for await (const chunk of request) { - raw += chunk.toString("utf8"); - } - return raw ? (JSON.parse(raw) as JsonRpcMessage) : null; -} diff --git a/test-fixtures/streamable-http-server/src/transcript.ts b/test-fixtures/streamable-http-server/src/transcript.ts deleted file mode 100644 index 19f3440..0000000 --- a/test-fixtures/streamable-http-server/src/transcript.ts +++ /dev/null @@ -1,101 +0,0 @@ -export type JsonRpcSummary = - | { - type: "request"; - id: string | number | null; - method: string; - } - | { - type: "notification"; - method: string; - } - | { - type: "response"; - id: string | number | null; - hasError: boolean; - }; - -export type TranscriptEvent = - | { - kind: "http_request"; - method: string; - scope: "bootstrap" | "connection" | "session"; - connectionId: string | null; - sessionId: string | null; - cookie: string | null; - jsonRpc: JsonRpcSummary | null; - } - | { - kind: "http_response"; - status: number; - scope: "bootstrap" | "connection" | "session"; - connectionId: string | null; - sessionId: string | null; - } - | { - kind: "sse_event"; - stream: "connection" | "session"; - sessionId: string | null; - jsonRpc: JsonRpcSummary; - }; - -export class TranscriptRecorder { - private readonly events: TranscriptEvent[] = []; - private readonly idAliases = new Map(); - - record(event: TranscriptEvent): void { - this.events.push(event); - } - - summarizeJsonRpc(message: unknown): JsonRpcSummary | null { - if (!message || typeof message !== "object") { - return null; - } - - const candidate = message as Record; - if (typeof candidate.method === "string" && "id" in candidate) { - return { - type: "request", - id: this.normalizeId(candidate.id), - method: candidate.method, - }; - } - - if (typeof candidate.method === "string") { - return { - type: "notification", - method: candidate.method, - }; - } - - if ("result" in candidate || "error" in candidate) { - return { - type: "response", - id: this.normalizeId(candidate.id), - hasError: candidate.error != null, - }; - } - - return null; - } - - toJSON(): TranscriptEvent[] { - return [...this.events]; - } - - serialize(): string { - return JSON.stringify(this.events, null, 2); - } - - private normalizeId(id: unknown): string | null { - if (typeof id !== "string" && typeof id !== "number") { - return null; - } - const existing = this.idAliases.get(id); - if (existing) { - return existing; - } - const next = `id-${this.idAliases.size + 1}`; - this.idAliases.set(id, next); - return next; - } -} diff --git a/test-fixtures/streamable-http-server/tsconfig.json b/test-fixtures/streamable-http-server/tsconfig.json deleted file mode 100644 index ce68458..0000000 --- a/test-fixtures/streamable-http-server/tsconfig.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2022", - "module": "NodeNext", - "moduleResolution": "NodeNext", - "strict": true, - "outDir": "dist", - "rootDir": "src", - "esModuleInterop": true, - "forceConsistentCasingInFileNames": true - }, - "include": ["src/**/*.ts"] -} From ad1540786a2184b53ec01de5338d1f39785234c3 Mon Sep 17 00:00:00 2001 From: Kaiser Dandangi Date: Sun, 24 May 2026 21:09:25 -0400 Subject: [PATCH 12/15] fix: serialize streamable HTTP sink emissions --- .../sdk/agent/transport/RemoteAcpConnection.java | 16 +++++++++++++--- .../StreamableHttpAcpClientTransport.java | 15 ++++++++++++--- 2 files changed, 25 insertions(+), 6 deletions(-) diff --git a/acp-core/src/main/java/com/agentclientprotocol/sdk/agent/transport/RemoteAcpConnection.java b/acp-core/src/main/java/com/agentclientprotocol/sdk/agent/transport/RemoteAcpConnection.java index d2162dd..79f9cac 100644 --- a/acp-core/src/main/java/com/agentclientprotocol/sdk/agent/transport/RemoteAcpConnection.java +++ b/acp-core/src/main/java/com/agentclientprotocol/sdk/agent/transport/RemoteAcpConnection.java @@ -154,6 +154,14 @@ private final class ConnectionTransport implements AcpAgentTransport { private final Sinks.Many inboundSink = Sinks.many().unicast().onBackpressureBuffer(); + /* + * Streamable HTTP can deliver multiple POST requests for one ACP connection on + * different server threads. Reactor unicast sinks require serialized producers, + * so all transport-adapter ingress is funneled through this monitor before + * emission. + */ + private final Object inboundEmitMonitor = new Object(); + private final Sinks.One terminationSink = Sinks.one(); private final AtomicBoolean transportStarted = new AtomicBoolean(false); @@ -190,9 +198,11 @@ void acceptInbound(JSONRPCMessage message) { if (transportClosing.get()) { throw new AcpConnectionException("Remote ACP connection is closing"); } - Sinks.EmitResult result = inboundSink.tryEmitNext(message); - if (result.isFailure()) { - throw new AcpConnectionException("Failed to enqueue inbound message: " + result); + synchronized (inboundEmitMonitor) { + Sinks.EmitResult result = inboundSink.tryEmitNext(message); + if (result.isFailure()) { + throw new AcpConnectionException("Failed to enqueue inbound message: " + result); + } } } diff --git a/acp-core/src/main/java/com/agentclientprotocol/sdk/client/transport/StreamableHttpAcpClientTransport.java b/acp-core/src/main/java/com/agentclientprotocol/sdk/client/transport/StreamableHttpAcpClientTransport.java index 7913a87..8f5a9db 100644 --- a/acp-core/src/main/java/com/agentclientprotocol/sdk/client/transport/StreamableHttpAcpClientTransport.java +++ b/acp-core/src/main/java/com/agentclientprotocol/sdk/client/transport/StreamableHttpAcpClientTransport.java @@ -146,6 +146,13 @@ private record HttpClientBundle(HttpClient httpClient, ExecutorService ownedExec private final Sinks.Many inboundSink; + /* + * A streamable HTTP client may have one connection SSE reader and multiple session + * SSE readers active at the same time. Reactor unicast sinks require serialized + * producers, so every SSE reader emits through this monitor. + */ + private final Object inboundEmitMonitor = new Object(); + private final AtomicBoolean connected = new AtomicBoolean(false); private final AtomicBoolean initialized = new AtomicBoolean(false); @@ -581,9 +588,11 @@ private Mono processInbound(RouteScope actualScope, JSONRPCMessage message private Mono emitInbound(JSONRPCMessage message) { return Mono.fromRunnable(() -> { - Sinks.EmitResult result = inboundSink.tryEmitNext(message); - if (result.isFailure()) { - throw new AcpConnectionException("Failed to enqueue inbound message: " + result); + synchronized (inboundEmitMonitor) { + Sinks.EmitResult result = inboundSink.tryEmitNext(message); + if (result.isFailure()) { + throw new AcpConnectionException("Failed to enqueue inbound message: " + result); + } } }); } From da691f340984d5ee2a6f89072c2c1be38e1db37b Mon Sep 17 00:00:00 2001 From: Kaiser Dandangi Date: Sun, 24 May 2026 22:43:08 -0400 Subject: [PATCH 13/15] chore: remove streamable HTTP fixtures and plans --- README.md | 11 - plans/DOCS-ROADMAP.md | 329 ----------------- plans/STREAMABLE-HTTP-AGENT-TRANSPORT.md | 136 ------- plans/STREAMABLE-HTTP-TRANSPORT.md | 176 --------- pom.xml | 1 - .../streamable-http-agent-server/README.md | 43 --- .../streamable-http-agent-server/pom.xml | 81 ----- .../StreamableHttpAgentDemoServer.java | 342 ------------------ .../src/main/resources/logback.xml | 10 - 9 files changed, 1129 deletions(-) delete mode 100644 plans/DOCS-ROADMAP.md delete mode 100644 plans/STREAMABLE-HTTP-AGENT-TRANSPORT.md delete mode 100644 plans/STREAMABLE-HTTP-TRANSPORT.md delete mode 100644 test-fixtures/streamable-http-agent-server/README.md delete mode 100644 test-fixtures/streamable-http-agent-server/pom.xml delete mode 100644 test-fixtures/streamable-http-agent-server/src/main/java/com/agentclientprotocol/sdk/fixtures/StreamableHttpAgentDemoServer.java delete mode 100644 test-fixtures/streamable-http-agent-server/src/main/resources/logback.xml diff --git a/README.md b/README.md index 0776ded..0d75e92 100644 --- a/README.md +++ b/README.md @@ -392,17 +392,6 @@ agent.start().block(); // Starts WebSocket server on port 8080 | WebSocket | `WebSocketAcpClientTransport` | `WebSocketAcpAgentTransport` | acp-core / acp-websocket-jetty | | Streamable HTTP | `StreamableHttpAcpClientTransport` | `StreamableHttpAcpAgentTransport` | acp-core / acp-streamable-http-jetty | -### Streamable HTTP Demo Server - -Build and run a local demo ACP agent over HTTP/SSE: - -```bash -./mvnw -q -pl test-fixtures/streamable-http-agent-server -am -DskipTests package -java -jar test-fixtures/streamable-http-agent-server/target/acp-streamable-http-agent-server.jar --port 8080 -``` - -The endpoint will be available at `http://127.0.0.1:8080/acp`. - --- ## Building diff --git a/plans/DOCS-ROADMAP.md b/plans/DOCS-ROADMAP.md deleted file mode 100644 index dc26c67..0000000 --- a/plans/DOCS-ROADMAP.md +++ /dev/null @@ -1,329 +0,0 @@ -# Roadmap: ACP Java SDK 0.9.0 Documentation - -> **Created**: 2026-02-10 -> **Design version**: 0.9.0 - -## Overview - -Documentation ships in three stages, ordered by launch criticality. Stage 1 creates the Mintlify documentation site (blocks the 0.9.0 blog post). Stage 2 updates tutorial READMEs and SDK metadata for the release. Stage 3 completes remaining pages post-launch. All documentation follows the code-first workflow: verify tutorial code compiles, then write docs based on working code. - -## Stage 1: Mintlify Site (Launch-Critical) - -### Step 1.1: Navigation and Scaffolding - -**Entry criteria**: -- [ ] Read: Claude Agent SDK Mintlify structure (`~/community/mintlify-docs/claude-agent-sdk/`) -- [ ] Read: `~/community/mintlify-docs/mint.json` - -**Work items**: -- [ ] UPDATE `~/community/mintlify-docs/mint.json` with ACP Java SDK section under Incubating Projects -- [ ] CREATE directory: `~/community/mintlify-docs/acp-java-sdk/` -- [ ] CREATE directory: `~/community/mintlify-docs/acp-java-sdk/reference/` -- [ ] CREATE directory: `~/community/mintlify-docs/acp-java-sdk/tutorial/` - -**Exit criteria**: -- [ ] mint.json validates and includes ACP Java SDK navigation -- [ ] Directory structure created -- [ ] Update `ROADMAP.md` checkboxes - -**Deliverables**: Site navigation and directory scaffolding - ---- - -### Step 1.2: Index Page - -**Entry criteria**: -- [ ] Step 1.1 complete -- [ ] Read: `~/community/mintlify-docs/claude-agent-sdk/index.md` (template) -- [ ] Read: `~/acp/acp-java/README.md` (source material) - -**Work items**: -- [ ] CREATE `~/community/mintlify-docs/acp-java-sdk/index.md` (~120 lines) -- [ ] Content: Overview, three-API-styles table, quick start (client + annotation-based agent), CardGroup to Reference + Tutorial, resource links - -**Exit criteria**: -- [ ] Index page renders in dev preview -- [ ] Code examples match SDK README -- [ ] Update `ROADMAP.md` checkboxes - -**Deliverables**: `acp-java-sdk/index.md` - ---- - -### Step 1.3: Tutorial Index Page - -**Entry criteria**: -- [ ] Step 1.1 complete -- [ ] Read: `~/community/mintlify-docs/claude-agent-sdk/tutorial/index.md` (template) - -**Work items**: -- [ ] CREATE `~/community/mintlify-docs/acp-java-sdk/tutorial/index.md` (~60 lines) -- [ ] Content: Overview, prerequisites, 3-part structure table, getting the code - -**Exit criteria**: -- [ ] Tutorial index renders and links resolve -- [ ] Update `ROADMAP.md` checkboxes - -**Deliverables**: `acp-java-sdk/tutorial/index.md` - ---- - -### Step 1.4: Priority Tutorial Pages (10 Pages) - -**Entry criteria**: -- [ ] Step 1.3 complete -- [ ] Read: `~/community/mintlify-docs/claude-agent-sdk/tutorial/01-hello-world.md` (template) -- [ ] VERIFY: `cd ~/projects/acp-java-tutorial && ./mvnw compile -pl module-01-first-contact,module-05-streaming-updates,module-12-echo-agent,module-13-agent-handlers,module-14-sending-updates,module-15-agent-requests,module-16-in-memory-testing,module-28-zed-integration,module-29-jetbrains-integration,module-30-vscode-integration -q` - -**Work items**: -- [ ] CREATE `tutorial/01-first-contact.md` — ACP client basics (module-01) -- [ ] CREATE `tutorial/05-streaming-updates.md` — Receiving real-time updates (module-05) -- [ ] CREATE `tutorial/12-echo-agent.md` — Building your first agent (module-12) -- [ ] CREATE `tutorial/13-agent-handlers.md` — All handler types (module-13) -- [ ] CREATE `tutorial/14-sending-updates.md` — Agent-side streaming (module-14) -- [ ] CREATE `tutorial/15-agent-requests.md` — File and permission requests (module-15) -- [ ] CREATE `tutorial/16-in-memory-testing.md` — Testing without subprocesses (module-16) -- [ ] CREATE `tutorial/28-zed-integration.md` — Running agents in Zed (module-28) -- [ ] CREATE `tutorial/29-jetbrains-integration.md` — Running agents in JetBrains (module-29) -- [ ] CREATE `tutorial/30-vscode-integration.md` — Running agents in VS Code (module-30) - -Each page follows template structure: -- What You'll Learn -- The Code (with explanation) -- Source Code GitHub link -- Run Command -- Next Module - -**Exit criteria**: -- [ ] All 10 pages render in dev preview -- [ ] Code examples match actual tutorial source -- [ ] All cross-links resolve -- [ ] Update `ROADMAP.md` checkboxes - -**Deliverables**: 10 tutorial pages in `acp-java-sdk/tutorial/` - ---- - -### Step 1.5: API Reference Page - -**Entry criteria**: -- [ ] Step 1.1 complete -- [ ] Read: `~/community/mintlify-docs/claude-agent-sdk/reference/java.md` (template) -- [ ] Read: `~/acp/acp-java/README.md` (source material) -- [ ] Read: `~/acp/acp-java/acp-agent-support/README.md` (source material) - -**Work items**: -- [ ] CREATE `~/community/mintlify-docs/acp-java-sdk/reference/java.md` (~500 lines) -- [ ] Sections: Installation, Three-API comparison, Client API, Agent API (annotation/sync/async), Protocol types, Capabilities, Transports, Errors, Test utilities - -**Exit criteria**: -- [ ] Reference page renders in dev preview -- [ ] All code examples verified against SDK -- [ ] Update `ROADMAP.md` checkboxes - -**Deliverables**: `acp-java-sdk/reference/java.md` - ---- - -### Step 1.6: Stage 1 Review - -**Entry criteria**: -- [ ] Steps 1.1-1.5 complete - -**Work items**: -- [ ] RUN `~/community/mintlify-docs/dev-preview.sh` to verify all pages render -- [ ] VERIFY all cross-links work (index → tutorial → reference) -- [ ] VERIFY code examples match actual tutorial source -- [ ] CHECK for forbidden marketing language -- [ ] VERIFY no internal implementation details exposed - -**Exit criteria**: -- [ ] All pages render without errors -- [ ] Zero forbidden-language violations -- [ ] All code examples match working tutorial code -- [ ] Update `ROADMAP.md` checkboxes - ---- - -## Stage 2: Tutorial READMEs + SDK Updates - -### Step 2.1: Lightweight Module READMEs (10 Priority Modules) - -**Entry criteria**: -- [ ] Stage 1 complete -- [ ] Read: `~/community/claude-agent-sdk-java-tutorial/module-01-hello-world/README.md` (template) - -**Work items**: -- [ ] CREATE README.md for module-01-first-contact (5-6 lines) -- [ ] CREATE README.md for module-05-streaming-updates -- [ ] CREATE README.md for module-12-echo-agent -- [ ] CREATE README.md for module-13-agent-handlers -- [ ] CREATE README.md for module-14-sending-updates -- [ ] CREATE README.md for module-15-agent-requests -- [ ] CREATE README.md for module-16-in-memory-testing -- [ ] UPDATE README.md for module-28 — add Mintlify link at top -- [ ] UPDATE README.md for module-29 — add Mintlify link at top -- [ ] UPDATE README.md for module-30 — add Mintlify link at top - -**Exit criteria**: -- [ ] All 10 modules have README.md files -- [ ] Mintlify links point to correct pages -- [ ] Update `ROADMAP.md` checkboxes - -**Deliverables**: 7 new READMEs, 3 updated READMEs - ---- - -### Step 2.2: Fix Tutorial README - -**Entry criteria**: -- [ ] Step 2.1 complete - -**Work items**: -- [ ] UPDATE `~/projects/acp-java-tutorial/README.md` -- [ ] MOVE modules 03, 04, 06, 09, 11 from "Coming Soon" to active (they have source code) -- [ ] ADD Mintlify docs link at top -- [ ] REORGANIZE into 3-part structure: Client → Agent → IDE Integration - -**Exit criteria**: -- [ ] No modules with source code listed as "Coming Soon" -- [ ] Mintlify link works -- [ ] Update `ROADMAP.md` checkboxes - -**Deliverables**: Updated tutorial README - ---- - -### Step 2.3: SDK README Updates - -**Entry criteria**: -- [ ] Step 2.2 complete - -**Work items**: -- [ ] UPDATE `~/acp/acp-java/README.md` -- [ ] ADD Mintlify docs link at top of Overview -- [ ] UPDATE Installation: change `0.9.0-SNAPSHOT` to `0.9.0`, remove snapshots repository XML - -**Exit criteria**: -- [ ] Version references updated -- [ ] Mintlify link present -- [ ] Update `ROADMAP.md` checkboxes - -**Deliverables**: Updated SDK README - ---- - -### Step 2.4: CHANGELOG for 0.9.0 - -**Entry criteria**: -- [ ] Step 2.3 complete - -**Work items**: -- [ ] UPDATE `~/acp/acp-java/CHANGELOG.md` -- [ ] REPLACE "[Unreleased]" with "[0.9.0] - 2026-02-XX" -- [ ] EXPAND with full feature list from SDK development - -**Exit criteria**: -- [ ] CHANGELOG reflects 0.9.0 release -- [ ] All major features listed -- [ ] Update `ROADMAP.md` checkboxes - -**Deliverables**: Updated CHANGELOG - ---- - -### Step 2.5: Stage 2 Review - -**Entry criteria**: -- [ ] Steps 2.1-2.4 complete - -**Work items**: -- [ ] VERIFY GitHub rendering of all module READMEs -- [ ] CLICK all cross-links (SDK README → tutorial modules → Mintlify) -- [ ] CONFIRM `./mvnw compile` passes for tutorial project - -**Exit criteria**: -- [ ] All links resolve -- [ ] Tutorial compiles -- [ ] Update `ROADMAP.md` checkboxes - ---- - -## Stage 3: Post-Launch Completion (Not Blocking Release) - -### Step 3.1: Remaining Mintlify Tutorial Pages (14 Pages) - -**Work items**: -- [ ] CREATE pages for modules: 02, 03, 04, 06, 07, 08, 09, 10, 11, 17, 18, 19, 21, 22 -- [ ] UPDATE mint.json navigation with expanded tutorial groups - ---- - -### Step 3.2: Remaining Tutorial Module READMEs (14 Modules) - -**Work items**: -- [ ] CREATE READMEs for all remaining modules with source code - ---- - -### Step 3.3: SDK Module READMEs - -**Work items**: -- [ ] CREATE lightweight READMEs for: acp-core, acp-annotations, acp-test, acp-websocket-jetty - ---- - -### Step 3.4: Enhancements - -**Work items**: -- [ ] ADD architecture diagram to Mintlify index -- [ ] ADD Gradle installation instructions to reference page - ---- - -## Execution Order (Stage 1 Priority) - -1. mint.json + directory scaffolding (unblocks everything) -2. Index page + tutorial index (site structure) -3. Tutorial pages: 12 (echo agent), 28 (Zed), 01 (first contact) — highest impact first -4. API reference page (largest single item) -5. Remaining 7 tutorial pages -6. Stage 1 review - -## Verification - -- `~/community/mintlify-docs/dev-preview.sh` — all pages render -- Every code example matches actual tutorial source -- All cross-links: SDK README → tutorial modules → Mintlify -- `./mvnw compile` passes for tutorial project - -## Writing Agents - -| Agent | Role | -|-------|------| -| `~/.claude/agents/technical-writer.md` | Primary — writes Mintlify pages and READMEs | -| `~/.claude/agents/doc-reviewer.md` | Review — validates against style guide | -| `~/.claude/agents/tutorial-code-sync.md` | Sync — ensures code examples match tutorial source | - -## Style Principles - -- Direct, plain-spoken, unadorned -- Assume reader competence -- Structure: context, mechanism, consequence -- Forbidden: exciting, game-changing, best-in-class, seamlessly, powerful, intuitive, revolutionary, cutting-edge -- Short paragraphs (3-4 sentences max), tables for comparisons, code blocks liberally -- Accuracy over aesthetics - -## Conventions - -### Commit Convention - -``` -Step X.Y: Brief description of what was done -``` - -### Code-First Workflow - -1. Verify tutorial code compiles: `./mvnw compile -pl module-XX-* -q` -2. THEN write docs based on working code -3. Code in docs must match working tutorial code exactly diff --git a/plans/STREAMABLE-HTTP-AGENT-TRANSPORT.md b/plans/STREAMABLE-HTTP-AGENT-TRANSPORT.md deleted file mode 100644 index 26410fb..0000000 --- a/plans/STREAMABLE-HTTP-AGENT-TRANSPORT.md +++ /dev/null @@ -1,136 +0,0 @@ -# Plan: Streamable HTTP Agent Transport - -> **Status**: Milestone one implemented -> **Created**: 2026-05-18 -> **Primary goal**: Java agents can be served from a running Java web server over the Streamable HTTP transport, including WebSocket upgrades on the same ACP endpoint. - -## Goal - -Add an agent-side Streamable HTTP transport backed by Jetty, with: - -- one fresh ACP agent runtime per accepted remote connection -- a public `AcpAgentFactory` seam for listener-backed transports -- RFD-oriented HTTP + SSE behavior -- WebSocket upgrade handling on the same ACP path -- strict and compatible routing modes -- a fixture-driven conformance harness that exercises a real running Java listener - -## Public Shape - -- Add `AcpAgentFactory` in `acp-core`. -- Make the async seam explicit: - - `AcpAgentFactory.async(...)` - - `AcpAgentFactory.sync(...)` -- Add `StreamableHttpAcpAgentTransport` in a dedicated Jetty adapter module: - - `acp-streamable-http-jetty` -- Keep the current legacy WebSocket single-agent API intact in `acp-websocket-jetty`. -- Serve the RFD-compatible remote WebSocket upgrade path from `StreamableHttpAcpAgentTransport`, matching the Rust SDK shape where one HTTP server owns POST/SSE and WebSocket upgrades. - -## Runtime Model - -```text -StreamableHttpAcpAgentTransport - accepts remote connection - -> AcpAgentFactory creates fresh agent runtime - -> per-connection AcpAgentTransport drives that runtime - -> one ACP connection contains many logical ACP sessionIds -``` - -- `Acp-Connection-Id` identifies one remote peer relationship. -- `Acp-Session-Id` identifies one logical ACP session inside that connection. -- The transport owns routing; the agent owns protocol meaning. - -## Routing / Lifecycle Decisions - -- `initialize` - - creates a provisional connection - - starts a fresh agent runtime - - returns `200 OK` with JSON-RPC response + `Acp-Connection-Id` - - publishes the connection only after successful initialize -- non-initialize POSTs require `Acp-Connection-Id` -- connection-scoped SSE streams carry: - - initialize follow-up traffic - - responses to `session/new` - - responses to `session/load` -- session-scoped SSE streams carry: - - responses to ordinary session-scoped requests - - session updates - - agent-originated session-scoped requests such as permission prompts -- DELETE tears down the connection and releases transport state. -- WebSocket upgrades: - - create one connection during the upgrade handshake - - return `Acp-Connection-Id` on the `101 Switching Protocols` response - - require the first client-originated JSON-RPC message on the socket to be `initialize` - - exchange JSON-RPC messages as text frames until the socket closes - -### Routing ledgers - -- client request id -> expected outbound response scope -- agent request id -> expected client response scope - -Wrong-stream client replies are protocol errors. Unknown response ids preserve current SDK parity and are allowed through for the session layer to decide. - -### Strict vs compatible - -- `STRICT` - - rejects unknown methods without explicit routing - - rejects unknown session stream opens with `404` -- `COMPATIBLE` - - falls back to `params.sessionId` inference for unknown methods - - permits provisional session streams before `session/load` - -## Known RFD Gap - -The RFD says unknown session-scoped GETs should return `404`, but the resume flow also asks clients to open the session SSE stream before sending `session/load`. This transport keeps that tension explicit: - -- strict mode preserves the literal 404 rule -- compatible mode creates a provisional `PENDING_LOAD` session stream - -PLAN: revisit this once the protocol resolves the resume/session-load ordering contract more precisely. - -## Test Harness - -Use Java integration tests only for this branch so the PR stays focused on SDK -transport behavior instead of adding a separate fixture harness. - -Covered scenarios: - -- happy path over Streamable HTTP POST + SSE -- permission round-trip -- session load / provisional pre-open -- two logical sessions -- wrong-stream response rejection -- validation failures -- strict unknown-session behavior -- WebSocket upgrade behavior on the same Java listener - -## Demo Server - -Add a runnable Java demo server at: - -```text -test-fixtures/streamable-http-agent-server/ -``` - -It packages a small echo-style ACP agent into a runnable jar backed by the real -Jetty `StreamableHttpAcpAgentTransport`, so manual testing can exercise a live -HTTP/SSE endpoint and WebSocket upgrade endpoint instead of only the -integration-test fixture lifecycle. - -## PLAN / Follow-Up Work - -- extract a shared remote-core layer only after HTTP parity is proven. - Here, "remote-core" means the transport-independent runtime machinery that - both remote listener transports need: per-connection agent factory creation, - connection/session registries, lifecycle teardown, request/response routing - ledgers, timeout/error propagation, and observability hooks. The actual wire - adapters should remain transport-specific: legacy WebSocket framing stays in - the WebSocket module, while the RFD Streamable HTTP endpoint owns HTTP - methods, headers, SSE parsing, status codes, and its WebSocket upgrade branch. - Deferring this extraction keeps the first implementation close to the RFD and - avoids prematurely forcing the existing legacy WebSocket behavior through an - abstraction before parity is proven. -- add idle/provisional-session eviction and replay retention policies -- revisit per-logical-session active-prompt tracking in `AcpAgentSession` -- expose richer diagnostics / observability hooks -- decide whether compatible provisional session streams remain necessary after the RFD is clarified diff --git a/plans/STREAMABLE-HTTP-TRANSPORT.md b/plans/STREAMABLE-HTTP-TRANSPORT.md deleted file mode 100644 index 188b3cc..0000000 --- a/plans/STREAMABLE-HTTP-TRANSPORT.md +++ /dev/null @@ -1,176 +0,0 @@ -# Plan: Streamable HTTP Client Transport - -> **Status**: Milestone one implemented -> **Created**: 2026-05-17 -> **Primary goal**: Java clients can communicate with compliant remote ACP agents over the Streamable HTTP transport. - -## Goal - -Add a client-side Streamable HTTP transport to `acp-core` so applications can use the existing Java client API against compliant remote ACP agents without changing their own code. - -This first milestone is intentionally client-only: - -- implement `StreamableHttpAcpClientTransport` -- preserve the existing ACP client API surface -- prove the wire contract with focused Java integration coverage -- defer remote transport negotiation, Java server support, and reconnect/resume behavior - -## Milestone-One Result - -Implemented in this branch: - -- `StreamableHttpAcpClientTransport` -- preserved public ACP client API -- compatibility note + isolated forwarding path for the existing client handler-emission ambiguity -- Java integration coverage against an in-process Streamable HTTP fixture server -- Java unit + integration coverage for: - - initialize bootstrap - - cookie persistence - - connection SSE - - `session/new` - - prompt flow with session updates - - `session/request_permission` - - `session/load` - - wrong-stream responses - - strict-routing rejection - - two logical sessions - - validation failures for missing connection headers and invalid SSE `Accept` - -## Contract Decisions - -### Public API - -- Add `StreamableHttpAcpClientTransport` in `acp-core`. -- Keep construction symmetrical with `WebSocketAcpClientTransport`: - - `new StreamableHttpAcpClientTransport(endpointUri, jsonMapper)` - - `new StreamableHttpAcpClientTransport(endpointUri, jsonMapper, httpClient)` -- Use a transport-owned default `CookieManager`, while allowing advanced callers to inject a custom `HttpClient`. -- Expose a public routing mode: - - `COMPATIBLE` - - `STRICT` - -### Client / Transport Boundary - -- `AcpClientSession` remains transport-agnostic and continues to own ACP request/response semantics. -- Streamable HTTP owns HTTP-only concerns internally: - - `Acp-Connection-Id` - - `Acp-Session-Id` - - SSE stream lifecycle - - routing correlation - - cookie propagation -- Preserve current WebSocket-compatible client handler-emission forwarding for behavioral parity in the first implementation. -- Isolate that compatibility behavior behind a small helper and flag it in code as an unresolved contract ambiguity. - -### Lifecycle - -- `connect(...)` - - registers the inbound handler - - prepares resources - - performs no network I/O by itself -- `initialize` - - is the first real HTTP exchange - - sends `POST /acp` without `Acp-Connection-Id` - - captures `Acp-Connection-Id` and cookies from the `200 OK` - - opens the connection-scoped SSE stream before delivering the initialize response upward -- `session/new` - - sends the POST first - - receives its JSON-RPC response on the connection stream - - opens the returned session’s SSE stream - - completes only after that session stream is established -- `session/load` - - opens the session stream first - - sends the POST second - - receives its response on the connection stream -- Session-scoped outbound messages require an already-open session stream. -- No automatic reconnect / resume behavior in milestone one. -- `closeGracefully()` - 1. stop accepting new outbound work - 2. cancel local SSE readers - 3. send `DELETE /acp` with `Acp-Connection-Id` - 4. clear local routing and stream state - -### Routing - -- Use an explicit routing table for known ACP methods. -- Compatible-mode fallback for unknown outbound requests / notifications: - - `params.sessionId` present → session-scoped - - otherwise → connection-scoped -- Strict mode rejects unknown outbound request / notification methods that lack explicit routing. -- The transport owns a minimal routing ledger: - - `outbound request id -> request kind + expected response scope` - - `inbound request id -> scope required for the later outbound response` - - `session id -> open session SSE stream` -- Wrong-stream responses are protocol errors. -- Unknown response ids retain current Java SDK parity and are left to the session layer’s existing behavior. - -### SSE Model - -- Treat each SSE `data:` payload as one JSON-RPC message. -- Ignore comments / keep-alives. -- Preserve order per SSE stream. -- Do not impose a synthetic global order across different streams. -- Treat SSE as the source of truth for server feedback and request completion, not as a receipt log for every POST envelope. - -## Test Harness - -Use an in-process Java Streamable HTTP fixture server for client transport tests. -This keeps the PR focused on Java SDK transport behavior and avoids adding a -separate conformance harness to the repository. - -### Milestone-One Scenarios - -- initialize bootstrap -- connection SSE stream -- `session/new` -- prompt flow with session updates -- agent → client `session/request_permission` -- `session/load` -- validation failures for wrong / missing headers -- wrong-stream response -- strict-routing rejection -- light two-session coverage - -### Future Harness Scenarios - -- reconnect / resume behavior -- concurrency / stress coverage -- broader interop matrix -- WebSocket scenarios for a future composite remote transport - -## Deferred Work - -- Composite `RemoteAcpClientTransport` - - prefer WebSocket - - fall back to Streamable HTTP -- Java server-side Streamable HTTP transport -- reconnect / resume behavior once the protocol defines it -- richer debugging / observability hooks -- broader interoperability testing against an official compliant server when one exists -- deeper multi-session stress coverage - -## Known Ambiguity / Follow-Up Decision - -### Client handler-emission forwarding - -The existing WebSocket client transport forwards any message emitted by the registered handler back onto the transport. `AcpClientSession` also sends responses explicitly through `sendMessage(...)`, so the client-side contract is currently ambiguous. - -Decision for this milestone: - -- preserve WebSocket-compatible forwarding in the new HTTP transport for parity -- isolate the forwarding path in a small helper -- document the ambiguity in code and in this plan -- have the default `AcpClientSession` consume inbound messages without re-emitting them, - because it already sends legitimate outbound replies explicitly through `sendMessage(...)` - -Follow-up: - -- decide whether `AcpClientTransport` should become explicitly receive-only on the client side -- if so, remove the compatibility forwarding path from both transports in a focused cleanup - -## Non-Goals for the First Milestone - -- WebSocket fallback orchestration -- server-side Java transport -- automatic reconnect / resume -- transport-specific public debugging APIs -- global ordering across streams diff --git a/pom.xml b/pom.xml index 51e7c93..22e5530 100644 --- a/pom.xml +++ b/pom.xml @@ -19,7 +19,6 @@ acp-agent-support acp-test acp-streamable-http-jetty - test-fixtures/streamable-http-agent-server acp-websocket-jetty diff --git a/test-fixtures/streamable-http-agent-server/README.md b/test-fixtures/streamable-http-agent-server/README.md deleted file mode 100644 index 066944c..0000000 --- a/test-fixtures/streamable-http-agent-server/README.md +++ /dev/null @@ -1,43 +0,0 @@ -# Streamable HTTP Agent Demo Server - -This is a small runnable Java ACP agent that serves the new Streamable HTTP -agent transport from a real Jetty web server. - -Build the runnable jar: - -```bash -./mvnw -q -pl test-fixtures/streamable-http-agent-server -am -DskipTests package -``` - -Run it: - -```bash -java -jar test-fixtures/streamable-http-agent-server/target/acp-streamable-http-agent-server.jar --port 8080 -``` - -Then connect any ACP client to either endpoint printed at startup: - -```text -http://127.0.0.1:8080/acp # Streamable HTTP POST + SSE -ws://127.0.0.1:8080/acp # WebSocket upgrade -``` - -The demo supports `initialize`, `session/new`, `session/load`, `session/prompt`, -and `session/cancel`. Prompts containing the word `permission` also exercise the -agent-to-client `session/request_permission` round trip. - -By default, the server uses a deterministic echo backend. To exercise the same -ACP transport with a real OpenAI-backed agent through Spring AI: - -```bash -export OPENAI_API_KEY=... -# Optional; defaults to OPENAI_MODEL or gpt-4o-mini. -export OPENAI_MODEL=gpt-4o-mini - -java -jar test-fixtures/streamable-http-agent-server/target/acp-streamable-http-agent-server.jar \ - --port 8080 \ - --backend spring-ai-openai -``` - -The Spring AI backend is intentionally scoped to this runnable fixture. It is not -part of the core SDK transport implementation. diff --git a/test-fixtures/streamable-http-agent-server/pom.xml b/test-fixtures/streamable-http-agent-server/pom.xml deleted file mode 100644 index f054b91..0000000 --- a/test-fixtures/streamable-http-agent-server/pom.xml +++ /dev/null @@ -1,81 +0,0 @@ - - - 4.0.0 - - - com.agentclientprotocol - acp-java-sdk - 0.12.0-SNAPSHOT - ../../pom.xml - - - acp-streamable-http-agent-server - jar - - ACP Streamable HTTP Agent Server Demo - Runnable demo server for the Streamable HTTP agent transport - - - - true - 1.1.6 - - - - - - org.springframework.ai - spring-ai-bom - ${spring-ai.version} - pom - import - - - - - - - com.agentclientprotocol - acp-streamable-http-jetty - - - org.springframework.ai - spring-ai-openai - - - ch.qos.logback - logback-classic - runtime - - - - - - - org.apache.maven.plugins - maven-shade-plugin - 3.5.3 - - - package - - shade - - - false - acp-streamable-http-agent-server - - - com.agentclientprotocol.sdk.fixtures.StreamableHttpAgentDemoServer - - - - - - - - - - diff --git a/test-fixtures/streamable-http-agent-server/src/main/java/com/agentclientprotocol/sdk/fixtures/StreamableHttpAgentDemoServer.java b/test-fixtures/streamable-http-agent-server/src/main/java/com/agentclientprotocol/sdk/fixtures/StreamableHttpAgentDemoServer.java deleted file mode 100644 index ab01011..0000000 --- a/test-fixtures/streamable-http-agent-server/src/main/java/com/agentclientprotocol/sdk/fixtures/StreamableHttpAgentDemoServer.java +++ /dev/null @@ -1,342 +0,0 @@ -/* - * Copyright 2025-2026 the original author or authors. - */ - -package com.agentclientprotocol.sdk.fixtures; - -import java.time.Duration; -import java.util.List; -import java.util.Locale; -import java.util.Map; -import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; -import java.util.concurrent.atomic.AtomicInteger; - -import com.agentclientprotocol.sdk.agent.AcpAgent; -import com.agentclientprotocol.sdk.agent.AcpAgentFactory; -import com.agentclientprotocol.sdk.agent.transport.StreamableHttpAcpAgentTransport; -import com.agentclientprotocol.sdk.json.AcpJsonMapper; -import com.agentclientprotocol.sdk.spec.AcpSchema; -import org.springframework.ai.chat.messages.SystemMessage; -import org.springframework.ai.chat.messages.UserMessage; -import org.springframework.ai.chat.model.ChatResponse; -import org.springframework.ai.chat.model.Generation; -import org.springframework.ai.chat.prompt.Prompt; -import org.springframework.ai.openai.OpenAiChatModel; -import org.springframework.ai.openai.OpenAiChatOptions; -import org.springframework.ai.openai.api.OpenAiApi; -import reactor.core.publisher.Mono; -import reactor.core.scheduler.Scheduler; -import reactor.core.scheduler.Schedulers; - -/** - * Small runnable ACP agent server for manually exercising the Streamable HTTP transport. - * - * @author Kaiser Dandangi - */ -public final class StreamableHttpAgentDemoServer { - - private static final Duration START_TIMEOUT = Duration.ofSeconds(30); - - private static final Duration STOP_TIMEOUT = Duration.ofSeconds(5); - - private static final String OPENAI_SYSTEM_PROMPT = """ - You are a small ACP demo agent running inside the Java SDK Streamable HTTP fixture. - Answer concisely. If the user asks about implementation details, say that this - fixture is exercising the ACP Streamable HTTP transport, not providing a full - production agent runtime. - """; - - private StreamableHttpAgentDemoServer() { - } - - public static void main(String[] args) { - Options options; - try { - options = Options.parse(args); - } - catch (IllegalArgumentException e) { - System.err.println(e.getMessage()); - printUsage(); - System.exit(2); - return; - } - - if (options.help()) { - printUsage(); - return; - } - - Map sessionCwds = new ConcurrentHashMap<>(); - AtomicInteger sessionCounter = new AtomicInteger(); - PromptBackend promptBackend; - try { - promptBackend = options.backend().create(); - } - catch (IllegalArgumentException e) { - System.err.println(e.getMessage()); - System.exit(2); - return; - } - - AcpAgentFactory agentFactory = AcpAgentFactory.async(transport -> AcpAgent.async(transport) - .requestTimeout(Duration.ofMinutes(2)) - .initializeHandler(request -> Mono.just(AcpSchema.InitializeResponse.ok( - new AcpSchema.AgentCapabilities(true, new AcpSchema.McpCapabilities(), - new AcpSchema.PromptCapabilities())))) - .newSessionHandler(request -> { - String sessionId = "demo-session-" + sessionCounter.incrementAndGet(); - sessionCwds.put(sessionId, request.cwd()); - return Mono.just(new AcpSchema.NewSessionResponse(sessionId, null, null)); - }) - .loadSessionHandler(request -> { - sessionCwds.put(request.sessionId(), request.cwd()); - return Mono.just(new AcpSchema.LoadSessionResponse(null, null)); - }) - .promptHandler((request, context) -> { - String text = request.text(); - String normalized = text == null || text.isBlank() ? "(empty prompt)" : text; - String cwd = sessionCwds.getOrDefault(request.sessionId(), "(unknown cwd)"); - Mono response = normalized.toLowerCase(Locale.ROOT).contains("permission") - ? context.askPermission("Demo agent permission check for session " + request.sessionId()) - .flatMap(allowed -> context.sendMessage( - "Permission " + (allowed ? "granted" : "denied") + ". Prompt: " + normalized)) - .onErrorResume(error -> context.sendMessage( - "Permission request failed in demo server: " + error.getMessage())) - : promptBackend.generate(normalized, request.sessionId(), cwd).flatMap(context::sendMessage); - return response.thenReturn(AcpSchema.PromptResponse.endTurn()); - }) - .cancelHandler(notification -> { - System.out.println("Received cancel for session " + notification.sessionId()); - return Mono.empty(); - }) - .build()); - - StreamableHttpAcpAgentTransport server = new StreamableHttpAcpAgentTransport(options.port(), options.path(), - AcpJsonMapper.createDefault(), agentFactory) - .routingMode(options.routingMode()); - - Runtime.getRuntime().addShutdownHook(new Thread(() -> { - try { - server.closeGracefully().block(STOP_TIMEOUT); - } - finally { - promptBackend.close(); - } - }, "acp-demo-shutdown")); - - server.start().block(START_TIMEOUT); - System.out.println("ACP Streamable HTTP demo agent listening at http://127.0.0.1:" + server.getPort() - + options.path()); - System.out.println("ACP WebSocket upgrade endpoint available at ws://127.0.0.1:" + server.getPort() - + options.path()); - System.out.println("Press Ctrl-C to stop."); - server.awaitTermination().block(); - } - - private static void printUsage() { - System.out.println(""" - Usage: java -jar acp-streamable-http-agent-server.jar [options] - - Options: - --port Port to listen on. Defaults to 8080. - --path ACP endpoint path. Defaults to /acp. - --backend Agent backend: echo or spring-ai-openai. Defaults to echo. - --openai-model OpenAI model for spring-ai-openai. Defaults to OPENAI_MODEL or gpt-4o-mini. - --strict Use strict transport routing. - --compatible Use compatible transport routing. This is the default. - -h, --help Show this help. - - Environment: - OPENAI_API_KEY Required when --backend spring-ai-openai is used. - OPENAI_MODEL Optional default model for --backend spring-ai-openai. - """); - } - - private record Options(int port, String path, StreamableHttpAcpAgentTransport.RoutingMode routingMode, - Backend backend, boolean help) { - - static Options parse(String[] args) { - int port = 8080; - String path = StreamableHttpAcpAgentTransport.DEFAULT_ACP_PATH; - StreamableHttpAcpAgentTransport.RoutingMode routingMode = - StreamableHttpAcpAgentTransport.RoutingMode.COMPATIBLE; - String backendName = "echo"; - String openAiModel = null; - boolean help = false; - - for (int i = 0; i < args.length; i++) { - String arg = args[i]; - switch (arg) { - case "--port" -> port = parsePort(requireValue(args, ++i, "--port")); - case "--path" -> path = requireValue(args, ++i, "--path"); - case "--backend" -> backendName = requireValue(args, ++i, "--backend"); - case "--openai-model" -> openAiModel = requireValue(args, ++i, "--openai-model"); - case "--strict" -> routingMode = StreamableHttpAcpAgentTransport.RoutingMode.STRICT; - case "--compatible" -> routingMode = StreamableHttpAcpAgentTransport.RoutingMode.COMPATIBLE; - case "-h", "--help" -> help = true; - default -> throw new IllegalArgumentException("Unknown option: " + arg); - } - } - - if (!path.startsWith("/")) { - throw new IllegalArgumentException("--path must start with /"); - } - Backend backend = Backend.parse(backendName, openAiModel); - return new Options(port, path, routingMode, backend, help); - } - - private static String requireValue(String[] args, int index, String option) { - if (index >= args.length || args[index].startsWith("--")) { - throw new IllegalArgumentException(option + " requires a value"); - } - return args[index]; - } - - private static int parsePort(String value) { - try { - int port = Integer.parseInt(value); - if (port <= 0) { - throw new IllegalArgumentException("--port must be positive"); - } - return port; - } - catch (NumberFormatException e) { - throw new IllegalArgumentException("--port must be a number", e); - } - } - - } - - private sealed interface Backend permits EchoBackend, SpringAiOpenAiBackend { - - PromptBackend create(); - - static Backend echo() { - return new EchoBackend(); - } - - static Backend springAiOpenAi(String model) { - return new SpringAiOpenAiBackend(model); - } - - static Backend parse(String value, String openAiModel) { - return switch (value) { - case "echo" -> echo(); - case "spring-ai-openai" -> springAiOpenAi(openAiModel); - default -> throw new IllegalArgumentException("Unknown backend: " + value); - }; - } - - } - - @FunctionalInterface - private interface PromptBackend { - - Mono generate(String prompt, String sessionId, String cwd); - - default void close() { - } - - } - - private record EchoBackend() implements Backend { - - @Override - public PromptBackend create() { - return new EchoPromptBackend(); - } - - } - - private static final class EchoPromptBackend implements PromptBackend { - - @Override - public Mono generate(String prompt, String sessionId, String cwd) { - return Mono.just("Demo agent received: " + prompt + " [cwd=" + cwd + "]"); - } - - } - - private record SpringAiOpenAiBackend(String model) implements Backend { - - @Override - public PromptBackend create() { - String apiKey = System.getenv("OPENAI_API_KEY"); - if (apiKey == null || apiKey.isBlank()) { - throw new IllegalArgumentException( - "OPENAI_API_KEY is required when --backend spring-ai-openai is used"); - } - - OpenAiApi openAiApi = OpenAiApi.builder().apiKey(apiKey).build(); - OpenAiChatOptions chatOptions = OpenAiChatOptions.builder() - .model(resolveOpenAiModel(this.model)) - .temperature(0.2) - .maxTokens(800) - .build(); - OpenAiChatModel chatModel = OpenAiChatModel.builder() - .openAiApi(openAiApi) - .defaultOptions(chatOptions) - .build(); - - return new SpringAiOpenAiPromptBackend(chatModel); - } - - } - - private static final class SpringAiOpenAiPromptBackend implements PromptBackend { - - private final OpenAiChatModel chatModel; - - private final ExecutorService executorService; - - private final Scheduler scheduler; - - private SpringAiOpenAiPromptBackend(OpenAiChatModel chatModel) { - this.chatModel = chatModel; - AtomicInteger threadCounter = new AtomicInteger(); - this.executorService = Executors.newCachedThreadPool(task -> { - Thread thread = new Thread(task, "acp-demo-openai-" + threadCounter.incrementAndGet()); - thread.setDaemon(true); - return thread; - }); - this.scheduler = Schedulers.fromExecutorService(this.executorService, "acp-demo-openai"); - } - - @Override - public Mono generate(String prompt, String sessionId, String cwd) { - return Mono.fromCallable(() -> generatePrompt(prompt, sessionId, cwd)).subscribeOn(this.scheduler); - } - - @Override - public void close() { - this.scheduler.dispose(); - this.executorService.shutdownNow(); - } - - private String generatePrompt(String prompt, String sessionId, String cwd) { - ChatResponse response = chatModel.call(new Prompt(List.of(new SystemMessage(OPENAI_SYSTEM_PROMPT), - new UserMessage("Session: " + sessionId + "\nCWD: " + cwd + "\n\nUser prompt:\n" + prompt)))); - Generation generation = response.getResult(); - if (generation == null || generation.getOutput() == null || generation.getOutput().getText() == null - || generation.getOutput().getText().isBlank()) { - return "(OpenAI returned an empty response)"; - } - return generation.getOutput().getText(); - } - - } - - private static String resolveOpenAiModel(String model) { - if (model != null && !model.isBlank()) { - return model; - } - String envModel = System.getenv("OPENAI_MODEL"); - if (envModel != null && !envModel.isBlank()) { - return envModel; - } - return "gpt-4o-mini"; - } - -} diff --git a/test-fixtures/streamable-http-agent-server/src/main/resources/logback.xml b/test-fixtures/streamable-http-agent-server/src/main/resources/logback.xml deleted file mode 100644 index 2bcf49b..0000000 --- a/test-fixtures/streamable-http-agent-server/src/main/resources/logback.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - - %d{HH:mm:ss.SSS} %-5level %logger{36} - %msg%n - - - - - - From e6b528f8adc4a9e25b8cd0c822dcbd5b5e1a9d47 Mon Sep 17 00:00:00 2001 From: Kaiser Dandangi Date: Sun, 24 May 2026 22:54:55 -0400 Subject: [PATCH 14/15] fix: serialize streamable websocket writes --- .../StreamableHttpAcpAgentTransport.java | 83 ++++++++++++++++--- ...gentTransportWebSocketIntegrationTest.java | 55 ++++++++++++ 2 files changed, 126 insertions(+), 12 deletions(-) diff --git a/acp-streamable-http-jetty/src/main/java/com/agentclientprotocol/sdk/agent/transport/StreamableHttpAcpAgentTransport.java b/acp-streamable-http-jetty/src/main/java/com/agentclientprotocol/sdk/agent/transport/StreamableHttpAcpAgentTransport.java index 56ef14e..fed1a2d 100644 --- a/acp-streamable-http-jetty/src/main/java/com/agentclientprotocol/sdk/agent/transport/StreamableHttpAcpAgentTransport.java +++ b/acp-streamable-http-jetty/src/main/java/com/agentclientprotocol/sdk/agent/transport/StreamableHttpAcpAgentTransport.java @@ -935,6 +935,12 @@ private final class WebSocketConnectionState { private final AtomicBoolean closed = new AtomicBoolean(false); + private final Object outboundLock = new Object(); + + private final ArrayDeque outboundQueue = new ArrayDeque<>(); + + private boolean outboundSendInProgress = false; + private volatile Session session; WebSocketConnectionState(String id) { @@ -970,20 +976,9 @@ void acceptFromClient(JSONRPCMessage message) { void sendToClient(JSONRPCMessage message) { try { - Session currentSession = this.session; - if (closed.get() || currentSession == null || !currentSession.isOpen()) { - throw new AcpConnectionException("Streamable ACP WebSocket connection is closed"); - } String payload = jsonMapper.writeValueAsString(message); logger.debug("Sending streamable ACP WebSocket message: {}", payload); - currentSession.sendText(payload, Callback.from(() -> { - // Jetty requires an explicit success callback; there is no - // follow-up work after the frame has been accepted for writing. - }, error -> { - if (!closed.get()) { - remoteConnection.signalException(error); - } - })); + enqueueOutbound(payload); } catch (Exception e) { remoteConnection.signalException(e); @@ -991,6 +986,66 @@ void sendToClient(JSONRPCMessage message) { } } + private void enqueueOutbound(String payload) { + boolean shouldDrain; + synchronized (outboundLock) { + if (closed.get()) { + throw new AcpConnectionException("Streamable ACP WebSocket connection is closed"); + } + outboundQueue.addLast(payload); + shouldDrain = !outboundSendInProgress; + if (shouldDrain) { + outboundSendInProgress = true; + } + } + if (shouldDrain) { + drainOutbound(); + } + } + + private void drainOutbound() { + String payload; + Session currentSession; + synchronized (outboundLock) { + if (closed.get()) { + outboundQueue.clear(); + outboundSendInProgress = false; + return; + } + payload = outboundQueue.pollFirst(); + if (payload == null) { + outboundSendInProgress = false; + return; + } + currentSession = this.session; + } + + if (currentSession == null || !currentSession.isOpen()) { + failOutbound(new AcpConnectionException("Streamable ACP WebSocket connection is closed")); + return; + } + + try { + /* + * Jetty WebSocket sessions do not allow overlapping writes. Agent messages can + * be produced by concurrent prompt handlers, so this per-connection queue sends + * exactly one frame at a time and advances only after Jetty completes the + * callback for the previous frame. + */ + currentSession.sendText(payload, Callback.from(this::drainOutbound, this::failOutbound)); + } + catch (Exception e) { + failOutbound(e); + } + } + + private void failOutbound(Throwable error) { + if (!closed.get()) { + remoteConnection.signalException(error); + close(StatusCode.SERVER_ERROR, "failed to send ACP message"); + } + } + void close() { close(StatusCode.NORMAL, "server closing"); } @@ -999,6 +1054,10 @@ void close(int statusCode, String reason) { if (!closed.compareAndSet(false, true)) { return; } + synchronized (outboundLock) { + outboundQueue.clear(); + outboundSendInProgress = false; + } webSocketConnections.remove(id, this); Session currentSession = this.session; if (currentSession != null && currentSession.isOpen()) { diff --git a/acp-streamable-http-jetty/src/test/java/com/agentclientprotocol/sdk/agent/transport/StreamableHttpAcpAgentTransportWebSocketIntegrationTest.java b/acp-streamable-http-jetty/src/test/java/com/agentclientprotocol/sdk/agent/transport/StreamableHttpAcpAgentTransportWebSocketIntegrationTest.java index 60e4432..fc5ac86 100644 --- a/acp-streamable-http-jetty/src/test/java/com/agentclientprotocol/sdk/agent/transport/StreamableHttpAcpAgentTransportWebSocketIntegrationTest.java +++ b/acp-streamable-http-jetty/src/test/java/com/agentclientprotocol/sdk/agent/transport/StreamableHttpAcpAgentTransportWebSocketIntegrationTest.java @@ -37,7 +37,9 @@ import com.agentclientprotocol.sdk.spec.AcpSchema; import org.eclipse.jetty.websocket.api.StatusCode; import org.junit.jupiter.api.Test; +import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; +import reactor.core.scheduler.Schedulers; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; @@ -209,6 +211,59 @@ void supportsMultipleConcurrentWebSocketClients() throws Exception { } } + @Test + void serializesConcurrentAgentMessagesOnOneWebSocketConnection() throws Exception { + AtomicInteger sessionCounter = new AtomicInteger(); + AtomicInteger receivedUpdates = new AtomicInteger(); + AcpAgentFactory agentFactory = AcpAgentFactory.async(transport -> AcpAgent.async(transport) + .initializeHandler(request -> Mono.just(new AcpSchema.InitializeResponse( + AcpSchema.LATEST_PROTOCOL_VERSION, new AcpSchema.AgentCapabilities(true, null, null), List.of()))) + .newSessionHandler(request -> Mono.just(new AcpSchema.NewSessionResponse( + "sess-" + sessionCounter.incrementAndGet(), null, null))) + .promptHandler((request, context) -> Mono.delay(Duration.ofMillis(25)) + .thenMany(Flux.range(0, 20) + .flatMap(i -> context.sendMessage(request.sessionId() + ": update-" + i) + .subscribeOn(Schedulers.parallel()), 8)) + .then(Mono.just(AcpSchema.PromptResponse.endTurn()))) + .build()); + + try (FixtureServer server = FixtureServer.start(agentFactory)) { + AcpAsyncClient client = AcpClient + .async(new WebSocketAcpClientTransport(server.endpoint(), AcpJsonMapper.createDefault())) + .sessionUpdateConsumer(update -> { + receivedUpdates.incrementAndGet(); + return Mono.empty(); + }) + .requestTimeout(TIMEOUT) + .build(); + try { + client.initialize(new AcpSchema.InitializeRequest( + AcpSchema.LATEST_PROTOCOL_VERSION, new AcpSchema.ClientCapabilities())) + .block(TIMEOUT); + AcpSchema.NewSessionResponse firstSession = client + .newSession(new AcpSchema.NewSessionRequest("/workspace/one", List.of())) + .block(TIMEOUT); + AcpSchema.NewSessionResponse secondSession = client + .newSession(new AcpSchema.NewSessionRequest("/workspace/two", List.of())) + .block(TIMEOUT); + + var prompts = Mono.zip( + client.prompt(new AcpSchema.PromptRequest(firstSession.sessionId(), + List.of(new AcpSchema.TextContent("first")))), + client.prompt(new AcpSchema.PromptRequest(secondSession.sessionId(), + List.of(new AcpSchema.TextContent("second"))))) + .block(TIMEOUT); + + assertThat(prompts.getT1().stopReason()).isEqualTo(AcpSchema.StopReason.END_TURN); + assertThat(prompts.getT2().stopReason()).isEqualTo(AcpSchema.StopReason.END_TURN); + assertThat(receivedUpdates).hasValue(40); + } + finally { + client.closeGracefully().block(TIMEOUT); + } + } + } + @Test void rejectsNonInitializeFirstMessage() throws Exception { try (FixtureServer server = FixtureServer.start(simpleAgentFactory())) { From 9c89c30fdbcad37be5f57292bfe0629fbba78781 Mon Sep 17 00:00:00 2001 From: Kaiser Dandangi Date: Sun, 24 May 2026 23:14:05 -0400 Subject: [PATCH 15/15] refactor: encapsulate websocket send queue --- .../StreamableHttpAcpAgentTransport.java | 149 ++++++++++-------- 1 file changed, 81 insertions(+), 68 deletions(-) diff --git a/acp-streamable-http-jetty/src/main/java/com/agentclientprotocol/sdk/agent/transport/StreamableHttpAcpAgentTransport.java b/acp-streamable-http-jetty/src/main/java/com/agentclientprotocol/sdk/agent/transport/StreamableHttpAcpAgentTransport.java index fed1a2d..6b40605 100644 --- a/acp-streamable-http-jetty/src/main/java/com/agentclientprotocol/sdk/agent/transport/StreamableHttpAcpAgentTransport.java +++ b/acp-streamable-http-jetty/src/main/java/com/agentclientprotocol/sdk/agent/transport/StreamableHttpAcpAgentTransport.java @@ -935,11 +935,7 @@ private final class WebSocketConnectionState { private final AtomicBoolean closed = new AtomicBoolean(false); - private final Object outboundLock = new Object(); - - private final ArrayDeque outboundQueue = new ArrayDeque<>(); - - private boolean outboundSendInProgress = false; + private final SerializedWebSocketSender outboundSender = new SerializedWebSocketSender(); private volatile Session session; @@ -978,7 +974,7 @@ void sendToClient(JSONRPCMessage message) { try { String payload = jsonMapper.writeValueAsString(message); logger.debug("Sending streamable ACP WebSocket message: {}", payload); - enqueueOutbound(payload); + outboundSender.send(payload); } catch (Exception e) { remoteConnection.signalException(e); @@ -986,84 +982,101 @@ void sendToClient(JSONRPCMessage message) { } } - private void enqueueOutbound(String payload) { - boolean shouldDrain; - synchronized (outboundLock) { - if (closed.get()) { - throw new AcpConnectionException("Streamable ACP WebSocket connection is closed"); + void close() { + close(StatusCode.NORMAL, "server closing"); + } + + void close(int statusCode, String reason) { + if (!closed.compareAndSet(false, true)) { + return; + } + outboundSender.close(); + webSocketConnections.remove(id, this); + Session currentSession = this.session; + if (currentSession != null && currentSession.isOpen()) { + currentSession.close(statusCode, reason, Callback.NOOP); + } + remoteConnection.closeGracefully().subscribe(); + } + + private final class SerializedWebSocketSender { + + private final Object lock = new Object(); + + private final ArrayDeque queue = new ArrayDeque<>(); + + private boolean sendInProgress = false; + + void send(String payload) { + boolean shouldDrain; + synchronized (lock) { + if (closed.get()) { + throw new AcpConnectionException("Streamable ACP WebSocket connection is closed"); + } + queue.addLast(payload); + shouldDrain = !sendInProgress; + if (shouldDrain) { + sendInProgress = true; + } } - outboundQueue.addLast(payload); - shouldDrain = !outboundSendInProgress; if (shouldDrain) { - outboundSendInProgress = true; + drain(); } } - if (shouldDrain) { - drainOutbound(); - } - } - private void drainOutbound() { - String payload; - Session currentSession; - synchronized (outboundLock) { - if (closed.get()) { - outboundQueue.clear(); - outboundSendInProgress = false; - return; + /* + * Jetty WebSocket sessions do not allow overlapping writes. Agent messages can + * be produced by concurrent prompt handlers, so this per-connection queue sends + * exactly one frame at a time and advances only after Jetty completes the + * callback for the previous frame. + */ + private void drain() { + String payload; + Session currentSession; + synchronized (lock) { + if (closed.get()) { + clear(); + return; + } + payload = queue.pollFirst(); + if (payload == null) { + sendInProgress = false; + return; + } + currentSession = session; } - payload = outboundQueue.pollFirst(); - if (payload == null) { - outboundSendInProgress = false; + + if (currentSession == null || !currentSession.isOpen()) { + fail(new AcpConnectionException("Streamable ACP WebSocket connection is closed")); return; } - currentSession = this.session; - } - if (currentSession == null || !currentSession.isOpen()) { - failOutbound(new AcpConnectionException("Streamable ACP WebSocket connection is closed")); - return; + try { + currentSession.sendText(payload, Callback.from(this::drain, this::fail)); + } + catch (Exception e) { + fail(e); + } } - try { - /* - * Jetty WebSocket sessions do not allow overlapping writes. Agent messages can - * be produced by concurrent prompt handlers, so this per-connection queue sends - * exactly one frame at a time and advances only after Jetty completes the - * callback for the previous frame. - */ - currentSession.sendText(payload, Callback.from(this::drainOutbound, this::failOutbound)); - } - catch (Exception e) { - failOutbound(e); + private void fail(Throwable error) { + if (!closed.get()) { + remoteConnection.signalException(error); + WebSocketConnectionState.this.close(StatusCode.SERVER_ERROR, "failed to send ACP message"); + } } - } - private void failOutbound(Throwable error) { - if (!closed.get()) { - remoteConnection.signalException(error); - close(StatusCode.SERVER_ERROR, "failed to send ACP message"); + void close() { + clear(); } - } - - void close() { - close(StatusCode.NORMAL, "server closing"); - } - void close(int statusCode, String reason) { - if (!closed.compareAndSet(false, true)) { - return; - } - synchronized (outboundLock) { - outboundQueue.clear(); - outboundSendInProgress = false; - } - webSocketConnections.remove(id, this); - Session currentSession = this.session; - if (currentSession != null && currentSession.isOpen()) { - currentSession.close(statusCode, reason, Callback.NOOP); + private void clear() { + synchronized (lock) { + queue.clear(); + sendInProgress = false; + } } - remoteConnection.closeGracefully().subscribe(); + } }