Skip to content

Commit e23cde8

Browse files
author
Mark Pollack
committed
Interoperate with the Rust, TypeScript and Python HTTP transports
An interop run over plain http against the three other SDKs found four Java-side problems: - Against a cleartext server without h2c, the client kept offering Upgrade: h2c on every request; the TypeScript example server routes any request with an Upgrade header to its WebSocket handler and answered initialize with 405. After the OPTIONS probe settles on HTTP/1.1 (or fails, or takes over five seconds) every request is now pinned to HTTP/1.1, including the initialize request built before it. - session/load always opened a new GET for the session stream, even when it was open; the TypeScript and Rust servers allow one receiver per stream and answered 409. An open stream is now reused. - The Rust and Python clients post every response without Acp-Session-Id, and the Python client sends session/load without it (the RFD is ambiguous there). The server accepts a missing header in both cases, taking the session from params; a header naming a different session is still rejected. - The provisional-session limit is checked and reserved atomically. The initialize response's Acp-Connection-Id requirement regains the test it lost with the fixture server.
1 parent 0e06973 commit e23cde8

6 files changed

Lines changed: 253 additions & 18 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,9 @@ building a second client on an already-connected transport now fails at construc
3333
- **HTTP/2 over plain `http://`.** The RFD requires HTTP/2, and localhost without TLS is a first-class
3434
deployment. Over cleartext the JDK client only offers the h2c upgrade on a request without a body, so
3535
`initialize`, a POST, went out on HTTP/1.1. `StreamableHttpAcpClientTransport` now sends a bodiless
36-
OPTIONS first on `http://` endpoints, and every request, streams included, runs on HTTP/2.
36+
OPTIONS first on `http://` endpoints, so against a server that speaks h2c (the SDK's own does) every
37+
request, streams included, runs on HTTP/2. Against one that does not, the probe settles on HTTP/1.1
38+
and later requests stop offering the upgrade, which some servers route to their WebSocket handler.
3739
- **Client sessions learn that their transport died.** `AcpClientTransport.awaitTermination()` (default:
3840
never) is implemented by the Streamable HTTP and WebSocket client transports; `AcpClientSession` fails
3941
pending requests at once with the cause, and every later request, instead of waiting out the request

‎acp-core/src/main/java/com/agentclientprotocol/sdk/client/transport/StreamableHttpAcpClientTransport.java‎

Lines changed: 58 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@
4040
import com.agentclientprotocol.sdk.spec.AcpClientTransport;
4141
import com.agentclientprotocol.sdk.spec.AcpSchema;
4242
import com.agentclientprotocol.sdk.spec.AcpSchema.JSONRPCMessage;
43+
import com.agentclientprotocol.sdk.util.AcpSchedulers;
4344
import com.agentclientprotocol.sdk.util.Assert;
4445
import org.slf4j.Logger;
4546
import org.slf4j.LoggerFactory;
@@ -299,12 +300,23 @@ public Mono<Void> sendMessage(JSONRPCMessage message) {
299300
}
300301

301302
/**
302-
* The transport requires HTTP/2 (RFD). Over {@code https} ALPN negotiates it on the first
303-
* request. Over cleartext {@code http} the JDK client only offers the h2c upgrade on a
304-
* request without a body, and {@code initialize} is a POST, so without this the whole
305-
* connection would stay on HTTP/1.1. A bodiless OPTIONS first upgrades the connection;
306-
* every later request, the POSTs and the SSE GETs, reuses it over HTTP/2. A failed probe
307-
* is ignored: initialize then proceeds and reports any real problem itself.
303+
* HTTP version pinned for every request after the cleartext probe, or {@code null} to
304+
* use the client's own setting. Set to HTTP/1.1 when an {@code http://} server does not
305+
* speak h2c, so later requests stop carrying {@code Upgrade: h2c}: some servers hand any
306+
* request with an Upgrade header to their WebSocket handler and answer 405 (the
307+
* TypeScript SDK's example server does).
308+
*/
309+
private volatile HttpClient.Version pinnedVersion;
310+
311+
private static final Duration PROBE_TIMEOUT = Duration.ofSeconds(5);
312+
313+
/**
314+
* The transport requires HTTP/2 (RFD). Over {@code https} ALPN negotiates it. Over
315+
* cleartext {@code http} the JDK offers an h2c upgrade on every request, but servers
316+
* (Jetty among them) only honour it on a request without a body, and {@code initialize}
317+
* is a POST. A bodiless OPTIONS first upgrades the connection when the server speaks
318+
* h2c; every later request reuses it over HTTP/2. When it does not (answer on HTTP/1.1,
319+
* an error, or no answer within five seconds), every later request is pinned to HTTP/1.1.
308320
*/
309321
private Mono<Void> upgradeCleartextToHttp2() {
310322
if (!"http".equalsIgnoreCase(endpointUri.getScheme()) || httpClient.version() != HttpClient.Version.HTTP_2) {
@@ -313,15 +325,41 @@ private Mono<Void> upgradeCleartextToHttp2() {
313325
HttpRequest probe = HttpRequest.newBuilder(endpointUri)
314326
.method("OPTIONS", HttpRequest.BodyPublishers.noBody())
315327
.build();
328+
// Cancelling the Mono on timeout cancels the HTTP exchange (see sendAsync).
316329
return sendAsync(probe, HttpResponse.BodyHandlers.discarding())
317-
.doOnNext(response -> logger.debug("Cleartext probe to {} negotiated {}", endpointUri, response.version()))
330+
.timeout(PROBE_TIMEOUT, AcpSchedulers.timeouts())
331+
.doOnNext(response -> {
332+
logger.debug("Cleartext probe to {} negotiated {}", endpointUri, response.version());
333+
if (response.version() != HttpClient.Version.HTTP_2) {
334+
this.pinnedVersion = HttpClient.Version.HTTP_1_1;
335+
}
336+
})
318337
.then()
319338
.onErrorResume(error -> {
320-
logger.debug("Cleartext HTTP/2 probe to {} failed: {}", endpointUri, error.getMessage());
339+
logger.debug("Cleartext HTTP/2 probe to {} failed ({}); using HTTP/1.1", endpointUri, error.getMessage());
340+
this.pinnedVersion = HttpClient.Version.HTTP_1_1;
321341
return Mono.empty();
322342
});
323343
}
324344

345+
/** Re-stamps a request built before the probe with the version the probe settled on. */
346+
private HttpRequest pinned(HttpRequest request) {
347+
HttpClient.Version version = this.pinnedVersion;
348+
if (version == null) {
349+
return request;
350+
}
351+
return HttpRequest.newBuilder(request, (name, value) -> true).version(version).build();
352+
}
353+
354+
private HttpRequest.Builder newRequest() {
355+
HttpRequest.Builder requestBuilder = HttpRequest.newBuilder(endpointUri);
356+
HttpClient.Version version = this.pinnedVersion;
357+
if (version != null) {
358+
requestBuilder.version(version);
359+
}
360+
return requestBuilder;
361+
}
362+
325363
private Mono<Void> initialize(AcpSchema.JSONRPCRequest request) {
326364
if (!initialized.compareAndSet(false, true)) {
327365
return Mono.error(new IllegalStateException("Transport is already initialized"));
@@ -338,8 +376,9 @@ private Mono<Void> initialize(AcpSchema.JSONRPCRequest request) {
338376
return Mono.error(new AcpConnectionException("Failed to serialize initialize request", e));
339377
}
340378

379+
HttpRequest initializeRequest = httpRequest;
341380
return upgradeCleartextToHttp2()
342-
.then(sendAsync(httpRequest, HttpResponse.BodyHandlers.ofString()))
381+
.then(Mono.defer(() -> sendAsync(pinned(initializeRequest), HttpResponse.BodyHandlers.ofString())))
343382
.flatMap(response -> {
344383
if (response.statusCode() != 200) {
345384
return Mono.error(new AcpConnectionException(
@@ -397,6 +436,13 @@ private Mono<Void> routeAndPost(JSONRPCMessage message) {
397436
private Mono<Void> prepareRoute(ResolvedOutboundRoute resolved) {
398437
if (resolved.message() instanceof AcpSchema.JSONRPCRequest request
399438
&& AcpSchema.METHOD_SESSION_LOAD.equals(request.method())) {
439+
// Open the session stream first (the RFD's reconnect order) unless it is already
440+
// open: servers allow one receiver per stream, and the TypeScript and Rust servers
441+
// answer a second GET with 409.
442+
SseStream existing = sessionStreams.get(resolved.scope().sessionId());
443+
if (existing != null && !existing.closed.get()) {
444+
return Mono.empty();
445+
}
400446
return openSessionStream(resolved.scope().sessionId());
401447
}
402448
if (resolved.scope().isSession() && !sessionStreams.containsKey(resolved.scope().sessionId())) {
@@ -427,7 +473,7 @@ private Mono<Void> postAccepted(JSONRPCMessage message, RouteScope scope) {
427473
}
428474

429475
private HttpRequest.Builder jsonPostBuilder(RouteScope scope) {
430-
HttpRequest.Builder builder = HttpRequest.newBuilder(endpointUri)
476+
HttpRequest.Builder builder = newRequest()
431477
.header("Content-Type", CONTENT_TYPE_JSON)
432478
.header("Accept", CONTENT_TYPE_JSON);
433479
addScopeHeaders(builder, scope);
@@ -473,7 +519,7 @@ private Mono<Void> createSessionStreamOpenMono(String sessionId) {
473519
}
474520

475521
private Mono<SseStream> openSseStream(RouteScope scope) {
476-
HttpRequest.Builder builder = HttpRequest.newBuilder(endpointUri).GET().header("Accept", CONTENT_TYPE_EVENT_STREAM);
522+
HttpRequest.Builder builder = newRequest().GET().header("Accept", CONTENT_TYPE_EVENT_STREAM);
477523
addScopeHeaders(builder, scope);
478524
HttpRequest request = builder.build();
479525

@@ -687,7 +733,7 @@ public Mono<Void> closeGracefully() {
687733

688734
Mono<Void> deleteRequest = Mono.empty();
689735
if (connectionId != null) {
690-
HttpRequest request = HttpRequest.newBuilder(endpointUri)
736+
HttpRequest request = newRequest()
691737
.DELETE()
692738
.header(HEADER_CONNECTION_ID, connectionId)
693739
.build();

‎acp-core/src/test/java/com/agentclientprotocol/sdk/client/transport/StreamableHttpAcpClientTransportTest.java‎

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -633,6 +633,11 @@ void reopensClosedSessionSseBeforePostingNextSessionRequest() throws Exception {
633633
*/
634634
private HttpClient twoStreamHttpClient(PipedInputStream connectionStreamBody,
635635
Map<String, PipedOutputStream> sessionWriters) {
636+
return twoStreamHttpClient(connectionStreamBody, sessionWriters, new java.util.concurrent.CopyOnWriteArrayList<>());
637+
}
638+
639+
private HttpClient twoStreamHttpClient(PipedInputStream connectionStreamBody,
640+
Map<String, PipedOutputStream> sessionWriters, List<String> sessionGets) {
636641
HttpClient httpClient = mock(HttpClient.class);
637642
when(httpClient.sendAsync(any(), any())).thenAnswer(invocation -> {
638643
HttpRequest request = invocation.getArgument(0);
@@ -648,6 +653,7 @@ private HttpClient twoStreamHttpClient(PipedInputStream connectionStreamBody,
648653
}
649654
if ("GET".equals(request.method())) {
650655
String sessionId = request.headers().firstValue("Acp-Session-Id").orElseThrow();
656+
sessionGets.add(sessionId);
651657
PipedInputStream sessionBody = new PipedInputStream(16 * 1024);
652658
sessionWriters.put(sessionId, new PipedOutputStream(sessionBody));
653659
return CompletableFuture.completedFuture(
@@ -739,6 +745,118 @@ void promptResponseArrivingOnTheConnectionStreamIsDelivered() throws Exception {
739745
}
740746
}
741747

748+
/**
749+
* Servers allow one receiver per stream; the TypeScript and Rust servers answer a second
750+
* GET with 409. session/load for a session whose stream is open must not open another.
751+
*/
752+
@Test
753+
void loadingASessionWhoseStreamIsOpenDoesNotOpenItAgain() throws Exception {
754+
Map<String, PipedOutputStream> sessionWriters = new ConcurrentHashMap<>();
755+
List<String> sessionGets = new java.util.concurrent.CopyOnWriteArrayList<>();
756+
PipedInputStream connectionStreamBody = new PipedInputStream();
757+
PipedOutputStream connectionStreamWriter = new PipedOutputStream(connectionStreamBody);
758+
BlockingQueue<AcpSchema.JSONRPCMessage> inboundMessages = new LinkedBlockingQueue<>();
759+
StreamableHttpAcpClientTransport transport = new StreamableHttpAcpClientTransport(
760+
URI.create("https://localhost:8443/acp"), jsonMapper,
761+
twoStreamHttpClient(connectionStreamBody, sessionWriters, sessionGets));
762+
try {
763+
transport.connect(message -> message.doOnNext(inboundMessages::add).then(Mono.empty())).block();
764+
transport.sendMessage(AcpTestFixtures.createJsonRpcRequest(AcpSchema.METHOD_INITIALIZE, "init-1",
765+
AcpTestFixtures.createInitializeRequest())).block();
766+
awaitResponse(inboundMessages, "init-1");
767+
transport.sendMessage(AcpTestFixtures.createJsonRpcRequest(AcpSchema.METHOD_SESSION_NEW, "new-1",
768+
AcpTestFixtures.createNewSessionRequest("/workspace"))).block();
769+
writeSse(connectionStreamWriter, new AcpSchema.JSONRPCResponse(AcpSchema.JSONRPC_VERSION, "new-1",
770+
new AcpSchema.NewSessionResponse("sess-1", null, null), null));
771+
awaitResponse(inboundMessages, "new-1");
772+
assertThat(sessionGets).containsExactly("sess-1");
773+
774+
transport.sendMessage(AcpTestFixtures.createJsonRpcRequest(AcpSchema.METHOD_SESSION_LOAD, "load-1",
775+
new AcpSchema.LoadSessionRequest("sess-1", "/workspace", List.of()))).block();
776+
writeSse(connectionStreamWriter, new AcpSchema.JSONRPCResponse(AcpSchema.JSONRPC_VERSION, "load-1",
777+
new AcpSchema.LoadSessionResponse(null, null), null));
778+
awaitResponse(inboundMessages, "load-1");
779+
780+
assertThat(sessionGets).as("no second GET for an open session stream").containsExactly("sess-1");
781+
}
782+
finally {
783+
connectionStreamWriter.close();
784+
sessionWriters.values().forEach(writer -> {
785+
try {
786+
writer.close();
787+
}
788+
catch (Exception ignored) {
789+
}
790+
});
791+
transport.close();
792+
}
793+
}
794+
795+
/**
796+
* Over http:// a server that does not speak h2c must not keep receiving Upgrade headers:
797+
* the TypeScript example server routes any request carrying one to its WebSocket handler
798+
* and answers 405. After a probe that comes back on HTTP/1.1, every request is pinned.
799+
*/
800+
@Test
801+
void cleartextServerWithoutH2cGetsPlainHttp11Requests() throws Exception {
802+
HttpClient httpClient = mock(HttpClient.class);
803+
when(httpClient.version()).thenReturn(HttpClient.Version.HTTP_2);
804+
List<HttpRequest> requests = new java.util.concurrent.CopyOnWriteArrayList<>();
805+
when(httpClient.sendAsync(any(), any())).thenAnswer(invocation -> {
806+
HttpRequest request = invocation.getArgument(0);
807+
requests.add(request);
808+
if ("OPTIONS".equals(request.method())) {
809+
HttpResponse<Object> probe = response(405, Map.of(), null);
810+
when(probe.version()).thenReturn(HttpClient.Version.HTTP_1_1);
811+
return CompletableFuture.completedFuture(probe);
812+
}
813+
String initializeResponse = jsonMapper.writeValueAsString(AcpTestFixtures
814+
.createJsonRpcResponse("init-1", AcpTestFixtures.createInitializeResponse()));
815+
if ("POST".equals(request.method())) {
816+
return CompletableFuture.completedFuture(response(200,
817+
Map.of("Content-Type", "application/json", "Acp-Connection-Id", "conn-1"), initializeResponse));
818+
}
819+
return CompletableFuture.completedFuture(
820+
response(200, Map.of("Content-Type", "text/event-stream"), emptyBody()));
821+
});
822+
StreamableHttpAcpClientTransport transport = new StreamableHttpAcpClientTransport(
823+
URI.create("http://localhost:8080/acp"), jsonMapper, httpClient);
824+
transport.setExceptionHandler(error -> {
825+
});
826+
try {
827+
transport.connect(message -> message.then(Mono.empty())).block();
828+
transport.sendMessage(AcpTestFixtures.createJsonRpcRequest(AcpSchema.METHOD_INITIALIZE, "init-1",
829+
AcpTestFixtures.createInitializeRequest())).block();
830+
831+
assertThat(requests.get(0).method()).isEqualTo("OPTIONS");
832+
assertThat(requests.subList(1, requests.size()))
833+
.as("every request after the probe is pinned to HTTP/1.1")
834+
.isNotEmpty()
835+
.allMatch(request -> request.version().equals(java.util.Optional.of(HttpClient.Version.HTTP_1_1)));
836+
}
837+
finally {
838+
transport.close();
839+
}
840+
}
841+
842+
@Test
843+
void initializeRequiresConnectionIdHeader() throws Exception {
844+
HttpClient httpClient = mock(HttpClient.class);
845+
String body = jsonMapper.writeValueAsString(
846+
AcpTestFixtures.createJsonRpcResponse("init-1", AcpTestFixtures.createInitializeResponse()));
847+
HttpResponse<Object> noConnectionId = response(200, Map.of("Content-Type", "application/json"), body);
848+
when(httpClient.sendAsync(any(), any())).thenReturn(CompletableFuture.completedFuture(noConnectionId));
849+
StreamableHttpAcpClientTransport transport = new StreamableHttpAcpClientTransport(
850+
URI.create("https://localhost:8443/acp"), jsonMapper, httpClient);
851+
transport.setExceptionHandler(error -> {
852+
});
853+
854+
assertThatThrownBy(() -> transport.sendMessage(AcpTestFixtures.createJsonRpcRequest(AcpSchema.METHOD_INITIALIZE,
855+
"init-1", AcpTestFixtures.createInitializeRequest())).block())
856+
.isInstanceOf(AcpConnectionException.class)
857+
.hasMessageContaining("Acp-Connection-Id");
858+
}
859+
742860
private void awaitSessionSseClosure() throws InterruptedException {
743861
Thread.sleep(100);
744862
}

‎acp-streamable-http-jetty/src/main/java/com/agentclientprotocol/sdk/agent/transport/StreamableHttpConnection.java‎

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -249,7 +249,16 @@ private void validateClientResponseScope(AcpSchema.JSONRPCResponse response, Str
249249
logger.warn("Client posted response for unknown agent request id {}", response.id());
250250
return;
251251
}
252-
RouteScope actual = sessionHeader == null ? RouteScope.connection() : RouteScope.session(sessionHeader);
252+
if (sessionHeader == null) {
253+
// The RFD asks for Acp-Session-Id on permission responses; the Rust and Python
254+
// clients omit it on every response. The id alone identifies the exchange, so a
255+
// missing header is accepted. A header naming a different scope is still an error.
256+
logger.debug("Client response {} carried no {}; accepting it for {}", response.id(),
257+
StreamableHttpAcpAgentTransport.HEADER_SESSION_ID, expected);
258+
agentRequestRoutes.remove(response.id(), expected);
259+
return;
260+
}
261+
RouteScope actual = RouteScope.session(sessionHeader);
253262
if (!Objects.equals(expected, actual)) {
254263
throw new AcpConnectionException(
255264
"Response id " + response.id() + " arrived on " + actual + " but expected " + expected);
@@ -272,7 +281,7 @@ private SseOutboundStream openSessionStream(String sessionId) {
272281
}
273282

274283
/** Provisional sessions are bounded: a client cannot grow state with arbitrary ids. */
275-
private void addProvisionalSession(String sessionId) {
284+
private synchronized void addProvisionalSession(String sessionId) {
276285
long provisional = sessions.values().stream().filter(state -> state == SessionState.PENDING_LOAD).count();
277286
if (provisional >= options.maxProvisionalSessions()
278287
&& sessions.get(sessionId) != SessionState.PENDING_LOAD) {

‎acp-streamable-http-jetty/src/main/java/com/agentclientprotocol/sdk/agent/transport/StreamableHttpRouting.java‎

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -141,7 +141,10 @@ else if (message instanceof AcpSchema.JSONRPCNotification notification) {
141141
break;
142142
case AcpSchema.METHOD_SESSION_LOAD:
143143
case AcpSchema.METHOD_SESSION_RESUME:
144-
requestScope = requireSessionScope(method, params, sessionHeader);
144+
// The RFD is ambiguous here: its reconnect diagram sends the header, its text
145+
// says load answers on the connection stream because the client has no session
146+
// yet. The Python client omits it. Accept either; the id comes from params.
147+
requestScope = sessionScopeFromParams(method, params, sessionHeader);
145148
kind = RequestKind.SESSION_LOAD;
146149
responseScope = RouteScope.connection();
147150
break;
@@ -174,6 +177,14 @@ else if (message instanceof AcpSchema.JSONRPCNotification notification) {
174177
return new ResolvedInboundRoute(message, requestScope, requestRoute);
175178
}
176179

180+
/** Like {@link #requireSessionScope} but tolerates a missing header; a conflicting one is still rejected. */
181+
RouteScope sessionScopeFromParams(String method, Object params, String sessionHeader) {
182+
if (sessionHeader == null) {
183+
return RouteScope.session(requireSessionId(params, method));
184+
}
185+
return requireSessionScope(method, params, sessionHeader);
186+
}
187+
177188
RouteScope requireSessionScope(String method, Object params, String sessionHeader) {
178189
String sessionId = requireSessionId(params, method);
179190
if (sessionHeader == null) {

0 commit comments

Comments
 (0)