From 7c24e32b046e0daa62b03071d86697015582b222 Mon Sep 17 00:00:00 2001 From: mikemolinet Date: Sun, 10 May 2026 20:52:11 -0700 Subject: [PATCH] feat(events): SDK methods for event-emit primitive (PR-1b) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds 4 SDK methods to AgentsResource (since all 4 endpoints are rooted under /v1/agents/{ref}/): - subscriptions_create(ref, *, event_type, delivery_target, webhook_url=None) - subscriptions_list(ref) - subscriptions_delete(ref, subscription_id) - events_pull(ref, *, since=None, limit=100, event_type=None) Mirrors the existing AgentsResource shape (.inbox, .sent, .webhook_secret_get etc are also sub-resource methods on agents). ## Wire format pinned - subscriptions_create: POST body with event_type + delivery_target + optional webhook_url (default-omit when None to match server's extra="forbid" expectation for pull subs) - subscriptions_list: GET (no params) - subscriptions_delete: DELETE on /subscriptions/{id} (idempotent per server contract — re-DELETE returns 200) - events_pull: GET with limit (default 100, server caps at 1000) + optional since (cursor) + optional event_type filter. Server endpoint takes since/event_type as query params. ## Tests 11 new tests in tests/test_agents_resource.py covering: - pull-subscription minimal body - webhook-subscription with URL - webhook_url omitted when None (default-omit discipline) - subscriptions_list GET path (opaque id + slug-form) - subscriptions_delete DELETE path - events_pull defaults - events_pull with since cursor - events_pull with event_type filter - events_pull with all params combined - events_pull explicit since=0 passed (not collapsed to default) 11/11 pass locally. Full local suite: 118 passed + 19 pre-existing errors (test_cues.py "api_key is required" — env-specific, not from this PR). ## Depends on cueapi-core PR-1b The endpoints these SDK methods call land via cueapi/cueapi-core PR #71 (in flight). This SDK PR will sit on the branch until #71 merges + cueapi-core deploys; safe to merge afterward (SDK methods return 404 against a server that doesn't have the endpoints yet, which is acceptable for a parity port). Closes Backlog row: cmp0h2mbg000104l8jgnzvgnu --- cueapi/resources/agents.py | 116 ++++++++++++++++++++++ tests/test_agents_resource.py | 180 ++++++++++++++++++++++++++++++++++ 2 files changed, 296 insertions(+) diff --git a/cueapi/resources/agents.py b/cueapi/resources/agents.py index f8882d6..c6c65ef 100644 --- a/cueapi/resources/agents.py +++ b/cueapi/resources/agents.py @@ -240,3 +240,119 @@ def presence(self, ref: str) -> Dict[str, Any]: Presence dict. """ return self._client._get(f"/v1/agents/{ref}/presence") + + # ─────────────────────────────────────────────────────────────── + # Event-emit primitive (PR-1b) + # ─────────────────────────────────────────────────────────────── + + def subscriptions_create( + self, + ref: str, + *, + event_type: str, + delivery_target: str, + webhook_url: Optional[str] = None, + ) -> Dict[str, Any]: + """Create a subscription for an agent (PR-1b event-emit primitive). + + Subscriptions are agent-scoped — an agent can only subscribe to + events FOR ITSELF. The caller must own the agent. + + Args: + ref: Agent opaque ID or slug-form (the subscribing agent). + event_type: The event type to subscribe to (e.g. + ``message.received``). + delivery_target: ``"pull"`` (poll via ``events_pull``) or + ``"webhook"`` (server POSTs to ``webhook_url`` with HMAC). + webhook_url: Required when ``delivery_target="webhook"``; + HTTPS only. Ignored for pull subscriptions. + + Returns: + Subscription dict. For webhook subscriptions, the response + includes ``webhook_secret`` ONE-TIME — save it now; the + server never re-exposes it. + + Errors: + 400 ``unknown_event_type`` / ``invalid_delivery_target`` / + ``invalid_webhook_url``; 404 ``agent_not_found``. + """ + body: Dict[str, Any] = { + "event_type": event_type, + "delivery_target": delivery_target, + } + if webhook_url is not None: + body["webhook_url"] = webhook_url + return self._client._post(f"/v1/agents/{ref}/subscriptions", json=body) + + def subscriptions_list(self, ref: str) -> Dict[str, Any]: + """List active subscriptions for an agent (PR-1b). + + ``webhook_url`` is redacted to host-only in the response; + ``webhook_secret`` is never exposed here (only on create). + Each entry includes dispatch-state fields + (``last_dispatched_event_id``, ``consecutive_failures``, + ``paused_until``, etc). + + Args: + ref: Agent opaque ID or slug-form. + + Returns: + Dict with ``subscriptions`` list. + """ + return self._client._get(f"/v1/agents/{ref}/subscriptions") + + def subscriptions_delete(self, ref: str, subscription_id: str) -> Dict[str, Any]: + """Soft-detach a subscription (PR-1b). Idempotent. + + Re-DELETE on an already-detached subscription returns 200 + regardless. The server does NOT delete the row — it marks it + detached so dispatch stops + audit history is preserved. + + Args: + ref: Agent opaque ID or slug-form (must match the + subscription's owning agent). + subscription_id: UUID of the subscription to detach. + + Returns: + Result dict. + """ + return self._client._delete( + f"/v1/agents/{ref}/subscriptions/{subscription_id}" + ) + + def events_pull( + self, + ref: str, + *, + since: Optional[int] = None, + limit: int = 100, + event_type: Optional[str] = None, + ) -> Dict[str, Any]: + """Pull events from the agent's event stream (PR-1b). + + Events are append-only with a monotonic ``id`` (BIGSERIAL). + Use ``since`` as a cursor: pass the last ``id`` you saw to + get only events newer than that. Default 0 fetches from the + beginning. + + Args: + ref: Agent opaque ID or slug-form. + since: Cursor — only return events with ``id > since``. + Default 0 (all events). Pass the highest ``id`` from + the previous page to continue. + limit: Page size (default 100, server caps at 1000). + event_type: Optional filter — only return events of this + type. Omit for all event types. + + Returns: + Dict with ``events`` list (each carrying ``id``, + ``event_type``, ``payload``, ``emitted_at``) and + ``next_cursor`` (highest ``id`` in this page; pass back as + ``since`` for the next call). + """ + params: Dict[str, Any] = {"limit": limit} + if since is not None: + params["since"] = since + if event_type is not None: + params["event_type"] = event_type + return self._client._get(f"/v1/agents/{ref}/events", params=params) diff --git a/tests/test_agents_resource.py b/tests/test_agents_resource.py index 3101afd..61e086a 100644 --- a/tests/test_agents_resource.py +++ b/tests/test_agents_resource.py @@ -270,3 +270,183 @@ def test_presence_by_slug_form(self): r.presence("foo@me") mock_client._get.assert_called_once_with("/v1/agents/foo@me/presence") + + +# ────────────────────────────────────────────────────────────────────────── +# Event-emit primitive (PR-1b) — subscriptions + events +# ────────────────────────────────────────────────────────────────────────── + + +class TestSubscriptionsCreate: + def test_pull_subscription_minimal(self): + mock_client = MagicMock() + mock_client._post.return_value = { + "id": "sub_uuid", + "agent_id": "agt_x", + "event_type": "message.received", + "delivery_target": "pull", + } + r = AgentsResource(mock_client) + + r.subscriptions_create( + "agt_x", + event_type="message.received", + delivery_target="pull", + ) + + mock_client._post.assert_called_once_with( + "/v1/agents/agt_x/subscriptions", + json={ + "event_type": "message.received", + "delivery_target": "pull", + }, + ) + + def test_webhook_subscription_with_url(self): + mock_client = MagicMock() + mock_client._post.return_value = { + "id": "sub_uuid", + "delivery_target": "webhook", + "webhook_secret": "wsec_oneshot", + } + r = AgentsResource(mock_client) + + r.subscriptions_create( + "agt_x", + event_type="message.received", + delivery_target="webhook", + webhook_url="https://example.com/hook", + ) + + mock_client._post.assert_called_once_with( + "/v1/agents/agt_x/subscriptions", + json={ + "event_type": "message.received", + "delivery_target": "webhook", + "webhook_url": "https://example.com/hook", + }, + ) + + def test_webhook_url_omitted_when_none(self): + # webhook_url is optional; default None must NOT appear in the + # body (server's MessageCreate-style schemas are extra="forbid" + # but more importantly: a null/absent webhook_url for a pull sub + # should not even reach the wire as None). + mock_client = MagicMock() + mock_client._post.return_value = {"id": "sub_uuid"} + r = AgentsResource(mock_client) + + r.subscriptions_create( + "agt_x", + event_type="message.received", + delivery_target="pull", + webhook_url=None, + ) + + body = mock_client._post.call_args.kwargs["json"] + assert "webhook_url" not in body + + +class TestSubscriptionsList: + def test_get_path(self): + mock_client = MagicMock() + mock_client._get.return_value = {"subscriptions": []} + r = AgentsResource(mock_client) + + r.subscriptions_list("agt_x") + + mock_client._get.assert_called_once_with("/v1/agents/agt_x/subscriptions") + + def test_slug_form_ref(self): + mock_client = MagicMock() + mock_client._get.return_value = {"subscriptions": []} + r = AgentsResource(mock_client) + + r.subscriptions_list("foo@me") + + mock_client._get.assert_called_once_with("/v1/agents/foo@me/subscriptions") + + +class TestSubscriptionsDelete: + def test_delete_path(self): + mock_client = MagicMock() + mock_client._delete.return_value = {"status": "detached"} + r = AgentsResource(mock_client) + + r.subscriptions_delete("agt_x", "sub-uuid-1234") + + mock_client._delete.assert_called_once_with( + "/v1/agents/agt_x/subscriptions/sub-uuid-1234" + ) + + +class TestEventsPull: + def test_defaults_only_limit(self): + mock_client = MagicMock() + mock_client._get.return_value = {"events": [], "next_cursor": 0} + r = AgentsResource(mock_client) + + r.events_pull("agt_x") + + mock_client._get.assert_called_once_with( + "/v1/agents/agt_x/events", + params={"limit": 100}, + ) + + def test_with_since_cursor(self): + mock_client = MagicMock() + mock_client._get.return_value = {"events": [], "next_cursor": 42} + r = AgentsResource(mock_client) + + r.events_pull("agt_x", since=42) + + mock_client._get.assert_called_once_with( + "/v1/agents/agt_x/events", + params={"limit": 100, "since": 42}, + ) + + def test_with_event_type_filter(self): + mock_client = MagicMock() + mock_client._get.return_value = {"events": [], "next_cursor": 0} + r = AgentsResource(mock_client) + + r.events_pull("agt_x", event_type="message.received") + + mock_client._get.assert_called_once_with( + "/v1/agents/agt_x/events", + params={"limit": 100, "event_type": "message.received"}, + ) + + def test_with_all_params(self): + mock_client = MagicMock() + mock_client._get.return_value = {"events": [], "next_cursor": 100} + r = AgentsResource(mock_client) + + r.events_pull( + "agt_x", + since=50, + limit=500, + event_type="message.received", + ) + + mock_client._get.assert_called_once_with( + "/v1/agents/agt_x/events", + params={ + "limit": 500, + "since": 50, + "event_type": "message.received", + }, + ) + + def test_since_zero_explicit_passed(self): + # Distinct from default None — explicit since=0 should send it. + mock_client = MagicMock() + mock_client._get.return_value = {"events": [], "next_cursor": 0} + r = AgentsResource(mock_client) + + r.events_pull("agt_x", since=0) + + mock_client._get.assert_called_once_with( + "/v1/agents/agt_x/events", + params={"limit": 100, "since": 0}, + )