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
14 changes: 10 additions & 4 deletions src/adcp/adagents.py
Original file line number Diff line number Diff line change
Expand Up @@ -2005,6 +2005,7 @@ async def fetch_agent_authorizations_from_directory(
*,
directory_url: str,
since: str | None = None,
cursor: str | None = None,
include: list[str] | None = None,
timeout: float = 10.0,
client: httpx.AsyncClient | None = None,
Expand All @@ -2025,9 +2026,12 @@ async def fetch_agent_authorizations_from_directory(
(e.g. ``"https://aao.example.com"``). The ``/v1/agents/...``
path is appended; pass the directory's root, not a
request-specific path.
since: Optional opaque cursor or RFC 3339 timestamp from a prior
since: Optional RFC 3339 timestamp from a prior
``directory_indexed_at`` — passed through as ``?since=...``
to limit the result to edges that changed since that point.
cursor: Optional opaque pagination cursor from a prior response's
``next_cursor`` — passed through as ``?cursor=...`` to fetch
the next page.
include: Optional list of expansion keys per the AAO directory
API spec (adcp#4894). Each value is emitted as a separate
``?include=<value>`` query parameter (repeated-key form, not
Expand Down Expand Up @@ -2059,8 +2063,8 @@ async def fetch_agent_authorizations_from_directory(
(HTTPS only, DNS pre-check, private/reserved address ban) as
publisher-side fetches.
- Response bodies are capped at 5 MiB. Bulk responses paginate
via ``next_cursor``; pass that value as ``since`` on the next
call — same wire field, different semantics per the schema.
via ``next_cursor``; pass that value as ``cursor`` on the next
call.
"""
if not isinstance(agent_url, str) or not agent_url:
raise AdagentsValidationError("agent_url must be a non-empty string")
Expand All @@ -2076,6 +2080,8 @@ async def fetch_agent_authorizations_from_directory(
query_pairs: list[tuple[str, str]] = []
if since is not None:
query_pairs.append(("since", since))
if cursor is not None:
query_pairs.append(("cursor", cursor))
if include:
# Repeated-key form per docs/aao/directory-api.mdx (style: form,
# explode: true). Comma-joined NOT accepted by spec-conformant
Expand Down Expand Up @@ -2226,7 +2232,7 @@ async def detect_publisher_properties_divergence(
page = await fetch_agent_authorizations_from_directory(
agent_url,
directory_url=directory_url,
since=cursor,
cursor=cursor,
include=["properties"],
timeout=timeout,
client=http,
Expand Down
86 changes: 77 additions & 9 deletions tests/test_adagents.py
Original file line number Diff line number Diff line change
Expand Up @@ -4025,15 +4025,16 @@ def handler(request: httpx.Request) -> httpx.Response:
assert result.next_cursor is None
assert result.agent_url == "https://agent.example.com/"

async def test_since_cursor_passes_through_as_query_string(self):
"""`since` is forwarded verbatim as ?since=… for pagination/incremental sync."""
async def test_since_passes_through_as_query_string(self):
"""`since` is forwarded verbatim as ?since=… for incremental sync."""
from adcp.adagents import fetch_agent_authorizations_from_directory

captured: dict[str, str] = {}

def handler(request: httpx.Request) -> httpx.Response:
captured["url"] = str(request.url)
captured["since"] = request.url.params.get("since") or ""
captured["cursor"] = request.url.params.get("cursor") or ""
return httpx.Response(
200,
json={
Expand All @@ -4047,12 +4048,45 @@ def handler(request: httpx.Request) -> httpx.Response:
await fetch_agent_authorizations_from_directory(
"https://agent.example.com/",
directory_url="https://aao.example.com/",
since="opaque-cursor-1",
since="2026-05-20T12:00:00Z",
client=client,
)

assert captured["since"] == "opaque-cursor-1"
assert "?since=opaque-cursor-1" in captured["url"]
assert captured["since"] == "2026-05-20T12:00:00Z"
assert captured["cursor"] == ""
assert "?since=2026-05-20T12%3A00%3A00Z" in captured["url"]

async def test_cursor_passes_through_as_cursor_query_string(self):
"""Pagination cursors use ?cursor=…, distinct from timestamp `since`."""
from adcp.adagents import fetch_agent_authorizations_from_directory

captured: dict[str, str] = {}

def handler(request: httpx.Request) -> httpx.Response:
captured["url"] = str(request.url)
captured["since"] = request.url.params.get("since") or ""
captured["cursor"] = request.url.params.get("cursor") or ""
return httpx.Response(
200,
json={
"agent_url": "https://agent.example.com/",
"directory_indexed_at": None,
"publishers": [],
},
)

async with self._client(handler) as client:
await fetch_agent_authorizations_from_directory(
"https://agent.example.com/",
directory_url="https://aao.example.com/",
cursor="opaque-cursor-1",
client=client,
)

assert captured["cursor"] == "opaque-cursor-1"
assert captured["since"] == ""
assert "?cursor=opaque-cursor-1" in captured["url"]
assert "since=opaque-cursor-1" not in captured["url"]

async def test_timeout_raises_adagents_timeout_error(self):
"""httpx timeouts surface as AdagentsTimeoutError (not generic Exception)."""
Expand Down Expand Up @@ -4278,14 +4312,15 @@ def handler(request: httpx.Request) -> httpx.Response:

assert result.publishers[0].property_ids is None

async def test_include_combines_with_since(self):
"""`since` and `include` both round-trip together in the URL."""
async def test_include_combines_with_since_and_cursor(self):
"""`since`, pagination `cursor`, and `include` round-trip together."""
from adcp.adagents import fetch_agent_authorizations_from_directory

captured: dict[str, object] = {}

def handler(request: httpx.Request) -> httpx.Response:
captured["since"] = request.url.params.get("since")
captured["cursor"] = request.url.params.get("cursor")
captured["include_list"] = request.url.params.get_list("include")
return httpx.Response(
200,
Expand All @@ -4300,12 +4335,14 @@ def handler(request: httpx.Request) -> httpx.Response:
await fetch_agent_authorizations_from_directory(
"https://agent.example.com/",
directory_url="https://aao.example.com",
since="opaque-cursor-1",
since="2026-05-20T12:00:00Z",
cursor="opaque-cursor-1",
include=["properties"],
client=client,
)

assert captured["since"] == "opaque-cursor-1"
assert captured["since"] == "2026-05-20T12:00:00Z"
assert captured["cursor"] == "opaque-cursor-1"
assert captured["include_list"] == ["properties"]


Expand Down Expand Up @@ -4603,6 +4640,37 @@ def fake_get_properties_by_agent(data, agent_url):

assert call_count == 1

async def test_divergence_uses_cursor_param_for_second_page(self):
"""Directory pagination sends next_cursor back as ?cursor=, not ?since=."""
from adcp.adagents import detect_publisher_properties_divergence

requests: list[httpx.URL] = []

def handler(request: httpx.Request) -> httpx.Response:
requests.append(request.url)
body: dict[str, object] = {
"agent_url": "https://agent.example.com/",
"directory_indexed_at": "2026-05-20T12:00:00Z",
"publishers": [],
}
if len(requests) == 1:
body["next_cursor"] = "page-2"
return httpx.Response(200, json=body)

async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
await detect_publisher_properties_divergence(
"https://agent.example.com/",
directory_url="https://aao.example.com",
sample_size=None,
client=client,
)

assert len(requests) == 2
assert requests[0].params.get("cursor") is None
assert requests[0].params.get("since") is None
assert requests[1].params.get("cursor") == "page-2"
assert requests[1].params.get("since") is None

async def test_divergence_aborts_on_repeated_cursor(self, monkeypatch):
"""Misbehaving directory returns the same next_cursor forever → raise."""
from adcp.adagents import detect_publisher_properties_divergence
Expand Down
Loading