Skip to content

Commit 72fb628

Browse files
authored
feat(events): SDK methods for event-emit primitive (PR-1b) (#38)
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
1 parent 67f0e5c commit 72fb628

2 files changed

Lines changed: 296 additions & 0 deletions

File tree

cueapi/resources/agents.py

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -240,3 +240,119 @@ def presence(self, ref: str) -> Dict[str, Any]:
240240
Presence dict.
241241
"""
242242
return self._client._get(f"/v1/agents/{ref}/presence")
243+
244+
# ───────────────────────────────────────────────────────────────
245+
# Event-emit primitive (PR-1b)
246+
# ───────────────────────────────────────────────────────────────
247+
248+
def subscriptions_create(
249+
self,
250+
ref: str,
251+
*,
252+
event_type: str,
253+
delivery_target: str,
254+
webhook_url: Optional[str] = None,
255+
) -> Dict[str, Any]:
256+
"""Create a subscription for an agent (PR-1b event-emit primitive).
257+
258+
Subscriptions are agent-scoped — an agent can only subscribe to
259+
events FOR ITSELF. The caller must own the agent.
260+
261+
Args:
262+
ref: Agent opaque ID or slug-form (the subscribing agent).
263+
event_type: The event type to subscribe to (e.g.
264+
``message.received``).
265+
delivery_target: ``"pull"`` (poll via ``events_pull``) or
266+
``"webhook"`` (server POSTs to ``webhook_url`` with HMAC).
267+
webhook_url: Required when ``delivery_target="webhook"``;
268+
HTTPS only. Ignored for pull subscriptions.
269+
270+
Returns:
271+
Subscription dict. For webhook subscriptions, the response
272+
includes ``webhook_secret`` ONE-TIME — save it now; the
273+
server never re-exposes it.
274+
275+
Errors:
276+
400 ``unknown_event_type`` / ``invalid_delivery_target`` /
277+
``invalid_webhook_url``; 404 ``agent_not_found``.
278+
"""
279+
body: Dict[str, Any] = {
280+
"event_type": event_type,
281+
"delivery_target": delivery_target,
282+
}
283+
if webhook_url is not None:
284+
body["webhook_url"] = webhook_url
285+
return self._client._post(f"/v1/agents/{ref}/subscriptions", json=body)
286+
287+
def subscriptions_list(self, ref: str) -> Dict[str, Any]:
288+
"""List active subscriptions for an agent (PR-1b).
289+
290+
``webhook_url`` is redacted to host-only in the response;
291+
``webhook_secret`` is never exposed here (only on create).
292+
Each entry includes dispatch-state fields
293+
(``last_dispatched_event_id``, ``consecutive_failures``,
294+
``paused_until``, etc).
295+
296+
Args:
297+
ref: Agent opaque ID or slug-form.
298+
299+
Returns:
300+
Dict with ``subscriptions`` list.
301+
"""
302+
return self._client._get(f"/v1/agents/{ref}/subscriptions")
303+
304+
def subscriptions_delete(self, ref: str, subscription_id: str) -> Dict[str, Any]:
305+
"""Soft-detach a subscription (PR-1b). Idempotent.
306+
307+
Re-DELETE on an already-detached subscription returns 200
308+
regardless. The server does NOT delete the row — it marks it
309+
detached so dispatch stops + audit history is preserved.
310+
311+
Args:
312+
ref: Agent opaque ID or slug-form (must match the
313+
subscription's owning agent).
314+
subscription_id: UUID of the subscription to detach.
315+
316+
Returns:
317+
Result dict.
318+
"""
319+
return self._client._delete(
320+
f"/v1/agents/{ref}/subscriptions/{subscription_id}"
321+
)
322+
323+
def events_pull(
324+
self,
325+
ref: str,
326+
*,
327+
since: Optional[int] = None,
328+
limit: int = 100,
329+
event_type: Optional[str] = None,
330+
) -> Dict[str, Any]:
331+
"""Pull events from the agent's event stream (PR-1b).
332+
333+
Events are append-only with a monotonic ``id`` (BIGSERIAL).
334+
Use ``since`` as a cursor: pass the last ``id`` you saw to
335+
get only events newer than that. Default 0 fetches from the
336+
beginning.
337+
338+
Args:
339+
ref: Agent opaque ID or slug-form.
340+
since: Cursor — only return events with ``id > since``.
341+
Default 0 (all events). Pass the highest ``id`` from
342+
the previous page to continue.
343+
limit: Page size (default 100, server caps at 1000).
344+
event_type: Optional filter — only return events of this
345+
type. Omit for all event types.
346+
347+
Returns:
348+
Dict with ``events`` list (each carrying ``id``,
349+
``event_type``, ``payload``, ``emitted_at``) and
350+
``next_cursor`` (highest ``id`` in this page; pass back as
351+
``since`` for the next call).
352+
"""
353+
params: Dict[str, Any] = {"limit": limit}
354+
if since is not None:
355+
params["since"] = since
356+
if event_type is not None:
357+
params["event_type"] = event_type
358+
return self._client._get(f"/v1/agents/{ref}/events", params=params)

tests/test_agents_resource.py

Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -270,3 +270,183 @@ def test_presence_by_slug_form(self):
270270
r.presence("foo@me")
271271

272272
mock_client._get.assert_called_once_with("/v1/agents/foo@me/presence")
273+
274+
275+
# ──────────────────────────────────────────────────────────────────────────
276+
# Event-emit primitive (PR-1b) — subscriptions + events
277+
# ──────────────────────────────────────────────────────────────────────────
278+
279+
280+
class TestSubscriptionsCreate:
281+
def test_pull_subscription_minimal(self):
282+
mock_client = MagicMock()
283+
mock_client._post.return_value = {
284+
"id": "sub_uuid",
285+
"agent_id": "agt_x",
286+
"event_type": "message.received",
287+
"delivery_target": "pull",
288+
}
289+
r = AgentsResource(mock_client)
290+
291+
r.subscriptions_create(
292+
"agt_x",
293+
event_type="message.received",
294+
delivery_target="pull",
295+
)
296+
297+
mock_client._post.assert_called_once_with(
298+
"/v1/agents/agt_x/subscriptions",
299+
json={
300+
"event_type": "message.received",
301+
"delivery_target": "pull",
302+
},
303+
)
304+
305+
def test_webhook_subscription_with_url(self):
306+
mock_client = MagicMock()
307+
mock_client._post.return_value = {
308+
"id": "sub_uuid",
309+
"delivery_target": "webhook",
310+
"webhook_secret": "wsec_oneshot",
311+
}
312+
r = AgentsResource(mock_client)
313+
314+
r.subscriptions_create(
315+
"agt_x",
316+
event_type="message.received",
317+
delivery_target="webhook",
318+
webhook_url="https://example.com/hook",
319+
)
320+
321+
mock_client._post.assert_called_once_with(
322+
"/v1/agents/agt_x/subscriptions",
323+
json={
324+
"event_type": "message.received",
325+
"delivery_target": "webhook",
326+
"webhook_url": "https://example.com/hook",
327+
},
328+
)
329+
330+
def test_webhook_url_omitted_when_none(self):
331+
# webhook_url is optional; default None must NOT appear in the
332+
# body (server's MessageCreate-style schemas are extra="forbid"
333+
# but more importantly: a null/absent webhook_url for a pull sub
334+
# should not even reach the wire as None).
335+
mock_client = MagicMock()
336+
mock_client._post.return_value = {"id": "sub_uuid"}
337+
r = AgentsResource(mock_client)
338+
339+
r.subscriptions_create(
340+
"agt_x",
341+
event_type="message.received",
342+
delivery_target="pull",
343+
webhook_url=None,
344+
)
345+
346+
body = mock_client._post.call_args.kwargs["json"]
347+
assert "webhook_url" not in body
348+
349+
350+
class TestSubscriptionsList:
351+
def test_get_path(self):
352+
mock_client = MagicMock()
353+
mock_client._get.return_value = {"subscriptions": []}
354+
r = AgentsResource(mock_client)
355+
356+
r.subscriptions_list("agt_x")
357+
358+
mock_client._get.assert_called_once_with("/v1/agents/agt_x/subscriptions")
359+
360+
def test_slug_form_ref(self):
361+
mock_client = MagicMock()
362+
mock_client._get.return_value = {"subscriptions": []}
363+
r = AgentsResource(mock_client)
364+
365+
r.subscriptions_list("foo@me")
366+
367+
mock_client._get.assert_called_once_with("/v1/agents/foo@me/subscriptions")
368+
369+
370+
class TestSubscriptionsDelete:
371+
def test_delete_path(self):
372+
mock_client = MagicMock()
373+
mock_client._delete.return_value = {"status": "detached"}
374+
r = AgentsResource(mock_client)
375+
376+
r.subscriptions_delete("agt_x", "sub-uuid-1234")
377+
378+
mock_client._delete.assert_called_once_with(
379+
"/v1/agents/agt_x/subscriptions/sub-uuid-1234"
380+
)
381+
382+
383+
class TestEventsPull:
384+
def test_defaults_only_limit(self):
385+
mock_client = MagicMock()
386+
mock_client._get.return_value = {"events": [], "next_cursor": 0}
387+
r = AgentsResource(mock_client)
388+
389+
r.events_pull("agt_x")
390+
391+
mock_client._get.assert_called_once_with(
392+
"/v1/agents/agt_x/events",
393+
params={"limit": 100},
394+
)
395+
396+
def test_with_since_cursor(self):
397+
mock_client = MagicMock()
398+
mock_client._get.return_value = {"events": [], "next_cursor": 42}
399+
r = AgentsResource(mock_client)
400+
401+
r.events_pull("agt_x", since=42)
402+
403+
mock_client._get.assert_called_once_with(
404+
"/v1/agents/agt_x/events",
405+
params={"limit": 100, "since": 42},
406+
)
407+
408+
def test_with_event_type_filter(self):
409+
mock_client = MagicMock()
410+
mock_client._get.return_value = {"events": [], "next_cursor": 0}
411+
r = AgentsResource(mock_client)
412+
413+
r.events_pull("agt_x", event_type="message.received")
414+
415+
mock_client._get.assert_called_once_with(
416+
"/v1/agents/agt_x/events",
417+
params={"limit": 100, "event_type": "message.received"},
418+
)
419+
420+
def test_with_all_params(self):
421+
mock_client = MagicMock()
422+
mock_client._get.return_value = {"events": [], "next_cursor": 100}
423+
r = AgentsResource(mock_client)
424+
425+
r.events_pull(
426+
"agt_x",
427+
since=50,
428+
limit=500,
429+
event_type="message.received",
430+
)
431+
432+
mock_client._get.assert_called_once_with(
433+
"/v1/agents/agt_x/events",
434+
params={
435+
"limit": 500,
436+
"since": 50,
437+
"event_type": "message.received",
438+
},
439+
)
440+
441+
def test_since_zero_explicit_passed(self):
442+
# Distinct from default None — explicit since=0 should send it.
443+
mock_client = MagicMock()
444+
mock_client._get.return_value = {"events": [], "next_cursor": 0}
445+
r = AgentsResource(mock_client)
446+
447+
r.events_pull("agt_x", since=0)
448+
449+
mock_client._get.assert_called_once_with(
450+
"/v1/agents/agt_x/events",
451+
params={"limit": 100, "since": 0},
452+
)

0 commit comments

Comments
 (0)