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" },