"""Tests for ratatoskr.tier3 per docs/contracts/issues/15.contract.md.""" from pathlib import Path import httpx import pytest import respx from ratatoskr.sessions import SessionApiFailed from ratatoskr.tier3 import ( Tier3AgentInfo, Tier3AgentNotFound, Tier3FieldNotMutable, Tier3LayerDeferred, Tier3QuotaExceeded, Tier3UserIdUnsupported, define_agent, delete_agent, main, patch_agent, ) _FULL_AGENT_RESP = { "agent_id": "ratatoskr:wizard", "user_id": "ratatoskr", "agent_name": "wizard", "system_prompt": "You are a wizard.", "model": "qwen3.6-35-a3b", "created_at": "2026-05-25T03:20:09.703601+00:00", "updated_at": "2026-05-25T03:20:09.703601+00:00", } class TestDefineAgent: @respx.mock async def test_happy_define(self) -> None: """happy_define [happy,tracer]: 201 → fully populated Tier3AgentInfo.""" respx.post("https://w.example/agents/define").mock( return_value=httpx.Response(201, json=_FULL_AGENT_RESP) ) async with httpx.AsyncClient(base_url="https://w.example") as client: info = await define_agent( client, agent_name="wizard", system_prompt="You are a wizard.", model="qwen3.6-35-a3b", ) assert isinstance(info, Tier3AgentInfo) assert info.agent_id == "ratatoskr:wizard" assert info.user_id == "ratatoskr" assert info.agent_name == "wizard" assert info.model == "qwen3.6-35-a3b" @respx.mock async def test_request_body_shape(self) -> None: """request_body_shape [trace]: outbound JSON is exactly the three keys.""" import json as _json route = respx.post("https://w.example/agents/define").mock( return_value=httpx.Response(201, json=_FULL_AGENT_RESP) ) async with httpx.AsyncClient(base_url="https://w.example") as client: await define_agent( client, agent_name="wizard", system_prompt="You are a wizard.", model="qwen3.6-35-a3b", ) body = _json.loads(route.calls[0].request.content) # INV-001: exactly these three keys — no layer fields, no metadata. assert body == { "agent_name": "wizard", "system_prompt": "You are a wizard.", "model": "qwen3.6-35-a3b", } @respx.mock async def test_quota_exceeded(self) -> None: """quota_exceeded [error]: 429 + Retry-After → Tier3QuotaExceeded.""" respx.post("https://w.example/agents/define").mock( return_value=httpx.Response( 429, headers={"Retry-After": "0"}, json={"detail": {"error_code": "agent_quota_exceeded"}}, ) ) async with httpx.AsyncClient(base_url="https://w.example") as client: with pytest.raises(Tier3QuotaExceeded) as exc: await define_agent( client, agent_name="overflow", system_prompt="x", model="m", ) assert exc.value.retry_after == 0 @respx.mock async def test_user_id_unsupported(self) -> None: """user_id_unsupported [error]: 403 + error_code → Tier3UserIdUnsupported.""" respx.post("https://w.example/agents/define").mock( return_value=httpx.Response( 403, json={"detail": {"error_code": "tier3_user_id_unsupported"}} ) ) async with httpx.AsyncClient(base_url="https://w.example") as client: with pytest.raises(Tier3UserIdUnsupported): await define_agent( client, agent_name="wizard", system_prompt="x", model="m" ) @respx.mock async def test_layer_deferred(self) -> None: """layer_deferred [error]: 422 + layer_deferred → Tier3LayerDeferred(field).""" respx.post("https://w.example/agents/define").mock( return_value=httpx.Response( 422, json={"detail": {"error_code": "layer_deferred", "field": "persona"}}, ) ) async with httpx.AsyncClient(base_url="https://w.example") as client: with pytest.raises(Tier3LayerDeferred) as exc: await define_agent( client, agent_name="wizard", system_prompt="x", model="m" ) assert exc.value.field == "persona" @respx.mock async def test_bad_slug_assert(self) -> None: """bad_slug_assert [adversarial]: agent_name with uppercase → AssertionError, no HTTP.""" route = respx.post("https://w.example/agents/define").mock( return_value=httpx.Response(201, json=_FULL_AGENT_RESP) ) async with httpx.AsyncClient(base_url="https://w.example") as client: with pytest.raises(AssertionError): await define_agent( client, agent_name="Wizard", system_prompt="x", model="m" ) assert route.call_count == 0 @respx.mock async def test_short_slug_assert(self) -> None: """short_slug_assert [adversarial]: agent_name len < 3 → AssertionError.""" route = respx.post("https://w.example/agents/define").mock( return_value=httpx.Response(201, json=_FULL_AGENT_RESP) ) async with httpx.AsyncClient(base_url="https://w.example") as client: with pytest.raises(AssertionError): await define_agent( client, agent_name="ab", system_prompt="x", model="m" ) assert route.call_count == 0 @respx.mock async def test_empty_prompt_assert(self) -> None: """empty_prompt_assert [adversarial]: empty system_prompt → AssertionError.""" route = respx.post("https://w.example/agents/define").mock( return_value=httpx.Response(201, json=_FULL_AGENT_RESP) ) async with httpx.AsyncClient(base_url="https://w.example") as client: with pytest.raises(AssertionError): await define_agent( client, agent_name="wizard", system_prompt="", model="m" ) assert route.call_count == 0 @respx.mock async def test_other_5xx(self) -> None: """other_5xx [error]: 503 → SessionApiFailed(status=503).""" respx.post("https://w.example/agents/define").mock( return_value=httpx.Response(503, content=b"upstream out") ) async with httpx.AsyncClient(base_url="https://w.example") as client: with pytest.raises(SessionApiFailed) as exc: await define_agent( client, agent_name="wizard", system_prompt="x", model="m" ) assert exc.value.status == 503 class TestPatchAgent: @respx.mock async def test_happy_patch_both_fields(self) -> None: """happy_patch_both_fields: both fields set → request body has both.""" import json as _json updated = { **_FULL_AGENT_RESP, "system_prompt": "new prompt", "model": "different-model", } route = respx.patch("https://w.example/agents/ratatoskr:wizard").mock( return_value=httpx.Response(200, json=updated) ) async with httpx.AsyncClient(base_url="https://w.example") as client: info = await patch_agent( client, "ratatoskr:wizard", system_prompt="new prompt", model="different-model", ) body = _json.loads(route.calls[0].request.content) assert body == {"system_prompt": "new prompt", "model": "different-model"} assert info.system_prompt == "new prompt" assert info.model == "different-model" @respx.mock async def test_happy_patch_single_field(self) -> None: """happy_patch_single_field: omit model → body has system_prompt only.""" import json as _json updated = {**_FULL_AGENT_RESP, "system_prompt": "only this"} route = respx.patch("https://w.example/agents/ratatoskr:wizard").mock( return_value=httpx.Response(200, json=updated) ) async with httpx.AsyncClient(base_url="https://w.example") as client: await patch_agent(client, "ratatoskr:wizard", system_prompt="only this") body = _json.loads(route.calls[0].request.content) # INV-002: body omits the None-valued field entirely assert body == {"system_prompt": "only this"} @respx.mock async def test_field_not_mutable(self) -> None: """field_not_mutable [error]: 422 + error_code → Tier3FieldNotMutable(field).""" respx.patch("https://w.example/agents/ratatoskr:wizard").mock( return_value=httpx.Response( 422, json={ "detail": {"error_code": "field_not_mutable", "field": "agent_name"} }, ) ) async with httpx.AsyncClient(base_url="https://w.example") as client: with pytest.raises(Tier3FieldNotMutable) as exc: await patch_agent( client, "ratatoskr:wizard", system_prompt="x" ) assert exc.value.field == "agent_name" @respx.mock async def test_404(self) -> None: """404 [error]: PATCH on non-existent agent → Tier3AgentNotFound.""" respx.patch("https://w.example/agents/ratatoskr:ghost").mock( return_value=httpx.Response(404, content=b"") ) async with httpx.AsyncClient(base_url="https://w.example") as client: with pytest.raises(Tier3AgentNotFound) as exc: await patch_agent( client, "ratatoskr:ghost", system_prompt="x" ) assert exc.value.agent_id == "ratatoskr:ghost" @respx.mock async def test_no_fields_assert(self) -> None: """no_fields_assert [adversarial]: both None → AssertionError, no HTTP.""" route = respx.patch("https://w.example/agents/ratatoskr:wizard").mock( return_value=httpx.Response(200, json=_FULL_AGENT_RESP) ) async with httpx.AsyncClient(base_url="https://w.example") as client: with pytest.raises(AssertionError): await patch_agent(client, "ratatoskr:wizard") assert route.call_count == 0 @respx.mock async def test_non_tier3_id_assert(self) -> None: """non_tier3_id_assert [adversarial]: agent_id without `:` → AssertionError.""" route = respx.patch("https://w.example/agents/mimir").mock( return_value=httpx.Response(200, json=_FULL_AGENT_RESP) ) async with httpx.AsyncClient(base_url="https://w.example") as client: with pytest.raises(AssertionError): await patch_agent(client, "mimir", system_prompt="x") assert route.call_count == 0 class TestDeleteAgent: @respx.mock async def test_happy_delete(self) -> None: """happy_delete [happy,tracer]: 204 → returns None.""" respx.delete("https://w.example/agents/ratatoskr:wizard").mock( return_value=httpx.Response(204) ) async with httpx.AsyncClient(base_url="https://w.example") as client: result = await delete_agent(client, "ratatoskr:wizard") assert result is None @respx.mock async def test_404(self) -> None: """404 [error]: DELETE on non-existent agent → Tier3AgentNotFound.""" respx.delete("https://w.example/agents/ratatoskr:ghost").mock( return_value=httpx.Response(404) ) async with httpx.AsyncClient(base_url="https://w.example") as client: with pytest.raises(Tier3AgentNotFound) as exc: await delete_agent(client, "ratatoskr:ghost") assert exc.value.agent_id == "ratatoskr:ghost" @respx.mock async def test_non_tier3_id_assert(self) -> None: """non_tier3_id_assert [adversarial]: agent_id without `:` → AssertionError.""" route = respx.delete("https://w.example/agents/mimir").mock( return_value=httpx.Response(204) ) async with httpx.AsyncClient(base_url="https://w.example") as client: with pytest.raises(AssertionError): await delete_agent(client, "mimir") assert route.call_count == 0 @respx.mock async def test_other_5xx(self) -> None: """other_5xx [error]: 500 → SessionApiFailed.""" respx.delete("https://w.example/agents/ratatoskr:wizard").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 exc: await delete_agent(client, "ratatoskr:wizard") assert exc.value.status == 500 @pytest.fixture def _isolated_local_agents( tmp_path: "Path", monkeypatch: pytest.MonkeyPatch ) -> "Path": """Isolate the v0.8.0 local-tier-3 index from the operator's real file.""" path = tmp_path / "local_agents.json" monkeypatch.setenv("RATATOSKR_LOCAL_AGENTS", str(path)) return path class TestCli: @respx.mock def test_cli_define_happy( self, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch, _isolated_local_agents: "Path", ) -> None: """cli_define_happy [happy]: argv → 201 mock → stdout confirmation; local index updated with the new entry (v0.8.0 hook). """ from ratatoskr.local_agents import load_local_agents monkeypatch.setenv("WORLDTREE_API_URL", "https://w.example") monkeypatch.setenv("WORLDTREE_API_KEY", "k") respx.post("https://w.example/agents/define").mock( return_value=httpx.Response(201, json=_FULL_AGENT_RESP) ) rc = main([ "define", "--name", "wizard", "--system-prompt", "You are a wizard.", "--model", "qwen3.6-35-a3b", ]) out = capsys.readouterr() assert rc == 0 assert out.out.strip() == "defined ratatoskr:wizard (qwen3.6-35-a3b)" # v0.8.0: local index now has the new entry. entries = load_local_agents() assert len(entries) == 1 assert entries[0].agent_id == "ratatoskr:wizard" assert entries[0].model == "qwen3.6-35-a3b" @respx.mock def test_cli_patch_happy( self, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch, _isolated_local_agents: "Path", ) -> None: """cli_patch_happy [happy]: argv → 200 mock → stdout confirmation; local index refreshed with the post-patch state. """ from ratatoskr.local_agents import load_local_agents monkeypatch.setenv("WORLDTREE_API_URL", "https://w.example") monkeypatch.setenv("WORLDTREE_API_KEY", "k") respx.patch("https://w.example/agents/ratatoskr:wizard").mock( return_value=httpx.Response(200, json=_FULL_AGENT_RESP) ) rc = main(["patch", "ratatoskr:wizard", "--system-prompt", "new"]) out = capsys.readouterr() assert rc == 0 assert out.out.strip() == "patched ratatoskr:wizard" entries = load_local_agents() assert len(entries) == 1 assert entries[0].agent_id == "ratatoskr:wizard" @respx.mock def test_cli_delete_happy( self, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch, _isolated_local_agents: "Path", ) -> None: """cli_delete_happy [happy]: argv → 204 mock → stdout confirmation; local index entry removed (v0.8.0 hook). """ from ratatoskr.local_agents import ( LocalAgentEntry, add_local_agent, load_local_agents, ) # Pre-populate so we can verify removal. add_local_agent(LocalAgentEntry( agent_id="ratatoskr:wizard", agent_name="wizard", model="m", description="d", defined_at="t", )) monkeypatch.setenv("WORLDTREE_API_URL", "https://w.example") monkeypatch.setenv("WORLDTREE_API_KEY", "k") respx.delete("https://w.example/agents/ratatoskr:wizard").mock( return_value=httpx.Response(204) ) rc = main(["delete", "ratatoskr:wizard"]) out = capsys.readouterr() assert rc == 0 assert out.out.strip() == "deleted ratatoskr:wizard" assert load_local_agents() == [] def test_cli_missing_auth( self, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch ) -> None: """cli_missing_auth [error]: no api-key → stderr [auth_error] + exit 11.""" monkeypatch.delenv("WORLDTREE_API_KEY", raising=False) rc = main([ "define", "--name", "wizard", "--system-prompt", "x", "--model", "m", ]) err = capsys.readouterr().err assert rc == 11 assert "[auth_error]" in err @respx.mock def test_cli_api_failed( self, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch ) -> None: """cli_api_failed [error]: 500 → stderr [api_failed] + exit 20.""" monkeypatch.setenv("WORLDTREE_API_URL", "https://w.example") monkeypatch.setenv("WORLDTREE_API_KEY", "k") respx.post("https://w.example/agents/define").mock( return_value=httpx.Response(500, content=b"upstream out") ) rc = main([ "define", "--name", "wizard", "--system-prompt", "x", "--model", "m", ]) err = capsys.readouterr().err assert rc == 20 assert "[api_failed]" in err @respx.mock def test_cli_quota_exceeded( self, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch ) -> None: """cli_quota_exceeded [error]: 429 → stderr [quota_exceeded] + exit 20.""" monkeypatch.setenv("WORLDTREE_API_URL", "https://w.example") monkeypatch.setenv("WORLDTREE_API_KEY", "k") respx.post("https://w.example/agents/define").mock( return_value=httpx.Response( 429, headers={"Retry-After": "0"}, json={"detail": {"error_code": "agent_quota_exceeded"}}, ) ) rc = main([ "define", "--name", "wizard", "--system-prompt", "x", "--model", "m", ]) err = capsys.readouterr().err assert rc == 20 assert "[quota_exceeded]" in err def test_cli_patch_no_fields( self, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch ) -> None: """cli_patch_no_fields [error]: patch with no flags → [usage_error] + exit 10.""" monkeypatch.setenv("WORLDTREE_API_URL", "https://w.example") monkeypatch.setenv("WORLDTREE_API_KEY", "k") rc = main(["patch", "ratatoskr:wizard"]) err = capsys.readouterr().err assert rc == 10 assert "[usage_error]" in err