Skip to content

Commit 3365b83

Browse files
committed
fix(client): preserve session update ordering
1 parent e8ff5bc commit 3365b83

2 files changed

Lines changed: 212 additions & 9 deletions

File tree

src/acp/client/connection.py

Lines changed: 63 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,13 @@
11
from __future__ import annotations
22

33
import asyncio
4-
from collections.abc import Callable
4+
from collections.abc import Awaitable, Callable
55
from typing import Any, cast, final
66

77
from .._transport import Transport
88
from ..connection import Connection
99
from ..interfaces import Agent, Client
10-
from ..meta import AGENT_METHODS
10+
from ..meta import AGENT_METHODS, CLIENT_METHODS
1111
from ..schema import (
1212
AcpMcpServer,
1313
AudioContentBlock,
@@ -37,6 +37,7 @@
3737
ResourceContentBlock,
3838
ResumeSessionRequest,
3939
ResumeSessionResponse,
40+
SessionNotification,
4041
SetSessionConfigOptionBooleanRequest,
4142
SetSessionConfigOptionResponse,
4243
SetSessionConfigOptionSelectRequest,
@@ -52,6 +53,40 @@
5253
_CLIENT_CONNECTION_ERROR = "ClientSideConnection requires asyncio StreamWriter/StreamReader"
5354

5455

56+
class _SessionUpdateTracker:
57+
"""Track in-flight session updates relative to each prompt."""
58+
59+
def __init__(self) -> None:
60+
self._latest: dict[str, int] = {}
61+
self._pending: dict[str, dict[int, asyncio.Future[None]]] = {}
62+
63+
def checkpoint(self, session_id: str) -> int:
64+
return self._latest.get(session_id, 0)
65+
66+
async def handle(self, session_id: str, notification: Awaitable[Any]) -> Any:
67+
sequence = self._latest.get(session_id, 0) + 1
68+
self._latest[session_id] = sequence
69+
completed: asyncio.Future[None] = asyncio.get_running_loop().create_future()
70+
pending = self._pending.setdefault(session_id, {})
71+
pending[sequence] = completed
72+
try:
73+
return await notification
74+
finally:
75+
if not completed.done():
76+
completed.set_result(None)
77+
pending.pop(sequence, None)
78+
if not pending:
79+
self._pending.pop(session_id, None)
80+
81+
async def wait(self, session_id: str, after: int) -> None:
82+
# Snapshot before yielding so updates received after the response are
83+
# not associated with this prompt.
84+
pending = self._pending.get(session_id, {})
85+
notifications = tuple(completed for sequence, completed in pending.items() if sequence > after)
86+
if notifications:
87+
await asyncio.gather(*(asyncio.shield(completed) for completed in notifications))
88+
89+
5590
@final
5691
@compatible_class
5792
class ClientSideConnection:
@@ -69,7 +104,17 @@ def __init__(
69104
**connection_kwargs: Any,
70105
) -> None:
71106
client = to_client(self) if callable(to_client) else to_client
72-
handler = build_client_router(cast(Client, client), use_unstable_protocol=use_unstable_protocol)
107+
router = build_client_router(cast(Client, client), use_unstable_protocol=use_unstable_protocol)
108+
self._session_updates = _SessionUpdateTracker()
109+
110+
async def handler(method: str, params: Any, is_notification: bool) -> Any:
111+
if is_notification and method == CLIENT_METHODS["session_update"]:
112+
notification = SessionNotification.model_validate(params)
113+
return await self._session_updates.handle(
114+
notification.session_id, router(method, params, is_notification)
115+
)
116+
return await router(method, params, is_notification)
117+
73118
if isinstance(input_stream, Transport):
74119
if output_stream is not None:
75120
raise TypeError(_CLIENT_CONNECTION_ERROR)
@@ -206,12 +251,21 @@ async def prompt(
206251
],
207252
**kwargs: Any,
208253
) -> PromptResponse:
209-
return await request_model(
210-
self._conn,
211-
AGENT_METHODS["session_prompt"],
212-
PromptRequest(prompt=prompt, session_id=session_id, field_meta=kwargs or None),
213-
PromptResponse,
214-
)
254+
checkpoint = self._session_updates.checkpoint(session_id)
255+
try:
256+
response = await request_model(
257+
self._conn,
258+
AGENT_METHODS["session_prompt"],
259+
PromptRequest(prompt=prompt, session_id=session_id, field_meta=kwargs or None),
260+
PromptResponse,
261+
)
262+
except asyncio.CancelledError:
263+
raise
264+
except Exception:
265+
await self._session_updates.wait(session_id, checkpoint)
266+
raise
267+
await self._session_updates.wait(session_id, checkpoint)
268+
return response
215269

