diff --git a/docs/web-transport.md b/docs/web-transport.md index 922ac8fe..ce77eba4 100644 --- a/docs/web-transport.md +++ b/docs/web-transport.md @@ -10,7 +10,8 @@ The SDK can run ACP over two remote connectivity profiles in addition to stdio: session), and `DELETE` to terminate. `initialize` returns `200 OK` with a JSON body; all other POSTs return `202 Accepted`. **Requires HTTP/2.** - **WebSocket** — a `GET` upgrade on the same endpoint carrying full-duplex - JSON-RPC text frames. + JSON-RPC text frames, one message per frame. Binary frames are ignored, as + the transport RFD specifies; the server logs a warning on the first one. Both reuse the existing JSON-RPC message format and ACP lifecycle (`initialize` → session methods → close). diff --git a/src/acp/ws/server.py b/src/acp/ws/server.py index f6179eab..cb63ab96 100644 --- a/src/acp/ws/server.py +++ b/src/acp/ws/server.py @@ -3,6 +3,7 @@ from __future__ import annotations import json +import logging import uuid from typing import TYPE_CHECKING, Any @@ -22,6 +23,7 @@ class _WebSocketTransport: def __init__(self, websocket: WebSocket) -> None: self._ws = websocket + self._warned_binary = False async def send(self, message: dict[str, Any]) -> None: if self._ws.client_state == WebSocketState.DISCONNECTED: @@ -34,6 +36,9 @@ async def receive(self) -> dict[str, Any] | None: if event["type"] == "websocket.disconnect": return None if event.get("text") is None: + if event.get("bytes") is not None and not self._warned_binary: + self._warned_binary = True + logging.warning("Ignoring binary WebSocket frame; ACP messages must be sent as text frames") continue try: message = json.loads(event["text"]) diff --git a/tests/http/test_websocket.py b/tests/http/test_websocket.py index bb88c071..eca6cbd5 100644 --- a/tests/http/test_websocket.py +++ b/tests/http/test_websocket.py @@ -4,6 +4,7 @@ import asyncio import json +import logging from typing import Any import pytest @@ -202,6 +203,31 @@ async def test_asgi_websocket_ignores_non_message_frames() -> None: await asyncio.wait_for(handler, timeout=1) +@pytest.mark.asyncio +async def test_asgi_websocket_warns_once_on_binary_frames(caplog: pytest.LogCaptureFixture) -> None: + socket = _FakeAsgiSocket() + socket.client_connect() + for _ in range(2): + socket._incoming.put_nowait({"type": "websocket.receive", "bytes": b'{"jsonrpc":"2.0"}\n'}) + handler = asyncio.create_task( + handle_websocket(lambda conn: _Agent(), WebSocket({"type": "websocket"}, socket.receive, socket.send)) + ) + try: + with caplog.at_level(logging.WARNING): + socket.client_send_text({ + "jsonrpc": "2.0", + "id": 0, + "method": "initialize", + "params": {"protocolVersion": 1}, + }) + await socket.wait_for_send(lambda m: m.get("id") == 0) + warnings = [r for r in caplog.records if "binary WebSocket frame" in r.getMessage()] + assert len(warnings) == 1 + finally: + socket.client_disconnect() + await asyncio.wait_for(handler, timeout=1) + + @pytest.mark.asyncio async def test_asgi_websocket_full_flow() -> None: socket = _FakeAsgiSocket()