"""Tests for ratatoskr.sessions per docs/contracts/issues/2.contract.md.""" import httpx import pytest import respx from ratatoskr.sessions import ( AgentInfo, AgentNotAvailable, AgentNotFound, AuthScopeDenied, InvalidCursor, PersonaNotConfigured, SessionApiFailed, SessionPage, create_session, get_persona_state, list_agents, list_sessions, ) class TestCreateSession: @respx.mock async def test_happy_create(self) -> None: """happy_create [happy,tracer]: full 201 body -> SessionInfo with create-origin defaults.""" respx.post("https://w.example/sessions").mock( return_value=httpx.Response( 201, json={ "session_id": "550e8400-e29b-41d4-a716-446655440000", "agent_id": "mimir", "message_count": 0, "created_at": "2026-04-15T12:00:00+00:00", "last_active": "2026-04-15T12:00:00+00:00", "metadata": {}, }, ) ) async with httpx.AsyncClient(base_url="https://w.example") as client: info = await create_session(client, "mimir") assert info.session_id == "550e8400-e29b-41d4-a716-446655440000" assert info.agent_id == "mimir" assert info.created_at == "2026-04-15T12:00:00+00:00" assert info.last_active == "2026-04-15T12:00:00+00:00" assert info.metadata == {} assert info.message_count == 0 # INV-001 create-origin fixed defaults assert info.name is None assert info.archived is False assert info.tags == [] @respx.mock async def test_happy_create_with_metadata(self) -> None: """happy_create_with_metadata: response carries metadata -> SessionInfo.metadata matches.""" respx.post("https://w.example/sessions").mock( return_value=httpx.Response( 201, json={ "session_id": "s1", "agent_id": "mimir", "message_count": 0, "created_at": "2026-04-15T12:00:00+00:00", "last_active": "2026-04-15T12:00:00+00:00", "metadata": {"model": "glm5-turbo"}, }, ) ) async with httpx.AsyncClient(base_url="https://w.example") as client: info = await create_session(client, "mimir") assert info.metadata == {"model": "glm5-turbo"} @respx.mock async def test_request_body_shape(self) -> None: """request_body_shape [trace]: outbound JSON is exactly {"agent_id": }.""" import json as _json route = respx.post("https://w.example/sessions").mock( return_value=httpx.Response( 201, json={ "session_id": "s1", "agent_id": "mimir", "message_count": 0, "created_at": "2026-04-15T12:00:00+00:00", "last_active": "2026-04-15T12:00:00+00:00", "metadata": {}, }, ) ) async with httpx.AsyncClient(base_url="https://w.example") as client: await create_session(client, "mimir") body = _json.loads(route.calls[0].request.content) assert body == {"agent_id": "mimir"} @respx.mock async def test_unknown_agent_id(self) -> None: """unknown_agent_id: 404 -> AgentNotFound(agent_id=).""" respx.post("https://w.example/sessions").mock( return_value=httpx.Response(404, json={"error": "unknown_agent_id"}) ) async with httpx.AsyncClient(base_url="https://w.example") as client: with pytest.raises(AgentNotFound) as exc_info: await create_session(client, "mimir") assert exc_info.value.agent_id == "mimir" @respx.mock async def test_validation_failed(self) -> None: """validation_failed: 422 -> SessionApiFailed(status=422); body truncated.""" respx.post("https://w.example/sessions").mock( return_value=httpx.Response( 422, json={"error_code": "validation_failed", "message": "missing agent_id"}, ) ) async with httpx.AsyncClient(base_url="https://w.example") as client: with pytest.raises(SessionApiFailed) as exc_info: await create_session(client, "mimir") assert exc_info.value.status == 422 assert len(exc_info.value.body) <= 1024 @respx.mock async def test_unexpected_status_truncates(self) -> None: """unexpected_status_truncates: 500 + 5000-byte body -> SessionApiFailed; body == 1024.""" big = b"x" * 5000 respx.post("https://w.example/sessions").mock( return_value=httpx.Response(500, content=big) ) async with httpx.AsyncClient(base_url="https://w.example") as client: with pytest.raises(SessionApiFailed) as exc_info: await create_session(client, "mimir") assert exc_info.value.status == 500 assert exc_info.value.body == big[:1024] @respx.mock async def test_empty_agent_id(self) -> None: """empty_agent_id [adversarial]: '' -> AssertionError; no HTTP issued.""" route = respx.post("https://w.example/sessions").mock( return_value=httpx.Response(201, content=b"{}") ) async with httpx.AsyncClient(base_url="https://w.example") as client: with pytest.raises(AssertionError): await create_session(client, "") assert route.call_count == 0 @respx.mock async def test_happy_create_with_end_user_id(self) -> None: """happy_create_with_end_user_id [happy]: body carries both keys (issue #5).""" import json as _json route = respx.post("https://w.example/sessions").mock( return_value=httpx.Response( 201, json={ "session_id": "s-new", "agent_id": "lofn", "message_count": 0, "created_at": "2026-05-22T12:00:00+00:00", "last_active": "2026-05-22T12:00:00+00:00", "metadata": {}, }, ) ) async with httpx.AsyncClient(base_url="https://w.example") as client: info = await create_session(client, "lofn", end_user_id="alice") body = _json.loads(route.calls[0].request.content) # INV: body MUST be exactly {"agent_id": ..., "end_user_id": ...} — byte-for-byte assert body == {"agent_id": "lofn", "end_user_id": "alice"} assert info.session_id == "s-new" assert info.agent_id == "lofn" @respx.mock async def test_default_omits_end_user_id(self) -> None: """default_omits_end_user_id [trace]: omit kwarg → body has no end_user_id (INV-002).""" import json as _json route = respx.post("https://w.example/sessions").mock( return_value=httpx.Response( 201, json={ "session_id": "s-new", "agent_id": "mimir", "message_count": 0, "created_at": "2026-05-22T12:00:00+00:00", "last_active": "2026-05-22T12:00:00+00:00", "metadata": {}, }, ) ) async with httpx.AsyncClient(base_url="https://w.example") as client: await create_session(client, "mimir") body = _json.loads(route.calls[0].request.content) # Exact equality — no end_user_id key in the body when the kwarg is omitted assert body == {"agent_id": "mimir"} assert "end_user_id" not in body @respx.mock async def test_empty_end_user_id(self) -> None: """empty_end_user_id [adversarial]: '' → AssertionError before HTTP (PRE-003).""" route = respx.post("https://w.example/sessions").mock( return_value=httpx.Response(201, content=b"{}") ) async with httpx.AsyncClient(base_url="https://w.example") as client: with pytest.raises(AssertionError): await create_session(client, "mimir", end_user_id="") assert route.call_count == 0 def _list_item( *, session_id: str = "s1", agent_id: str = "mimir", created_at: str = "2026-04-15T12:00:00+00:00", last_active: str = "2026-04-15T12:05:00+00:00", metadata: dict[str, object] | None = None, name: str | None = "Research session", archived: bool = False, tags: list[str] | None = None, ) -> dict[str, object]: """Build a GET /sessions list-item body for tests.""" item: dict[str, object] = { "session_id": session_id, "agent_id": agent_id, "created_at": created_at, "last_active": last_active, "metadata": metadata if metadata is not None else {}, "name": name, "archived": archived, "tags": tags if tags is not None else ["work"], } return item class TestListSessions: @respx.mock async def test_explicit_null_list_defaults(self) -> None: """INV-002: explicit-null archived -> False; explicit-null tags -> [].""" raw_item = { "session_id": "s1", "agent_id": "mimir", "created_at": "2026-04-15T12:00:00+00:00", "last_active": "2026-04-15T12:05:00+00:00", "metadata": {}, "name": None, "archived": None, "tags": None, } respx.get("https://w.example/sessions").mock( return_value=httpx.Response( 200, json={"items": [raw_item], "next_cursor": None} ) ) async with httpx.AsyncClient(base_url="https://w.example") as client: page = await list_sessions(client) info = page.items[0] assert info.archived is False, "explicit-null archived must default to False" assert info.tags == [], "explicit-null tags must default to []" assert info.name is None @respx.mock async def test_happy_first_page(self) -> None: """happy_first_page [happy,tracer]: one item + next_cursor -> SessionPage shape.""" respx.get("https://w.example/sessions").mock( return_value=httpx.Response( 200, json={ "items": [_list_item()], "next_cursor": "v1.eyJhYmMifQ", }, ) ) async with httpx.AsyncClient(base_url="https://w.example") as client: page = await list_sessions(client) assert isinstance(page, SessionPage) assert len(page.items) == 1 assert page.next_cursor == "v1.eyJhYmMifQ" info = page.items[0] assert info.session_id == "s1" assert info.message_count is None # INV-002: not in list response assert info.name == "Research session" assert info.archived is False assert info.tags == ["work"] @respx.mock async def test_happy_last_page(self) -> None: """happy_last_page: next_cursor=null -> SessionPage(next_cursor=None).""" respx.get("https://w.example/sessions").mock( return_value=httpx.Response( 200, json={"items": [_list_item()], "next_cursor": None}, ) ) async with httpx.AsyncClient(base_url="https://w.example") as client: page = await list_sessions(client) assert page.next_cursor is None @respx.mock async def test_empty_results(self) -> None: """empty_results: {items: [], next_cursor: null} -> SessionPage([], None).""" respx.get("https://w.example/sessions").mock( return_value=httpx.Response(200, json={"items": [], "next_cursor": None}) ) async with httpx.AsyncClient(base_url="https://w.example") as client: page = await list_sessions(client) assert page == SessionPage(items=[], next_cursor=None) @respx.mock async def test_include_archived_query(self) -> None: """include_archived_query: True -> has param; default -> NO param at all.""" route = respx.get("https://w.example/sessions").mock( return_value=httpx.Response( 200, json={"items": [], "next_cursor": None} ) ) async with httpx.AsyncClient(base_url="https://w.example") as client: await list_sessions(client, include_archived=True) await list_sessions(client) # default url_with = str(route.calls[0].request.url) url_default = str(route.calls[1].request.url) assert "include_archived=true" in url_with assert "include_archived" not in url_default @respx.mock async def test_cursor_threaded(self) -> None: """cursor_threaded: cursor=opaque -> URL has cursor=opaque.""" route = respx.get("https://w.example/sessions").mock( return_value=httpx.Response( 200, json={"items": [], "next_cursor": None} ) ) async with httpx.AsyncClient(base_url="https://w.example") as client: await list_sessions(client, cursor="opaque-from-prev-page") assert "cursor=opaque-from-prev-page" in str(route.calls[0].request.url) @respx.mock async def test_limit_query(self) -> None: """limit_query: limit=10 -> URL has limit=10.""" route = respx.get("https://w.example/sessions").mock( return_value=httpx.Response( 200, json={"items": [], "next_cursor": None} ) ) async with httpx.AsyncClient(base_url="https://w.example") as client: await list_sessions(client, limit=10) assert "limit=10" in str(route.calls[0].request.url) @respx.mock async def test_invalid_cursor_server(self) -> None: """invalid_cursor_server: 422 cursor_invalid -> InvalidCursor(raw=).""" respx.get("https://w.example/sessions").mock( return_value=httpx.Response( 422, json={"error_code": "cursor_invalid", "message": "bad cursor"}, ) ) async with httpx.AsyncClient(base_url="https://w.example") as client: with pytest.raises(InvalidCursor) as exc_info: await list_sessions(client, cursor="bogus") assert exc_info.value.raw == "bogus" @respx.mock async def test_other_validation_failed(self) -> None: """other_validation_failed: 422 other error_code -> SessionApiFailed(422); truncated.""" respx.get("https://w.example/sessions").mock( return_value=httpx.Response( 422, json={"error_code": "validation_failed", "message": "limit out of range"}, ) ) async with httpx.AsyncClient(base_url="https://w.example") as client: with pytest.raises(SessionApiFailed) as exc_info: await list_sessions(client) assert exc_info.value.status == 422 assert len(exc_info.value.body) <= 1024 @respx.mock async def test_unexpected_status_truncates(self) -> None: """unexpected_status_truncates: 500 + 5000-byte body -> SessionApiFailed; body == 1024.""" big = b"x" * 5000 respx.get("https://w.example/sessions").mock( return_value=httpx.Response(500, content=big) ) async with httpx.AsyncClient(base_url="https://w.example") as client: with pytest.raises(SessionApiFailed) as exc_info: await list_sessions(client) assert exc_info.value.status == 500 assert exc_info.value.body == big[:1024] @respx.mock async def test_limit_below_one(self) -> None: """limit_below_one [adversarial]: limit=0 -> AssertionError; no HTTP.""" route = respx.get("https://w.example/sessions").mock( return_value=httpx.Response(200, json={"items": [], "next_cursor": None}) ) async with httpx.AsyncClient(base_url="https://w.example") as client: with pytest.raises(AssertionError): await list_sessions(client, limit=0) assert route.call_count == 0 @respx.mock async def test_limit_above_max(self) -> None: """limit_above_max [adversarial]: limit=300 -> AssertionError; no HTTP.""" route = respx.get("https://w.example/sessions").mock( return_value=httpx.Response(200, json={"items": [], "next_cursor": None}) ) async with httpx.AsyncClient(base_url="https://w.example") as client: with pytest.raises(AssertionError): await list_sessions(client, limit=300) assert route.call_count == 0 @respx.mock async def test_empty_cursor(self) -> None: """empty_cursor [adversarial]: cursor='' -> AssertionError; no HTTP.""" route = respx.get("https://w.example/sessions").mock( return_value=httpx.Response(200, json={"items": [], "next_cursor": None}) ) async with httpx.AsyncClient(base_url="https://w.example") as client: with pytest.raises(AssertionError): await list_sessions(client, cursor="") assert route.call_count == 0 # ---- Issue #8: list_agents + AgentInfo -------------------------------------- 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"