From 0fbbeb171ce1beb4d0668a4a245534c534ce714c Mon Sep 17 00:00:00 2001 From: Vuong Hoang Date: Wed, 27 May 2026 19:11:16 -0700 Subject: [PATCH] fix(sessions): unwrap FastAPI detail envelope in get_persona_state (v0.15.1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- pyproject.toml | 2 +- src/ratatoskr/sessions.py | 15 ++++++++++++--- tests/test_sessions.py | 40 +++++++++++++++++++++++++++++++++++++++ uv.lock | 2 +- 4 files changed, 54 insertions(+), 5 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 9d41e2e..9239579 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" diff --git a/src/ratatoskr/sessions.py b/src/ratatoskr/sessions.py index 00fe709..df6acd6 100644 --- a/src/ratatoskr/sessions.py +++ b/src/ratatoskr/sessions.py @@ -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": diff --git a/tests/test_sessions.py b/tests/test_sessions.py index a7774f7..081a373 100644 --- a/tests/test_sessions.py +++ b/tests/test_sessions.py @@ -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" diff --git a/uv.lock b/uv.lock index 8b504fd..9dff353 100644 --- a/uv.lock +++ b/uv.lock @@ -1013,7 +1013,7 @@ wheels = [ [[package]] name = "ratatoskr" -version = "0.15.0" +version = "0.15.1" source = { editable = "." } dependencies = [ { name = "httpx" },