Skip to content

Commit 921dc9a

Browse files
committed
fix(connection): preserve notification response ordering
1 parent 40faed9 commit 921dc9a

4 files changed

Lines changed: 128 additions & 7 deletions

File tree

src/acp/connection.py

Lines changed: 41 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,11 @@ def __init__(
7777
) -> None:
7878
self._handler = handler
7979
self._next_request_id = 0
80+
# Track the notification interval for each outgoing request so its
81+
# response cannot overtake notifications received during that request.
82+
self._notification_sequence = 0
83+
self._pending_notifications: dict[int, asyncio.Future[None]] = {}
84+
self._request_notification_starts: dict[int, int] = {}
8085
self._state = state_store or InMemoryMessageStateStore()
8186
self._tasks = TaskSupervisor(source="acp.Connection")
8287
self._tasks.add_error_handler(self._on_task_error)
@@ -145,6 +150,7 @@ async def send_request(self, method: str, params: JsonValue | None = None) -> An
145150
self._raise_if_unavailable()
146151
request_id = self._next_request_id
147152
self._next_request_id += 1
153+
self._request_notification_starts[request_id] = self._notification_sequence
148154
future = self._state.register_outgoing(request_id, method)
149155
payload = {"jsonrpc": "2.0", "id": request_id, "method": method, "params": params}
150156
try:
@@ -153,10 +159,14 @@ async def send_request(self, method: str, params: JsonValue | None = None) -> An
153159
# A synchronous send failure (e.g. HTTP POST rejected before any
154160
# JSON-RPC response exists) must reject the correlated future so the
155161
# caller gets a real, attributable error.
162+
self._request_notification_starts.pop(request_id, None)
156163
self._state.reject_outgoing(request_id, exc)
157164
raise
158165
self._notify_observers(StreamDirection.OUTGOING, payload)
159-
return await future
166+
try:
167+
return await future
168+
finally:
169+
self._request_notification_starts.pop(request_id, None)
160170

161171
async def send_notification(self, method: str, params: JsonValue | None = None) -> None:
162172
self._raise_if_unavailable()
@@ -185,10 +195,38 @@ async def _process_message(self, message: dict[str, Any]) -> None:
185195
await self._queue.publish(RpcTask(RpcTaskKind.REQUEST, message))
186196
return
187197
if method is not None and not has_id:
188-
await self._queue.publish(RpcTask(RpcTaskKind.NOTIFICATION, message))
198+
self._notification_sequence += 1
199+
sequence = self._notification_sequence
200+
completion = asyncio.get_running_loop().create_future()
201+
self._pending_notifications[sequence] = completion
202+
completion.add_done_callback(lambda _: self._pending_notifications.pop(sequence, None))
203+
await self._queue.publish(RpcTask(RpcTaskKind.NOTIFICATION, message, completion))
189204
return
190205
if has_id:
191-
await self._handle_response(message)
206+
request_id = message["id"]
207+
# Excluding notifications received before this request began keeps
208+
# notification handlers free to make nested requests without those
209+
# responses waiting on the handler that issued them.
210+
start_sequence = self._request_notification_starts.get(request_id, self._notification_sequence)
211+
preceding_notifications = tuple(
212+
completion for sequence, completion in self._pending_notifications.items() if sequence > start_sequence
213+
)
214+
if preceding_notifications:
215+
self._tasks.create(
216+
self._handle_response_after_notifications(message, preceding_notifications),
217+
name="acp.Connection.response",
218+
on_error=self._on_receive_error,
219+
)
220+
else:
221+
await self._handle_response(message)
222+
223+
async def _handle_response_after_notifications(
224+
self,
225+
message: dict[str, Any],
226+
preceding_notifications: tuple[asyncio.Future[None], ...],
227+
) -> None:
228+
await asyncio.gather(*(asyncio.shield(completion) for completion in preceding_notifications))
229+
await self._handle_response(message)
192230

193231
def _notify_observers(self, direction: StreamDirection, message: dict[str, Any]) -> None:
194232
if not self._observers:

src/acp/task/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from __future__ import annotations
22

3+
import asyncio
34
from dataclasses import dataclass
45
from enum import Enum
56
from typing import Any
@@ -16,6 +17,7 @@ class RpcTaskKind(Enum):
1617
class RpcTask:
1718
kind: RpcTaskKind
1819
message: dict[str, Any]
20+
completion: asyncio.Future[None] | None = None
1921

2022

