33from __future__ import annotations
44
55import asyncio
6+ import contextlib
67import json
78import logging
89from typing import Any
910
1011import pytest
1112from starlette .websockets import WebSocket
1213from websockets .asyncio .server import serve
14+ from websockets .protocol import State
1315
16+ from acp .connection import Connection
1417from acp .http .protocol import CONNECTION_ID_HEADER
1518from acp .schema import NewSessionResponse , PromptResponse
1619from acp .ws .client import create_websocket_stream
@@ -89,6 +92,155 @@ async def send_binary_then_text(ws: Any) -> None:
8992 await transport .close ()
9093
9194
95+ @pytest .mark .asyncio
96+ @pytest .mark .parametrize ("frame" , ["null" , "[]" , "{}" , "123" , "1.5" , '"x"' , "true" , "false" , "invalid json" , b"binary" ])
97+ async def test_client_transport_receives_valid_message_after_invalid_frame (frame : str | bytes ) -> None :
98+ message = {"jsonrpc" : "2.0" , "id" : 0 , "result" : {"items" : [], "value" : None }}
99+
100+ async def send_frames (ws : Any ) -> None :
101+ await ws .send (frame )
102+ await ws .send (json .dumps (message ))
103+ await ws .close ()
104+
105+ async with serve (send_frames , "127.0.0.1" , 0 ) as server :
106+ port = server .sockets [0 ].getsockname ()[1 ]
107+ transport = await create_websocket_stream (f"ws://127.0.0.1:{ port } " )
108+ try :
109+ assert await asyncio .wait_for (transport .receive (), timeout = 1 ) == message
110+ assert await asyncio .wait_for (transport .receive (), timeout = 1 ) is None
111+ finally :
112+ await transport .close ()
113+
114+
115+ @pytest .mark .asyncio
116+ @pytest .mark .parametrize ("frame" , ["null" , "[]" , "{}" ])
117+ async def test_client_connection_recovers_after_invalid_frame (frame : str ) -> None :
118+ async def respond (ws : Any ) -> None :
119+ request = json .loads (await ws .recv ())
120+ await ws .send (frame )
121+ await ws .send (json .dumps ({"jsonrpc" : "2.0" , "id" : request ["id" ], "result" : {"protocolVersion" : 1 }}))
122+ await ws .wait_closed ()
123+
124+ async def handler (method : str , params : Any , is_notification : bool ) -> None :
125+ pass
126+
127+ async with serve (respond , "127.0.0.1" , 0 ) as server :
128+ port = server .sockets [0 ].getsockname ()[1 ]
129+ transport = await create_websocket_stream (f"ws://127.0.0.1:{ port } " )
130+ async with Connection (handler , transport ) as conn :
131+ result = await asyncio .wait_for (conn .send_request ("initialize" , {"protocolVersion" : 1 }), timeout = 1 )
132+ assert result == {"protocolVersion" : 1 }
133+
134+
135+ @pytest .mark .asyncio
136+ @pytest .mark .parametrize (
137+ ("frame" , "code" , "message" ),
138+ [
139+ ("invalid json" , - 32700 , "Parse error" ),
140+ ("null" , - 32600 , "Invalid request" ),
141+ ("[]" , - 32600 , "Invalid request" ),
142+ ("{}" , - 32600 , "Invalid request" ),
143+ ("123" , - 32600 , "Invalid request" ),
144+ ("1.5" , - 32600 , "Invalid request" ),
145+ ('"x"' , - 32600 , "Invalid request" ),
146+ ("true" , - 32600 , "Invalid request" ),
147+ ("false" , - 32600 , "Invalid request" ),
148+ ],
149+ )
150+ async def test_client_reports_protocol_error_then_receives_message (frame : str , code : int , message : str ) -> None :
151+ valid = {"jsonrpc" : "2.0" , "id" : "response" , "result" : None }
152+ replies = []
153+
154+ async def respond (ws : Any ) -> None :
155+ await ws .send (frame )
156+ replies .append (json .loads (await ws .recv ()))
157+ await ws .send (json .dumps (valid ))
158+ await ws .wait_closed ()
159+
160+ async with serve (respond , "127.0.0.1" , 0 ) as server :
161+ port = server .sockets [0 ].getsockname ()[1 ]
162+ transport = await create_websocket_stream (f"ws://127.0.0.1:{ port } " )
163+ try :
164+ assert await asyncio .wait_for (transport .receive (), timeout = 1 ) == valid
165+ assert replies == [
166+ {"jsonrpc" : "2.0" , "id" : None , "error" : {"code" : code , "message" : message , "data" : None }}
167+ ]
168+ finally :
169+ await transport .close ()
170+
171+
172+ @pytest .mark .asyncio
173+ @pytest .mark .parametrize ("code" , [1000 , 1011 ])
174+ async def test_client_drains_buffered_response_when_error_reply_cannot_be_sent (code : int ) -> None :
175+ closed = asyncio .Event ()
176+ valid = {"jsonrpc" : "2.0" , "id" : 0 , "result" : {"items" : [], "value" : None }}
177+
178+ async def respond (ws : Any ) -> None :
179+ await ws .send ("invalid json" )
180+ await ws .send ("null" )
181+ await ws .send (json .dumps (valid ))
182+ await ws .close (code = code )
183+ closed .set ()
184+
185+ async with serve (respond , "127.0.0.1" , 0 ) as server :
186+ port = server .sockets [0 ].getsockname ()[1 ]
187+ transport = await create_websocket_stream (f"ws://127.0.0.1:{ port } " )
188+ try :
189+ await asyncio .wait_for (closed .wait (), timeout = 1 )
190+ assert await asyncio .wait_for (transport .receive (), timeout = 1 ) == valid
191+ assert await asyncio .wait_for (transport .receive (), timeout = 1 ) is None
192+ finally :
193+ await transport .close ()
194+
195+
196+ @pytest .mark .asyncio
197+ async def test_client_drains_buffered_response_before_close_handshake_finishes (monkeypatch : pytest .MonkeyPatch ) -> None :
198+ from websockets .asyncio .client import connect
199+
200+ closing = asyncio .Event ()
201+ release = asyncio .Event ()
202+ valid = {"jsonrpc" : "2.0" , "id" : 0 , "result" : None }
203+ connection : Any = None
204+
205+ async def open_socket (url : str , ** kwargs : Any ) -> Any :
206+ nonlocal connection
207+ connection = await connect (url , ** kwargs )
208+ return connection
209+
210+ monkeypatch .setattr ("acp.ws.client.ws_connect" , open_socket )
211+
212+ async def respond (ws : Any ) -> None :
213+ await ws .send ("null" )
214+ await ws .send (json .dumps (valid ))
215+ ws .transport .pause_reading ()
216+ close = asyncio .create_task (ws .close ())
217+ try :
218+ while ws .state == State .OPEN :
219+ await asyncio .sleep (0 )
220+ closing .set ()
221+ await release .wait ()
222+ finally :
223+ ws .transport .resume_reading ()
224+ await close
225+
226+ async with serve (respond , "127.0.0.1" , 0 ) as server :
227+ port = server .sockets [0 ].getsockname ()[1 ]
228+ transport = await create_websocket_stream (f"ws://127.0.0.1:{ port } " )
229+ try :
230+
231+ async def wait_for_closing () -> None :
232+ await closing .wait ()
233+ while connection .state == State .OPEN :
234+ await asyncio .sleep (0 )
235+
236+ await asyncio .wait_for (wait_for_closing (), timeout = 1 )
237+ assert await asyncio .wait_for (transport .receive (), timeout = 1 ) == valid
238+ finally :
239+ release .set ()
240+ with contextlib .suppress (Exception ):
241+ await transport .close ()
242+
243+
92244# -- ASGI websocket server handler ---------------------------------------------
93245
94246
@@ -186,10 +338,12 @@ async def prompt(self, session_id: str, prompt: Any = None, **kwargs: Any) -> Pr
186338
187339
188340@pytest .mark .asyncio
189- async def test_asgi_websocket_ignores_non_message_frames () -> None :
341+ async def test_asgi_websocket_reports_invalid_frames_and_ignores_binary () -> None :
190342 socket = _FakeAsgiSocket ()
191343 socket .client_connect ()
192- for frame in ({"bytes" : b"binary" }, {"text" : "invalid json" }, {"text" : "[]" }, {"text" : "null" }):
344+ frames = [b"binary" , "invalid json" , "[]" , "{}" , "null" , "123" , "1.5" , '"x"' , "true" , "false" ]
345+ for frame in frames :
346+ frame = {"bytes" : frame } if isinstance (frame , bytes ) else {"text" : frame }
193347 socket ._incoming .put_nowait ({"type" : "websocket.receive" , ** frame })
194348 handler = asyncio .create_task (
195349 handle_websocket (lambda conn : _Agent (), WebSocket ({"type" : "websocket" }, socket .receive , socket .send ))
@@ -198,6 +352,38 @@ async def test_asgi_websocket_ignores_non_message_frames() -> None:
198352 socket .client_send_text ({"jsonrpc" : "2.0" , "id" : 0 , "method" : "initialize" , "params" : {"protocolVersion" : 1 }})
199353 response = await socket .wait_for_send (lambda m : m .get ("id" ) == 0 )
200354 assert "result" in response
355+ assert [m ["error" ]["code" ] for m in socket .sent if "error" in m ] == [- 32700 , * ([- 32600 ] * 8 )]
356+ assert all (m ["id" ] is None and m ["jsonrpc" ] == "2.0" for m in socket .sent if "error" in m )
357+ finally :
358+ socket .client_disconnect ()
359+ await asyncio .wait_for (handler , timeout = 1 )
360+
361+
362+ @pytest .mark .asyncio
363+ async def test_asgi_websocket_delivers_notification_after_error_reply_fails () -> None :
364+ received = asyncio .Event ()
365+
366+ class Agent (_Agent ):
367+ async def cancel (self , session_id : str , ** kwargs : Any ) -> None :
368+ assert session_id == "sess-ws"
369+ received .set ()
370+
371+ socket = _FakeAsgiSocket ()
372+ socket .client_connect ()
373+
374+ async def failed_send (message : dict [str , Any ]) -> None :
375+ if message ["type" ] == "websocket.send" :
376+ raise OSError ("Peer closed" )
377+ await socket .send (message )
378+
379+ for text in ("invalid json" , "null" , "{}" ):
380+ socket ._incoming .put_nowait ({"type" : "websocket.receive" , "text" : text })
381+ socket .client_send_text ({"jsonrpc" : "2.0" , "method" : "session/cancel" , "params" : {"sessionId" : "sess-ws" }})
382+ handler = asyncio .create_task (
383+ handle_websocket (lambda conn : Agent (), WebSocket ({"type" : "websocket" }, socket .receive , failed_send ))
384+ )
385+ try :
386+ await asyncio .wait_for (received .wait (), timeout = 1 )
201387 finally :
202388 socket .client_disconnect ()
203389 await asyncio .wait_for (handler , timeout = 1 )
0 commit comments