Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion docs/web-transport.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
5 changes: 5 additions & 0 deletions src/acp/ws/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from __future__ import annotations

import json
import logging
import uuid
from typing import TYPE_CHECKING, Any

Expand All @@ -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:
Expand All @@ -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"])
Expand Down
26 changes: 26 additions & 0 deletions tests/http/test_websocket.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

import asyncio
import json
import logging
from typing import Any

import pytest
Expand Down Expand Up @@ -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()
Expand Down
Loading