Files
ratatoskr/tests/test_local_agents.py
vh c62b4eecb3 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.
2026-07-19 09:58:01 -07:00

188 lines
6.5 KiB
Python

"""Tests for ratatoskr.local_agents.
Use ``$RATATOSKR_LOCAL_AGENTS`` env-var override + pytest tmp_path to
isolate from the operator's real ``~/.config/ratatoskr/local_agents.json``.
"""
from __future__ import annotations
import json
from pathlib import Path
import pytest
from ratatoskr.local_agents import (
LocalAgentEntry,
_local_agents_path,
add_local_agent,
load_local_agents,
make_description,
remove_local_agent,
update_local_agent,
)
@pytest.fixture
def local_path(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
"""Point $RATATOSKR_LOCAL_AGENTS at a fresh tmp file for the test."""
path = tmp_path / "local_agents.json"
monkeypatch.setenv("RATATOSKR_LOCAL_AGENTS", str(path))
return path
def _entry(
agent_id: str = "ratatoskr:wizard",
agent_name: str = "wizard",
role: str = "qwen3.6-35-a3b",
description: str = "(tier 3) test agent",
defined_at: str = "2026-05-25T00:00:00+00:00",
) -> LocalAgentEntry:
return LocalAgentEntry(
agent_id=agent_id,
agent_name=agent_name,
role=role,
description=description,
defined_at=defined_at,
)
class TestPathResolution:
def test_env_override(self, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("RATATOSKR_LOCAL_AGENTS", "/tmp/custom-agents.json")
assert _local_agents_path() == Path("/tmp/custom-agents.json")
def test_xdg_config_home(self, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.delenv("RATATOSKR_LOCAL_AGENTS", raising=False)
monkeypatch.setenv("XDG_CONFIG_HOME", "/tmp/xdg-config")
assert (
_local_agents_path()
== Path("/tmp/xdg-config/ratatoskr/local_agents.json")
)
def test_default_home(self, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.delenv("RATATOSKR_LOCAL_AGENTS", raising=False)
monkeypatch.delenv("XDG_CONFIG_HOME", raising=False)
path = _local_agents_path()
assert path == Path.home() / ".config" / "ratatoskr" / "local_agents.json"
class TestLoadEmpty:
def test_missing_file_returns_empty(self, local_path: Path) -> None:
assert not local_path.exists()
assert load_local_agents() == []
def test_corrupt_json_returns_empty(self, local_path: Path) -> None:
local_path.parent.mkdir(parents=True, exist_ok=True)
local_path.write_text("not json at all")
assert load_local_agents() == []
def test_wrong_schema_version_returns_empty(self, local_path: Path) -> None:
local_path.parent.mkdir(parents=True, exist_ok=True)
local_path.write_text(json.dumps({"version": 999, "agents": []}))
assert load_local_agents() == []
def test_missing_version_key_returns_empty(self, local_path: Path) -> None:
local_path.parent.mkdir(parents=True, exist_ok=True)
local_path.write_text(json.dumps({"agents": []}))
assert load_local_agents() == []
def test_malformed_row_skipped(self, local_path: Path) -> None:
local_path.parent.mkdir(parents=True, exist_ok=True)
local_path.write_text(
json.dumps(
{
"version": 2,
"agents": [
{"agent_id": "incomplete"}, # missing required fields
{
"agent_id": "ratatoskr:good",
"agent_name": "good",
"role": "m",
"description": "d",
"defined_at": "t",
},
],
}
)
)
entries = load_local_agents()
assert len(entries) == 1
assert entries[0].agent_id == "ratatoskr:good"
class TestAdd:
def test_add_one(self, local_path: Path) -> None:
add_local_agent(_entry())
entries = load_local_agents()
assert len(entries) == 1
assert entries[0].agent_id == "ratatoskr:wizard"
def test_add_two_different(self, local_path: Path) -> None:
add_local_agent(_entry(agent_id="ratatoskr:a", agent_name="a"))
add_local_agent(_entry(agent_id="ratatoskr:b", agent_name="b"))
ids = {e.agent_id for e in load_local_agents()}
assert ids == {"ratatoskr:a", "ratatoskr:b"}
def test_add_replaces_same_id(self, local_path: Path) -> None:
add_local_agent(_entry(role="old-role"))
add_local_agent(_entry(role="new-role"))
entries = load_local_agents()
assert len(entries) == 1
assert entries[0].role == "new-role"
def test_creates_parent_dirs(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
nested = tmp_path / "deep" / "nested" / "path" / "agents.json"
monkeypatch.setenv("RATATOSKR_LOCAL_AGENTS", str(nested))
add_local_agent(_entry())
assert nested.exists()
class TestUpdate:
def test_update_changes_existing(self, local_path: Path) -> None:
add_local_agent(_entry(role="v1"))
update_local_agent(_entry(role="v2"))
entries = load_local_agents()
assert len(entries) == 1
assert entries[0].role == "v2"
class TestRemove:
def test_remove_existing(self, local_path: Path) -> None:
add_local_agent(_entry())
remove_local_agent("ratatoskr:wizard")
assert load_local_agents() == []
def test_remove_missing_is_noop(self, local_path: Path) -> None:
add_local_agent(_entry())
remove_local_agent("ratatoskr:doesnotexist")
assert len(load_local_agents()) == 1
class TestMakeDescription:
def test_first_nonempty_line(self) -> None:
prompt = "\n\n# IDENTITY\nYou are a test agent..."
desc = make_description(prompt)
assert desc.startswith("(tier 3) IDENTITY")
def test_strips_heading_markers(self) -> None:
prompt = "# A nice heading\nMore prompt..."
desc = make_description(prompt)
assert "(tier 3) A nice heading" == desc
def test_truncates_long(self) -> None:
prompt = "x" * 200
desc = make_description(prompt)
# 80 char cap including the prefix
assert len(desc) == 81 # 80 + ellipsis char
assert desc.endswith("")
def test_empty_prompt_fallback(self) -> None:
desc = make_description("")
assert desc == "(tier 3) custom system prompt"
def test_whitespace_only_fallback(self) -> None:
desc = make_description(" \n\n ")
assert desc == "(tier 3) custom system prompt"