Skip to content
Open
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
2 changes: 1 addition & 1 deletion docs/web-transport.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ Both reuse the existing JSON-RPC message format and ACP lifecycle
pip install "agent-client-protocol[http]"
```

This pulls in `httpx[http2]` (HTTP/2 + SSE consumption), `websockets`, and
This pulls in `httpx2[http2]` (HTTP/2 + SSE consumption), `websockets`, and
`starlette` (the server application). The core SDK and stdio transport do not
require these optional dependencies.

Expand Down
4 changes: 2 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ dev = [
"mkdocstrings[python]>=0.26.1",
"python-dotenv>=1.1.1",
"prek>=0.2.17",
"httpx[http2]>=0.27",
"httpx2[http2]>=2.12",
"websockets>=12.0",
"uvicorn>=0.30",
"starlette>=0.49.3",
Expand All @@ -54,7 +54,7 @@ dev = [
[project.optional-dependencies]
logfire = ["logfire>=0.14", "opentelemetry-sdk>=1.28.0"]
# Experimental remote transports (Streamable HTTP + WebSocket), client + server.
http = ["httpx[http2]>=0.27", "websockets>=12.0", "starlette>=0.49.3"]
http = ["httpx2[http2]>=2.12", "websockets>=12.0", "starlette>=0.49.3"]

[build-system]
requires = ["pdm-backend"]
Expand Down
2 changes: 1 addition & 1 deletion src/acp/_cookies.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"""In-memory cookie store for the WebSocket handshake.

The HTTP client relies on ``httpx``'s built-in cookie jar for session affinity,
The HTTP client relies on ``httpx2``'s built-in cookie jar for session affinity,
but the WebSocket handshake needs a small, explicit store to collect
``Set-Cookie`` headers from the upgrade response and echo them back as a
``Cookie`` request header for the socket lifetime.
Expand Down
2 changes: 1 addition & 1 deletion src/acp/http/__init__.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
"""Streamable HTTP transport for ACP (experimental).

Public exports are import-guarded: the heavy client/server implementations pull
in optional dependencies (``httpx[http2]`` and ``starlette``). Importing a symbol without the
in optional dependencies (``httpx2[http2]`` and ``starlette``). Importing a symbol without the
extra installed raises a friendly ``ImportError`` pointing at
``pip install agent-client-protocol[http]``.
"""
Expand Down
14 changes: 7 additions & 7 deletions src/acp/http/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@
)

try:
import httpx
import httpx2
except ImportError as exc: # pragma: no cover - exercised via import guard message
msg = "The Streamable HTTP transport requires the 'http' extra: pip install agent-client-protocol[http]"
raise ImportError(msg) from exc
Expand Down Expand Up @@ -68,7 +68,7 @@ def __init__(
self,
url: str,
*,
client: httpx.AsyncClient,
client: httpx2.AsyncClient,
owns_client: bool,
headers: dict[str, str] | None = None,
) -> None:
Expand Down Expand Up @@ -191,7 +191,7 @@ async def _consume_stream(self, *, session_id: str | None) -> None:
return
async for event in parse_sse_stream(_aiter_raw(response)):
self._handle_incoming(event)
except (httpx.HTTPError, asyncio.CancelledError):
except (httpx2.HTTPError, asyncio.CancelledError):
return
finally:
self._on_stream_closed(session_id)
Expand Down Expand Up @@ -229,22 +229,22 @@ def _handle_incoming(self, message: dict[str, Any]) -> None:
self._inbox.put_nowait(message)


async def _aiter_raw(response: httpx.Response) -> AsyncIterator[bytes]:
async def _aiter_raw(response: httpx2.Response) -> AsyncIterator[bytes]:
async for chunk in response.aiter_bytes():
yield chunk


def create_http_stream(
url: str,
*,
client: httpx.AsyncClient | None = None,
client: httpx2.AsyncClient | None = None,
headers: dict[str, str] | None = None,
) -> Transport:
"""Create a Streamable HTTP client :class:`Transport`.

Args:
url: The ACP endpoint URL (e.g. ``https://host/acp``).
client: An optional pre-configured ``httpx.AsyncClient``. If omitted, an
client: An optional pre-configured ``httpx2.AsyncClient``. If omitted, an
HTTP/2-enabled client with a cookie jar is created and owned by the
transport (closed on ``close()``).
headers: Extra headers sent on every request.
Expand All @@ -255,5 +255,5 @@ def create_http_stream(
owns_client = client is None
if client is None:
# SSE GET streams are long-lived, so disable read timeouts by default.
client = httpx.AsyncClient(http2=True, timeout=httpx.Timeout(None))
client = httpx2.AsyncClient(http2=True, timeout=httpx2.Timeout(None))
return _HttpStreamTransport(url, client=client, owns_client=owns_client, headers=headers)
12 changes: 6 additions & 6 deletions tests/http/test_asgi.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
from contextlib import asynccontextmanager
from typing import Any

import httpx
import httpx2
import pytest
from starlette.applications import Starlette
from starlette.routing import Mount
Expand Down Expand Up @@ -40,7 +40,7 @@ async def test_http_errors(method: str, headers: dict[str, str], body: str, stat
app = create_asgi_app(lambda conn: TestAgent())
async with (
app.router.lifespan_context(app),
httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as client,
httpx2.AsyncClient(transport=httpx2.ASGITransport(app=app), base_url="http://test") as client,
):
response = await client.request(method, "/acp", headers=headers, content=body)
assert response.status_code == status
Expand All @@ -53,7 +53,7 @@ async def test_unsupported_methods_do_not_open_a_stream(method: str) -> None:
app = create_asgi_app(lambda conn: TestAgent())
async with (
app.router.lifespan_context(app),
httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as client,
httpx2.AsyncClient(transport=httpx2.ASGITransport(app=app), base_url="http://test") as client,
):
response = await client.request(method, "/acp")
assert response.status_code == 405
Expand Down Expand Up @@ -82,7 +82,7 @@ async def lifespan(app: Starlette):
prefix = "/agents" if mounted else ""
path = prefix + (endpoint or "/acp")
server = await serve_asgi(app)
async with httpx.AsyncClient(base_url=f"http://{server.host}:{server.port}") as client:
async with httpx2.AsyncClient(base_url=f"http://{server.host}:{server.port}") as client:
response = await client.post(path, json=INITIALIZE)
assert response.status_code == 200
assert response.json()["id"] == 0
Expand Down Expand Up @@ -111,7 +111,7 @@ async def lifespan(app: Starlette):
@pytest.mark.asyncio
async def test_lifespan_closes_http_connections() -> None:
app = create_asgi_app(lambda conn: TestAgent())
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as client:
async with httpx2.AsyncClient(transport=httpx2.ASGITransport(app=app), base_url="http://test") as client:
async with app.router.lifespan_context(app):
response = await client.post("/acp", json=INITIALIZE)
connection_id = response.headers[CONNECTION_ID_HEADER]
Expand All @@ -130,7 +130,7 @@ def factory(conn):

app = create_asgi_app(factory, path=path)
async with app.router.lifespan_context(app):
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as client:
async with httpx2.AsyncClient(transport=httpx2.ASGITransport(app=app), base_url="http://test") as client:
response = await client.post(path, json=INITIALIZE)
connection_id = response.headers[CONNECTION_ID_HEADER]
scope = {
Expand Down
14 changes: 7 additions & 7 deletions tests/http/test_fixes.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
import json
from typing import Any

import httpx
import httpx2
import pytest

import acp.http.server as server_mod
Expand Down Expand Up @@ -177,22 +177,22 @@ async def test_http_client_surfaces_eof_when_connection_stream_ends() -> None:
"""When the connection-scoped SSE stream ends, receive() must return None."""
conn_id = "conn-eof"

def handler(request: httpx.Request) -> httpx.Response:
def handler(request: httpx2.Request) -> httpx2.Response:
if request.method == "POST":
body = json.loads(request.content)
if body.get("method") == "initialize":
return httpx.Response(
return httpx2.Response(
200,
headers={CONNECTION_ID_HEADER: conn_id, "Content-Type": CONTENT_TYPE_JSON},
json={"jsonrpc": "2.0", "id": body["id"], "result": {}},
)
return httpx.Response(202)
return httpx2.Response(202)
if request.method == "GET":
# SSE stream that immediately ends (empty body -> EOF).
return httpx.Response(200, headers={"Content-Type": "text/event-stream"}, content=b"")
return httpx.Response(202)
return httpx2.Response(200, headers={"Content-Type": "text/event-stream"}, content=b"")
return httpx2.Response(202)

client = httpx.AsyncClient(transport=httpx.MockTransport(handler))
client = httpx2.AsyncClient(transport=httpx2.MockTransport(handler))
transport = create_http_stream("http://testserver/acp", client=client)
try:
await transport.send({"jsonrpc": "2.0", "id": 0, "method": "initialize", "params": {}})
Expand Down
40 changes: 20 additions & 20 deletions tests/http/test_http_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
import json
from typing import Any

import httpx
import httpx2
import pytest

from acp._sse import serialize_sse_event
Expand All @@ -17,7 +17,7 @@


class FakeServer:
"""A minimal in-memory Streamable HTTP server backed by httpx.MockTransport."""
"""A minimal in-memory Streamable HTTP server backed by httpx2.MockTransport."""

def __init__(self) -> None:
self.posts: list[dict[str, Any]] = []
Expand All @@ -26,28 +26,28 @@ def __init__(self) -> None:
self.conn_stream: asyncio.Queue[bytes | None] = asyncio.Queue()
self.session_streams: dict[str, asyncio.Queue[bytes | None]] = {}

def handler(self, request: httpx.Request) -> httpx.Response:
def handler(self, request: httpx2.Request) -> httpx2.Response:
if request.method == "POST":
return self._handle_post(request)
if request.method == "GET":
return self._handle_get(request)
if request.method == "DELETE":
self.deleted = True
return httpx.Response(202)
return httpx.Response(405)
return httpx2.Response(202)
return httpx2.Response(405)

def _handle_post(self, request: httpx.Request) -> httpx.Response:
def _handle_post(self, request: httpx2.Request) -> httpx2.Response:
body = json.loads(request.content)
self.posts.append(body)
if body.get("method") == "initialize":
return httpx.Response(
return httpx2.Response(
200,
headers={CONNECTION_ID_HEADER: CONN_ID, "Content-Type": CONTENT_TYPE_JSON},
json={"jsonrpc": "2.0", "id": body["id"], "result": {"protocolVersion": 1}},
)
return httpx.Response(202)
return httpx2.Response(202)

def _handle_get(self, request: httpx.Request) -> httpx.Response:
def _handle_get(self, request: httpx2.Request) -> httpx2.Response:
session_id = request.headers.get(SESSION_ID_HEADER)
if session_id is not None:
queue = self.session_streams.setdefault(session_id, asyncio.Queue())
Expand All @@ -61,7 +61,7 @@ async def body() -> Any:
return
yield chunk

return httpx.Response(200, headers={"Content-Type": "text/event-stream"}, stream=_AsyncByteStream(body()))
return httpx2.Response(200, headers={"Content-Type": "text/event-stream"}, stream=_AsyncByteStream(body()))

def push_conn(self, message: dict[str, Any]) -> None:
self.conn_stream.put_nowait(serialize_sse_event(message))
Expand All @@ -71,7 +71,7 @@ def push_session(self, session_id: str, message: dict[str, Any]) -> None:
queue.put_nowait(serialize_sse_event(message))


class _AsyncByteStream(httpx.AsyncByteStream):
class _AsyncByteStream(httpx2.AsyncByteStream):
def __init__(self, iterator: Any) -> None:
self._iterator = iterator

Expand All @@ -81,7 +81,7 @@ async def __aiter__(self) -> Any:


def _make_transport(server: FakeServer):
client = httpx.AsyncClient(transport=httpx.MockTransport(server.handler))
client = httpx2.AsyncClient(transport=httpx2.MockTransport(server.handler))
return create_http_stream("http://testserver/acp", client=client), client


Expand All @@ -102,10 +102,10 @@ async def test_initialize_posts_and_reads_connection_id() -> None:

@pytest.mark.asyncio
async def test_initialize_failure_raises() -> None:
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(500)
def handler(request: httpx2.Request) -> httpx2.Response:
return httpx2.Response(500)

client = httpx.AsyncClient(transport=httpx.MockTransport(handler))
client = httpx2.AsyncClient(transport=httpx2.MockTransport(handler))
transport = create_http_stream("http://testserver/acp", client=client)
try:
with pytest.raises(AcpHttpStatusError) as exc:
Expand Down Expand Up @@ -188,19 +188,19 @@ async def test_close_deletes_connection() -> None:

@pytest.mark.asyncio
async def test_post_error_status_raises() -> None:
def handler(request: httpx.Request) -> httpx.Response:
def handler(request: httpx2.Request) -> httpx2.Response:
if request.method == "POST":
body = json.loads(request.content)
if body.get("method") == "initialize":
return httpx.Response(
return httpx2.Response(
200,
headers={CONNECTION_ID_HEADER: CONN_ID},
json={"jsonrpc": "2.0", "id": body["id"], "result": {}},
)
return httpx.Response(404)
return httpx.Response(200, headers={"Content-Type": "text/event-stream"})
return httpx2.Response(404)
return httpx2.Response(200, headers={"Content-Type": "text/event-stream"})

client = httpx.AsyncClient(transport=httpx.MockTransport(handler))
client = httpx2.AsyncClient(transport=httpx2.MockTransport(handler))
transport = create_http_stream("http://testserver/acp", client=client)
try:
await transport.send({"jsonrpc": "2.0", "id": 0, "method": "initialize", "params": {}})
Expand Down
2 changes: 1 addition & 1 deletion tests/http/test_loopback.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"""End-to-end in-process loopback tests: Python client transport <-> ASGI server.

Boots the ASGI app under a real uvicorn server (httpx's ASGITransport buffers
Boots the ASGI app under a real uvicorn server (httpx2's ASGITransport buffers
whole responses and cannot consume infinite SSE streams), then drives the full
ACP flow over both the Streamable HTTP and WebSocket transports.
"""
Expand Down
Loading
Loading