2121import static org .junit .jupiter .api .Assertions .assertThrows ;
2222import static org .junit .jupiter .api .Assertions .assertTrue ;
2323
24+ import java .lang .reflect .Proxy ;
25+ import java .net .http .WebSocket ;
26+ import java .util .ArrayDeque ;
2427import java .util .ArrayList ;
2528import java .util .List ;
29+ import java .util .Queue ;
30+ import java .util .concurrent .CompletableFuture ;
2631import java .util .concurrent .CompletionException ;
2732import java .util .concurrent .TimeUnit ;
2833
@@ -67,6 +72,44 @@ private JsonNode frameAt(List<String> sent, int index) {
6772 }
6873 }
6974
75+ private String methodOf (String frame ) {
76+ try {
77+ return mapper .readTree (frame ).path ("method" ).asText ("" );
78+ } catch (Exception ex ) {
79+ throw new IllegalStateException ("Invalid frame" , ex );
80+ }
81+ }
82+
83+ private WebSocket recordingSocket (List <String > writes ,
84+ Queue <CompletableFuture <WebSocket >> completions ) {
85+ return (WebSocket ) Proxy .newProxyInstance (
86+ WebSocket .class .getClassLoader (),
87+ new Class <?>[]{WebSocket .class },
88+ (proxy , method , args ) -> {
89+ if ("sendText" .equals (method .getName ())) {
90+ writes .add ((String ) args [0 ]);
91+ CompletableFuture <WebSocket > completion = completions .poll ();
92+ return completion == null
93+ ? CompletableFuture .completedFuture ((WebSocket ) proxy )
94+ : completion ;
95+ }
96+ if ("sendClose" .equals (method .getName ())
97+ || "sendPing" .equals (method .getName ())
98+ || "sendPong" .equals (method .getName ())
99+ || "sendBinary" .equals (method .getName ())) {
100+ return CompletableFuture .completedFuture ((WebSocket ) proxy );
101+ }
102+ if ("getSubprotocol" .equals (method .getName ())) {
103+ return "" ;
104+ }
105+ if ("isInputClosed" .equals (method .getName ())
106+ || "isOutputClosed" .equals (method .getName ())) {
107+ return false ;
108+ }
109+ return null ;
110+ });
111+ }
112+
70113 /**
71114 * Drives {@code begin()} through the mandatory initialize handshake:
72115 * initialize (id=1) → notifications/initialized → thread/start|resume (id=2).
@@ -77,6 +120,55 @@ private void handshake(CodexAppServerTurn turn) {
77120 "{\" jsonrpc\" :\" 2.0\" ,\" id\" :1,\" result\" :{\" serverInfo\" :{\" name\" :\" codex\" }}}" );
78121 }
79122
123+ @ Test
124+ void shouldWaitForInitializedSendBeforeThreadStart () {
125+ CodexAppServerTurn turn = newTurn (
126+ AppServerTurnRequest .builder ().prompt ("hi" ).build (),
127+ new ThreadMappingCache (10 ));
128+
129+ List <String > socketWrites = new ArrayList <>();
130+ Queue <CompletableFuture <WebSocket >> completions = new ArrayDeque <>();
131+ CompletableFuture <WebSocket > initializeWrite = new CompletableFuture <>();
132+ CompletableFuture <WebSocket > initializedWrite = new CompletableFuture <>();
133+ CompletableFuture <WebSocket > threadStartWrite = new CompletableFuture <>();
134+ completions .add (initializeWrite );
135+ completions .add (initializedWrite );
136+ completions .add (threadStartWrite );
137+ WebSocket socket = recordingSocket (socketWrites , completions );
138+ initializeWrite .complete (socket );
139+
140+ turn .onOpen (socket );
141+ turn .handleFrame ("{\" jsonrpc\" :\" 2.0\" ,\" id\" :1,\" result\" :{}}" );
142+
143+ assertEquals (2 , socketWrites .size (),
144+ "thread/start must wait until notifications/initialized finishes sending" );
145+ assertEquals (CodexAppServerProtocol .INITIALIZED , methodOf (socketWrites .get (1 )));
146+
147+ initializedWrite .complete (socket );
148+ assertEquals (3 , socketWrites .size ());
149+ assertEquals (CodexAppServerProtocol .THREAD_START , methodOf (socketWrites .get (2 )));
150+ threadStartWrite .complete (socket );
151+ }
152+
153+ @ Test
154+ void shouldFailImmediatelyWhenWebSocketSendFails () {
155+ CodexAppServerTurn turn = newTurn (
156+ AppServerTurnRequest .builder ().prompt ("hi" ).build (),
157+ new ThreadMappingCache (10 ));
158+
159+ List <String > socketWrites = new ArrayList <>();
160+ Queue <CompletableFuture <WebSocket >> completions = new ArrayDeque <>();
161+ CompletableFuture <WebSocket > initializeWrite = new CompletableFuture <>();
162+ completions .add (initializeWrite );
163+ WebSocket socket = recordingSocket (socketWrites , completions );
164+
165+ turn .onOpen (socket );
166+ initializeWrite .completeExceptionally (new RuntimeException ("write failed" ));
167+
168+ assertTrue (turn .future ().isCompletedExceptionally (),
169+ "write failure must fail the turn immediately instead of waiting for read timeout" );
170+ }
171+
80172 @ Test
81173 void shouldMapWebSocketUrls () {
82174 assertEquals ("ws://host:8081" , CodexAppServerTurn .toWebSocketUrl ("ws://host:8081" ));
0 commit comments