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
116 changes: 116 additions & 0 deletions cueapi/resources/agents.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
180 changes: 180 additions & 0 deletions tests/test_agents_resource.py
Original file line number Diff line number Diff line change
Expand Up @@ -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},
)
Loading