Skip to content

Commit 69374b8

Browse files
committed
feat(http): serve v2 over Streamable HTTP and WebSocket
The Streamable HTTP + WebSocket transports predate the experimental v2 runtime and were hard-wired to the v1 AgentSideConnection and its method table. This completes the port so the transports are protocol-agnostic, matching the TypeScript reference (AcpServer takes an AgentConnector): - AcpServer / handle_websocket accept an AgentProtocolRouter, which negotiates v1 or v2 from the first initialize and attaches the right runtime. - AgentProtocolConnection gains a public listen() so stdio, HTTP and WebSocket drive it uniformly. - session/resume joins session/load as a replay method, so v2 resume routes replay over the connection stream exactly like v1 load. - v2 AgentSideConnection.attach now calls on_connect, matching __init__ and the v1 attach (agents like crow-cli v2 that learn their connection from on_connect would otherwise never receive it). Tests: v2 over HTTP, v2 over WebSocket, v2 resume replay, and a dual-stack test serving v1 and v2 clients on one port. Session-Id: enchanted-spectral-skunk-of-ampleness
1 parent 450fab5 commit 69374b8

6 files changed

Lines changed: 256 additions & 6 deletions

File tree

‎src/acp/experimental/negotiation.py‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,9 @@ def __init__(self, connection: Connection) -> None:
136136
async def _listen(self) -> None:
137137
await self._connection.main_loop()
138138

