Skip to content

Commit 6b5d9ed

Browse files
committed
fix(v2): align initialization and JSON-RPC batch behavior
Answer a v2 initialize request for any protocol version with the version this runtime supports instead of rejecting it with Invalid params, and treat an omitted `params` member as "no arguments" so all-optional methods such as `session/list` validate. Decode a JSON-RPC batch into a list, dispatch each entry as it would be in its own frame, and return the collected replies as one ordered array. Empty batches answer a single -32600, malformed entries answer in place, and response entries are routed rather than echoed. Batches are carried by the ndjson, SSE and WebSocket transports; Streamable HTTP POST keeps answering a batch root with 501, so the HTTP client refuses a batch send.
1 parent 05f831c commit 6b5d9ed

19 files changed

Lines changed: 464 additions & 65 deletions

‎src/acp/_jsonrpc.py‎

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,20 @@
55

66
from .exceptions import RequestError
77

8+
JsonRpcMessage = dict[str, Any] | list[Any]
89

9-
def decode_message(payload: str | bytes) -> dict[str, Any] | None:
10-
"""Decode a message, returning None for unsupported non-empty batches."""
10+
11+
def decode_message(payload: str | bytes) -> JsonRpcMessage | None:
12+
"""Decode a JSON-RPC message, returning a non-empty batch as a list."""
1113
try:
1214
message = json.loads(payload)
1315
except (json.JSONDecodeError, UnicodeDecodeError) as exc:
1416
raise RequestError.parse_error() from exc
17+
if isinstance(message, list):
18+
# An empty batch is not a batch: the spec answers it with a single -32600 object.
19+
if not message:
20+
raise RequestError.invalid_request()
21+
return message
1522
return require_message_object(message)
1623

1724

‎src/acp/_sse.py‎

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,9 @@
99

1010
import codecs
1111
import json
12-
from typing import TYPE_CHECKING, Any
12+
from typing import TYPE_CHECKING
1313

14-
from ._jsonrpc import decode_message
14+
from ._jsonrpc import JsonRpcMessage, decode_message
1515
from .exceptions import RequestError
1616

1717
if TYPE_CHECKING:
@@ -24,7 +24,7 @@
2424
]
2525

2626

27-
def serialize_sse_event(message: dict[str, Any]) -> bytes:
27+
def serialize_sse_event(message: JsonRpcMessage) -> bytes:
2828
"""Serialize a JSON-RPC message as an SSE ``data:`` event.
2929
3030
The payload is JSON-encoded on a single line and terminated by a blank line,
@@ -41,8 +41,8 @@ def serialize_sse_keepalive() -> bytes:
4141

4242
async def _decode_event(
4343
data_lines: list[str], on_error: Callable[[RequestError], Awaitable[None]] | None
44-
) -> dict[str, Any] | None:
45-
"""Decode buffered ``data:`` lines into a JSON object, or None to skip."""
44+
) -> JsonRpcMessage | None:
45+
"""Decode buffered ``data:`` lines into a JSON object or batch, or None to skip."""
4646
payload = "\n".join(data_lines)
4747
if not payload:
4848
return None
@@ -65,11 +65,12 @@ def _append_field(line: str, data_lines: list[str]) -> None:
6565

6666
async def parse_sse_stream(
6767
chunks: AsyncIterator[bytes], *, on_error: Callable[[RequestError], Awaitable[None]] | None = None
68-
) -> AsyncIterator[dict[str, Any]]:
68+
) -> AsyncIterator[JsonRpcMessage]:
6969
"""Parse an SSE byte stream, yielding decoded JSON-RPC ``data:`` payloads.
7070
7171
Comments (lines starting with ``:``) and non-``data`` fields are ignored.
72-
Invalid JSON and non-object JSON payloads are skipped.
72+
Invalid JSON and other non-message JSON payloads are skipped; a JSON array
73+
is delivered as a batch.
7374
Multi-line ``data:`` fields are concatenated with newlines per the spec. A
7475
blank line dispatches the buffered event.
7576
"""

‎src/acp/_transport.py‎

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
import contextlib
1212
from typing import TYPE_CHECKING, Any, Protocol, runtime_checkable
1313

14-
from ._jsonrpc import decode_message, error_response
14+
from ._jsonrpc import JsonRpcMessage, decode_message, error_response
1515
from .exceptions import RequestError
1616

1717
if TYPE_CHECKING:
@@ -36,9 +36,9 @@ class Transport(Protocol):
3636
response exists); callers correlate such failures with pending requests.
3737
"""
3838

39-
async def send(self, message: dict[str, Any]) -> None: ...
39+
async def send(self, message: dict[str, Any] | list[Any]) -> None: ...
4040

41-
async def receive(self) -> dict[str, Any] | None: ...
41+
async def receive(self) -> JsonRpcMessage | None: ...
4242

