c086ae2b32
Issue #15. Worldtree Phase 2.0 ships Tier 3 (consumer-defined) agents at `<user_id>:<agent_name>`; ratatoskr now exposes their lifecycle via a dedicated module + CLI tool. The picker handles the colon-containing agent_id generically (per issue #8 out-of- scope clause); session creation works unchanged. What was missing was a way to DEFINE / PATCH / DELETE these agents from ratatoskr itself — operators previously had to curl the API directly. ## Public surface (ratatoskr.tier3) Tier3AgentInfo (frozen dataclass) define_agent (client, *, agent_name, system_prompt, model) → Info patch_agent (client, agent_id, *, system_prompt?, model?) → Info delete_agent (client, agent_id) → None Tier3QuotaExceeded — 429 agent_quota_exceeded (50-agent cap) Tier3UserIdUnsupported — 403 tier3_user_id_unsupported Tier3FieldNotMutable — 422 field_not_mutable (PATCH) Tier3LayerDeferred — 422 layer_deferred (define, defense-only) Tier3AgentNotFound — 404 SessionApiFailed (reused) — all other non-2xx Caller-owned httpx.AsyncClient posture (same as ratatoskr.sessions). Module is standalone — does NOT import sessions/sse_client/tui/cli beyond reusing the USER_AGENT constant from cli. ## CLI (python -m ratatoskr.tier3 <subcommand>) define --name <slug> --system-prompt <str> --model <id> patch <agent_id> [--system-prompt <str>] [--model <id>] delete <agent_id> Auth resolution mirrors ratatoskr.cli verbatim — --api-key flag > $WORLDTREE_API_KEY > exit 11. Server URL via --server > $WORLDTREE_API_URL > http://localhost:8000. Exit codes follow the cli.py matrix: 0 / 10 (usage) / 11 (auth) / 20 (api-failure) / 21 (network). ## Real-world finding from live smoke Tier-3 agents do NOT appear in `GET /agents` — the public list filters them out. The picker won't surface tier-3 agents; operators bypass it via `ratatoskr --send "..." --new --agent ratatoskr:<n>` directly. This contradicts the contract's acceptance assumption ("the new tier-3 agent should appear in the list") — caught at smoke time. The picker integration was hopeful; the real shape is "you know your tier-3 agent_id because you defined it." Adding a ratatoskr-side `tier3 list` subcommand would need a Worldtree endpoint that doesn't exist today; surfacing to worldtree-dev as a followup. ## Live lifecycle smoke (personal Worldtree v0.16.2) $ python -m ratatoskr.tier3 define --name smoke-tier3 \ --system-prompt "..." --model qwen3.6-35-a3b → defined ratatoskr:smoke-tier3 (qwen3.6-35-a3b) $ ratatoskr --send "hello via tier-3" --new --agent ratatoskr:smoke-tier3 → [done] turn_id=286 model=qwen3.6-35-a3b duration=14.2s usage 44 in → 390 out (434 total, 0 cached) $ python -m ratatoskr.tier3 delete ratatoskr:smoke-tier3 → deleted ratatoskr:smoke-tier3 $ python -m ratatoskr.tier3 delete ratatoskr:smoke-tier3 → [agent_not_found] ratatoskr:smoke-tier3 (exit 20) The colon-containing agent_id flowed transparently through ratatoskr.sessions.create_session, the SSE stream's text + worker_phase + done events all rendered correctly, and the ratatoskr.sessions module needed zero changes. ## Contract docs/contracts/issues/15.contract.md — new module spec; drift-check clean. Acceptance criterion about "appears in GET /agents" should be amended in a follow-up to reflect the empirical finding. ## Tests +26 tests (264 total GREEN, was 238). Covers all error paths via respx mocking — quota, user_id, layer_deferred, field_not_mutable, 404, 5xx — plus CLI happy + error paths. ruff clean. Minor bump (v0.6.5 → v0.7.0) per SemVer etiquette: new public module + CLI surface; new caller-visible behavior.
438 lines
17 KiB
Python
438 lines
17 KiB
Python
"""Tests for ratatoskr.tier3 per docs/contracts/issues/15.contract.md."""
|
|
|
|
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
|
|
|
|
|
|
class TestCli:
|
|
@respx.mock
|
|
def test_cli_define_happy(
|
|
self, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
"""cli_define_happy [happy]: argv → 201 mock → stdout confirmation."""
|
|
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)"
|
|
|
|
@respx.mock
|
|
def test_cli_patch_happy(
|
|
self, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
"""cli_patch_happy [happy]: argv → 200 mock → stdout confirmation."""
|
|
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"
|
|
|
|
@respx.mock
|
|
def test_cli_delete_happy(
|
|
self, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
"""cli_delete_happy [happy]: argv → 204 mock → stdout confirmation."""
|
|
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"
|
|
|
|
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
|