feat(sessions): get_persona_state client + persona error taxonomy (v0.12.0)
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.
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user