Skip to content

Commit 1647b4a

Browse files
authored
fix(client): preserve session update ordering (#129)
1 parent e8ff5bc commit 1647b4a

2 files changed

Lines changed: 219 additions & 8 deletions

File tree

src/acp/client/connection.py

Lines changed: 70 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,15 @@
22

33
import asyncio
44
from collections.abc import Callable
5+
from contextvars import ContextVar
56
from typing import Any, cast, final
67

78
from .._transport import Transport
89
from ..connection import Connection
10+
from ..exceptions import RequestError
911
from ..interfaces import Agent, Client
10-
from ..meta import AGENT_METHODS
12+
from ..meta import AGENT_METHODS, CLIENT_METHODS
13+
from ..router import _resolve_handler, _warn_legacy_handler
1114
from ..schema import (
1215
AcpMcpServer,
1316
AudioContentBlock,
@@ -37,6 +40,7 @@
3740
ResourceContentBlock,
3841
ResumeSessionRequest,
3942
ResumeSessionResponse,
43+
SessionNotification,
4044
SetSessionConfigOptionBooleanRequest,
4145
SetSessionConfigOptionResponse,
4246
SetSessionConfigOptionSelectRequest,
@@ -52,6 +56,56 @@
5256
_CLIENT_CONNECTION_ERROR = "ClientSideConnection requires asyncio StreamWriter/StreamReader"
5357

5458

59+
class _SessionUpdateTracker:
60+
"""Client proxy that tracks in-flight session updates."""
61+
62+
def __init__(self, client: Client) -> None:
63+
self._client = client
64+
self._session_update, self._session_update_attr, self._legacy_session_update = _resolve_handler(
65+
client, "session_update"
66+
)
67+
self._pending: dict[str, set[asyncio.Future[None]]] = {}
68+
self._current_update: ContextVar[asyncio.Future[None] | None] = ContextVar(
69+
"acp_current_session_update", default=None
70+
)
71+
72+
async def session_update(self, session_id: str, update: Any, **kwargs: Any) -> None:
73+
completed: asyncio.Future[None] = asyncio.get_running_loop().create_future()
74+
pending = self._pending.setdefault(session_id, set())
75+
pending.add(completed)
76+
token = self._current_update.set(completed)
77+
78+
try:
79+
if self._session_update is None:
80+
raise RequestError.method_not_found(CLIENT_METHODS["session_update"])
81+
if self._legacy_session_update:
82+
_warn_legacy_handler(self._client, self._session_update_attr)
83+
notification = SessionNotification(session_id=session_id, update=update, field_meta=kwargs or None)
84+
await self._session_update(notification)
85+
else:
86+
await self._session_update(session_id=session_id, update=update, **kwargs)
87+
finally:
88+
self._current_update.reset(token)
89+
if not completed.done():
90+
completed.set_result(None)
91+
pending.discard(completed)
92+
if not pending:
93+
self._pending.pop(session_id, None)
94+
95+
async def wait(self, session_id: str) -> None:
96+
# Snapshot before yielding so updates received after the response are
97+
# not associated with this prompt.
98+
current = self._current_update.get()
99+
notifications = tuple(
100+
completed for completed in self._pending.get(session_id, set()) if completed is not current
101+
)
102+
if notifications:
103+
await asyncio.gather(*(asyncio.shield(completed) for completed in notifications))
104+
105+
def __getattr__(self, name: str) -> Any:
106+
return getattr(self._client, name)
107+
108+
55109
@final
56110
@compatible_class
57111
class ClientSideConnection:
@@ -69,7 +123,9 @@ def __init__(
69123
**connection_kwargs: Any,
70124
) -> None:
71125
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)
126+
self._session_updates = _SessionUpdateTracker(cast(Client, client))
127+
handler = build_client_router(cast(Client, self._session_updates), use_unstable_protocol=use_unstable_protocol)
128+
73129
if isinstance(input_stream, Transport):
74130
if output_stream is not None:
75131
raise TypeError(_CLIENT_CONNECTION_ERROR)
@@ -206,12 +262,18 @@ async def prompt(
206262
],
207263
**kwargs: Any,
208264
) -> 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-
)
265+
try:
266+
response = await request_model(
267+
self._conn,
268+
AGENT_METHODS["session_prompt"],
269+
PromptRequest(prompt=prompt, session_id=session_id, field_meta=kwargs or None),
270+
PromptResponse,
271+
)
272+
except Exception:
273+
await self._session_updates.wait(session_id)
274+
raise
275+
await self._session_updates.wait(session_id)
276+
return response
215277

216278
@param_model(ForkSessionRequest)
217279
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)