c62b4eecb3
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.
188 lines
6.5 KiB
Python
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"
|