fix(sessions): unwrap FastAPI detail envelope in get_persona_state (v0.15.1)

Live smoke against personal:8081 during the v0.15.0 web-companion
verification surfaced that real Worldtree returns persona_state
errors in the FastAPI default envelope shape:

    {"detail": {"error_code": "auth_scope_denied", "message": "..."}}

The v0.12.0 `get_persona_state` parser only inspected the top-level
`error_code` key. When the field was nested under `detail`, the
typed exception (AuthScopeDenied / PersonaNotConfigured /
AgentNotAvailable) wasn't raised; the call fell through to
SessionApiFailed, which then surfaced through the web companion as
an opaque HTTP 500 on /api/agents/{id}/persona_state.

The original test_sessions.py mocks used the flat-shape envelope, so
the bug was invisible in unit tests until the real-wire smoke.

Fix: extract error_code from either `err.get("error_code")` (flat)
OR `err.get("detail", {}).get("error_code")` (FastAPI default).

Patch per SemVer discipline — bug fix to v0.12.0 surface, no public
signature change, no new behavior. Callers that were getting the
wrong exception now get the right one; callers that were already
getting the right exception (flat-shape paths) are unchanged.

Tests: 2 new regression cases in TestGetPersonaState — one each for
the detail-envelope shape of 403 auth_scope_denied and 404
persona_not_configured. Suite: 358 passing.
This commit is contained in:
2026-05-27 19:11:16 -07:00
parent 1228c37e6f
commit 0fbbeb171c
4 changed files with 54 additions and 5 deletions
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "ratatoskr"
version = "0.15.0"
version = "0.15.1"
description = "Worldtree Conversation API debug TUI — multi-pane observability dashboard"
readme = "README.md"
requires-python = ">=3.12"
+12 -3
View File
@@ -269,12 +269,21 @@ async def get_persona_state(
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.
# Discriminate the 4xx error_code sub-codes; everything else falls
# through. Worldtree returns errors as either flat `{"error_code": …}`
# OR FastAPI-default `{"detail": {"error_code": …}}` depending on
# which handler raised — unwrap both shapes (real wire observed
# 2026-05-28 returning the detail-nested form for auth_scope_denied
# from /agents/{id}/persona_state).
try:
err = resp.json()
error_code = err.get("error_code") if isinstance(err, dict) else None
except ValueError:
error_code = None
err = None
error_code: str | None = None
if isinstance(err, dict):
error_code = err.get("error_code")
if error_code is None and isinstance(err.get("detail"), dict):
error_code = err["detail"].get("error_code")
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":
+40
View File
@@ -665,3 +665,43 @@ class TestGetPersonaState:
with pytest.raises(SessionApiFailed) as exc_info:
await get_persona_state(client, "mimir")
assert exc_info.value.status == 500
@respx.mock
async def test_auth_scope_denied_detail_envelope(self) -> None:
"""auth_scope_denied_detail_envelope [regression]: real Worldtree
returns `{"detail": {"error_code": "auth_scope_denied", …}}`
(FastAPI default), not flat `{"error_code": …}`. Smoke against
personal:8081 2026-05-28 surfaced this — pre-fix the response
fell through to SessionApiFailed(403) instead of AuthScopeDenied.
"""
respx.get("https://w.example/agents/mimir/persona_state").mock(
return_value=httpx.Response(
403,
json={
"detail": {
"error_code": "auth_scope_denied",
"message": "Missing required scope: 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_persona_not_configured_detail_envelope(self) -> None:
"""persona_not_configured_detail_envelope [regression]: same
envelope-shape unwrap on 404 + persona_not_configured.
"""
respx.get("https://w.example/agents/domari/persona_state").mock(
return_value=httpx.Response(
404,
json={"detail": {"error_code": "persona_not_configured"}},
)
)
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"
Generated
+1 -1
View File
@@ -1013,7 +1013,7 @@ wheels = [
[[package]]
name = "ratatoskr"
version = "0.15.0"
version = "0.15.1"
source = { editable = "." }
dependencies = [
{ name = "httpx" },