4343
async def close(self) -> None: ...
4444

@@ -58,10 +58,10 @@ def __init__(
5858
self._receive_timeout = receive_timeout
5959
self._error_send_failed = False
6060

61-
async def send(self, message: dict[str, Any]) -> None:
61+
async def send(self, message: dict[str, Any] | list[Any]) -> None:
6262
await self._sender.send(message)
6363

64-
async def receive(self) -> dict[str, Any] | None:
64+
async def receive(self) -> JsonRpcMessage | None:
6565
while True:
6666
line = await self._read_line()
6767
if not line:
@@ -109,19 +109,19 @@ class _MemoryTransport:
109109

110110
def __init__(
111111
self,
112-
outbox: asyncio.Queue[dict[str, Any] | None],
113-
inbox: asyncio.Queue[dict[str, Any] | None],
112+
outbox: asyncio.Queue[JsonRpcMessage | None],
113+
inbox: asyncio.Queue[JsonRpcMessage | None],
114114
) -> None:
115115
self._outbox = outbox
116116
self._inbox = inbox
117117
self._closed = False
118118

119-
async def send(self, message: dict[str, Any]) -> None:
119+
async def send(self, message: dict[str, Any] | list[Any]) -> None:
120120
if self._closed:
121121
raise ConnectionError("Transport closed")
122-
await self._outbox.put(dict(message))
122+
await self._outbox.put(dict(message) if isinstance(message, dict) else list(message))
123123

124-
async def receive(self) -> dict[str, Any] | None:
124+
async def receive(self) -> JsonRpcMessage | None:
125125
return await self._inbox.get()
126126

127127
async def close(self) -> None:
@@ -138,8 +138,8 @@ def memory_transport_pair() -> tuple[Transport, Transport]:
138138
A message ``send`` on one end becomes available via ``receive`` on the
139139
other. Closing an end enqueues an EOF (``None``) for its peer.
140140
"""
141-
a_to_b: asyncio.Queue[dict[str, Any] | None] = asyncio.Queue()
142-
b_to_a: asyncio.Queue[dict[str, Any] | None] = asyncio.Queue()
141+
a_to_b: asyncio.Queue[JsonRpcMessage | None] = asyncio.Queue()
142+
b_to_a: asyncio.Queue[JsonRpcMessage | None] = asyncio.Queue()
143143
left = _MemoryTransport(outbox=a_to_b, inbox=b_to_a)
144144
right = _MemoryTransport(outbox=b_to_a, inbox=a_to_b)
145145
return left, right

‎src/acp/connection.py‎

Lines changed: 60 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212

1313
from pydantic import BaseModel, ValidationError
1414

15+
from ._jsonrpc import error_response as _error_response
1516
from ._transport import NdjsonTransport, Transport
1617
from .exceptions import RequestError
1718
from .meta import PROTOCOL_METHODS
@@ -33,14 +34,19 @@ class StreamDirection(str, Enum):
3334
@dataclass(frozen=True, slots=True)
3435
class StreamEvent:
3536
direction: StreamDirection
36-
message: dict[str, Any]
37+
message: dict[str, Any] | list[Any]
3738

3839

3940
StreamObserver = Callable[[StreamEvent], Awaitable[None] | None]
4041

4142
_CANCEL_REQUEST_METHOD = PROTOCOL_METHODS["cancel_request"]
4243

4344

45+
async def _answer_invalid_entry(payload: dict[str, Any]) -> dict[str, Any]:
46+
"""Answer a batch entry that is not a valid request without dispatching it."""
47+
return payload
48+
49+
4450
def _is_request_id(value: Any) -> bool:
4551
"""Whether ``value`` is a JSON-RPC ``RequestId``: ``null``, an integer or a string."""
4652
return value is None or isinstance(value, str) or (isinstance(value, int) and not isinstance(value, bool))
@@ -148,6 +154,9 @@ async def _receive_loop(self) -> None:
148154
message = await self._transport.receive()
149155
if message is None:
150156
break
157+
if isinstance(message, list):
158+
self._process_batch(message)
159+
continue
151160
self._notify_observers(StreamDirection.INCOMING, message)
152161
self._process_message(message)
153162
except asyncio.CancelledError:
@@ -177,7 +186,56 @@ def _process_message(self, message: dict[str, Any]) -> None:
177186
if has_id: # this is a response, {"id", "result" | "error"}
178187
self._handle_response(message)
179188

180-
def _notify_observers(self, direction: StreamDirection, message: dict[str, Any]) -> None:
189+
def _process_batch(self, batch: list[Any]) -> None:
190+
# JSON-RPC 2.0 batches: entries are dispatched as if they had arrived one per frame, but
191+
# every reply is collected and sent as a single array in the order the entries appeared.
192+
# A batch with no requests (only notifications, or entries that answer nothing such as
193+
# responses) produces no output at all. An empty array never reaches here: ``decode_message``
194+
# answers it with a single ``-32600`` object on every transport that decodes frames.
195+
replies: list[asyncio.Task[Any]] = []
196+
for item in batch:
197+
entry = _error_response(RequestError.invalid_request())
198+
if isinstance(item, dict):
199+
self._notify_observers(StreamDirection.INCOMING, item)
200+
method = item.get("method")
201+
if method is None:
202+
if "id" in item:
203+
# A response to one of our own requests never joins the reply array.
204+
self._handle_response(item)
205+
continue
206+
elif "id" not in item:
207+
if method == _CANCEL_REQUEST_METHOD:
208+
self._cancel_incoming(item.get("params"))
209+
else:
210+
self._tasks.create(self._run_notification(item), name="acp.Connection.notification")
211+
continue
212+
else:
213+
entry = item
214+
# Non-object entries keep the null-id error response built above, as the spec requires.
215+
if "method" in entry:
216+
handler = self._tasks.create(self._execute_request(entry), name="acp.Connection.request")
217+
self._track_incoming(entry["id"], handler)
218+
else:
219+
handler = self._tasks.create(_answer_invalid_entry(entry), name="acp.Connection.batch_entry")
220+
replies.append(self._tasks.create(self._batch_reply(handler, entry), name="acp.Connection.batch_reply"))
221+
if replies:
222+
self._tasks.create(self._send_batch(replies), name="acp.Connection.batch_response")
223+
224+
async def _batch_reply(self, handler: asyncio.Task[Any], entry: dict[str, Any]) -> dict[str, Any]:
225+
# Mirrors ``_run_request``: ``asyncio.wait`` keeps a handler cancelled by
226+
# ``$/cancel_request`` from aborting the reply for the whole batch.
227+
await asyncio.wait({handler})
228+
if not handler.cancelled():
229+
return handler.result()
230+
return {"jsonrpc": "2.0", "id": entry.get("id"), "error": RequestError.request_cancelled().to_error_obj()}
231+
232+
async def _send_batch(self, replies: list[asyncio.Task[Any]]) -> None:
233+
# ``gather`` preserves the order of the entries, so the array mirrors the request batch.
234+
payload = list(await asyncio.gather(*replies))
235+
await self._transport.send(payload)
236+
self._notify_observers(StreamDirection.OUTGOING, payload)
237+
238+
def _notify_observers(self, direction: StreamDirection, message: dict[str, Any] | list[Any]) -> None:
181239
if not self._observers:
182240
return
183241
snapshot = copy.deepcopy(message)

‎src/acp/experimental/v2/_initialization.py‎

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -24,11 +24,11 @@ def phase(self) -> InitializationPhase:
2424
def begin(self, request: schema.InitializeRequest) -> None:
2525
if self._phase != "uninitialized":
2626
raise RequestError.invalid_request({"details": "ACP v2 connections may only be initialized once"})
27-
if request.protocol_version != PROTOCOL_VERSION:
28-
raise RequestError.invalid_params({
29-
"expectedProtocolVersion": PROTOCOL_VERSION,
30-
"receivedProtocolVersion": request.protocol_version,
31-
})
27+
# A request for any version other than PROTOCOL_VERSION is answered by the response, not
28+
# rejected: initialization requires an unsupported request to succeed with the newest
29+
# version this runtime supports (TCK ACP-INIT-003/201/202). The requested value is
30+
# therefore validated only by the generated request model; `complete` still enforces that
31+
# the response advertises PROTOCOL_VERSION.
3232
self._phase = "initializing"
3333

3434
def complete(self, response: schema.InitializeResponse) -> None:

‎src/acp/experimental/v2/_router.py‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,11 @@ async def handle_request(self, spec: RequestSpec, params: Any) -> Any:
3737
handler = self._handler(spec.handler)
3838
if handler is None:
3939
raise RequestError.method_not_found(spec.method)
40-
request = spec.request.validate_python(params)
40+
# ``params`` is optional on every ACP request envelope, so an omitted one means "no
41+
# arguments" and validates like ``{}``. A request whose fields are required still fails
42+
# validation, so this only rescues methods such as ``session/list`` whose fields are all
43+
# optional (TCK ACP-BATCH-204/205/JSONRPC-001 send ``session/list`` with no params).
44+
request = spec.request.validate_python({} if params is None else params)
4145
response = await handler(**model_to_kwargs(request, type(request)))
4246
if response is None and spec.empty_response:
4347
response = {}

‎src/acp/http/client.py‎

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@
2323
import contextlib
2424
from typing import TYPE_CHECKING, Any
2525

26-
from .._jsonrpc import error_response
26+
from .._jsonrpc import JsonRpcMessage, error_response
2727
from .._sse import parse_sse_stream
2828
from ..exceptions import RequestError
2929
from .protocol import (
@@ -88,9 +88,13 @@ def __init__(
8888

8989
# -- Transport protocol -------------------------------------------------
9090

91-
async def send(self, message: dict[str, Any]) -> None:
91+
async def send(self, message: dict[str, Any] | list[Any]) -> None:
9292
if self._closed:
9393
raise ConnectionError("Transport closed")
94+
if isinstance(message, list):
95+
# A Streamable HTTP POST carries a single JSON-RPC message and the server answers a
96+
# batch root with 501, so a batch can never be delivered over this transport.
97+
raise ConnectionError("Batch requests are not supported over Streamable HTTP")
9498
if is_initialize_request(message):
9599
await self._send_initialize(message)
96100
return
@@ -105,7 +109,7 @@ async def send(self, message: dict[str, Any]) -> None:
105109
self._pending_loads.pop(key, None)
106110
raise
107111

108-
async def receive(self) -> dict[str, Any] | None:
112+
async def receive(self) -> JsonRpcMessage | None:
109113
item = await self._inbox.get()
110114
if item is _EOF:
111115
return None
@@ -221,7 +225,12 @@ async def _send_protocol_error(self, error: RequestError) -> None:
221225
with contextlib.suppress(ConnectionError, httpx2.HTTPError, AcpHttpStatusError):
222226
await self.send(error_response(error))
223227

224-
def _handle_incoming(self, message: dict[str, Any]) -> None:
228+
def _handle_incoming(self, message: dict[str, Any] | list[Any]) -> None:
229+
if isinstance(message, list):
230+
for entry in message:
231+
if isinstance(entry, dict):
232+
self._handle_incoming(entry)
233+
return
225234
# Load and resume responses may omit sessionId; the ID is in the request.
226235
if is_response_message(message):
227236
key = message_id_key(message.get("id"))

‎src/acp/task/sender.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ def __init__(self, writer: asyncio.StreamWriter, supervisor: TaskSupervisor) ->
2525
self._closed = False
2626
self._task = supervisor.create(self._loop(), name="acp.Sender.loop", on_error=self._on_error)
2727

28-
async def send(self, payload: dict[str, Any]) -> None:
28+
async def send(self, payload: dict[str, Any] | list[Any]) -> None:
2929
data = (json.dumps(payload, separators=(",", ":")) + "\n").encode("utf-8")
3030
future: asyncio.Future[None] = asyncio.get_running_loop().create_future()
3131
await self._queue.put(_PendingSend(data, future))

‎src/acp/ws/client.py‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
from typing import TYPE_CHECKING, Any
1414

1515
from .._cookies import MemoryAcpCookieStore
16-
from .._jsonrpc import decode_message, error_response
16+
from .._jsonrpc import JsonRpcMessage, decode_message, error_response
1717
from ..exceptions import RequestError
1818

1919
try:
@@ -40,12 +40,12 @@ def __init__(self, connection: Any) -> None:
4040
self._ws = connection
4141
self._closed = False
4242

43-
async def send(self, message: dict[str, Any]) -> None:
43+
async def send(self, message: dict[str, Any] | list[Any]) -> None:
4444
if self._closed:
4545
raise ConnectionError("Transport closed")
4646
await self._ws.send(json.dumps(message, separators=(",", ":")))
4747

48-
async def receive(self) -> dict[str, Any] | None:
48+
async def receive(self) -> JsonRpcMessage | None:
4949
while True:
5050
try:
5151
frame = await self._ws.recv()

‎src/acp/ws/server.py‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99

1010
from starlette.websockets import WebSocket, WebSocketDisconnect, WebSocketState
1111

12-
from .._jsonrpc import decode_message, error_response
12+
from .._jsonrpc import JsonRpcMessage, decode_message, error_response
1313
from ..agent.connection import AgentSideConnection
1414
from ..exceptions import RequestError
1515
from ..http.protocol import CONNECTION_ID_HEADER
@@ -28,12 +28,12 @@ def __init__(self, websocket: WebSocket) -> None:
2828
self._ws = websocket
2929
self._warned_binary = False
3030

31-
async def send(self, message: dict[str, Any]) -> None:
31+
async def send(self, message: dict[str, Any] | list[Any]) -> None:
3232
if self._ws.client_state == WebSocketState.DISCONNECTED:
3333
raise ConnectionError("Transport closed")
3434
await self._ws.send_json(message)
3535

36-
async def receive(self) -> dict[str, Any] | None:
36+
async def receive(self) -> JsonRpcMessage | None:
3737
while self._ws.client_state != WebSocketState.DISCONNECTED:
3838
event = await self._ws.receive()
3939
if event["type"] == "websocket.disconnect":

0 commit comments

Comments
 (0)