diff --git a/docs/accounts-ensure-and-change.md b/docs/accounts-ensure-and-change.md index c7bbecae2f..66bdb52c4f 100644 --- a/docs/accounts-ensure-and-change.md +++ b/docs/accounts-ensure-and-change.md @@ -57,3 +57,42 @@ A change to `billing`, `billing_entity` or `payment_terms` needs the explicit ac ## Cold start `list_accounts` with `include_requests` reads back your open requests and their pending actions. `refresh()` uses `account_request_ids`. + +## Serving `request_account` (sellers) + +On the decisioning platform, an `AccountStore` serves AdCP 3.3 `account.requests` with two optional hooks: + +```python +from adcp.decisioning import AccountRequestsPage, DecisioningCapabilities +from adcp.decisioning.capabilities import Account as AccountCaps + +class MyAccounts: + def resolve(self, ref, auth_info=None): ... + def list(self, filter=None, ctx=None): ... # Account(..., revision=3) serves revision + + async def request_account(self, params, ctx=None): + # Open, match or change; return the wire AccountRequest record. + return await db.request(params, caller=ctx.agent) + + async def list_requests(self, params, ctx=None): + # Only when the buyer sends include_requests, request_ids or requests_pagination. + page = await db.requests_for(ctx.agent, ids=params.request_ids, + cursor=params.requests_pagination and params.requests_pagination.cursor) + return AccountRequestsPage(items=page.records, next_cursor=page.next) + +capabilities = DecisioningCapabilities( + specialisms=["sales-non-guaranteed"], + account=AccountCaps( + supported_billing=["operator"], + # Optional, advisory: what change requests accept and which pending actions you issue. + requests={"supported": True, "changeable_attributes": ["billing", "payment_terms", "timezone"], + "pending_action_kinds": ["review", "accept_terms"]}, + ), +) +``` + +- **Advertised for you.** With `request_account`, the handler serves the tool and advertises `account.requests` on `get_adcp_capabilities`: `supported: true`, `request_task: "request_account"`, `read_task: "list_accounts"`, plus your optional lists. It also adds `account.requests` to `experimental_features`. Callers on releases before 3.3 see neither, and an `experimental_features` claim without the hook is dropped. Buyers route `ensure` and `change` by this block. +- **`supported_billing` is yours to state.** The account block requires it, and the framework never invents one: a store with `request_account` and no `account.supported_billing` is refused at construction (`ConfigurationError`). +- **Return values.** `request_account` returns the request record, which is served as `{"status": "completed", "request": ...}`. `in_review`, `ambiguous` and `rejected` are normal outcomes. Raise `AdcpError` only for calls you cannot process. A change names `params.account_id`: check that the caller may change that account, and refuse a money change (`billing`, `billing_entity`, `payment_terms`) without `revision`. +- **Polling.** `list_requests` serves `account_requests[]` and `account_requests_pagination` on `list_accounts`. It runs only when the buyer asks, and only for 3.3 callers. Return only the caller's requests, newest `updated_at` first, at most 100 per page, and omit unknown or foreign `request_ids` silently. It needs `list`. With `request_account` but no `list_requests`, the handler warns at construction that buyers cannot poll a pending request. +- **`revision`.** `Account.revision` is served on `list_accounts` to 3.2+ callers. Buyers send it with a change. diff --git a/src/adcp/decisioning/__init__.py b/src/adcp/decisioning/__init__.py index 7071ae7d81..1dffb1a6e0 100644 --- a/src/adcp/decisioning/__init__.py +++ b/src/adcp/decisioning/__init__.py @@ -62,8 +62,11 @@ def create_media_buy( to_wire_sync_governance_row, ) from adcp.decisioning.accounts import ( + AccountRequestsPage, AccountStore, AccountStoreList, + AccountStoreListRequests, + AccountStoreRequestAccount, AccountStoreResolveForTask, AccountStoreSyncGovernance, AccountStoreUpsert, @@ -382,8 +385,11 @@ def __init__(self, *args: object, **kwargs: object) -> None: "Account", "AccountMode", "AccountNotFoundError", + "AccountRequestsPage", "AccountStore", "AccountStoreList", + "AccountStoreListRequests", + "AccountStoreRequestAccount", "AccountStoreSyncGovernance", "AccountStoreUpsert", "AccountStoreUpsertRequest", diff --git a/src/adcp/decisioning/account_projection.py b/src/adcp/decisioning/account_projection.py index 3e52fa8b6d..e414ee116f 100644 --- a/src/adcp/decisioning/account_projection.py +++ b/src/adcp/decisioning/account_projection.py @@ -253,6 +253,9 @@ def to_wire_account(account: DecisioningAccount[Any]) -> dict[str, Any]: wire["timezone"] = account.timezone if account.operator is not None: wire["operator"] = account.operator + revision = getattr(account, "revision", None) + if revision is not None: + wire["revision"] = revision return wire diff --git a/src/adcp/decisioning/accounts.py b/src/adcp/decisioning/accounts.py index f1a125c80f..0f5dc7852b 100644 --- a/src/adcp/decisioning/accounts.py +++ b/src/adcp/decisioning/accounts.py @@ -64,7 +64,12 @@ if TYPE_CHECKING: from adcp.decisioning.registry import BuyerAgent - from adcp.types import AccountReference, SyncAccountsRequest + from adcp.types import ( + AccountReference, + ListAccountsRequest, + RequestAccountRequest, + SyncAccountsRequest, + ) #: Per-platform metadata generic. TMeta = TypeVar("TMeta", default=dict[str, Any]) @@ -400,6 +405,97 @@ def list( ... +@dataclass +class AccountRequestsPage: + """One page of the caller's account requests, returned by + :meth:`AccountStoreListRequests.list_requests`. + + The framework serves ``items`` as ``account_requests[]`` on + ``list_accounts`` and builds ``account_requests_pagination`` from + ``next_cursor`` (``has_more`` is ``next_cursor is not None``) and + ``total_count``. Items are wire ``AccountRequest`` records, as dicts + or Pydantic models. + """ + + items: list[Any] + next_cursor: str | None = None + total_count: int | None = None + + +@runtime_checkable +class AccountStoreRequestAccount(Protocol): + """``request_account`` API surface (AdCP 3.3, experimental ``account.requests``). + + Implementing it serves the ``request_account`` tool and advertises + ``account.requests`` on ``get_adcp_capabilities`` (``supported: true``, + ``request_task: "request_account"``, ``read_task: "list_accounts"``, and + ``account.requests`` in ``experimental_features``) to callers on 3.3 or + later. Optional lists in the block (``changeable_attributes``, + ``pending_action_kinds``, ``event_type``) come from + ``DecisioningCapabilities.account.requests``. The block needs + ``account.supported_billing``: the framework never invents billing + claims, so a store with this hook and no declared ``supported_billing`` + is refused at construction. + + Pair it with :class:`AccountStoreListRequests`: buyers poll a pending + request through ``list_accounts``. + """ + + def request_account( + self, + params: RequestAccountRequest, + ctx: ResolveContext | None = None, + ) -> Awaitable[Any] | Any: + """Open, match or change an account; return the wire ``AccountRequest``. + + Return the request record (a dict or model with ``request_id``, + ``state``, ``requested``, ...); the framework serves it as + ``{"status": "completed", "request": ...}``. A full response dict + with a ``request`` key passes through. An ``in_review``, + ``ambiguous`` or ``rejected`` request is a normal outcome, not an + error. Raise :class:`adcp.decisioning.AdcpError` only for a call + you cannot process (``INVALID_REQUEST``, a stale ``revision`` as + ``CONFLICT``, an unsupported change as ``UNSUPPORTED_FEATURE``). + + A change names ``params.account_id``: check that the caller + (``ctx.agent`` / ``ctx.auth_info``) may change that account, as + :meth:`AccountStore.resolve` does, and refuse a money change + (``billing``, ``billing_entity``, ``payment_terms``) without + ``revision``. Idempotency on ``params.idempotency_key`` is the + framework's, as for every mutating task. + """ + ... + + +@runtime_checkable +class AccountStoreListRequests(Protocol): + """The caller's account requests on ``list_accounts`` (AdCP 3.3, experimental). + + Called only when the buyer asks for them, with ``include_requests``, + ``request_ids`` or ``requests_pagination``, and only for callers on + 3.3 or later. The accounts page is unaffected. Requires + :class:`AccountStoreList`. + """ + + def list_requests( + self, + params: ListAccountsRequest, + ctx: ResolveContext | None = None, + ) -> Awaitable[AccountRequestsPage | list[Any]] | AccountRequestsPage | list[Any]: + """Return the caller's requests, newest ``updated_at`` first. + + Return only the caller's own requests (identify the caller from + ``ctx.agent`` / ``ctx.auth_info``, as for ``list``): every open + request (``in_review``, ``ambiguous``) plus requests that changed + state in the last 30 days, at most 100 per page. With + ``params.request_ids``, return only those, omitting unknown and + other callers' ids silently. Page from + ``params.requests_pagination.cursor``. A plain list is one + complete page. + """ + ... + + @runtime_checkable class AccountStoreSyncGovernance(Protocol): """``sync_governance`` API surface. Optional adopter-side feature. diff --git a/src/adcp/decisioning/handler.py b/src/adcp/decisioning/handler.py index d34edc19b5..7006780019 100644 --- a/src/adcp/decisioning/handler.py +++ b/src/adcp/decisioning/handler.py @@ -216,6 +216,7 @@ class _PublicAccount(_DecisioningAccount[Any]): RefineProposalsResponse, ReportPlanOutcomeRequest, ReportPlanOutcomeResponse, + RequestAccountRequest, RequestProposalsRequest, RequestProposalsResponse, SyncAccountsRequest, @@ -330,6 +331,70 @@ def _is_adcp_32_or_newer(version: str | None) -> bool: return major > 3 or (major == 3 and minor >= 2) +#: The first release that defines ``request_account`` and ``account.requests``. +_ACCOUNT_REQUESTS_MIN_ADCP_VERSION = "3.3.0-beta.0" +_ACCOUNT_REQUESTS_FEATURE = "account.requests" + + +def _serves_account_requests(version: str | None) -> bool: + """Whether a response served at ``version`` may carry ``account.requests``.""" + if version is None: + return False + try: + return is_adcp_version_at_least( + resolve_adcp_version_alias(version.strip()), _ACCOUNT_REQUESTS_MIN_ADCP_VERSION + ) + except ValueError: + return False + + +def _validate_account_requests_config(platform: Any) -> None: + """Refuse an ``account.requests`` configuration the framework would have to invent. + + The advertised block needs ``account.supported_billing``, which only the + seller can state, and ``list_requests`` rides on ``list_accounts``. + """ + from adcp.exceptions import ConfigurationError + + accounts = getattr(platform, "accounts", None) + capabilities = getattr(platform, "capabilities", None) + serves = callable(getattr(accounts, "request_account", None)) + lists = callable(getattr(accounts, "list_requests", None)) + account_caps = getattr(capabilities, "account", None) + declared = getattr(account_caps, "requests", None) + if lists and not callable(getattr(accounts, "list", None)): + raise ConfigurationError( + "accounts.list_requests serves account_requests[] on list_accounts and needs " + "accounts.list. Implement both on the AccountStore." + ) + if declared is not None and not serves: + raise ConfigurationError( + "capabilities.account.requests describes request_account, but the AccountStore " + "has no request_account hook. Implement accounts.request_account, or remove " + "account.requests from the capabilities." + ) + if not serves: + return + billing = getattr(account_caps, "supported_billing", None) or getattr( + capabilities, "supported_billing", None + ) + if not billing: + raise ConfigurationError( + "accounts.request_account advertises account.requests, whose account block " + "requires supported_billing, and none is declared. Set " + "capabilities.account=Account(supported_billing=[...]) to the billing parties " + "this seller invoices." + ) + if not lists: + warnings.warn( + "accounts.request_account is set without accounts.list_requests: list_accounts " + "(the advertised account.requests read_task) returns no account_requests, so " + "buyers cannot poll a pending request. Implement accounts.list_requests.", + UserWarning, + stacklevel=3, + ) + + # --------------------------------------------------------------------------- # Class-level advertised tool surface # --------------------------------------------------------------------------- @@ -1086,6 +1151,58 @@ def _project_list_accounts(result: Any) -> Any: return strip_credentials_from_wire_result("list_accounts", envelope) +def _wire(value: Any) -> Any: + if hasattr(value, "model_dump"): + return value.model_dump(mode="json", exclude_none=True) + return value + + +def _project_request_account(result: Any) -> dict[str, Any]: + """Serve a ``request_account`` hook's return as the wire response. + + An ``AccountRequest`` record becomes ``{"status": "completed", + "request": ...}``; a full response (a mapping with ``request`` or + ``errors``) passes through. + """ + body = _wire(result) + if not isinstance(body, Mapping): + raise TypeError( + "accounts.request_account returns the wire AccountRequest (a dict or model), " + f"not {type(result).__name__}." + ) + if "request" in body or "errors" in body: + response = dict(body) + response.setdefault("status", "completed" if "request" in body else "failed") + if "request" in response: + response["request"] = _wire(response["request"]) + return response + return {"status": "completed", "request": dict(body)} + + +def _project_account_requests_page(page: Any) -> dict[str, Any]: + """``account_requests[]`` and ``account_requests_pagination`` from a ``list_requests`` page.""" + from adcp.decisioning.accounts import AccountRequestsPage + + if isinstance(page, AccountRequestsPage): + items, cursor, total = page.items, page.next_cursor, page.total_count + elif isinstance(page, (list, tuple)): + items, cursor, total = list(page), None, None + else: + raise TypeError( + "accounts.list_requests returns an AccountRequestsPage or a list of wire " + f"AccountRequest records, not {type(page).__name__}." + ) + pagination: dict[str, Any] = {"has_more": cursor is not None} + if cursor is not None: + pagination["cursor"] = cursor + if total is not None: + pagination["total_count"] = total + return { + "account_requests": [_wire(item) for item in items], + "account_requests_pagination": pagination, + } + + def _build_list_accounts_filter(params: Any) -> dict[str, Any]: """Build the filter dict :meth:`AccountStoreList.list` expects from a parsed :class:`ListAccountsRequest`. Strips ``None`` so adopter @@ -1319,6 +1436,8 @@ class PlatformHandler(ADCPHandler[ToolContext]): | set(_PROPERTY_LISTS_ADVERTISED_TOOLS) | set(_COLLECTION_LISTS_ADVERTISED_TOOLS) | { + # AdCP 3.3 account.requests, when the AccountStore implements it. + "request_account", "get_reporting_status", "sync_reporting_status", "sync_reporting_receipts", @@ -1410,6 +1529,9 @@ def advertised_tools_for_instance(self) -> frozenset[str]: ): serving.discard("list_accounts") self._log_account_tool_dropped("list_accounts", "list") + if accounts is not None and callable(getattr(accounts, "request_account", None)): + # AdCP 3.3 account.requests: served whenever the store implements it. + serving.add("request_account") for wire_name, adopter_name in _OPTIONAL_LEGACY_WIRE_TO_ADOPTER.items(): if wire_name in serving and not _implements_optional_method( self._platform, adopter_name @@ -1531,6 +1653,7 @@ def __init__( supported_versions, adcp_version=self._adcp_version ) self._platform = platform + _validate_account_requests_config(platform) installed_reporting = getattr(platform, "_reliable_reporting_service", None) if installed_reporting is not None: # Managed reporting subscriptions read accounts and buys through @@ -2252,6 +2375,10 @@ async def get_adcp_capabilities( # alongside, which the flat field can't supply. if caps.supported_billing and caps.account is None: response["account"] = {"supported_billing": list(caps.supported_billing)} + self._project_account_requests( + response, + (context.resolved_adcp_version if context is not None else None) or self._adcp_version, + ) if caps.pricing_models and caps.media_buy is None and "media_buy" in supported_protocols: # Spec requires uniqueItems on supported_pricing_models; # dedupe via dict.fromkeys to preserve declaration order. @@ -2301,6 +2428,47 @@ async def get_adcp_capabilities( return response + def _project_account_requests(self, response: dict[str, Any], version: str | None) -> None: + """Advertise AdCP 3.3 ``account.requests`` when the store serves ``request_account``. + + ``supported``, ``request_task`` and ``read_task`` are always the + framework's; the advisory lists come from the declared + ``account.requests``. A caller before 3.3 sees neither the block nor + the ``account.requests`` feature id, and neither does any caller when + the store has no ``request_account`` hook. + """ + serves = callable(getattr(self._platform.accounts, "request_account", None)) + advertise = serves and _serves_account_requests(version) + account = response.get("account") + features = [ + item + for item in response.get("experimental_features") or () + if item != _ACCOUNT_REQUESTS_FEATURE + ] + if advertise: + if not isinstance(account, dict) or not account.get("supported_billing"): + from adcp.exceptions import ConfigurationError + + raise ConfigurationError( + "accounts.request_account advertises account.requests, whose account " + "block requires supported_billing, and these capabilities declare none. " + "Set account=Account(supported_billing=[...])." + ) + declared = account.get("requests") + account["requests"] = { + **(declared if isinstance(declared, dict) else {}), + "supported": True, + "request_task": "request_account", + "read_task": "list_accounts", + } + features.append(_ACCOUNT_REQUESTS_FEATURE) + elif isinstance(account, dict): + account.pop("requests", None) + if features: + response["experimental_features"] = features + else: + response.pop("experimental_features", None) + # ----- Sales tools ----- async def _invoke_compact_lifecycle( @@ -3358,6 +3526,27 @@ async def list_accounts( # type: ignore[override] projected = omit_account_activity(projected) served = strip_credentials_from_wire_result("list_accounts", projected) + version = tool_ctx.resolved_adcp_version or self._adcp_version + if isinstance(served, dict) and not _is_adcp_32_or_newer(version): + # Account.revision is an AdCP 3.2 field. + for row in served.get("accounts") or (): + if isinstance(row, dict): + row.pop("revision", None) + list_requests = getattr(self._platform.accounts, "list_requests", None) + if ( + isinstance(served, dict) + and callable(list_requests) + and _serves_account_requests(version) + and ( + getattr(params, "include_requests", None) is True + or getattr(params, "request_ids", None) is not None + or getattr(params, "requests_pagination", None) is not None + ) + ): + page = _call_with_optional_ctx(list_requests, params, ctx=resolve_ctx) + if inspect.isawaitable(page): + page = await page + served.update(_project_account_requests_page(page)) reporting = getattr(self._platform, "_reliable_reporting_service", None) if reporting is not None: # Managed reporting subscriptions serve the grant journal's @@ -3365,6 +3554,29 @@ async def list_accounts( # type: ignore[override] served = await reporting._project_accounts(served, tool_ctx) return cast("ListAccountsResponse", served) + async def request_account( # type: ignore[override] + self, + params: RequestAccountRequest, + context: ToolContext | None = None, + ) -> Any: + """Route ``request_account`` to :meth:`AccountStoreRequestAccount.request_account`. + + AdCP 3.3 ``account.requests``. ``OPERATION_NOT_SUPPORTED`` when the + store has no hook. Like ``sync_accounts``, this is a bootstrap + task: it primes the caller's identity without resolving an + account. The hook scopes a change to the caller. + """ + request_account = getattr(self._platform.accounts, "request_account", None) + if not callable(request_account): + return self._not_supported("request_account") + tool_ctx = context or ToolContext() + await self._prime_auth_context(tool_ctx) + resolve_ctx = self._make_resolve_context(tool_ctx, "request_account") + result = _call_with_optional_ctx(request_account, params, ctx=resolve_ctx) + if inspect.isawaitable(result): + result = await result + return _project_request_account(result) + # ----- Optional-method gate ----- def _require_platform_method(self, method_name: str) -> None: diff --git a/src/adcp/decisioning/platform_router.py b/src/adcp/decisioning/platform_router.py index f650a9c7df..d23d6fe63b 100644 --- a/src/adcp/decisioning/platform_router.py +++ b/src/adcp/decisioning/platform_router.py @@ -186,7 +186,15 @@ async def _run_sync_delegate(method: Any, *args: Any, **kwargs: Any) -> Any: # per-tenant (the AccountStore IS the tenant resolver, so threading # resolution through itself would loop). _ACCOUNT_STORE_METHODS: frozenset[str] = frozenset( - {"resolve", "upsert", "upsert_request", "list", "sync_governance"} + { + "resolve", + "upsert", + "upsert_request", + "list", + "list_requests", + "request_account", + "sync_governance", + } ) diff --git a/src/adcp/decisioning/types.py b/src/adcp/decisioning/types.py index b2db5651e1..ebe5e98e44 100644 --- a/src/adcp/decisioning/types.py +++ b/src/adcp/decisioning/types.py @@ -480,6 +480,8 @@ class Account(Generic[TMeta]): authorization for any individual request. :param timezone: The account's IANA timezone, emitted as ``timezone``. :param operator: The account's operator domain, emitted as ``operator``. + :param revision: The account's revision, emitted as ``revision`` to + AdCP 3.2+ callers. :param mode: SDK-internal account mode — ``'live'`` (default, production), ``'sandbox'`` (adopter's test infra), or ``'mock'`` (Phase 2 — SDK routes to mock-server backend). @@ -517,6 +519,10 @@ class Account(Generic[TMeta]): timezone: str | None = None #: The operator domain the account belongs to (wire ``operator``). operator: str | None = None + #: The account's optimistic-concurrency revision (wire ``revision``, AdCP + #: 3.2+). Buyers read it from ``list_accounts`` and send it with a + #: ``request_account`` change, so a concurrent change is never overwritten. + revision: int | None = None # SDK-internal account mode for sandbox-authority gating. Default # ``'live'`` preserves all existing-adopter behavior — pre-mode diff --git a/tests/test_account_requests_seller.py b/tests/test_account_requests_seller.py new file mode 100644 index 0000000000..8f2202d5d8 --- /dev/null +++ b/tests/test_account_requests_seller.py @@ -0,0 +1,290 @@ +"""The seller side of ``request_account`` (AdCP 3.3, experimental ``account.requests``). + +An AccountStore with ``request_account`` serves the tool and advertises the +``account.requests`` block to 3.3 callers; ``list_requests`` serves the caller's +requests on ``list_accounts`` when the buyer asks for them. +""" + +from __future__ import annotations + +import time +from concurrent.futures import ThreadPoolExecutor +from typing import Any +from unittest.mock import AsyncMock + +import pytest +from pydantic import TypeAdapter + +from adcp.decisioning import ( + Account, + AccountRequestsPage, + DecisioningCapabilities, + DecisioningPlatform, + InMemoryTaskRegistry, + ResolveContext, +) +from adcp.decisioning.capabilities import Account as AccountCapabilities +from adcp.decisioning.handler import PlatformHandler +from adcp.exceptions import ConfigurationError +from adcp.server.base import ToolContext +from adcp.types import ( + GetAdcpCapabilitiesResponse, + ListAccountsRequest, + ListAccountsResponse, + RequestAccountRequest, + RequestAccountResponse, +) +from adcp.types.core import TaskResult, TaskStatus + +KEY = {"brand": {"domain": "acme.example"}, "operator": "agency.example"} + + +def _record(request_id: str, state: str, **fields: Any) -> dict[str, Any]: + return { + "request_id": request_id, + "state": state, + "requested": KEY, + "created_at": "2026-11-01T00:00:00Z", + "updated_at": "2026-11-01T00:00:00Z", + **fields, + } + + +class _Store: + """A store that opens accounts after review and lists the caller's requests.""" + + def __init__(self) -> None: + self.requests: dict[str, dict[str, Any]] = {} + self.accounts: list[Account[Any]] = [] + self.list_requests_calls = 0 + self.seen_ctx: ResolveContext | None = None + + def resolve(self, ref: Any = None, auth_info: Any = None) -> Account[Any]: + return Account(id="acc-1") + + def list(self, filter: dict[str, Any] | None = None, ctx: Any = None) -> list[Account[Any]]: + return list(self.accounts) + + async def request_account( + self, params: RequestAccountRequest, ctx: ResolveContext | None = None + ) -> dict[str, Any]: + self.seen_ctx = ctx + for record in self.requests.values(): + if record["state"] in ("in_review", "resolved"): + return record + record = _record( + f"req-{len(self.requests) + 1}", + "in_review", + pending_action={"waiting_on": "seller", "kind": "review", "message": "Checking."}, + ) + self.requests[record["request_id"]] = record + return record + + async def list_requests( + self, params: ListAccountsRequest, ctx: ResolveContext | None = None + ) -> AccountRequestsPage: + self.list_requests_calls += 1 + items = list(self.requests.values()) + if params.request_ids is not None: + items = [item for item in items if item["request_id"] in params.request_ids] + return AccountRequestsPage(items=items[:1], next_cursor="c1" if len(items) > 1 else None) + + def approve(self, request_id: str) -> None: + self.requests[request_id].update( + state="resolved", + resolution="created", + account_id="acc-1", + resolved_at="2026-11-02T00:00:00Z", + ) + self.accounts.append(Account(id="acc-1", name="Acme", status="active", revision=2)) + + +def _caps(**account: Any) -> DecisioningCapabilities: + return DecisioningCapabilities( + specialisms=["sales-non-guaranteed"], + account=AccountCapabilities.model_validate({"supported_billing": ["operator"], **account}), + ) + + +def _handler(store: Any, capabilities: DecisioningCapabilities | None = None) -> PlatformHandler: + class _Platform(DecisioningPlatform): + accounts = store + + _Platform.capabilities = capabilities or _caps() + return PlatformHandler( + _Platform(), executor=ThreadPoolExecutor(max_workers=1), registry=InMemoryTaskRegistry() + ) + + +def _ctx(version: str | None = "3.3-beta.3") -> ToolContext: + return ToolContext(resolved_adcp_version=version) + + +@pytest.mark.asyncio +async def test_request_account_advertises_account_requests_to_33_callers_only() -> None: + handler = _handler( + _Store(), + _caps(requests={"supported": True, "changeable_attributes": ["billing", "timezone"]}), + ) + assert "request_account" in handler.advertised_tools_for_instance() + caps = await handler.get_adcp_capabilities({}, _ctx()) + assert caps["account"]["requests"] == { + "supported": True, + "request_task": "request_account", + "read_task": "list_accounts", + "changeable_attributes": ["billing", "timezone"], + } + assert caps["account"]["supported_billing"] == ["operator"] + assert "account.requests" in caps["experimental_features"] + GetAdcpCapabilitiesResponse.model_validate(caps) + older = await handler.get_adcp_capabilities({}, _ctx("3.2")) + assert "requests" not in older["account"] + assert "account.requests" not in (older.get("experimental_features") or []) + + +@pytest.mark.asyncio +async def test_without_the_hook_nothing_is_advertised_even_when_features_claim_it() -> None: + class _Plain: + def resolve(self, ref: Any = None, auth_info: Any = None) -> Account[Any]: + return Account(id="acc-1") + + def list(self, filter: Any = None, ctx: Any = None) -> list[Account[Any]]: + return [] + + capabilities = _caps() + capabilities.experimental_features = ["account.requests"] + handler = _handler(_Plain(), capabilities) + assert "request_account" not in handler.advertised_tools_for_instance() + caps = await handler.get_adcp_capabilities({}, _ctx()) + assert "requests" not in caps["account"] + assert "account.requests" not in (caps.get("experimental_features") or []) + refused = await handler.request_account( + RequestAccountRequest.model_validate({"idempotency_key": "k" * 16, **KEY}), _ctx() + ) + assert "not supported" in str(refused).lower() or "NOT_SUPPORTED" in str(refused) + + +def test_configuration_is_refused_rather_than_invented() -> None: + no_billing = DecisioningCapabilities(specialisms=["sales-non-guaranteed"]) + with pytest.raises(ConfigurationError, match="supported_billing"): + _handler(_Store(), no_billing) + + class _ListsOnly: + def resolve(self, ref: Any = None, auth_info: Any = None) -> Account[Any]: + return Account(id="acc-1") + + def list_requests(self, params: Any, ctx: Any = None) -> list[Any]: + return [] + + with pytest.raises(ConfigurationError, match="needs accounts.list"): + _handler(_ListsOnly()) + + class _NoHook: + def resolve(self, ref: Any = None, auth_info: Any = None) -> Account[Any]: + return Account(id="acc-1") + + with pytest.raises(ConfigurationError, match="no request_account hook"): + _handler(_NoHook(), _caps(requests={"supported": True})) + + class _NoListRequests: + def resolve(self, ref: Any = None, auth_info: Any = None) -> Account[Any]: + return Account(id="acc-1") + + def list(self, filter: Any = None, ctx: Any = None) -> list[Account[Any]]: + return [] + + def request_account(self, params: Any, ctx: Any = None) -> dict[str, Any]: + return _record("req-1", "in_review") + + with pytest.warns(UserWarning, match="list_requests"): + _handler(_NoListRequests()) + + +@pytest.mark.asyncio +async def test_request_account_serves_the_hooks_record() -> None: + store = _Store() + handler = _handler(store) + response = await handler.request_account( + RequestAccountRequest.model_validate({"idempotency_key": "k" * 16, **KEY}), _ctx() + ) + assert response["status"] == "completed" + assert response["request"]["state"] == "in_review" + TypeAdapter(RequestAccountResponse).validate_python(response) + assert store.seen_ctx is not None and store.seen_ctx.tool_name == "request_account" + + +@pytest.mark.asyncio +async def test_list_accounts_serves_requests_only_when_asked_and_only_to_33() -> None: + store = _Store() + handler = _handler(store) + await handler.request_account( + RequestAccountRequest.model_validate({"idempotency_key": "k" * 16, **KEY}), _ctx() + ) + store.requests["req-9"] = _record("req-9", "rejected") + plain = await handler.list_accounts(ListAccountsRequest(), _ctx()) + assert "account_requests" not in plain and store.list_requests_calls == 0 + asked = await handler.list_accounts(ListAccountsRequest(include_requests=True), _ctx()) + ListAccountsResponse.model_validate(asked) + assert [item["request_id"] for item in asked["account_requests"]] == ["req-1"] + assert asked["account_requests_pagination"] == {"has_more": True, "cursor": "c1"} + by_id = await handler.list_accounts(ListAccountsRequest(request_ids=["req-9"]), _ctx()) + assert [item["request_id"] for item in by_id["account_requests"]] == ["req-9"] + assert by_id["account_requests_pagination"] == {"has_more": False} + older = await handler.list_accounts(ListAccountsRequest(include_requests=True), _ctx("3.2")) + assert "account_requests" not in older and store.list_requests_calls == 2 + + +@pytest.mark.asyncio +async def test_list_accounts_serves_revision_from_32() -> None: + store = _Store() + store.accounts.append(Account(id="acc-1", revision=4)) + handler = _handler(store) + current = await handler.list_accounts(ListAccountsRequest(), _ctx("3.2")) + assert current["accounts"][0]["revision"] == 4 + older = await handler.list_accounts(ListAccountsRequest(), _ctx("3.1")) + assert "revision" not in older["accounts"][0] + + +@pytest.mark.asyncio +async def test_a_python_buyer_ensures_against_the_seller_and_refreshes_to_ready() -> None: + from adcp import AccountPending, AccountReady + from adcp.client import ADCPClient + from adcp.types.core import AgentConfig + + store = _Store() + handler = _handler(store) + client = ADCPClient( + AgentConfig(id="seller", agent_uri="https://seller.example/mcp", protocol="mcp"), + account_policy="auto", + ) + client._capabilities = GetAdcpCapabilitiesResponse.model_validate( + await handler.get_adcp_capabilities({}, _ctx()) + ) + client._capabilities_fetched_at = time.monotonic() + + async def request_account(params: dict[str, Any]) -> TaskResult: + request = RequestAccountRequest.model_validate(params) + return TaskResult( + status=TaskStatus.COMPLETED, data=await handler.request_account(request, _ctx()) + ) + + async def list_accounts(params: dict[str, Any]) -> TaskResult: + request = ListAccountsRequest.model_validate(params) + return TaskResult( + status=TaskStatus.COMPLETED, data=await handler.list_accounts(request, _ctx()) + ) + + client.adapter.request_account = AsyncMock(side_effect=request_account) + client.adapter.list_accounts = AsyncMock(side_effect=list_accounts) + client.adapter.sync_accounts = AsyncMock() + client.adapter._parse_response = lambda raw, _type: raw + + outcome = await client.accounts.ensure(KEY) + assert isinstance(outcome, AccountPending) and outcome.request_id == "req-1" + client.adapter.sync_accounts.assert_not_called() + store.approve("req-1") + refreshed = await client.accounts.refresh() + assert [type(item) for item in refreshed] == [AccountReady] + again = await client.accounts.ensure(KEY) + assert isinstance(again, AccountReady) and again.account_id == "acc-1" + assert len(store.requests) == 1 diff --git a/tests/test_decisioning_handler_shims.py b/tests/test_decisioning_handler_shims.py index ce4edbdf57..d5a1148231 100644 --- a/tests/test_decisioning_handler_shims.py +++ b/tests/test_decisioning_handler_shims.py @@ -94,6 +94,8 @@ def test_advertised_tools_covers_every_specialism_wire_tool() -> None: # Account roster (unioned into every sales-* claim) "sync_accounts", "list_accounts", + # AdCP 3.3 account.requests (advertised only when the AccountStore serves it) + "request_account", # Creative (Builder + AdServer) "build_creative", "preview_creative", diff --git a/tests/test_platform_router.py b/tests/test_platform_router.py index 72ee4f93fc..19f514ce45 100644 --- a/tests/test_platform_router.py +++ b/tests/test_platform_router.py @@ -601,6 +601,8 @@ def test_account_store_methods_denylist_matches_protocols() -> None: from adcp.decisioning.accounts import ( AccountStore, AccountStoreList, + AccountStoreListRequests, + AccountStoreRequestAccount, AccountStoreSyncGovernance, AccountStoreUpsert, AccountStoreUpsertRequest, @@ -612,6 +614,8 @@ def test_account_store_methods_denylist_matches_protocols() -> None: expected = ( _protocol_method_names(AccountStore) | _protocol_method_names(AccountStoreList) + | _protocol_method_names(AccountStoreListRequests) + | _protocol_method_names(AccountStoreRequestAccount) | _protocol_method_names(AccountStoreUpsert) | _protocol_method_names(AccountStoreUpsertRequest) | _protocol_method_names(AccountStoreSyncGovernance)