feat(#20): agents/tier3 family onto the wt adapter + model→role fold (slice-4)
Cut ratatoskr's consumer agent-lifecycle routes over to worldtree-sdk (issue #20 slice-4). Five routes now flow through `ratatoskr.wt` over the SDK's `client.agents.*`, returning open-world dicts and mapping the SDK's undiscriminated `ApiError` floor by route+(status,error_code) per INV-CUT-2: - `list_agents` → `agents.list` - `get_persona_state`→ `agents.persona_state` (404 persona_not_configured / 404 agent_not_available / 403 auth_scope_denied) - `define_agent` → `agents.define` (429→Tier3QuotaExceeded(retry_after=0), 403→Tier3UserIdUnsupported, 422 layer_deferred→…) - `patch_agent` → `agents.patch` (404→Tier3AgentNotFound, 422 field_not_mutable) - `delete_agent` → `agents.delete` (404→Tier3AgentNotFound; NOT hide-existence) Rewired call-sites: the `python -m ratatoskr.tier3` CLI (define/patch/delete) and the web `_agents_endpoint` / `_persona_state_endpoint`, both catching the SDK's `ConnectFailed` transport-failure normalization. Deleted the hand-rolled paths: `sessions.list_agents` / `get_persona_state` / `AgentInfo`, and `tier3.define/patch/delete_agent` / `Tier3AgentInfo` / parse+extract helpers. model→role fold (scope B): the define/patch response echoes `role` (spec 1.2 / b128), read off the open-world dict; `LocalAgentEntry.model`→`.role`, local-index schema v1→2 (old index discarded, no-backwards-compat). The Tier-3 caller-semantic exceptions move to `sessions.py`: running the CLI as `__main__` while `wt` imports `ratatoskr.tier3` bound two copies of each exception class, so a raised `Tier3AgentNotFound` escaped the CLI's `except` as an uncaught traceback. Homing them in `sessions` (never `__main__`) makes the class identity single. The live smoke — not the unit tests, which call `main()` in-process — caught this. Error-map rows + slice-4 notes added to the cutover contract; coverage-map re-anchored. LIVE-SMOKE on personal :8081 (b128): define(thoughtful-character) → patch → list(6 agents) → persona_state(→PersonaNotConfigured mapped) → delete → index empty; non-existent-id patch via `-m` → [agent_not_found] exit 20. Suite 465 green.
This commit is contained in:
+65
-319
@@ -1,4 +1,14 @@
|
||||
"""Tests for ratatoskr.tier3 per docs/contracts/issues/15.contract.md."""
|
||||
"""CLI integration tests for `python -m ratatoskr.tier3`.
|
||||
|
||||
The define/patch/delete wire calls route through the worldtree-sdk adapter
|
||||
(`ratatoskr.wt`); the adapter's body-building + error-mapping are unit-tested in
|
||||
`test_wt.py` (against a fake `client.agents`). These tests exercise the CLI
|
||||
end-to-end — argv → the real SDK over a respx-mocked HTTP layer → exit code +
|
||||
stdout + the local tier3 index side effects.
|
||||
|
||||
The define/patch response echoes `role` (Worldtree spec 1.2 / b128), read off the
|
||||
SDK's open-world dict; `LocalAgentEntry.role` is the v2-schema field.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
@@ -6,317 +16,20 @@ 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,
|
||||
)
|
||||
from ratatoskr.tier3 import main
|
||||
|
||||
# The consumer-agent response echoes `role` (b128), not the former `model`.
|
||||
_FULL_AGENT_RESP = {
|
||||
"agent_id": "ratatoskr:wizard",
|
||||
"user_id": "ratatoskr",
|
||||
"agent_name": "wizard",
|
||||
"system_prompt": "You are a wizard.",
|
||||
"model": "qwen3.6-35-a3b",
|
||||
"role": "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.",
|
||||
role="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.",
|
||||
role="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.",
|
||||
"role": "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",
|
||||
role="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", role="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", role="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", role="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", role="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="", role="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", role="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",
|
||||
role="different-model",
|
||||
)
|
||||
body = _json.loads(route.calls[0].request.content)
|
||||
assert body == {"system_prompt": "new prompt", "role": "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 role → 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
|
||||
@@ -335,8 +48,8 @@ class TestCli:
|
||||
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).
|
||||
"""cli_define_happy [happy,tracer]: argv → 201 mock → stdout confirmation;
|
||||
local index updated with the new entry (role echoed).
|
||||
"""
|
||||
from ratatoskr.local_agents import load_local_agents
|
||||
|
||||
@@ -354,11 +67,34 @@ class TestCli:
|
||||
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"
|
||||
assert entries[0].role == "qwen3.6-35-a3b"
|
||||
|
||||
@respx.mock
|
||||
def test_cli_define_body_shape(
|
||||
self, monkeypatch: pytest.MonkeyPatch, _isolated_local_agents: "Path"
|
||||
) -> None:
|
||||
"""cli_define_body_shape [trace]: outbound JSON is exactly the three keys
|
||||
(the adapter sends AgentDefineInput, no layer fields)."""
|
||||
import json as _json
|
||||
|
||||
monkeypatch.setenv("WORLDTREE_API_URL", "https://w.example")
|
||||
monkeypatch.setenv("WORLDTREE_API_KEY", "k")
|
||||
route = respx.post("https://w.example/agents/define").mock(
|
||||
return_value=httpx.Response(201, json=_FULL_AGENT_RESP)
|
||||
)
|
||||
main([
|
||||
"define", "--name", "wizard",
|
||||
"--system-prompt", "You are a wizard.", "--role", "qwen3.6-35-a3b",
|
||||
])
|
||||
body = _json.loads(route.calls[0].request.content)
|
||||
assert body == {
|
||||
"agent_name": "wizard",
|
||||
"role": "qwen3.6-35-a3b",
|
||||
"system_prompt": "You are a wizard.",
|
||||
}
|
||||
|
||||
@respx.mock
|
||||
def test_cli_patch_happy(
|
||||
@@ -384,6 +120,7 @@ class TestCli:
|
||||
entries = load_local_agents()
|
||||
assert len(entries) == 1
|
||||
assert entries[0].agent_id == "ratatoskr:wizard"
|
||||
assert entries[0].role == "qwen3.6-35-a3b"
|
||||
|
||||
@respx.mock
|
||||
def test_cli_delete_happy(
|
||||
@@ -401,11 +138,11 @@ class TestCli:
|
||||
load_local_agents,
|
||||
)
|
||||
|
||||
# Pre-populate so we can verify removal.
|
||||
# Pre-populate so we can verify removal (v2 schema: role, not model).
|
||||
add_local_agent(LocalAgentEntry(
|
||||
agent_id="ratatoskr:wizard",
|
||||
agent_name="wizard",
|
||||
model="m",
|
||||
role="m",
|
||||
description="d",
|
||||
defined_at="t",
|
||||
))
|
||||
@@ -426,10 +163,7 @@ class TestCli:
|
||||
"""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",
|
||||
"--role", "m",
|
||||
"define", "--name", "wizard", "--system-prompt", "x", "--role", "m",
|
||||
])
|
||||
err = capsys.readouterr().err
|
||||
assert rc == 11
|
||||
@@ -446,10 +180,7 @@ class TestCli:
|
||||
return_value=httpx.Response(500, content=b"upstream out")
|
||||
)
|
||||
rc = main([
|
||||
"define",
|
||||
"--name", "wizard",
|
||||
"--system-prompt", "x",
|
||||
"--role", "m",
|
||||
"define", "--name", "wizard", "--system-prompt", "x", "--role", "m",
|
||||
])
|
||||
err = capsys.readouterr().err
|
||||
assert rc == 20
|
||||
@@ -470,15 +201,30 @@ class TestCli:
|
||||
)
|
||||
)
|
||||
rc = main([
|
||||
"define",
|
||||
"--name", "wizard",
|
||||
"--system-prompt", "x",
|
||||
"--role", "m",
|
||||
"define", "--name", "wizard", "--system-prompt", "x", "--role", "m",
|
||||
])
|
||||
err = capsys.readouterr().err
|
||||
assert rc == 20
|
||||
assert "[quota_exceeded]" in err
|
||||
|
||||
@respx.mock
|
||||
def test_cli_network_error(
|
||||
self, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""cli_network_error [error]: a transport failure surfaces as the SDK's
|
||||
ConnectFailed (not a raw httpx error) → stderr [network_error] + exit 21."""
|
||||
monkeypatch.setenv("WORLDTREE_API_URL", "https://w.example")
|
||||
monkeypatch.setenv("WORLDTREE_API_KEY", "k")
|
||||
respx.post("https://w.example/agents/define").mock(
|
||||
side_effect=httpx.ConnectError("refused")
|
||||
)
|
||||
rc = main([
|
||||
"define", "--name", "wizard", "--system-prompt", "x", "--role", "m",
|
||||
])
|
||||
err = capsys.readouterr().err
|
||||
assert rc == 21
|
||||
assert "[network_error]" in err
|
||||
|
||||
def test_cli_patch_no_fields(
|
||||
self, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
|
||||
Reference in New Issue
Block a user