139+
async def listen(self) -> None:
140+
await self._listen()
141+
139142
async def close(self) -> None:
140143
await self._connection.close()
141144

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,8 @@ def attach(
8484
self._state = InitializationState()
8585
self._conn = connection
8686
agent = agent_factory(self)
87+
if on_connect := getattr(agent, "on_connect", None):
88+
on_connect(self)
8789
router = _AgentRouter(agent, self._state)
8890
return self, router
8991

‎src/acp/http/asgi.py‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
from collections.abc import AsyncIterator
1111
from contextlib import asynccontextmanager
1212
from functools import partial
13+
from typing import TYPE_CHECKING
1314

1415
from starlette.applications import Starlette
1516
from starlette.requests import Request
@@ -21,10 +22,13 @@
2122
from .protocol import ACP_ENDPOINT_PATH, CONNECTION_ID_HEADER, CONTENT_TYPE_SSE, SESSION_ID_HEADER
2223
from .server import AcpServer, AgentFactory
2324

25+
if TYPE_CHECKING:
26+
from ..experimental.negotiation import AgentProtocolRouter
27+
2428
__all__ = ["create_asgi_app"]
2529

2630

27-
def create_asgi_app(agent_factory: AgentFactory, *, path: str = ACP_ENDPOINT_PATH) -> Starlette:
31+
def create_asgi_app(agent_factory: AgentFactory | AgentProtocolRouter, *, path: str = ACP_ENDPOINT_PATH) -> Starlette:
2832
"""Create a Starlette app with one agent instance per connection.
2933
3034
The app handles POST/GET/DELETE and WebSocket at ``path`` (default: /acp).

‎src/acp/http/server.py‎

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@
4545
if TYPE_CHECKING:
4646
from collections.abc import AsyncGenerator
4747

48+
from ..experimental.negotiation import AgentProtocolConnection, AgentProtocolRouter
4849
from ..interfaces import Agent
4950

5051
__all__ = [
@@ -224,9 +225,17 @@ class AcpServer:
224225
``AgentSideConnection`` to produce a per-connection ``Agent``.
225226
"""
226227

227-
def __init__(self, agent_factory: AgentFactory) -> None:
228+
def __init__(self, agent_factory: AgentFactory | AgentProtocolRouter) -> None:
228229
self.agent_factory = agent_factory
229-
self._connections: dict[str, tuple[AgentSideConnection, _HttpTransport]] = {}
230+
self._connections: dict[str, tuple[AgentSideConnection | AgentProtocolConnection, _HttpTransport]] = {}
231+
232+
def _connect(self, transport: _HttpTransport) -> AgentSideConnection | AgentProtocolConnection:
233+
"""Bind one agent (v1) or protocol router (v1/v2) to a transport."""
234+
from ..experimental.negotiation import AgentProtocolRouter
235+
236+
if isinstance(self.agent_factory, AgentProtocolRouter):
237+
return self.agent_factory.connect(transport)
238+
return AgentSideConnection(self.agent_factory, transport)
230239

231240
# -- POST ---------------------------------------------------------------
232241

@@ -268,7 +277,7 @@ async def handle_post(
268277
async def _handle_initialize(self, message: dict[str, Any]) -> Response:
269278
connection_id = uuid.uuid4().hex
270279
transport = _HttpTransport(message.get("id"))
271-
conn = AgentSideConnection(self.agent_factory, transport)
280+
conn = self._connect(transport)
272281
self._connections[connection_id] = (conn, transport)
273282
# Deliver initialize to the agent and await its response so we can return
274283
# the 200 body synchronously (initialize is the one blocking POST). If the

‎src/acp/ws/server.py‎

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
from ..http.protocol import CONNECTION_ID_HEADER
1414

1515
if TYPE_CHECKING:
16+
from ..experimental.negotiation import AgentProtocolRouter
1617
from ..http.server import AgentFactory
1718

1819
__all__ = ["handle_websocket"]
@@ -53,11 +54,16 @@ async def close(self) -> None:
5354
await self._ws.close()
5455

5556

56-
async def handle_websocket(agent_factory: AgentFactory, websocket: WebSocket) -> None:
57+
async def handle_websocket(agent_factory: AgentFactory | AgentProtocolRouter, websocket: WebSocket) -> None:
5758
"""Run one agent for the lifetime of the socket; disconnect cancels its work."""
5859
await websocket.accept(headers=[(CONNECTION_ID_HEADER.lower().encode(), uuid.uuid4().hex.encode())])
5960
transport = _WebSocketTransport(websocket)
60-
conn = AgentSideConnection(agent_factory, transport, listening=False)
61+
from ..experimental.negotiation import AgentProtocolRouter
62+
63+
if isinstance(agent_factory, AgentProtocolRouter):
64+
conn = agent_factory.connect(transport, listening=False)
65+
else:
66+
conn = AgentSideConnection(agent_factory, transport, listening=False)
6167
try:
6268
await conn.listen()
6369
finally:

‎tests/http/test_v2_loopback.py‎

Lines changed: 226 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,226 @@
1+
"""v2 end-to-end loopback over the version-agnostic HTTP/WS transports.
2+
3+
The v2 runtime is served through an :class:`AgentProtocolRouter` instead of a
4+
v1 ``AgentFactory``; the ASGI server must negotiate v2 from ``initialize`` and
5+
route ``session/update`` traffic (and ``session/resume`` replay) the same way it
6+
already routes v1's ``session/load``.
7+
"""
8+
9+
from __future__ import annotations
10+
11+
import asyncio
12+
from typing import Any
13+
14+
import pytest
15+
16+
import acp
17+
from acp.experimental import AgentProtocolRouter, v2
18+
from acp.http.asgi import create_asgi_app
19+
from acp.http.client import create_http_stream
20+
from acp.ws.client import create_websocket_stream
21+
from tests.conftest import TestAgent, TestClient
22+
23+
24+
class RoutedV2Agent:
25+
def __init__(self) -> None:
26+
self.connection: v2.AgentSideConnection | None = None
27+
28+
def on_connect(self, connection: v2.AgentSideConnection) -> None:
29+
self.connection = connection
30+
31+
async def initialize(
32+
self,
33+
protocol_version: int,
34+
info: v2.schema.Implementation,
35+
capabilities: v2.schema.ClientCapabilities | None = None,
36+
**kwargs: Any,
37+
) -> v2.schema.InitializeResponse:
38+
return v2.schema.InitializeResponse(
39+
protocol_version=v2.PROTOCOL_VERSION,
40+
info=v2.schema.Implementation(name="v2-agent", version="1.0.0"),
41+
)
42+
43+
async def new_session(
44+
self,
45+
cwd: str,
46+
additional_directories: list[str] | None = None,
47+
mcp_servers: list[Any] | None = None,
48+
**kwargs: Any,
49+
) -> v2.schema.NewSessionResponse:
50+
return v2.schema.NewSessionResponse(session_id="sess-v2")
51+
52+
async def prompt(
53+
self,
54+
session_id: str,
55+
prompt: list[Any],
56+
**kwargs: Any,
57+
) -> v2.schema.PromptResponse:
58+
assert self.connection is not None
59+
await self.connection.session_update(
60+
session_id=session_id,
61+
update=v2.schema.AgentMessageChunk(
62+
message_id="msg-1",
63+
content=v2.schema.TextContentBlock(text="hello-v2"),
64+
),
65+
)
66+
return v2.schema.PromptResponse(message_id="user-msg-1")
67+
68+
async def resume_session(
69+
self,
70+
session_id: str,
71+
cwd: str,
72+
additional_directories: list[str] | None = None,
73+
mcp_servers: list[Any] | None = None,
74+
replay_from: Any = None,
75+
**kwargs: Any,
76+
) -> v2.schema.ResumeSessionResponse:
77+
assert self.connection is not None
78+
for i in range(3):
79+
await self.connection.session_update(
80+
session_id=session_id,
81+
update=v2.schema.AgentMessageChunk(
82+
message_id=f"history-{i}",
83+
content=v2.schema.TextContentBlock(text=f"history-{i}"),
84+
),
85+
)
86+
return v2.schema.ResumeSessionResponse()
87+
88+
89+
class CapturingClient:
90+
def __init__(self) -> None:
91+
self.updates: list[tuple[str, Any]] = []
92+
93+
async def session_update(self, session_id: str, update: Any, **kwargs: Any) -> None:
94+
self.updates.append((session_id, update))
95+
96+
97+
def _make_app(router: AgentProtocolRouter) -> Any:
98+
return create_asgi_app(router)
99+
100+
101+
def _v2_agent_factory(connection: v2.AgentSideConnection) -> RoutedV2Agent:
102+
return RoutedV2Agent()
103+
104+
105+
def _router() -> AgentProtocolRouter:
106+
return AgentProtocolRouter(v2=_v2_agent_factory)
107+
108+
109+
async def _v2_client(transport: Any) -> tuple[v2.ClientSideConnection, CapturingClient]:
110+
client = CapturingClient()
111+
conn = v2.connect_to_agent(client, transport)
112+
return conn, client
113+
114+
115+
@pytest.mark.asyncio
116+
async def test_v2_http_prompt_streams_update(serve_asgi) -> None:
117+
server = await serve_asgi(_make_app(_router()))
118+
transport = create_http_stream(server.http_url)
119+
conn, client = await _v2_client(transport)
120+
try:
121+
init = await asyncio.wait_for(
122+
conn.initialize(
123+
protocol_version=v2.PROTOCOL_VERSION,
124+
info=v2.schema.Implementation(name="v2-client", version="1.0.0"),
125+
),
126+
timeout=10,
127+
)
128+
assert init.protocol_version == v2.PROTOCOL_VERSION
129+
new = await asyncio.wait_for(conn.new_session(cwd="."), timeout=10)
130+
assert new.session_id == "sess-v2"
131+
result = await asyncio.wait_for(
132+
conn.prompt(session_id=new.session_id, prompt=[v2.schema.TextContentBlock(text="hi")]),
133+
timeout=10,
134+
)
135+
assert result.message_id == "user-msg-1"
136+
await asyncio.sleep(0.2)
137+
assert client.updates, "expected a session/update over SSE"
138+
assert client.updates[0][0] == "sess-v2"
139+
assert client.updates[0][1].content.text == "hello-v2"
140+
finally:
141+
await conn.close()
142+
await transport.close()
143+
144+
145+
@pytest.mark.asyncio
146+
async def test_v2_websocket_prompt_streams_update(serve_asgi) -> None:
147+
server = await serve_asgi(_make_app(_router()))
148+
transport = await create_websocket_stream(server.ws_url)
149+
conn, client = await _v2_client(transport)
150+
try:
151+
await asyncio.wait_for(
152+
conn.initialize(
153+
protocol_version=v2.PROTOCOL_VERSION,
154+
info=v2.schema.Implementation(name="v2-client", version="1.0.0"),
155+
),
156+
timeout=10,
157+
)
158+
new = await asyncio.wait_for(conn.new_session(cwd="."), timeout=10)
159+
await asyncio.wait_for(
160+
conn.prompt(session_id=new.session_id, prompt=[v2.schema.TextContentBlock(text="hi")]),
161+
timeout=10,
162+
)
163+
await asyncio.sleep(0.2)
164+
assert client.updates, "expected a session/update over WS"
165+
assert client.updates[0][1].content.text == "hello-v2"
166+
finally:
167+
await conn.close()
168+
await transport.close()
169+
170+
171+
@pytest.mark.asyncio
172+
async def test_v2_http_resume_replays_history(serve_asgi) -> None:
173+
server = await serve_asgi(_make_app(_router()))
174+
transport = create_http_stream(server.http_url)
175+
conn, client = await _v2_client(transport)
176+
try:
177+
await asyncio.wait_for(
178+
conn.initialize(
179+
protocol_version=v2.PROTOCOL_VERSION,
180+
info=v2.schema.Implementation(name="v2-client", version="1.0.0"),
181+
),
182+
timeout=10,
183+
)
184+
loaded = await asyncio.wait_for(
185+
conn.resume_session(session_id="saved-session", cwd="/"),
186+
timeout=10,
187+
)
188+
assert loaded == v2.schema.ResumeSessionResponse()
189+
assert [update.content.text for _, update in client.updates] == [f"history-{i}" for i in range(3)]
190+
finally:
191+
await conn.close()
192+
await transport.close()
193+
194+
195+
@pytest.mark.asyncio
196+
async def test_dual_stack_http_serves_v1_and_v2(serve_asgi) -> None:
197+
router = AgentProtocolRouter(v1=lambda conn: TestAgent(), v2=_v2_agent_factory)
198+
server = await serve_asgi(create_asgi_app(router))
199+
200+
v1_transport = create_http_stream(server.http_url)
201+
v1_conn = acp.connect_to_agent(TestClient(), v1_transport)
202+
try:
203+
init = await asyncio.wait_for(v1_conn.initialize(protocol_version=acp.PROTOCOL_VERSION), timeout=10)
204+
assert init.protocol_version == acp.PROTOCOL_VERSION
205+
new = await asyncio.wait_for(v1_conn.new_session(cwd="."), timeout=10)
206+
assert new.session_id == "test-session-123"
207+
finally:
208+
await v1_conn.close()
209+
await v1_transport.close()
210+
211+
v2_transport = create_http_stream(server.http_url)
212+
v2_conn, _ = await _v2_client(v2_transport)
213+
try:
214+
init = await asyncio.wait_for(
215+
v2_conn.initialize(
216+
protocol_version=v2.PROTOCOL_VERSION,
217+
info=v2.schema.Implementation(name="v2-client", version="1.0.0"),
218+
),
219+
timeout=10,
220+
)
221+
assert init.protocol_version == v2.PROTOCOL_VERSION
222+
new = await asyncio.wait_for(v2_conn.new_session(cwd="."), timeout=10)
223+
assert new.session_id == "sess-v2"
224+
finally:
225+
await v2_conn.close()
226+
await v2_transport.close()

0 commit comments

Comments
 (0)