"""Unit tests for the worldtree-sdk adapter (`ratatoskr.wt`) — slice-1 foundation. Covers the two foundation surfaces (issue #20 cutover contract, slice 1): * `build_client` — construction wiring + injected-transport ownership (INV-CUT-1: the SDK must never close ratatoskr's transport). * `translate_error` — the § Error map DEFAULT (`ApiError` → `SessionApiFailed`) plus discriminated-`WorldtreeError` passthrough (INV-CUT-2). No ratatoskr surface (CLI / web / TUI) is exercised here — that wiring lands in slice 2. These tests hit no network (WorldtreeClient does no I/O at construction). """ from __future__ import annotations from typing import Any, cast import httpx import pytest import worldtree_sdk as wtsdk from worldtree_sdk import ApiError, CancelResult, PadState, WorldtreeClient from ratatoskr.sessions import ( AgentNotAvailable as PersonaAgentNotAvailable, ) from ratatoskr.sessions import ( AgentNotFound, AuthoredHistoryUnavailable, AuthScopeDenied, BifrostBinding, BifrostConsumerKeyMissing, BifrostHandshakeFailed, InvalidCursor, PersonaNotConfigured, Tier3AgentNotFound, Tier3FieldNotMutable, Tier3LayerDeferred, Tier3QuotaExceeded, Tier3UserIdUnsupported, ) from ratatoskr.sse_client import ( AgentNotAvailable, CancelAlreadyCompleted, CancelFailed, CancelTurnNotFound, MalformedSseData, MalformedSseId, SseConnectFailed, SseConnectionDropped, TurnIdFlip, TurnLaunchUnavailable, ) from ratatoskr.wt import ( SessionApiFailed, build_client, cancel_turn, create_character, create_session, define_agent, delete_agent, delete_character, get_capabilities, get_character_state, get_me, get_persona_state, get_session_messages, get_session_tools, list_agents, list_character_models, list_sessions, patch_agent, set_persona_state, stream_turn, translate_error, write_authored_history, ) class _FakeSessions: """A stand-in for `WorldtreeClient.sessions` — records the last call and returns a canned result or raises a canned error. Lets the adapter's body-building + error-mapping be unit-tested without any SDK HTTP.""" def __init__( self, *, result: Any = None, error: BaseException | None = None, events: list[Any] | None = None, stream_error: BaseException | None = None, ) -> None: self._result = result self._error = error self._events = events or [] self._stream_error = stream_error self.calls: list[tuple[str, tuple[Any, ...], dict[str, Any]]] = [] async def _dispatch(self, name: str, *args: Any, **kwargs: Any) -> Any: self.calls.append((name, args, kwargs)) if self._error is not None: raise self._error return self._result async def create(self, *args: Any, **kwargs: Any) -> Any: return await self._dispatch("create", *args, **kwargs) async def list(self, *args: Any, **kwargs: Any) -> Any: return await self._dispatch("list", *args, **kwargs) async def messages(self, *args: Any, **kwargs: Any) -> Any: return await self._dispatch("messages", *args, **kwargs) async def tools(self, *args: Any, **kwargs: Any) -> Any: return await self._dispatch("tools", *args, **kwargs) def stream_turn(self, *args: Any, **kwargs: Any) -> Any: self.calls.append(("stream_turn", args, kwargs)) return self._astream() async def _astream(self) -> Any: for event in self._events: yield event if self._stream_error is not None: raise self._stream_error async def cancel_turn(self, *args: Any, **kwargs: Any) -> Any: return await self._dispatch("cancel_turn", *args, **kwargs) async def set_persona_state(self, *args: Any, **kwargs: Any) -> Any: return await self._dispatch("set_persona_state", *args, **kwargs) async def write_history(self, *args: Any, **kwargs: Any) -> Any: return await self._dispatch("write_history", *args, **kwargs) class _FakeClient: def __init__(self, sessions: _FakeSessions) -> None: self.sessions = sessions def _wt(sessions: _FakeSessions) -> WorldtreeClient: """Cast the structural fake to the nominal client type the adapter is typed against — the route functions only touch `client.sessions.*`, which the fake provides. (No network; construction does no I/O.)""" return cast(WorldtreeClient, _FakeClient(sessions)) class TestBuildClient: async def test_constructs_worldtree_client(self) -> None: transport = httpx.AsyncClient() try: client = build_client( "https://wt.example:8081", api_key="ck-test", transport=transport ) assert isinstance(client, WorldtreeClient) assert client.base_url == "https://wt.example:8081" finally: await transport.aclose() async def test_injected_transport_is_ratatoskr_owned(self) -> None: # INV-CUT-1 [hard]: aclose() on the SDK client must NOT close ratatoskr's # transport — ratatoskr owns the lifecycle exactly as it does today. transport = httpx.AsyncClient() client = build_client("https://wt.example", api_key="ck", transport=transport) await client.aclose() assert client.closed is True assert transport.is_closed is False await transport.aclose() async def test_admin_key_optional(self) -> None: transport = httpx.AsyncClient() try: # Absent admin_key → admin_auth=None; still constructs. without_admin = build_client( "https://wt.example", api_key="ck", transport=transport ) assert isinstance(without_admin, WorldtreeClient) # Present admin_key → constructs (admin surface available in later slices). with_admin = build_client( "https://wt.example", api_key="ck", admin_key="ak", transport=transport ) assert isinstance(with_admin, WorldtreeClient) finally: await transport.aclose() class TestTranslateError: def test_apierror_maps_to_session_api_failed_default(self) -> None: exc = ApiError("some_code", "boom", status=500, body="raw-body") mapped = translate_error(exc) assert isinstance(mapped, SessionApiFailed) assert mapped.status == 500 assert mapped.error_code == "some_code" assert mapped.body == "raw-body" def test_apierror_with_no_body_maps_cleanly(self) -> None: exc = ApiError("nope", "no body", status=404) mapped = translate_error(exc) assert isinstance(mapped, SessionApiFailed) assert mapped.status == 404 assert mapped.error_code == "nope" assert mapped.body is None def test_discriminated_subclass_passes_through_unchanged(self) -> None: # Discriminated WorldtreeError subclasses are already the right semantic # type at the REST layer — translate_error passes them through by identity # (the stream routes re-wrap them; that is stream_turn's job, not this one). exc = wtsdk.AgentNotAvailable("agent_not_available", "gone", status=409) assert translate_error(exc) is exc def test_non_worldtree_error_passes_through_unchanged(self) -> None: exc = ValueError("unrelated") assert translate_error(exc) is exc class TestCreateSession: async def test_happy_returns_sdk_dict_and_builds_body(self) -> None: info = {"session_id": "s-1", "agent_id": "mimir", "created_at": "t", "last_active": "t"} fake = _FakeSessions(result=info) client = _wt(fake) out = await create_session(client, "mimir", end_user_id="u-9") assert out is info # open-world passthrough — no re-shaping name, args, kwargs = fake.calls[-1] assert name == "create" assert args[0] == {"agent_id": "mimir", "end_user_id": "u-9"} assert kwargs["consumer_key"] is None async def test_config_passthrough(self) -> None: fake = _FakeSessions(result={"session_id": "s"}) await create_session( _wt(fake), "echo", config={"system_prompt": "be terse"} ) assert fake.calls[-1][1][0] == { "agent_id": "echo", "config": {"system_prompt": "be terse"}, } async def test_bifrost_bound_body_and_consumer_key(self) -> None: fake = _FakeSessions(result={"session_id": "s"}) binding = BifrostBinding(endpoint_url="http://h:8391", scope=None) await create_session( _wt(fake), "sindra", bifrost=binding, consumer_key="ck-real" ) _name, args, kwargs = fake.calls[-1] assert args[0] == { "agent_id": "sindra", "bifrost": {"endpoint_url": "http://h:8391", "scope": None}, } # INV-CUT: the consumer key rides the SDK's per-request auth, NOT a header. assert kwargs["consumer_key"] == "ck-real" async def test_unbound_create_drops_consumer_key(self) -> None: # A consumer_key must NOT reach the SDK on an UNBOUND create — the SDK's # credential precedence would otherwise auth as the Bifrost consumer instead # of the default bearer (heid-bug-hunt Gróa#4 / Regin#4). fake = _FakeSessions(result={"session_id": "s"}) await create_session(_wt(fake), "mimir", consumer_key="ck-should-be-dropped") assert fake.calls[-1][2]["consumer_key"] is None async def test_bifrost_without_consumer_key_rejected_pre_http(self) -> None: fake = _FakeSessions(result={"session_id": "s"}) binding = BifrostBinding(endpoint_url="http://h:8391", scope=None) with pytest.raises(BifrostConsumerKeyMissing): await create_session(_wt(fake), "sindra", bifrost=binding) assert fake.calls == [] # never reached the SDK async def test_404_maps_to_agent_not_found(self) -> None: fake = _FakeSessions(error=ApiError("agent_not_found", "no", status=404)) with pytest.raises(AgentNotFound) as ei: await create_session(_wt(fake), "ghost") assert ei.value.agent_id == "ghost" async def test_bound_502_maps_to_bifrost_handshake_failed(self) -> None: body = '{"detail": {"bifrost_error": "bifrost.auth_rejected"}}' fake = _FakeSessions( error=ApiError("bifrost_handshake_failed", "boom", status=502, body=body) ) binding = BifrostBinding(endpoint_url="http://h:8391", scope=None) with pytest.raises(BifrostHandshakeFailed) as ei: await create_session( _wt(fake), "sindra", bifrost=binding, consumer_key="ck" ) assert ei.value.bifrost_error == "bifrost.auth_rejected" async def test_unbound_502_stays_session_api_failed(self) -> None: fake = _FakeSessions(error=ApiError("upstream", "boom", status=502, body="x")) with pytest.raises(SessionApiFailed) as ei: await create_session(_wt(fake), "mimir") assert ei.value.status == 502 async def test_default_error_maps_to_session_api_failed(self) -> None: fake = _FakeSessions(error=ApiError("weird", "boom", status=418, body="teapot")) with pytest.raises(SessionApiFailed) as ei: await create_session(_wt(fake), "mimir") assert ei.value.status == 418 assert ei.value.error_code == "weird" class TestListSessions: async def test_passes_params_and_returns_dict(self) -> None: page: dict[str, Any] = {"items": [], "next_cursor": None} fake = _FakeSessions(result=page) out = await list_sessions(_wt(fake), limit=10, cursor="c1", include_archived=True) assert out is page kwargs = fake.calls[-1][2] assert kwargs["limit"] == 10 assert kwargs["cursor"] == "c1" assert kwargs["include_archived"] is True async def test_422_cursor_invalid_maps_to_invalid_cursor(self) -> None: fake = _FakeSessions(error=ApiError("cursor_invalid", "bad", status=422)) with pytest.raises(InvalidCursor) as ei: await list_sessions(_wt(fake), cursor="bogus") assert ei.value.raw == "bogus" async def test_other_422_stays_session_api_failed(self) -> None: fake = _FakeSessions(error=ApiError("validation_failed", "x", status=422)) with pytest.raises(SessionApiFailed): await list_sessions(_wt(fake)) class TestReadPassthroughs: async def test_messages_returns_dict(self) -> None: data = {"session_id": "s", "items": []} fake = _FakeSessions(result=data) assert await get_session_messages(_wt(fake), "s") is data assert fake.calls[-1][0] == "messages" async def test_tools_returns_dict(self) -> None: data = {"agent_id": "mimir", "builtin_tools": []} fake = _FakeSessions(result=data) assert await get_session_tools(_wt(fake), "s") is data assert fake.calls[-1][0] == "tools" async def test_messages_error_maps_to_session_api_failed(self) -> None: fake = _FakeSessions(error=ApiError("auth_revoked", "no", status=401)) with pytest.raises(SessionApiFailed) as ei: await get_session_messages(_wt(fake), "s") assert ei.value.status == 401 async def test_tools_error_maps_to_session_api_failed(self) -> None: fake = _FakeSessions(error=ApiError("auth_revoked", "no", status=401)) with pytest.raises(SessionApiFailed) as ei: await get_session_tools(_wt(fake), "s") assert ei.value.status == 401 async def _drain(aiter: Any) -> list[Any]: out: list[Any] = [] async for ev in aiter: out.append(ev) return out class TestStreamTurn: async def test_yields_events_verbatim(self) -> None: e1, e2 = object(), object() fake = _FakeSessions(events=[e1, e2]) got = await _drain(stream_turn(_wt(fake), "s-1", "hello")) assert got == [e1, e2] assert fake.calls[-1] == ("stream_turn", ("s-1", "hello"), {}) async def test_session_retired_maps_to_session_api_failed(self) -> None: fake = _FakeSessions( stream_error=wtsdk.SessionRetired("session_retired", "gone", status=410) ) with pytest.raises(SessionApiFailed) as ei: await _drain(stream_turn(_wt(fake), "s", "hi")) assert ei.value.status == 410 assert ei.value.error_code == "session_retired" async def test_agent_not_available_rewraps_to_ratatoskr(self) -> None: fake = _FakeSessions( stream_error=wtsdk.AgentNotAvailable("agent_not_available", "no agent", status=409) ) with pytest.raises(AgentNotAvailable) as ei: await _drain(stream_turn(_wt(fake), "s", "hi")) assert ei.value.error_code == "agent_not_available" assert ei.value.status == 409 async def test_turn_launch_unavailable_rewraps_to_ratatoskr(self) -> None: fake = _FakeSessions( stream_error=wtsdk.TurnLaunchUnavailable("not_ready", "busy", status=503) ) with pytest.raises(TurnLaunchUnavailable) as ei: await _drain(stream_turn(_wt(fake), "s", "hi")) assert ei.value.retryable is True async def test_generic_connect_failed_maps_to_sse_connect_failed(self) -> None: fake = _FakeSessions(stream_error=wtsdk.ConnectFailed("connect_failed", "boom", status=500)) with pytest.raises(SseConnectFailed) as ei: await _drain(stream_turn(_wt(fake), "s", "hi")) assert ei.value.status == 500 async def test_connection_dropped_maps_and_carries_cursor(self) -> None: fake = _FakeSessions(stream_error=wtsdk.ConnectionDropped("12:3")) with pytest.raises(SseConnectionDropped) as ei: await _drain(stream_turn(_wt(fake), "s", "hi")) assert ei.value.last_seen_sse_id == "12:3" async def test_resume_error_maps_to_sse_connect_failed(self) -> None: fake = _FakeSessions(stream_error=wtsdk.ResumeError("resume_failed", "boom", status=412)) with pytest.raises(SseConnectFailed) as ei: await _drain(stream_turn(_wt(fake), "s", "hi")) assert ei.value.status == 412 async def test_malformed_sse_id_passes_through_as_ratatoskr(self) -> None: fake = _FakeSessions(stream_error=wtsdk.MalformedSseId("bad-id")) with pytest.raises(MalformedSseId) as ei: await _drain(stream_turn(_wt(fake), "s", "hi")) assert ei.value.raw == "bad-id" async def test_malformed_sse_data_passes_through_as_ratatoskr(self) -> None: fake = _FakeSessions(stream_error=wtsdk.MalformedSseData("not json")) with pytest.raises(MalformedSseData): await _drain(stream_turn(_wt(fake), "s", "hi")) async def test_turn_id_flip_carries_established_and_got(self) -> None: fake = _FakeSessions(stream_error=wtsdk.TurnIdFlip(5, 7)) with pytest.raises(TurnIdFlip) as ei: await _drain(stream_turn(_wt(fake), "s", "hi")) assert (ei.value.established, ei.value.got) == (5, 7) async def test_undiscriminated_api_error_maps_to_session_api_failed(self) -> None: # INV-CUT-2 default: an undiscriminated ApiError surfacing from the stream # (not a discriminated stream error) → SessionApiFailed. fake = _FakeSessions(stream_error=ApiError("weird", "boom", status=500)) with pytest.raises(SessionApiFailed) as ei: await _drain(stream_turn(_wt(fake), "s", "hi")) assert ei.value.status == 500 class TestCancelTurn: async def test_happy_returns_cancel_result(self) -> None: res = CancelResult(turn_id=42, cancelled=True, reason=None, partial_message_id=None) fake = _FakeSessions(result=res) out = await cancel_turn(_wt(fake), "s-1", 42, persist_partial=True) assert out is res assert fake.calls[-1] == ("cancel_turn", ("s-1", 42), {"persist_partial": True}) async def test_late_cancel_race_is_a_result_not_an_error(self) -> None: # B-CAN-3: a 200 with cancelled=False is the benign late-cancel no-op. res = CancelResult(turn_id=42, cancelled=False, reason=None, partial_message_id=None) out = await cancel_turn(_wt(_FakeSessions(result=res)), "s", 42) assert out.cancelled is False async def test_turn_not_found_maps_to_ratatoskr(self) -> None: fake = _FakeSessions( error=wtsdk.CancelTurnNotFound(42, error_code="turn_not_found", message="gone") ) with pytest.raises(CancelTurnNotFound) as ei: await cancel_turn(_wt(fake), "s", 42) assert ei.value.turn_id == 42 async def test_turn_finished_maps_to_ratatoskr(self) -> None: fake = _FakeSessions( error=wtsdk.CancelAlreadyCompleted(42, error_code="turn_finished", message="done") ) with pytest.raises(CancelAlreadyCompleted): await cancel_turn(_wt(fake), "s", 42) async def test_other_cancel_failure_maps_to_cancel_failed(self) -> None: fake = _FakeSessions(error=wtsdk.CancelFailed(42, error_code="boom", message="failed")) with pytest.raises(CancelFailed): await cancel_turn(_wt(fake), "s", 42) async def test_undiscriminated_api_error_maps_to_session_api_failed(self) -> None: # INV-CUT-2 default: an undiscriminated ApiError on the cancel route (not a # typed Cancel* race) → SessionApiFailed, never leaked as a bare ApiError. fake = _FakeSessions(error=ApiError("weird", "boom", status=500)) with pytest.raises(SessionApiFailed) as ei: await cancel_turn(_wt(fake), "s", 42) assert ei.value.status == 500 class TestSetPersonaState: """slice-3: set_persona_state → SDK sessions.set_persona_state(PadState). The adapter builds the canonical PadState (the SDK owns the {"pad": {...}} wire shape); no error row beyond the § Error map default (SessionApiFailed).""" async def test_happy_builds_padstate_and_returns_none(self) -> None: fake = _FakeSessions(result=None) # SDK resolves the 204 to None out = await set_persona_state( _wt(fake), "s-1", pleasure=0.4, arousal=0.1, dominance=-0.2 ) assert out is None name, args, _kwargs = fake.calls[-1] assert name == "set_persona_state" assert args[0] == "s-1" pad = args[1] assert isinstance(pad, PadState) assert (pad.pleasure, pad.arousal, pad.dominance) == (0.4, 0.1, -0.2) async def test_falsy_zero_pad_preserved(self) -> None: # A 0.0 axis must survive verbatim (not be dropped as falsy). fake = _FakeSessions(result=None) await set_persona_state(_wt(fake), "s", pleasure=0.0, arousal=0.0, dominance=0.0) pad = fake.calls[-1][1][1] assert (pad.pleasure, pad.arousal, pad.dominance) == (0.0, 0.0, 0.0) async def test_error_maps_to_session_api_failed(self) -> None: fake = _FakeSessions(error=ApiError("upstream", "boom", status=500, body="x")) with pytest.raises(SessionApiFailed) as ei: await set_persona_state(_wt(fake), "s", pleasure=0.0, arousal=0.0, dominance=0.0) assert ei.value.status == 500 async def test_non_finite_pad_rejected_at_the_chokepoint(self) -> None: # The finite-PAD invariant is enforced at the adapter (not only the CLI): a # non-finite axis would serialize to null and corrupt the injection, so a # direct caller is rejected pre-SDK — never a leaked SDK ConfigurationError. fake = _FakeSessions(result=None) for bad in (float("nan"), float("inf"), float("-inf")): with pytest.raises(AssertionError): await set_persona_state(_wt(fake), "s", pleasure=bad, arousal=0.0, dominance=0.0) assert fake.calls == [] # never reached the SDK class TestWriteAuthoredHistory: """slice-3: write_authored_history → SDK sessions.write_history. Builds the v1 authored-write entry (author="assistant", the only accepted author); 404 → AuthoredHistoryUnavailable (hide-existence); else the default.""" async def test_happy_builds_entry_and_returns_dict(self) -> None: ack = {"seq": 0, "phase": "seeded", "turn_id": "t1", "content_chars": 3} fake = _FakeSessions(result=ack) out = await write_authored_history( _wt(fake), "s-1", content="hi!", idempotency_key="k1" ) assert out is ack # open-world passthrough name, args, _kwargs = fake.calls[-1] assert name == "write_history" assert args[0] == "s-1" assert args[1] == { "author": "assistant", "content": "hi!", "idempotency_key": "k1", } async def test_404_maps_to_authored_history_unavailable(self) -> None: # Hide-existence: the ROUTE is the discriminator (never the body) — any 404 # on write_history → AuthoredHistoryUnavailable, no capability-probe. fake = _FakeSessions(error=ApiError("session_not_found", "no", status=404)) with pytest.raises(AuthoredHistoryUnavailable) as ei: await write_authored_history(_wt(fake), "s-1", content="hi", idempotency_key="k") assert ei.value.session_id == "s-1" async def test_other_error_maps_to_session_api_failed(self) -> None: # 409 generation_active (retryable) is NOT a hide-existence 404 → default. fake = _FakeSessions(error=ApiError("generation_active", "busy", status=409)) with pytest.raises(SessionApiFailed) as ei: await write_authored_history(_wt(fake), "s", content="hi", idempotency_key="k") assert ei.value.status == 409 # ── slice-4: agents (Tier-3) adapter routes ────────────────────────────────── class _FakeAgents: """Stand-in for `WorldtreeClient.agents` — records the last call and returns a canned result or raises a canned error. Same shape as `_FakeSessions`.""" def __init__(self, *, result: Any = None, error: BaseException | None = None) -> None: self._result = result self._error = error self.calls: list[tuple[str, tuple[Any, ...], dict[str, Any]]] = [] async def _dispatch(self, name: str, *args: Any, **kwargs: Any) -> Any: self.calls.append((name, args, kwargs)) if self._error is not None: raise self._error return self._result async def list(self, *args: Any, **kwargs: Any) -> Any: return await self._dispatch("list", *args, **kwargs) async def persona_state(self, *args: Any, **kwargs: Any) -> Any: return await self._dispatch("persona_state", *args, **kwargs) async def define(self, *args: Any, **kwargs: Any) -> Any: return await self._dispatch("define", *args, **kwargs) async def patch(self, *args: Any, **kwargs: Any) -> Any: return await self._dispatch("patch", *args, **kwargs) async def delete(self, *args: Any, **kwargs: Any) -> Any: return await self._dispatch("delete", *args, **kwargs) class _FakeAgentsClient: def __init__(self, agents: _FakeAgents) -> None: self.agents = agents def _wta(agents: _FakeAgents) -> WorldtreeClient: """Cast the structural agents-fake to the nominal client type (the agent route functions only touch `client.agents.*`).""" return cast(WorldtreeClient, _FakeAgentsClient(agents)) class TestListAgents: """slice-4: list_agents → SDK agents.list(); open-world array verbatim.""" async def test_happy_returns_array_verbatim(self) -> None: data = [{"agent_id": "mimir", "name": "Mimir", "description": "k"}] fake = _FakeAgents(result=data) out = await list_agents(_wta(fake)) assert out is data # open-world passthrough, no AgentInfo normalization assert fake.calls[-1][0] == "list" async def test_error_maps_to_session_api_failed(self) -> None: fake = _FakeAgents(error=ApiError("upstream", "boom", status=500)) with pytest.raises(SessionApiFailed) as ei: await list_agents(_wta(fake)) assert ei.value.status == 500 class TestGetPersonaState: """slice-4: get_persona_state → SDK agents.persona_state(id); dict verbatim. 404 sub-codes + 403 auth_scope_denied map by (status, error_code).""" async def test_happy_returns_dict_verbatim(self) -> None: snap = {"pad": {}, "dominant_emotion": "curiosity"} fake = _FakeAgents(result=snap) out = await get_persona_state(_wta(fake), "mimir") assert out is snap assert fake.calls[-1] == ("persona_state", ("mimir",), {}) async def test_persona_not_configured(self) -> None: fake = _FakeAgents(error=ApiError("persona_not_configured", "no", status=404)) with pytest.raises(PersonaNotConfigured) as ei: await get_persona_state(_wta(fake), "domari") assert ei.value.agent_id == "domari" async def test_agent_not_available(self) -> None: fake = _FakeAgents(error=ApiError("agent_not_available", "no", status=404)) with pytest.raises(PersonaAgentNotAvailable) as ei: await get_persona_state(_wta(fake), "bogus") assert ei.value.agent_id == "bogus" async def test_auth_scope_denied(self) -> None: fake = _FakeAgents(error=ApiError("auth_scope_denied", "no", status=403)) with pytest.raises(AuthScopeDenied) as ei: await get_persona_state(_wta(fake), "mimir") assert ei.value.scope == "persona.read" async def test_other_404_without_code_maps_to_default(self) -> None: # A 404 whose error_code is neither persona sub-code → generic default, # NOT a spurious PersonaNotConfigured (the code is the discriminator). fake = _FakeAgents(error=ApiError("weird", "no", status=404)) with pytest.raises(SessionApiFailed) as ei: await get_persona_state(_wta(fake), "mimir") assert ei.value.status == 404 async def test_empty_agent_id_asserts(self) -> None: fake = _FakeAgents(result={}) with pytest.raises(AssertionError): await get_persona_state(_wta(fake), "") assert fake.calls == [] class TestDefineAgent: """slice-4: define_agent → SDK agents.define(); open-world DefinedAgent dict (echoes `role` post-b128). Slug validated client-side; tier3 error rows.""" async def test_happy_builds_body_and_returns_dict(self) -> None: resp = {"agent_id": "ratatoskr:wizard", "role": "thoughtful-character"} fake = _FakeAgents(result=resp) out = await define_agent( _wta(fake), agent_name="wizard", system_prompt="You are a wizard.", role="thoughtful-character", ) assert out is resp # open-world passthrough (no Tier3AgentInfo) name, args, _kwargs = fake.calls[-1] assert name == "define" # AgentDefineInput body: exactly the three keys, no layer fields. assert args[0] == { "agent_name": "wizard", "role": "thoughtful-character", "system_prompt": "You are a wizard.", } async def test_quota_exceeded_defaults_retry_after_zero(self) -> None: # The SDK's ApiError floor drops the Retry-After header; spec §2675 pins it # to 0, so the adapter defaults retry_after=0. fake = _FakeAgents(error=ApiError("agent_quota_exceeded", "full", status=429)) with pytest.raises(Tier3QuotaExceeded) as ei: await define_agent(_wta(fake), agent_name="overflow", system_prompt="x", role="m") assert ei.value.retry_after == 0 async def test_user_id_unsupported(self) -> None: fake = _FakeAgents(error=ApiError("tier3_user_id_unsupported", "no", status=403)) with pytest.raises(Tier3UserIdUnsupported): await define_agent(_wta(fake), agent_name="wizard", system_prompt="x", role="m") async def test_layer_deferred_field_parsed_from_body(self) -> None: # `field` is not on ApiError — the adapter body-parses detail.field. fake = _FakeAgents(error=ApiError( "layer_deferred", "no", status=422, body='{"detail": {"error_code": "layer_deferred", "field": "persona"}}', )) with pytest.raises(Tier3LayerDeferred) as ei: await define_agent(_wta(fake), agent_name="wizard", system_prompt="x", role="m") assert ei.value.field == "persona" async def test_layer_deferred_flat_field_body(self) -> None: # _error_field_from_body also handles a flat top-level `field` (both-shape # unwrap) — locks the contract's "detail.field / flat field" claim. fake = _FakeAgents(error=ApiError( "layer_deferred", "no", status=422, body='{"field": "valence"}', )) with pytest.raises(Tier3LayerDeferred) as ei: await define_agent(_wta(fake), agent_name="wizard", system_prompt="x", role="m") assert ei.value.field == "valence" async def test_layer_deferred_non_string_field_is_none(self) -> None: # A non-string `field` value collapses to None — the exception surface is # `field: str | None` and the CLI prints it (heid-bug-hunt hardening). fake = _FakeAgents(error=ApiError( "layer_deferred", "no", status=422, body='{"detail": {"field": {"x": 1}}}', )) with pytest.raises(Tier3LayerDeferred) as ei: await define_agent(_wta(fake), agent_name="wizard", system_prompt="x", role="m") assert ei.value.field is None async def test_403_wrong_code_maps_to_default(self) -> None: # Dual-key negative: a 403 whose code is NOT tier3_user_id_unsupported → # generic default, not a spurious Tier3UserIdUnsupported (INV-CUT-2). fake = _FakeAgents(error=ApiError("auth_revoked", "no", status=403)) with pytest.raises(SessionApiFailed) as ei: await define_agent(_wta(fake), agent_name="wizard", system_prompt="x", role="m") assert ei.value.status == 403 async def test_422_wrong_code_maps_to_default(self) -> None: # Dual-key negative: a 422 whose code is NOT layer_deferred → default. fake = _FakeAgents(error=ApiError("validation_failed", "no", status=422)) with pytest.raises(SessionApiFailed) as ei: await define_agent(_wta(fake), agent_name="wizard", system_prompt="x", role="m") assert ei.value.status == 422 async def test_bad_slug_asserts_no_call(self) -> None: fake = _FakeAgents(result={}) with pytest.raises(AssertionError): await define_agent(_wta(fake), agent_name="Wizard", system_prompt="x", role="m") assert fake.calls == [] async def test_short_slug_asserts_no_call(self) -> None: fake = _FakeAgents(result={}) with pytest.raises(AssertionError): await define_agent(_wta(fake), agent_name="ab", system_prompt="x", role="m") assert fake.calls == [] async def test_empty_prompt_asserts(self) -> None: fake = _FakeAgents(result={}) with pytest.raises(AssertionError): await define_agent(_wta(fake), agent_name="wizard", system_prompt="", role="m") assert fake.calls == [] async def test_empty_role_asserts(self) -> None: fake = _FakeAgents(result={}) with pytest.raises(AssertionError): await define_agent(_wta(fake), agent_name="wizard", system_prompt="x", role="") assert fake.calls == [] async def test_other_5xx_maps_to_default(self) -> None: fake = _FakeAgents(error=ApiError("upstream", "out", status=503)) with pytest.raises(SessionApiFailed) as ei: await define_agent(_wta(fake), agent_name="wizard", system_prompt="x", role="m") assert ei.value.status == 503 class TestPatchAgent: """slice-4: patch_agent → SDK agents.patch(id, changes); open PatchedAgent dict.""" async def test_happy_both_fields(self) -> None: resp = {"agent_id": "ratatoskr:wizard", "role": "different"} fake = _FakeAgents(result=resp) out = await patch_agent( _wta(fake), "ratatoskr:wizard", system_prompt="new", role="different" ) assert out is resp name, args, _kwargs = fake.calls[-1] assert name == "patch" assert args[0] == "ratatoskr:wizard" assert args[1] == {"system_prompt": "new", "role": "different"} async def test_happy_single_field_omits_none(self) -> None: fake = _FakeAgents(result={}) await patch_agent(_wta(fake), "ratatoskr:wizard", system_prompt="only this") assert fake.calls[-1][1][1] == {"system_prompt": "only this"} async def test_404_maps_to_agent_not_found(self) -> None: fake = _FakeAgents(error=ApiError("not_found", "no", status=404)) with pytest.raises(Tier3AgentNotFound) as ei: await patch_agent(_wta(fake), "ratatoskr:ghost", system_prompt="x") assert ei.value.agent_id == "ratatoskr:ghost" async def test_field_not_mutable_field_parsed(self) -> None: fake = _FakeAgents(error=ApiError( "field_not_mutable", "no", status=422, body='{"detail": {"error_code": "field_not_mutable", "field": "agent_name"}}', )) with pytest.raises(Tier3FieldNotMutable) as ei: await patch_agent(_wta(fake), "ratatoskr:wizard", system_prompt="x") assert ei.value.field == "agent_name" async def test_422_wrong_code_maps_to_default(self) -> None: # Dual-key negative: a 422 whose code is NOT field_not_mutable → default, # not a spurious Tier3FieldNotMutable (INV-CUT-2). fake = _FakeAgents(error=ApiError("validation_failed", "no", status=422)) with pytest.raises(SessionApiFailed) as ei: await patch_agent(_wta(fake), "ratatoskr:wizard", system_prompt="x") assert ei.value.status == 422 async def test_no_fields_asserts_no_call(self) -> None: fake = _FakeAgents(result={}) with pytest.raises(AssertionError): await patch_agent(_wta(fake), "ratatoskr:wizard") assert fake.calls == [] async def test_non_tier3_id_asserts(self) -> None: fake = _FakeAgents(result={}) with pytest.raises(AssertionError): await patch_agent(_wta(fake), "mimir", system_prompt="x") assert fake.calls == [] async def test_other_error_maps_to_default(self) -> None: fake = _FakeAgents(error=ApiError("upstream", "boom", status=500)) with pytest.raises(SessionApiFailed) as ei: await patch_agent(_wta(fake), "ratatoskr:wizard", system_prompt="x") assert ei.value.status == 500 class TestDeleteAgent: """slice-4: delete_agent → SDK agents.delete(id); None on 204; 404 → not-found.""" async def test_happy_returns_none(self) -> None: fake = _FakeAgents(result=None) out = await delete_agent(_wta(fake), "ratatoskr:wizard") assert out is None assert fake.calls[-1] == ("delete", ("ratatoskr:wizard",), {}) async def test_404_maps_to_agent_not_found(self) -> None: fake = _FakeAgents(error=ApiError("not_found", "no", status=404)) with pytest.raises(Tier3AgentNotFound) as ei: await delete_agent(_wta(fake), "ratatoskr:ghost") assert ei.value.agent_id == "ratatoskr:ghost" async def test_non_tier3_id_asserts(self) -> None: fake = _FakeAgents(result=None) with pytest.raises(AssertionError): await delete_agent(_wta(fake), "mimir") assert fake.calls == [] async def test_other_error_maps_to_default(self) -> None: fake = _FakeAgents(error=ApiError("upstream", "oops", status=500)) with pytest.raises(SessionApiFailed) as ei: await delete_agent(_wta(fake), "ratatoskr:wizard") assert ei.value.status == 500 # ── slice-5: characters + me/capabilities/models adapter routes ─────────────── # One canned result / error per fake (each slice-5 adapter fn touches exactly one # sub-resource method), recorded by qualified name so the test can assert the route. class _FakeMe: def __init__(self, rec: _FakeMisc) -> None: self._rec = rec async def get(self, *a: Any, **k: Any) -> Any: return await self._rec._dispatch("me.get", *a, **k) class _FakeCapabilities: def __init__(self, rec: _FakeMisc) -> None: self._rec = rec async def get(self, *a: Any, **k: Any) -> Any: return await self._rec._dispatch("capabilities.get", *a, **k) class _FakeModels: def __init__(self, rec: _FakeMisc) -> None: self._rec = rec async def available_for_characters(self, *a: Any, **k: Any) -> Any: return await self._rec._dispatch("models.available_for_characters", *a, **k) class _FakeCharacters: def __init__(self, rec: _FakeMisc) -> None: self._rec = rec async def create(self, *a: Any, **k: Any) -> Any: return await self._rec._dispatch("characters.create", *a, **k) async def state(self, *a: Any, **k: Any) -> Any: return await self._rec._dispatch("characters.state", *a, **k) async def delete(self, *a: Any, **k: Any) -> Any: return await self._rec._dispatch("characters.delete", *a, **k) class _FakeMisc: """Stand-in for the slice-5 client surface — exposes `.me` / `.capabilities` / `.models` / `.characters`, recording each call under its qualified name and returning a canned result or raising a canned error (same shape as `_FakeSessions` / `_FakeAgents`).""" def __init__(self, *, result: Any = None, error: BaseException | None = None) -> None: self._result = result self._error = error self.calls: list[tuple[str, tuple[Any, ...], dict[str, Any]]] = [] self.me = _FakeMe(self) self.capabilities = _FakeCapabilities(self) self.models = _FakeModels(self) self.characters = _FakeCharacters(self) async def _dispatch(self, name: str, *args: Any, **kwargs: Any) -> Any: self.calls.append((name, args, kwargs)) if self._error is not None: raise self._error return self._result def _wtm(misc: _FakeMisc) -> WorldtreeClient: """Cast the slice-5 misc-surface fake (me/capabilities/models/characters) to the nominal client type the route functions are typed against.""" return cast(WorldtreeClient, misc) class TestGetMe: """slice-5: get_me → SDK me.get(); open-world dict verbatim.""" async def test_happy_returns_dict_verbatim(self) -> None: me = {"user_id": "alice", "scopes": ["conversations.read"], "tier": "user"} fake = _FakeMisc(result=me) out = await get_me(_wtm(fake)) assert out is me assert fake.calls[-1][0] == "me.get" async def test_401_maps_to_session_api_failed(self) -> None: fake = _FakeMisc(error=ApiError("auth_invalid", "no", status=401)) with pytest.raises(SessionApiFailed) as ei: await get_me(_wtm(fake)) assert ei.value.status == 401 class TestGetCapabilities: """slice-5: get_capabilities → SDK capabilities.get(); open-world verbatim.""" async def test_happy_returns_dict_verbatim(self) -> None: caps = {"ephemeral_templates": {"echo": {"default_role": "echo"}}} fake = _FakeMisc(result=caps) out = await get_capabilities(_wtm(fake)) assert out is caps assert fake.calls[-1][0] == "capabilities.get" async def test_error_maps_to_session_api_failed(self) -> None: fake = _FakeMisc(error=ApiError("upstream", "boom", status=500)) with pytest.raises(SessionApiFailed) as ei: await get_capabilities(_wtm(fake)) assert ei.value.status == 500 class TestListCharacterModels: """slice-5: list_character_models → SDK models.available_for_characters().""" async def test_happy_returns_dict_verbatim(self) -> None: models = {"items": [{"name": "fast", "thinking": False}]} fake = _FakeMisc(result=models) out = await list_character_models(_wtm(fake)) assert out is models assert fake.calls[-1][0] == "models.available_for_characters" async def test_error_maps_to_session_api_failed(self) -> None: fake = _FakeMisc(error=ApiError("auth_scope_denied", "no", status=403)) with pytest.raises(SessionApiFailed) as ei: await list_character_models(_wtm(fake)) assert ei.value.status == 403 class TestCreateCharacter: """slice-5: create_character → SDK characters.create(body); body-building + parity.""" async def test_happy_omits_state_when_none(self) -> None: # SDK-idiomatic body: {character} only — no redundant explicit state:null. created = {"character_id": "char_x", "ttl_expires_at": "t"} fake = _FakeMisc(result=created) out = await create_character(_wtm(fake), {"schema_version": "1", "name": "H"}) assert out is created name, args, _ = fake.calls[-1] assert name == "characters.create" assert args[0] == {"character": {"schema_version": "1", "name": "H"}} async def test_includes_state_when_supplied(self) -> None: fake = _FakeMisc(result={"character_id": "c1"}) await create_character( _wtm(fake), {"name": "H"}, state={"mood": "calm"} ) assert fake.calls[-1][1][0] == { "character": {"name": "H"}, "state": {"mood": "calm"}, } async def test_empty_character_asserts_no_call(self) -> None: fake = _FakeMisc(result={}) with pytest.raises(AssertionError): await create_character(_wtm(fake), {}) assert fake.calls == [] async def test_403_maps_to_session_api_failed(self) -> None: fake = _FakeMisc(error=ApiError("auth_scope_denied", "no", status=403)) with pytest.raises(SessionApiFailed) as ei: await create_character(_wtm(fake), {"name": "H"}) assert ei.value.status == 403 class TestGetCharacterState: """slice-5: get_character_state → SDK characters.state(id); open-world verbatim.""" async def test_happy_returns_dict_verbatim(self) -> None: state = {"schema_version": "1", "pad": [0.4, 0.1, -0.2]} fake = _FakeMisc(result=state) out = await get_character_state(_wtm(fake), "char_x") assert out is state assert fake.calls[-1] == ("characters.state", ("char_x",), {}) async def test_empty_id_asserts_no_call(self) -> None: fake = _FakeMisc(result={}) with pytest.raises(AssertionError): await get_character_state(_wtm(fake), "") assert fake.calls == [] async def test_error_maps_to_session_api_failed(self) -> None: fake = _FakeMisc(error=ApiError("not_found", "no", status=404)) with pytest.raises(SessionApiFailed) as ei: await get_character_state(_wtm(fake), "char_x") assert ei.value.status == 404 class TestDeleteCharacter: """slice-5: delete_character → SDK characters.delete(id); open ack verbatim.""" async def test_returns_ack_verbatim(self) -> None: # Worldtree returns an open ack body here (not 204) — passed through, NOT # normalized to None (parity posture). ack = {"deleted": True, "character_id": "char_x"} fake = _FakeMisc(result=ack) out = await delete_character(_wtm(fake), "char_x") assert out is ack assert fake.calls[-1] == ("characters.delete", ("char_x",), {}) async def test_none_on_204(self) -> None: # A 204 no-content yields None from the SDK — passed through unchanged. fake = _FakeMisc(result=None) assert await delete_character(_wtm(fake), "char_x") is None async def test_empty_id_asserts_no_call(self) -> None: fake = _FakeMisc(result=None) with pytest.raises(AssertionError): await delete_character(_wtm(fake), "") assert fake.calls == [] async def test_error_maps_to_session_api_failed(self) -> None: fake = _FakeMisc(error=ApiError("upstream", "oops", status=500)) with pytest.raises(SessionApiFailed) as ei: await delete_character(_wtm(fake), "char_x") assert ei.value.status == 500