Skip to content

Commit cb33e8b

Browse files
committed
fix: initialize streamable connections asynchronously
1 parent b2d39f7 commit cb33e8b

2 files changed

Lines changed: 152 additions & 10 deletions

File tree

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

Lines changed: 98 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@
5757
import org.slf4j.LoggerFactory;
5858
import reactor.core.publisher.Mono;
5959
import reactor.core.publisher.Sinks;
60+
import reactor.core.scheduler.Schedulers;
6061

6162
/**
6263
* Listener-backed ACP Streamable HTTP transport for agents.
@@ -97,6 +98,9 @@ public class StreamableHttpAcpAgentTransport {
9798

9899
private static final int MAX_PENDING_SSE_EVENTS = 1024;
99100

101+
// Commits the SSE response when there are no replay events, without emitting an ACP message.
102+
private static final byte[] SSE_OPEN_COMMENT = ": connected\n\n".getBytes(StandardCharsets.UTF_8);
103+
100104
private static final Duration INITIALIZE_TIMEOUT = Duration.ofSeconds(30);
101105

102106
private enum ScopeKind {
@@ -308,9 +312,7 @@ int activeConnectionCount() {
308312

309313
private ConnectionState createConnection() {
310314
String connectionId = UUID.randomUUID().toString();
311-
ConnectionState connection = new ConnectionState(connectionId);
312-
connection.start();
313-
return connection;
315+
return new ConnectionState(connectionId);
314316
}
315317

316318
private WebSocketConnectionState createWebSocketConnection() {
@@ -430,9 +432,47 @@ private void handleInitialize(HttpServletRequest request, HttpServletResponse re
430432
}
431433

432434
ConnectionState connection = createConnection();
435+
AsyncContext asyncContext = request.startAsync();
436+
asyncContext.setTimeout(INITIALIZE_TIMEOUT.toMillis());
437+
AtomicBoolean completed = new AtomicBoolean(false);
438+
asyncContext.addListener(new AsyncListener() {
439+
440+
@Override
441+
public void onComplete(AsyncEvent event) {
442+
}
443+
444+
@Override
445+
public void onTimeout(AsyncEvent event) {
446+
completeInitializeFailure(asyncContext, response, connection, completed);
447+
}
448+
449+
@Override
450+
public void onError(AsyncEvent event) {
451+
completeInitializeFailure(asyncContext, response, connection, completed);
452+
}
453+
454+
@Override
455+
public void onStartAsync(AsyncEvent event) {
456+
event.getAsyncContext().addListener(this);
457+
}
458+
459+
});
460+
461+
connection.start()
462+
.then(Mono.defer(() -> connection.initialize(initializeRequest)))
463+
.timeout(INITIALIZE_TIMEOUT)
464+
.subscribeOn(Schedulers.boundedElastic())
465+
.subscribe(initializeResponse -> completeInitializeSuccess(asyncContext, response, connection,
466+
completed, initializeResponse),
467+
error -> completeInitializeFailure(asyncContext, response, connection, completed));
468+
}
469+
470+
private void completeInitializeSuccess(AsyncContext asyncContext, HttpServletResponse response,
471+
ConnectionState connection, AtomicBoolean completed, JSONRPCMessage initializeResponse) {
472+
if (!completed.compareAndSet(false, true)) {
473+
return;
474+
}
433475
try {
434-
JSONRPCMessage initializeResponse = connection.initialize(initializeRequest)
435-
.block(INITIALIZE_TIMEOUT);
436476
if (!(initializeResponse instanceof AcpSchema.JSONRPCResponse)) {
437477
throw new AcpConnectionException("initialize did not produce a JSON-RPC response");
438478
}
@@ -443,9 +483,44 @@ private void handleInitialize(HttpServletRequest request, HttpServletResponse re
443483
response.getWriter().write(jsonMapper.writeValueAsString(initializeResponse));
444484
}
445485
catch (Exception e) {
486+
connections.remove(connection.id(), connection);
446487
connection.close();
488+
try {
489+
writeText(response, HttpServletResponse.SC_INTERNAL_SERVER_ERROR, "initialize failed");
490+
}
491+
catch (IOException writeError) {
492+
logger.warn("Failed to write Streamable HTTP initialize failure", writeError);
493+
}
494+
}
495+
finally {
496+
completeAsyncContext(asyncContext);
497+
}
498+
}
499+
500+
private void completeInitializeFailure(AsyncContext asyncContext, HttpServletResponse response,
501+
ConnectionState connection, AtomicBoolean completed) {
502+
if (!completed.compareAndSet(false, true)) {
503+
return;
504+
}
505+
connections.remove(connection.id(), connection);
506+
connection.close();
507+
try {
447508
writeText(response, HttpServletResponse.SC_INTERNAL_SERVER_ERROR, "initialize failed");
448509
}
510+
catch (IOException writeError) {
511+
logger.warn("Failed to write Streamable HTTP initialize failure", writeError);
512+
}
513+
finally {
514+
completeAsyncContext(asyncContext);
515+
}
516+
}
517+
518+
private void completeAsyncContext(AsyncContext asyncContext) {
519+
try {
520+
asyncContext.complete();
521+
}
522+
catch (IllegalStateException ignored) {
523+
}
449524
}
450525

451526
}
@@ -513,8 +588,8 @@ String id() {
513588
return id;
514589
}
515590

516-
void start() {
517-
this.connection.start(agentFactory).block(INITIALIZE_TIMEOUT);
591+
Mono<Void> start() {
592+
return this.connection.start(agentFactory);
518593
}
519594

520595
Mono<JSONRPCMessage> initialize(AcpSchema.JSONRPCRequest request) {
@@ -869,14 +944,17 @@ private final class SseSubscriber implements AsyncListener, WriteListener {
869944

870945
private final AtomicBoolean closed = new AtomicBoolean(false);
871946

947+
private boolean flushPending;
948+
872949
SseSubscriber(OutboundStream parent, AsyncContext asyncContext, HttpServletResponse response) throws IOException {
873950
this.parent = parent;
874951
this.asyncContext = asyncContext;
875952
this.output = response.getOutputStream();
876953
}
877954

878-
void start() {
955+
synchronized void start() {
879956
asyncContext.addListener(this);
957+
pendingEvents.addLast(SSE_OPEN_COMMENT);
880958
output.setWriteListener(this);
881959
}
882960

@@ -895,19 +973,29 @@ synchronized void send(String payload) {
895973

896974
synchronized void drain() {
897975
try {
976+
flushIfReady();
898977
while (!closed.get() && output.isReady()) {
899978
byte[] event = pendingEvents.pollFirst();
900979
if (event == null) {
901-
return;
980+
break;
902981
}
903982
output.write(event);
983+
flushPending = true;
904984
}
985+
flushIfReady();
905986
}
906-
catch (IOException e) {
987+
catch (IOException | IllegalStateException e) {
907988
close();
908989
}
909990
}
910991

992+
private void flushIfReady() throws IOException {
993+
if (flushPending && output.isReady()) {
994+
output.flush();
995+
flushPending = false;
996+
}
997+
}
998+
911999
@Override
9121000
public void onWritePossible() {
9131001
drain();

acp-streamable-http-jetty/src/test/java/com/agentclientprotocol/sdk/agent/transport/StreamableHttpAcpAgentTransportIntegrationTest.java

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,56 @@ class StreamableHttpAcpAgentTransportIntegrationTest {
4747

4848
private static final AcpJsonMapper JSON_MAPPER = AcpJsonMapper.createDefault();
4949

50+
@Test
51+
void delayedInitializePublishesConnectionOnlyAfterSuccess() throws Exception {
52+
CountDownLatch initializeEntered = new CountDownLatch(1);
53+
AcpAgentFactory agentFactory = AcpAgentFactory.async(transport -> AcpAgent.async(transport)
54+
.initializeHandler(request -> Mono.defer(() -> {
55+
initializeEntered.countDown();
56+
return Mono.delay(Duration.ofMillis(250)).thenReturn(new AcpSchema.InitializeResponse(
57+
AcpSchema.LATEST_PROTOCOL_VERSION, new AcpSchema.AgentCapabilities(true, null, null), null));
58+
}))
59+
.build());
60+
try (FixtureServer server = FixtureServer.start(agentFactory)) {
61+
HttpClient rawClient = HttpClient.newHttpClient();
62+
var initialize = rawClient.sendAsync(HttpRequest.newBuilder(server.endpoint())
63+
.header("Content-Type", "application/json")
64+
.header("Accept", "application/json")
65+
.POST(HttpRequest.BodyPublishers.ofString("""
66+
{"jsonrpc":"2.0","id":"init-delayed","method":"initialize","params":{"protocolVersion":1,"clientCapabilities":{}}}
67+
"""))
68+
.build(), HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
69+
70+
assertThat(initializeEntered.await(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS)).isTrue();
71+
assertThat(server.transport.activeConnectionCount()).isZero();
72+
73+
HttpResponse<String> response = initialize.get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
74+
assertThat(response.statusCode()).isEqualTo(200);
75+
assertThat(response.headers().firstValue("Acp-Connection-Id")).isPresent();
76+
assertThat(server.transport.activeConnectionCount()).isEqualTo(1);
77+
}
78+
}
79+
80+
@Test
81+
void failedInitializeDoesNotPublishConnection() throws Exception {
82+
AcpAgentFactory agentFactory = transport -> {
83+
throw new IllegalStateException("agent creation failed");
84+
};
85+
try (FixtureServer server = FixtureServer.start(agentFactory)) {
86+
HttpClient rawClient = HttpClient.newHttpClient();
87+
HttpResponse<String> response = rawClient.send(HttpRequest.newBuilder(server.endpoint())
88+
.header("Content-Type", "application/json")
89+
.header("Accept", "application/json")
90+
.POST(HttpRequest.BodyPublishers.ofString("""
91+
{"jsonrpc":"2.0","id":"init-failed","method":"initialize","params":{"protocolVersion":1,"clientCapabilities":{}}}
92+
"""))
93+
.build(), HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
94+
95+
assertThat(response.statusCode()).isEqualTo(500);
96+
assertThat(server.transport.activeConnectionCount()).isZero();
97+
}
98+
}
99+
50100
@Test
51101
void javaClientCanTalkToRunningJavaServer() throws Exception {
52102
try (FixtureServer server = FixtureServer.start()) {
@@ -527,6 +577,10 @@ static FixtureServer start() throws Exception {
527577
.thenReturn(AcpSchema.PromptResponse.endTurn());
528578
})
529579
.build());
580+
return start(agentFactory);
581+
}
582+
583+
static FixtureServer start(AcpAgentFactory agentFactory) throws Exception {
530584
StreamableHttpAcpAgentTransport transport = new StreamableHttpAcpAgentTransport(
531585
freePort(), AcpJsonMapper.createDefault(), agentFactory);
532586
transport.start().block(TIMEOUT);

0 commit comments

Comments
 (0)