From d516537b08b02361e5c7a82b1a0c9aa108f38050 Mon Sep 17 00:00:00 2001 From: Vuong Hoang Date: Mon, 25 May 2026 18:55:12 -0700 Subject: [PATCH] feat(sessions): get_persona_state client + persona error taxonomy (v0.12.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the read-side half of Worldtree #204's persona-state observability surface. Pairs with v0.11.0's AffectUpdate SSE event — together they let a consumer hydrate a persona pane on session-open (this GET) and keep it live as turns fire (the SSE event). Public surface: - `get_persona_state(client, agent_id) -> dict[str, Any]` — GET /agents/{agent_id}/persona_state, returns the same `snapshot` dict shape as AffectUpdate.snapshot - New exception types mapped from the spec's documented 4xx error_codes: - `PersonaNotConfigured` (404 persona_not_configured) — agent has no persona surface (domari, muninn, all Tier 3 in Phase 2.0) - `AgentNotAvailable` (404 agent_not_available) — unknown agent_id - `AuthScopeDenied` (403 auth_scope_denied) — key lacks the requested scope (persona.read here; reusable for future scoped endpoints) - Other non-2xx falls through to the existing SessionApiFailed precedent so novel failure modes aren't silently absorbed Tests: 6 new cases covering happy snapshot return, each typed 4xx sub-code, unknown 404 fall-through, and 5xx SessionApiFailed parity. Not yet consumed: TUI persona surface (Persona TabPane / sticky header line). UX shape pending operator direction — step 3. --- pyproject.toml | 2 +- src/ratatoskr/sessions.py | 82 ++++++++++++++++++++++++++++ tests/test_sessions.py | 109 ++++++++++++++++++++++++++++++++++++++ uv.lock | 2 +- 4 files changed, 193 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index d74c7d9..8e7f01c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "ratatoskr" -version = "0.11.0" +version = "0.12.0" description = "Worldtree Conversation API debug TUI — multi-pane observability dashboard" readme = "README.md" requires-python = ">=3.12" diff --git a/src/ratatoskr/sessions.py b/src/ratatoskr/sessions.py index 1c2ba08..00fe709 100644 --- a/src/ratatoskr/sessions.py +++ b/src/ratatoskr/sessions.py @@ -89,6 +89,46 @@ class SessionApiFailed(Exception): self.body = body +# Worldtree #204 / v0.28.0 — persona_state endpoint failure modes. +class PersonaNotConfigured(Exception): + """Raised on HTTP 404 `persona_not_configured` from GET persona_state. + + Agent exists but has no persona surface: persona-disabled Tier 1/2 + agents (e.g. `domari`, `muninn`) and all Tier 3 consumer-defined + agents (Phase 2.0). Distinct from `AgentNotAvailable` which means the + agent_id is unknown entirely. + """ + + def __init__(self, *, agent_id: str) -> None: + super().__init__(f"persona not configured for agent_id: {agent_id!r}") + self.agent_id = agent_id + + +class AgentNotAvailable(Exception): + """Raised on HTTP 404 `agent_not_available` from GET persona_state. + + The agent_id is unknown to the server. Distinct from + `PersonaNotConfigured` (agent exists but has no persona). + """ + + def __init__(self, *, agent_id: str) -> None: + super().__init__(f"agent not available: {agent_id!r}") + self.agent_id = agent_id + + +class AuthScopeDenied(Exception): + """Raised on HTTP 403 `auth_scope_denied` from a Heimdall-scoped endpoint. + + The API key lacks the required scope (e.g. `persona.read` for + GET /agents/{id}/persona_state). User-tier keys carry `persona.read` + by default; this surfaces when a narrower key is in use. + """ + + def __init__(self, *, scope: str) -> None: + super().__init__(f"auth scope denied: required={scope!r}") + self.scope = scope + + async def list_sessions( client: httpx.AsyncClient, *, @@ -200,3 +240,45 @@ async def list_agents(client: httpx.AsyncClient) -> list[AgentInfo]: ) for item in body ] + + +async def get_persona_state( + client: httpx.AsyncClient, agent_id: str +) -> dict[str, Any]: + """GET /agents/{agent_id}/persona_state — fetch current persona snapshot. + + Worldtree #204 / v0.28.0. Returns the same `snapshot` dict shape as the + `affect_update` SSE event's `status="current"` emission: pad, + dominant_emotion, emotions_active, baseline_pad, mood_drift, + last_updated_at. Bootstrap read for clients that want to populate a + persona pane on session-open without waiting for turn-1's `affect_update`. + + Auth: requires Heimdall `persona.read` scope (user-tier default). + + Failure modes (mapped to typed exceptions per the spec error_codes): + - 404 `persona_not_configured` → PersonaNotConfigured (persona-disabled + agents: domari / muninn, and all Tier 3 in Phase 2.0) + - 404 `agent_not_available` → AgentNotAvailable (unknown agent_id) + - 403 `auth_scope_denied` → AuthScopeDenied (key lacks persona.read) + - any other non-2xx → SessionApiFailed (preserves the broader-error + precedent from list_agents / list_sessions / create_session) + """ + assert client is not None + assert agent_id and isinstance(agent_id, str) + + resp = await client.get(f"/agents/{agent_id}/persona_state") + if resp.status_code == 200: + return resp.json() + # Discriminate the 4xx error_code sub-codes; everything else falls through. + try: + err = resp.json() + error_code = err.get("error_code") if isinstance(err, dict) else None + except ValueError: + error_code = None + if resp.status_code == 404 and error_code == "persona_not_configured": + raise PersonaNotConfigured(agent_id=agent_id) + if resp.status_code == 404 and error_code == "agent_not_available": + raise AgentNotAvailable(agent_id=agent_id) + if resp.status_code == 403 and error_code == "auth_scope_denied": + raise AuthScopeDenied(scope="persona.read") + raise SessionApiFailed(status=resp.status_code, body=resp.content) diff --git a/tests/test_sessions.py b/tests/test_sessions.py index 8c25a8f..a7774f7 100644 --- a/tests/test_sessions.py +++ b/tests/test_sessions.py @@ -6,11 +6,15 @@ import respx from ratatoskr.sessions import ( AgentInfo, + AgentNotAvailable, AgentNotFound, + AuthScopeDenied, InvalidCursor, + PersonaNotConfigured, SessionApiFailed, SessionPage, create_session, + get_persona_state, list_agents, list_sessions, ) @@ -556,3 +560,108 @@ class TestListAgents: with pytest.raises(SessionApiFailed) as excinfo: await list_agents(client) assert excinfo.value.status == 401 + + +class TestGetPersonaState: + """Worldtree #204 / v0.28.0 — GET /agents/{agent_id}/persona_state. + + Bootstrap read for the persona snapshot — same shape as `affect_update`'s + `current` snapshot. Auth via `persona.read` scope (user-tier default). + """ + + @respx.mock + async def test_happy_full_snapshot(self) -> None: + """happy_full_snapshot [happy,tracer]: 200 → snapshot dict with pad + + dominant_emotion + emotions_active + baseline_pad + mood_drift. + """ + snapshot = { + "agent_id": "mimir", + "pad": {"pleasure": 0.52, "arousal": 0.47, "dominance": 0.50}, + "dominant_emotion": "curiosity", + "emotions_active": [ + {"type": "curiosity", "intensity": 0.6, "decay_remaining_s": 202.7} + ], + "baseline_pad": {"pleasure": 0.50, "arousal": 0.40, "dominance": 0.50}, + "mood_drift": {"valence_delta": 0.02, "arousal_delta": 0.07}, + "last_updated_at": "2026-05-25T22:30:18+00:00", + } + respx.get("https://w.example/agents/mimir/persona_state").mock( + return_value=httpx.Response(200, json=snapshot) + ) + async with httpx.AsyncClient(base_url="https://w.example") as client: + result = await get_persona_state(client, "mimir") + assert result == snapshot + + @respx.mock + async def test_persona_not_configured_404(self) -> None: + """persona_not_configured_404 [error]: 404 with error_code + persona_not_configured → PersonaNotConfigured. Agent exists but has + no persona surface (e.g. domari, muninn, Tier 3). + """ + respx.get("https://w.example/agents/domari/persona_state").mock( + return_value=httpx.Response( + 404, json={"error_code": "persona_not_configured", "message": "no persona"} + ) + ) + async with httpx.AsyncClient(base_url="https://w.example") as client: + with pytest.raises(PersonaNotConfigured) as exc_info: + await get_persona_state(client, "domari") + assert exc_info.value.agent_id == "domari" + + @respx.mock + async def test_agent_not_available_404(self) -> None: + """agent_not_available_404 [error]: 404 with error_code + agent_not_available → AgentNotAvailable. Distinct from + persona_not_configured — the agent_id itself is unknown. + """ + respx.get("https://w.example/agents/bogus/persona_state").mock( + return_value=httpx.Response( + 404, json={"error_code": "agent_not_available", "message": "unknown agent"} + ) + ) + async with httpx.AsyncClient(base_url="https://w.example") as client: + with pytest.raises(AgentNotAvailable) as exc_info: + await get_persona_state(client, "bogus") + assert exc_info.value.agent_id == "bogus" + + @respx.mock + async def test_auth_scope_denied_403(self) -> None: + """auth_scope_denied_403 [error]: 403 with error_code auth_scope_denied + → AuthScopeDenied. Key lacks `persona.read` scope. + """ + respx.get("https://w.example/agents/mimir/persona_state").mock( + return_value=httpx.Response( + 403, + json={"error_code": "auth_scope_denied", "message": "missing persona.read"}, + ) + ) + async with httpx.AsyncClient(base_url="https://w.example") as client: + with pytest.raises(AuthScopeDenied) as exc_info: + await get_persona_state(client, "mimir") + assert exc_info.value.scope == "persona.read" + + @respx.mock + async def test_404_unknown_error_code_falls_through(self) -> None: + """404_unknown_error_code_falls_through [adversarial]: 404 without the + two known error codes → SessionApiFailed (don't swallow novel failure + modes as something more specific than they are). + """ + respx.get("https://w.example/agents/mimir/persona_state").mock( + return_value=httpx.Response(404, json={"error_code": "novel_404"}) + ) + async with httpx.AsyncClient(base_url="https://w.example") as client: + with pytest.raises(SessionApiFailed) as exc_info: + await get_persona_state(client, "mimir") + assert exc_info.value.status == 404 + + @respx.mock + async def test_500_unexpected_status(self) -> None: + """500_unexpected_status [error]: 5xx → SessionApiFailed (matches the + list_agents / list_sessions / create_session precedent).""" + respx.get("https://w.example/agents/mimir/persona_state").mock( + return_value=httpx.Response(500, content=b"boom") + ) + async with httpx.AsyncClient(base_url="https://w.example") as client: + with pytest.raises(SessionApiFailed) as exc_info: + await get_persona_state(client, "mimir") + assert exc_info.value.status == 500 diff --git a/uv.lock b/uv.lock index 049da1a..787f710 100644 --- a/uv.lock +++ b/uv.lock @@ -968,7 +968,7 @@ wheels = [ [[package]] name = "ratatoskr" -version = "0.11.0" +version = "0.12.0" source = { editable = "." } dependencies = [ { name = "httpx" },