2123
from .dispatcher import ( # noqa: E402

src/acp/task/dispatcher.py

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
from contextlib import suppress
66
from typing import Any, Protocol
77

8-
from . import RpcTaskKind
8+
from . import RpcTask, RpcTaskKind
99
from .queue import MessageQueue
1010
from .state import MessageStateStore
1111
from .supervisor import TaskSupervisor
@@ -60,7 +60,7 @@ async def _run(self) -> None:
6060
if task.kind is RpcTaskKind.REQUEST:
6161
await self._dispatch_request(task.message)
6262
else:
63-
await self._dispatch_notification(task.message)
63+
await self._dispatch_notification(task)
6464
finally:
6565
self._queue.task_done()
6666
except asyncio.CancelledError:
@@ -87,8 +87,12 @@ async def runner() -> None:
8787

8888
self._supervisor.create(runner(), name="acp.Dispatcher.request")
8989

90-
async def _dispatch_notification(self, message: dict[str, Any]) -> None:
90+
async def _dispatch_notification(self, task: RpcTask) -> None:
9191
async def runner() -> None:
92-
await self._notification_runner(message)
92+
try:
93+
await self._notification_runner(task.message)
94+
finally:
95+
if task.completion is not None and not task.completion.done():
96+
task.completion.set_result(None)
9397

9498
self._supervisor.create(runner(), name="acp.Dispatcher.notification")

tests/test_rpc.py

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,83 @@ async def test_session_notifications_flow(connect, client):
144144
assert client.notifications[0].session_id == "sess"
145145

146146

147+
@pytest.mark.asyncio
148+
async def test_response_waits_for_preceding_notification(server):
149+
notification_started = asyncio.Event()
150+
release_notification = asyncio.Event()
151+
notifications: list[Any] = []
152+
153+
async def handler(method: str, params: Any, is_notification: bool) -> None:
154+
assert method == "session/update"
155+
assert is_notification
156+
notification_started.set()
157+
await release_notification.wait()
158+
notifications.append(params)
159+
160+
conn = Connection(handler, server.client_writer, server.client_reader)
161+
request = asyncio.create_task(conn.send_request("session/prompt", {"sessionId": "sess"}))
162+
163+
request_message = json.loads(await server.server_reader.readline())
164+
notification = {
165+
"jsonrpc": "2.0",
166+
"method": "session/update",
167+
"params": {"sessionId": "sess", "update": "answer"},
168+
}
169+
response = {"jsonrpc": "2.0", "id": request_message["id"], "result": {"stopReason": "end_turn"}}
170+
server.server_writer.write((json.dumps(notification) + "\n" + json.dumps(response) + "\n").encode())
171+
await server.server_writer.drain()
172+
173+
await asyncio.wait_for(notification_started.wait(), timeout=1)
174+
await asyncio.sleep(0)
175+
assert not request.done()
176+
177+
release_notification.set()
178+
assert await asyncio.wait_for(request, timeout=1) == {"stopReason": "end_turn"}
179+
assert notifications == [notification["params"]]
180+
await conn.close()
181+
182+
183+
@pytest.mark.asyncio
184+
async def test_notification_can_await_nested_request(server):
185+
nested_result: Any = None
186+
notification_finished = asyncio.Event()
187+
conn: Connection | None = None
188+
189+
async def handler(method: str, params: Any, is_notification: bool) -> None:
190+
nonlocal nested_result
191+
assert conn is not None
192+
assert method == "session/update"
193+
assert is_notification
194+
nested_result = await conn.send_request("nested/request", params)
195+
notification_finished.set()
196+
197+
conn = Connection(handler, server.client_writer, server.client_reader)
198+
outer_request = asyncio.create_task(conn.send_request("session/prompt", {"sessionId": "sess"}))
199+
outer_message = json.loads(await server.server_reader.readline())
200+
201+
notification = {
202+
"jsonrpc": "2.0",
203+
"method": "session/update",
204+
"params": {"sessionId": "sess"},
205+
}
206+
server.server_writer.write((json.dumps(notification) + "\n").encode())
207+
await server.server_writer.drain()
208+
209+
nested_message = json.loads(await asyncio.wait_for(server.server_reader.readline(), timeout=1))
210+
nested_response = {"jsonrpc": "2.0", "id": nested_message["id"], "result": {"ok": True}}
211+
server.server_writer.write((json.dumps(nested_response) + "\n").encode())
212+
await server.server_writer.drain()
213+
214+
await asyncio.wait_for(notification_finished.wait(), timeout=1)
215+
assert nested_result == {"ok": True}
216+
217+
outer_response = {"jsonrpc": "2.0", "id": outer_message["id"], "result": {"stopReason": "end_turn"}}
218+
server.server_writer.write((json.dumps(outer_response) + "\n").encode())
219+
await server.server_writer.drain()
220+
assert await asyncio.wait_for(outer_request, timeout=1) == {"stopReason": "end_turn"}
221+
await conn.close()
222+
223+
147224
@pytest.mark.asyncio
148225
async def test_on_connect_create_terminal_handle(server):
149226
class _TerminalAgent(Agent):

0 commit comments

Comments
 (0)