216270
@param_model(ForkSessionRequest)
217271
async def fork_session(

tests/test_rpc.py

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
)
3535
from acp.connection import Connection
3636
from acp.core import AgentSideConnection, ClientSideConnection
37+
from acp.exceptions import RequestError
3738
from acp.schema import (
3839
AgentMessageChunk,
3940
AllowedOutcome,
@@ -144,6 +145,154 @@ async def test_session_notifications_flow(connect, client):
144145
assert client.notifications[0].session_id == "sess"
145146

146147

148+
@pytest.mark.asyncio
149+
async def test_response_waits_for_preceding_notification(server):
150+
notification_started = asyncio.Event()
151+
release_notification = asyncio.Event()
152+
153+
class _BlockingClient(TestClient):
154+
async def session_update(self, session_id: str, update: Any, **kwargs: Any) -> None:
155+
notification_started.set()
156+
await release_notification.wait()
157+
await super().session_update(session_id, update, **kwargs)
158+
159+
client = _BlockingClient()
160+
conn = ClientSideConnection(client, server.client_writer, server.client_reader)
161+
request = asyncio.create_task(
162+
conn.prompt(session_id="sess", prompt=[TextContentBlock(type="text", text="question")])
163+
)
164+
165+
request_message = json.loads(await server.server_reader.readline())
166+
notification = {
167+
"jsonrpc": "2.0",
168+
"method": "session/update",
169+
"params": {
170+
"sessionId": "sess",
171+
"update": {
172+
"sessionUpdate": "agent_message_chunk",
173+
"content": {"type": "text", "text": "answer"},
174+
},
175+
},
176+
}
177+
response = {"jsonrpc": "2.0", "id": request_message["id"], "result": {"stopReason": "end_turn"}}
178+
server.server_writer.write((json.dumps(notification) + "\n" + json.dumps(response) + "\n").encode())
179+
await server.server_writer.drain()
180+
181+
await asyncio.wait_for(notification_started.wait(), timeout=1)
182+
await asyncio.sleep(0)
183+
assert not request.done()
184+
185+
release_notification.set()
186+
prompt_response = await asyncio.wait_for(request, timeout=1)
187+
assert prompt_response.stop_reason == "end_turn"
188+
assert len(client.notifications) == 1
189+
assert client.notifications[0].session_id == "sess"
190+
await conn.close()
191+
192+
193+
@pytest.mark.asyncio
194+
async def test_error_response_waits_for_preceding_notification(server):
195+
notification_started = asyncio.Event()
196+
release_notification = asyncio.Event()
197+
198+
class _BlockingClient(TestClient):
199+
async def session_update(self, session_id: str, update: Any, **kwargs: Any) -> None:
200+
notification_started.set()
201+
await release_notification.wait()
202+
203+
conn = ClientSideConnection(_BlockingClient(), server.client_writer, server.client_reader)
204+
request = asyncio.create_task(
205+
conn.prompt(session_id="sess", prompt=[TextContentBlock(type="text", text="question")])
206+
)
207+
208+
request_message = json.loads(await server.server_reader.readline())
209+
notification = {
210+
"jsonrpc": "2.0",
211+
"method": "session/update",
212+
"params": {
213+
"sessionId": "sess",
214+
"update": {
215+
"sessionUpdate": "agent_message_chunk",
216+
"content": {"type": "text", "text": "partial answer"},
217+
},
218+
},
219+
}
220+
response = {
221+
"jsonrpc": "2.0",
222+
"id": request_message["id"],
223+
"error": {"code": -32603, "message": "prompt failed"},
224+
}
225+
server.server_writer.write((json.dumps(notification) + "\n" + json.dumps(response) + "\n").encode())
226+
await server.server_writer.drain()
227+
228+
await asyncio.wait_for(notification_started.wait(), timeout=1)
229+
await asyncio.sleep(0)
230+
assert not request.done()
231+
232+
release_notification.set()
233+
with pytest.raises(RequestError, match="prompt failed"):
234+
await asyncio.wait_for(request, timeout=1)
235+
await conn.close()
236+
237+
238+
@pytest.mark.asyncio
239+
async def test_notification_can_await_nested_request(server):
240+
notification_finished = asyncio.Event()
241+
242+
class _NestedPromptClient(TestClient):
243+
def __init__(self) -> None:
244+
super().__init__()
245+
self.conn: Agent | None = None
246+
self.nested_result: PromptResponse | None = None
247+
248+
def on_connect(self, conn: Agent) -> None:
249+
self.conn = conn
250+
251+
async def session_update(self, session_id: str, update: Any, **kwargs: Any) -> None:
252+
assert self.conn is not None
253+
self.nested_result = await self.conn.prompt(
254+
session_id=session_id,
255+
prompt=[TextContentBlock(type="text", text="nested question")],
256+
)
257+
notification_finished.set()
258+
259+
client = _NestedPromptClient()
260+
conn = ClientSideConnection(client, server.client_writer, server.client_reader)
261+
outer_request = asyncio.create_task(
262+
conn.prompt(session_id="sess", prompt=[TextContentBlock(type="text", text="outer question")])
263+
)
264+
outer_message = json.loads(await server.server_reader.readline())
265+
266+
notification = {
267+
"jsonrpc": "2.0",
268+
"method": "session/update",
269+
"params": {
270+
"sessionId": "sess",
271+
"update": {
272+
"sessionUpdate": "agent_message_chunk",
273+
"content": {"type": "text", "text": "answer"},
274+
},
275+
},
276+
}
277+
server.server_writer.write((json.dumps(notification) + "\n").encode())
278+
await server.server_writer.drain()
279+
280+
nested_message = json.loads(await asyncio.wait_for(server.server_reader.readline(), timeout=1))
281+
nested_response = {"jsonrpc": "2.0", "id": nested_message["id"], "result": {"stopReason": "end_turn"}}
282+
server.server_writer.write((json.dumps(nested_response) + "\n").encode())
283+
await server.server_writer.drain()
284+
285+
await asyncio.wait_for(notification_finished.wait(), timeout=1)
286+
assert client.nested_result is not None
287+
assert client.nested_result.stop_reason == "end_turn"
288+
289+
outer_response = {"jsonrpc": "2.0", "id": outer_message["id"], "result": {"stopReason": "end_turn"}}
290+
server.server_writer.write((json.dumps(outer_response) + "\n").encode())
291+
await server.server_writer.drain()
292+
assert (await asyncio.wait_for(outer_request, timeout=1)).stop_reason == "end_turn"
293+
await conn.close()
294+
295+
147296
@pytest.mark.asyncio
148297
async def test_on_connect_create_terminal_handle(server):
149298
class _TerminalAgent(Agent):

0 commit comments

Comments
 (0)