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 skills/build-seller-agent/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -168,7 +168,7 @@ class MySeller(ADCPHandler):
Sellers emit webhooks to notify buyers asynchronously. AdCP 3.0 uses RFC 9421 HTTP Message Signatures — use `adcp.webhooks.WebhookSender` (one-call helper) or `sign_webhook` for lower-level control.

**When to emit:**
- Async-approval media buy transitions (`pending_activation` → `active`, or `→ rejected`)
- Async-approval media buy transitions (`pending_creatives` → `pending_start` → `active`, or `→ rejected`)
- Artifact-ready notifications after long-running operations
- Delivery reports the buyer subscribed to via `reporting_webhook` on `create_media_buy`

Expand Down
23 changes: 21 additions & 2 deletions src/adcp/server/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,15 @@ def adcp_error(
# Actions are operations available via update_media_buy for each status.
# Public constant — servers can inspect, test against, or extend this.
MEDIA_BUY_STATE_MACHINE: dict[str, list[str]] = {
"pending_activation": [
"pending_creatives": [
"cancel",
"update_budget",
"update_dates",
"update_packages",
"add_packages",
"sync_creatives",
],
"pending_start": [
"cancel",
"update_budget",
"update_dates",
Expand All @@ -164,7 +172,18 @@ def adcp_error(


def valid_actions_for_status(status: str) -> list[str]:
"""Get valid buyer actions for a media buy status."""
"""Get valid buyer actions for a media buy status.

Returns the list of ``update_media_buy`` actions available to a buyer for
the given status string. Returns ``[]`` for terminal statuses and for any
unrecognized status string.

Valid statuses per ``enums/media-buy-status.json``:
``pending_creatives``, ``pending_start``, ``active``, ``paused``,
``completed``, ``rejected``, ``canceled``.

Inspect or extend :data:`MEDIA_BUY_STATE_MACHINE` to add custom actions.
"""
return list(MEDIA_BUY_STATE_MACHINE.get(status, []))


Expand Down
34 changes: 32 additions & 2 deletions tests/test_server_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -85,11 +85,41 @@ def test_terminal_statuses_empty(self) -> None:
for status in ("completed", "rejected", "canceled"):
assert valid_actions_for_status(status) == []

def test_pending_activation_allows_cancel(self) -> None:
actions = valid_actions_for_status("pending_activation")
def test_pending_start_allows_cancel(self) -> None:
actions = valid_actions_for_status("pending_start")
assert "cancel" in actions
assert "update_packages" in actions

def test_pending_creatives_allows_sync_creatives(self) -> None:
actions = valid_actions_for_status("pending_creatives")
assert "sync_creatives" in actions
assert "cancel" in actions

def test_pending_activation_is_not_recognized(self) -> None:
# AdCP v3 renamed pending_activation to pending_creatives + pending_start.
# Lock the rename so a copy-paste from old docs / old SDK code returns
# an empty action list rather than silently matching a stale entry.
from adcp.server.helpers import MEDIA_BUY_STATE_MACHINE

assert valid_actions_for_status("pending_activation") == []
assert "pending_activation" not in MEDIA_BUY_STATE_MACHINE

def test_state_machine_keys_match_spec_enum(self) -> None:
# Keys must exactly match enums/media-buy-status.json. Terminal
# statuses are present with empty action lists. Guards against
# future silent drift between the spec enum and the dispatcher.
from adcp.server.helpers import MEDIA_BUY_STATE_MACHINE

assert set(MEDIA_BUY_STATE_MACHINE) == {
"pending_creatives",
"pending_start",
"active",
"paused",
"completed",
"rejected",
"canceled",
}

def test_unknown_status_empty(self) -> None:
assert valid_actions_for_status("nonexistent") == []

Expand Down
Loading