From aafd33476131233e94271fd91689548123f70407 Mon Sep 17 00:00:00 2001 From: mikemolinet Date: Wed, 6 May 2026 17:40:50 -0700 Subject: [PATCH] feat(agents): add roster() + presence() methods (cueapi #630 + #662 parity) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds two SDK methods to ``AgentsResource`` covering the Agent Directory v0/v1/v2 surface: - ``roster(*, if_none_match=None)`` — GET /v1/agents/roster Lists every agent owned by the calling key with a presence block (online, derived_status, bucketed_seen, default_live cue, labeled sessions, etag). Supports ``If-None-Match`` header so cheap-poll callers can skip the payload when the directory hasn't changed. - ``presence(ref)`` — GET /v1/agents/{ref}/presence Lighter than ``get(ref)`` — returns just the presence-relevant fields (online, derived_status, bucketed_seen, default_live, labeled_sessions, etag) without the full agent record. Designed for UIs refreshing a single tile every few seconds without re-fetching the directory or full agent record. 4 new mock-based tests in test_agents_resource.py: - test_roster_no_etag — no If-None-Match header by default - test_roster_with_if_none_match — header flows as ``If-None-Match`` - test_presence_by_id — opaque agent_id path - test_presence_by_slug_form — slug@user path Source: drift audit handoff/cueapi-package-drift-2026-05-06; Backlog rows "Parity port: PR #630 (GET /v1/agents/roster) → cueapi-python" + "Parity port: PR #662 (GET /v1/agents/{ref}/presence) → cueapi-python" (both p2, CTO-SEC-DRIFT-AUDIT-AUTHORIZE 2026-05-06). Co-Authored-By: Claude Opus 4.7 (1M context) --- cueapi/resources/agents.py | 50 +++++++++++++++++++++++++++++++++ tests/test_agents_resource.py | 53 +++++++++++++++++++++++++++++++++++ 2 files changed, 103 insertions(+) diff --git a/cueapi/resources/agents.py b/cueapi/resources/agents.py index d7c8933..f8882d6 100644 --- a/cueapi/resources/agents.py +++ b/cueapi/resources/agents.py @@ -190,3 +190,53 @@ def sent( """List messages sent by this agent.""" params: Dict[str, Any] = {"limit": limit, "offset": offset} return self._client._get(f"/v1/agents/{ref}/sent", params=params) + + def roster( + self, + *, + if_none_match: Optional[str] = None, + ) -> Dict[str, Any]: + """List the agent directory (Surface 6, cueapi #630). + + Returns the user's agent directory — every agent owned by the + calling key with a presence block (online state, derived status, + bucketed last-seen, default-live cue, labeled live sessions). + Used by Directory v0/v1/v2 UIs and by senders that want to + choose recipients based on presence. + + Args: + if_none_match: Optional ETag from a prior call. Server + returns ``304 Not Modified`` (raised as + ``CueAPIError`` with status 304) if the directory + hasn't changed. Use to cheap-poll without re-fetching + payloads. + + Returns: + Dict with ``agents`` list (each carrying presence block) and + ``etag`` for the next call. + """ + headers: Dict[str, str] = {} + if if_none_match is not None: + headers["If-None-Match"] = if_none_match + kwargs: Dict[str, Any] = {} + if headers: + kwargs["headers"] = headers + return self._client._get("/v1/agents/roster", **kwargs) + + def presence(self, ref: str) -> Dict[str, Any]: + """Cheap-poll a single agent's presence block (cueapi #662). + + Lighter than ``get(ref)`` — returns just the presence-relevant + fields (online, derived_status, bucketed_seen, default_live, + labeled_sessions, etag) without the full agent record. + Designed for UIs that need to refresh a single tile every few + seconds without re-fetching the full directory or agent record. + + Args: + ref: Agent opaque ID (``agt_<12 alnum>``) or slug-form + (``slug@user``). + + Returns: + Presence dict. + """ + return self._client._get(f"/v1/agents/{ref}/presence") diff --git a/tests/test_agents_resource.py b/tests/test_agents_resource.py index 58c6de1..3101afd 100644 --- a/tests/test_agents_resource.py +++ b/tests/test_agents_resource.py @@ -217,3 +217,56 @@ def test_sent_basic(self): "/v1/agents/agt_x/sent", params={"limit": 50, "offset": 0}, ) + + +class TestRoster: + """Agent directory roster — cueapi #630 parity.""" + + def test_roster_no_etag(self): + mock_client = MagicMock() + mock_client._get.return_value = {"agents": [], "etag": "abc"} + r = AgentsResource(mock_client) + + r.roster() + + # No If-None-Match header when if_none_match is None + mock_client._get.assert_called_once_with("/v1/agents/roster") + + def test_roster_with_if_none_match(self): + """If-None-Match flows as a header (not a query param).""" + mock_client = MagicMock() + mock_client._get.return_value = {"agents": [], "etag": "v2"} + r = AgentsResource(mock_client) + + r.roster(if_none_match="W/\"abc\"") + + mock_client._get.assert_called_once_with( + "/v1/agents/roster", + headers={"If-None-Match": 'W/"abc"'}, + ) + + +class TestPresence: + """Cheap-poll presence — cueapi #662 parity.""" + + def test_presence_by_id(self): + mock_client = MagicMock() + mock_client._get.return_value = { + "online": True, + "derived_status": "active", + "bucketed_seen": "now", + } + r = AgentsResource(mock_client) + + r.presence("agt_abcdef123456") + + mock_client._get.assert_called_once_with("/v1/agents/agt_abcdef123456/presence") + + def test_presence_by_slug_form(self): + mock_client = MagicMock() + mock_client._get.return_value = {"online": False} + r = AgentsResource(mock_client) + + r.presence("foo@me") + + mock_client._get.assert_called_once_with("/v1/agents/foo@me/presence")