"""Tests for ratatoskr.sessions per docs/contracts/issues/2.contract.md.""" import httpx import pytest import respx from ratatoskr.sessions import ( AgentInfo, AgentNotAvailable, AuthScopeDenied, PersonaNotConfigured, SessionApiFailed, create_character, delete_character, endpoint_for_plane, get_capabilities, get_character_state, get_me, get_persona_state, get_session_bifrost, list_agents, list_character_models, ) class TestEndpointForPlane: """Issue #17 — endpoint_for_plane: plane name → Worldtree-visible base URL.""" def test_memory_plane_maps_to_8391(self) -> None: """memory [tracer]: 'memory' → http://:8391 (POST-001).""" assert endpoint_for_plane("memory", "10.100.10.50") == "http://10.100.10.50:8391" def test_affect_plane_maps_to_8390(self) -> None: """affect: 'affect' → http://:8390 (POST-001).""" assert endpoint_for_plane("affect", "10.100.10.50") == "http://10.100.10.50:8390" def test_combined_plane_maps_to_8392(self) -> None: """combined [#18 composite]: 'combined' → http://:8392 (POST-001).""" assert endpoint_for_plane("combined", "10.100.10.50") == "http://10.100.10.50:8392" def test_unknown_plane_raises_value_error(self) -> None: """unknown_plane [adversarial]: any other plane → ValueError (PRE-001).""" with pytest.raises(ValueError): endpoint_for_plane("persona", "10.100.10.50") class TestListAgents: @respx.mock async def test_happy_full_shape(self) -> None: """happy_full_shape [happy,tracer]: spec full-shape mimir example → all fields.""" respx.get("https://w.example/agents").mock( return_value=httpx.Response( 200, json=[ { "agent_id": "mimir", "name": "Mimir", "description": "Keeper of the Well of Knowledge.", "version": "0.2.0", "capabilities": ["knowledge_base", "semantic_search"], "supported_models": ["default", "heavy"], "persona_traits": { "ocean": { "openness": 0.7, "conscientiousness": 0.9, "extraversion": 0.1, "agreeableness": 0.5, "neuroticism": 0.3, }, "vibe": "contemplative", }, "ui_hints": {"icon": "well", "color_hint": "#5b8aa3"}, } ], ) ) async with httpx.AsyncClient(base_url="https://w.example") as client: agents = await list_agents(client) assert len(agents) == 1 a = agents[0] assert isinstance(a, AgentInfo) assert a.agent_id == "mimir" assert a.name == "Mimir" assert a.description == "Keeper of the Well of Knowledge." assert a.version == "0.2.0" assert a.capabilities == ["knowledge_base", "semantic_search"] assert a.supported_models == ["default", "heavy"] assert a.persona_traits["vibe"] == "contemplative" assert a.ui_hints["icon"] == "well" @respx.mock async def test_happy_minimum_shape(self) -> None: """happy_minimum_shape: required-only agent → optional fields default.""" respx.get("https://w.example/agents").mock( return_value=httpx.Response( 200, json=[ { "agent_id": "minimal", "name": "Minimal Agent", "description": "Just a sketch.", } ], ) ) async with httpx.AsyncClient(base_url="https://w.example") as client: agents = await list_agents(client) a = agents[0] assert a.agent_id == "minimal" assert a.version is None assert a.capabilities == [] assert a.supported_models == [] assert a.persona_traits == {} assert a.ui_hints == {} @respx.mock async def test_happy_multi_agent(self) -> None: """happy_multi_agent: 3 agents preserve order.""" respx.get("https://w.example/agents").mock( return_value=httpx.Response( 200, json=[ {"agent_id": "a", "name": "A", "description": "x"}, {"agent_id": "b", "name": "B", "description": "y"}, {"agent_id": "c", "name": "C", "description": "z"}, ], ) ) async with httpx.AsyncClient(base_url="https://w.example") as client: agents = await list_agents(client) assert [a.agent_id for a in agents] == ["a", "b", "c"] @respx.mock async def test_happy_empty(self) -> None: """happy_empty: 200 with [] returns empty list (no error).""" respx.get("https://w.example/agents").mock(return_value=httpx.Response(200, json=[])) async with httpx.AsyncClient(base_url="https://w.example") as client: agents = await list_agents(client) assert agents == [] @respx.mock async def test_omit_capabilities_empty_list(self) -> None: """omit_capabilities_empty: explicit [] from server still defaults to [].""" respx.get("https://w.example/agents").mock( return_value=httpx.Response( 200, json=[ { "agent_id": "a", "name": "A", "description": "x", "capabilities": [], } ], ) ) async with httpx.AsyncClient(base_url="https://w.example") as client: agents = await list_agents(client) assert agents[0].capabilities == [] @respx.mock async def test_500_raises_session_api_failed(self) -> None: """500 → SessionApiFailed with status=500.""" respx.get("https://w.example/agents").mock( return_value=httpx.Response(500, content=b"oops") ) async with httpx.AsyncClient(base_url="https://w.example") as client: with pytest.raises(SessionApiFailed) as excinfo: await list_agents(client) assert excinfo.value.status == 500 @respx.mock async def test_401_raises_session_api_failed(self) -> None: """401 → SessionApiFailed with status=401.""" respx.get("https://w.example/agents").mock( return_value=httpx.Response(401, content=b'{"error":"unauthorized"}') ) async with httpx.AsyncClient(base_url="https://w.example") as client: 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 @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" class TestGetMe: """docs/contracts/issues/2.contract.md FN get_me (slice: capabilities+me).""" @respx.mock async def test_happy_authenticated(self) -> None: """happy_authenticated [happy,tracer]: 200 → parsed identity dict verbatim.""" respx.get("https://w.example/me").mock( return_value=httpx.Response( 200, json={ "user_id": "alice", "scopes": ["conversations.read", "conversations.write"], "tier": "user", "key_id": "a1b2c3d4", "key_label": "alice phone", }, ) ) async with httpx.AsyncClient(base_url="https://w.example") as client: me = await get_me(client) assert me["user_id"] == "alice" assert me["tier"] == "user" assert me["key_id"] == "a1b2c3d4" assert me["scopes"] == ["conversations.read", "conversations.write"] @respx.mock async def test_anonymous_dev_mode(self) -> None: """anonymous_dev_mode: 200 anonymous shape → dict with tier=anonymous.""" respx.get("https://w.example/me").mock( return_value=httpx.Response( 200, json={ "user_id": "anonymous", "scopes": ["conversations.read"], "tier": "anonymous", }, ) ) async with httpx.AsyncClient(base_url="https://w.example") as client: me = await get_me(client) assert me["tier"] == "anonymous" assert "key_id" not in me # optional fields omitted, not null @respx.mock async def test_401_raises_session_api_failed(self) -> None: """401_raises [error]: bad/absent key → SessionApiFailed(status=401).""" respx.get("https://w.example/me").mock( return_value=httpx.Response(401, json={"detail": "auth_invalid"}) ) async with httpx.AsyncClient(base_url="https://w.example") as client: with pytest.raises(SessionApiFailed) as exc: await get_me(client) assert exc.value.status == 401 class TestGetCapabilities: """docs/contracts/issues/2.contract.md FN get_capabilities (slice: capabilities+me).""" @respx.mock async def test_happy(self) -> None: """happy [happy]: 200 → ephemeral_templates dict verbatim.""" respx.get("https://w.example/capabilities").mock( return_value=httpx.Response( 200, json={ "ephemeral_templates": { "echo": { "allowed_models": ["glm5-turbo", "glm4.7"], "default_model": "glm5-turbo", "system_prompt_max_bytes": 32768, } } }, ) ) async with httpx.AsyncClient(base_url="https://w.example") as client: caps = await get_capabilities(client) echo = caps["ephemeral_templates"]["echo"] assert echo["default_model"] == "glm5-turbo" assert echo["system_prompt_max_bytes"] == 32768 @respx.mock async def test_non_200_raises(self) -> None: """non_200_raises [error]: 500 → SessionApiFailed(status=500).""" respx.get("https://w.example/capabilities").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: await get_capabilities(client) assert exc.value.status == 500 class TestGetSessionBifrost: """#2 contract — get_session_bifrost (GET /admin/sessions/{id}/bifrost, #176).""" @respx.mock async def test_happy_uses_admin_bearer(self) -> None: """happy [happy,tracer]: 200 → binding dict; request carries the ADMIN bearer (override).""" route = respx.get("https://w.example/admin/sessions/s1/bifrost").mock( return_value=httpx.Response( 200, json={ "endpoint_url": "https://bifrost.example/mcp", "consumer_id": "alice", "connected": True, "capabilities_granted": ["tools:call", "tools:read"], "tools": [{"name": "bifrost.alice.echo", "description": "echo"}], }, ) ) async with httpx.AsyncClient( base_url="https://w.example", headers={"Authorization": "Bearer consumer-key"}, ) as client: state = await get_session_bifrost(client, "s1", admin_key="admin-xyz") assert state["connected"] is True assert state["tools"][0]["name"] == "bifrost.alice.echo" # the request overrode the client's default consumer bearer with the admin key assert route.calls[0].request.headers["Authorization"] == "Bearer admin-xyz" @respx.mock async def test_403_scope_denied(self) -> None: """403 [error]: admin key lacks admin.sessions.read → SessionApiFailed(403).""" respx.get("https://w.example/admin/sessions/s1/bifrost").mock( return_value=httpx.Response(403, json={"error_code": "auth_scope_denied"}) ) async with httpx.AsyncClient(base_url="https://w.example") as client: with pytest.raises(SessionApiFailed) as exc: await get_session_bifrost(client, "s1", admin_key="k") assert exc.value.status == 403 @respx.mock async def test_404_not_bound(self) -> None: """404 [error]: session_not_bifrost_bound → SessionApiFailed(404).""" respx.get("https://w.example/admin/sessions/s1/bifrost").mock( return_value=httpx.Response(404, json={"error_code": "session_not_bifrost_bound"}) ) async with httpx.AsyncClient(base_url="https://w.example") as client: with pytest.raises(SessionApiFailed) as exc: await get_session_bifrost(client, "s1", admin_key="k") assert exc.value.status == 404 @respx.mock async def test_empty_admin_key_asserts(self) -> None: """empty_admin_key [adversarial]: '' → AssertionError; no HTTP issued.""" route = respx.get("https://w.example/admin/sessions/s1/bifrost").mock( return_value=httpx.Response(200, json={}) ) async with httpx.AsyncClient(base_url="https://w.example") as client: with pytest.raises(AssertionError): await get_session_bifrost(client, "s1", admin_key="") assert route.call_count == 0 class TestTransientCharacters: """docs/contracts/issues/2.contract.md — transient-character wrappers (#161).""" @respx.mock async def test_list_models(self) -> None: """list_models [happy,tracer]: 200 → {items:[...]} verbatim.""" respx.get("https://w.example/models/available-for-characters").mock( return_value=httpx.Response(200, json={"items": [{"name": "fast", "thinking": False}]}) ) async with httpx.AsyncClient(base_url="https://w.example") as client: models = await list_character_models(client) assert models["items"][0]["name"] == "fast" @respx.mock async def test_create_body_and_response(self) -> None: """create [happy]: body is {character, state}; 201 → {character_id, ttl_expires_at}.""" import json as _json route = respx.post("https://w.example/characters").mock( return_value=httpx.Response(201, json={"character_id": "char_x", "ttl_expires_at": "t"}) ) async with httpx.AsyncClient(base_url="https://w.example") as client: out = await create_character(client, {"schema_version": "1", "name": "H"}) assert out["character_id"] == "char_x" body = _json.loads(route.calls[0].request.content) assert body == {"character": {"schema_version": "1", "name": "H"}, "state": None} @respx.mock async def test_get_state(self) -> None: """get_state [happy]: 200 → live PAD/emotions snapshot.""" respx.get("https://w.example/characters/char_x/state").mock( return_value=httpx.Response(200, json={"schema_version": "1", "pad": [0.4, 0.1, -0.2]}) ) async with httpx.AsyncClient(base_url="https://w.example") as client: state = await get_character_state(client, "char_x") assert state["pad"] == [0.4, 0.1, -0.2] @respx.mock async def test_delete_204(self) -> None: """delete [happy]: 204 → None.""" respx.delete("https://w.example/characters/char_x").mock(return_value=httpx.Response(204)) async with httpx.AsyncClient(base_url="https://w.example") as client: assert await delete_character(client, "char_x") is None @respx.mock async def test_create_403_scope(self) -> None: """create_403 [error]: key lacks character.write → SessionApiFailed(403).""" respx.post("https://w.example/characters").mock( return_value=httpx.Response(403, json={"error_code": "auth_scope_denied"}) ) async with httpx.AsyncClient(base_url="https://w.example") as client: with pytest.raises(SessionApiFailed) as exc: await create_character(client, {"name": "H"}) assert exc.value.status == 403