5757import org .slf4j .LoggerFactory ;
5858import reactor .core .publisher .Mono ;
5959import 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 ();
0 commit comments