Co-authored-by: Kimi Agent <kimi@timmy.local> Co-committed-by: Kimi Agent <kimi@timmy.local>
394 lines
14 KiB
Python
394 lines
14 KiB
Python
"""Unit tests for timmy.agents.loader — YAML-driven agent factory."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
import timmy.agents.loader as loader
|
|
|
|
# ── Fixtures ──────────────────────────────────────────────────────────────────
|
|
|
|
MINIMAL_YAML = """
|
|
defaults:
|
|
model: test-model
|
|
prompt_tier: lite
|
|
max_history: 5
|
|
tools: []
|
|
|
|
routing:
|
|
method: pattern
|
|
patterns:
|
|
coder:
|
|
- code
|
|
- fix bug
|
|
writer:
|
|
- write
|
|
- draft
|
|
|
|
agents:
|
|
helper:
|
|
name: Helper
|
|
role: general
|
|
prompt: "You are a helpful agent."
|
|
coder:
|
|
name: Forge
|
|
role: code
|
|
model: big-model
|
|
prompt_tier: full
|
|
max_history: 15
|
|
tools:
|
|
- python
|
|
- shell
|
|
prompt: "You are a coding agent."
|
|
"""
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _reset_loader_cache():
|
|
"""Reset module-level caches before each test."""
|
|
loader._agents = None
|
|
loader._config = None
|
|
yield
|
|
loader._agents = None
|
|
loader._config = None
|
|
|
|
|
|
@pytest.fixture()
|
|
def mock_yaml_config(tmp_path):
|
|
"""Write a minimal agents.yaml and patch settings.repo_root to point at it."""
|
|
config_dir = tmp_path / "config"
|
|
config_dir.mkdir()
|
|
(config_dir / "agents.yaml").write_text(MINIMAL_YAML)
|
|
|
|
with patch.object(loader.settings, "repo_root", str(tmp_path)):
|
|
yield tmp_path
|
|
|
|
|
|
# ── _find_config_path ─────────────────────────────────────────────────────────
|
|
|
|
|
|
def test_find_config_path_returns_path(mock_yaml_config):
|
|
path = loader._find_config_path()
|
|
assert path.exists()
|
|
assert path.name == "agents.yaml"
|
|
|
|
|
|
def test_find_config_path_raises_when_missing(tmp_path):
|
|
with patch.object(loader.settings, "repo_root", str(tmp_path)):
|
|
with pytest.raises(FileNotFoundError, match="Agent config not found"):
|
|
loader._find_config_path()
|
|
|
|
|
|
# ── _load_config ──────────────────────────────────────────────────────────────
|
|
|
|
|
|
def test_load_config_parses_yaml(mock_yaml_config):
|
|
config = loader._load_config()
|
|
assert "defaults" in config
|
|
assert "agents" in config
|
|
assert "routing" in config
|
|
|
|
|
|
def test_load_config_caches(mock_yaml_config):
|
|
cfg1 = loader._load_config()
|
|
cfg2 = loader._load_config()
|
|
assert cfg1 is cfg2
|
|
|
|
|
|
def test_load_config_force_reload(mock_yaml_config):
|
|
cfg1 = loader._load_config()
|
|
cfg2 = loader._load_config(force_reload=True)
|
|
assert cfg1 is not cfg2
|
|
assert cfg1 == cfg2
|
|
|
|
|
|
# ── _resolve_model ────────────────────────────────────────────────────────────
|
|
|
|
|
|
def test_resolve_model_agent_specific():
|
|
assert loader._resolve_model("custom-model", {"model": "default-model"}) == "custom-model"
|
|
|
|
|
|
def test_resolve_model_defaults_fallback():
|
|
assert loader._resolve_model(None, {"model": "default-model"}) == "default-model"
|
|
|
|
|
|
def test_resolve_model_settings_fallback():
|
|
with patch.object(loader.settings, "ollama_model", "settings-model"):
|
|
assert loader._resolve_model(None, {}) == "settings-model"
|
|
|
|
|
|
# ── _resolve_prompt_tier ──────────────────────────────────────────────────────
|
|
|
|
|
|
def test_resolve_prompt_tier_agent_specific():
|
|
assert loader._resolve_prompt_tier("full", {"prompt_tier": "lite"}) == "full"
|
|
|
|
|
|
def test_resolve_prompt_tier_defaults_fallback():
|
|
assert loader._resolve_prompt_tier(None, {"prompt_tier": "full"}) == "full"
|
|
|
|
|
|
def test_resolve_prompt_tier_default_is_lite():
|
|
assert loader._resolve_prompt_tier(None, {}) == "lite"
|
|
|
|
|
|
# ── _build_system_prompt ──────────────────────────────────────────────────────
|
|
|
|
|
|
def test_build_system_prompt_full_tier():
|
|
with patch("timmy.prompts.get_system_prompt", return_value="BASE") as mock_gsp:
|
|
result = loader._build_system_prompt({"prompt": "Custom."}, "full")
|
|
mock_gsp.assert_called_once_with(tools_enabled=True)
|
|
assert result == "Custom.\n\nBASE"
|
|
|
|
|
|
def test_build_system_prompt_lite_tier():
|
|
with patch("timmy.prompts.get_system_prompt", return_value="BASE") as mock_gsp:
|
|
result = loader._build_system_prompt({"prompt": "Custom."}, "lite")
|
|
mock_gsp.assert_called_once_with(tools_enabled=False)
|
|
assert result == "Custom.\n\nBASE"
|
|
|
|
|
|
def test_build_system_prompt_no_custom():
|
|
with patch("timmy.prompts.get_system_prompt", return_value="BASE"):
|
|
result = loader._build_system_prompt({}, "lite")
|
|
assert result == "BASE"
|
|
|
|
|
|
def test_build_system_prompt_empty_custom():
|
|
with patch("timmy.prompts.get_system_prompt", return_value="BASE"):
|
|
result = loader._build_system_prompt({"prompt": " "}, "lite")
|
|
assert result == "BASE"
|
|
|
|
|
|
# ── load_agents ───────────────────────────────────────────────────────────────
|
|
|
|
|
|
def test_load_agents_creates_subagents(mock_yaml_config):
|
|
with (
|
|
patch("timmy.agents.base.SubAgent") as MockSubAgent,
|
|
patch("timmy.prompts.get_system_prompt", return_value="BASE"),
|
|
):
|
|
MockSubAgent.side_effect = lambda **kw: MagicMock(**kw)
|
|
agents = loader.load_agents()
|
|
|
|
assert len(agents) == 2
|
|
assert "helper" in agents
|
|
assert "coder" in agents
|
|
|
|
|
|
def test_load_agents_passes_correct_params(mock_yaml_config):
|
|
with (
|
|
patch("timmy.agents.base.SubAgent") as MockSubAgent,
|
|
patch("timmy.prompts.get_system_prompt", return_value="BASE"),
|
|
):
|
|
MockSubAgent.side_effect = lambda **kw: MagicMock(**kw)
|
|
loader.load_agents()
|
|
|
|
calls = {c.kwargs["agent_id"]: c.kwargs for c in MockSubAgent.call_args_list}
|
|
coder_kw = calls["coder"]
|
|
|
|
assert coder_kw["name"] == "Forge"
|
|
assert coder_kw["role"] == "code"
|
|
assert coder_kw["model"] == "big-model"
|
|
assert coder_kw["max_history"] == 15
|
|
assert coder_kw["tools"] == ["python", "shell"]
|
|
|
|
|
|
def test_load_agents_uses_defaults(mock_yaml_config):
|
|
with (
|
|
patch("timmy.agents.base.SubAgent") as MockSubAgent,
|
|
patch("timmy.prompts.get_system_prompt", return_value="BASE"),
|
|
):
|
|
MockSubAgent.side_effect = lambda **kw: MagicMock(**kw)
|
|
loader.load_agents()
|
|
|
|
calls = {c.kwargs["agent_id"]: c.kwargs for c in MockSubAgent.call_args_list}
|
|
helper_kw = calls["helper"]
|
|
|
|
assert helper_kw["model"] == "test-model"
|
|
assert helper_kw["max_history"] == 5
|
|
assert helper_kw["tools"] == []
|
|
|
|
|
|
def test_load_agents_caches(mock_yaml_config):
|
|
with (
|
|
patch("timmy.agents.base.SubAgent") as MockSubAgent,
|
|
patch("timmy.prompts.get_system_prompt", return_value="BASE"),
|
|
):
|
|
MockSubAgent.side_effect = lambda **kw: MagicMock(**kw)
|
|
a1 = loader.load_agents()
|
|
a2 = loader.load_agents()
|
|
assert a1 is a2
|
|
|
|
|
|
def test_load_agents_force_reload(mock_yaml_config):
|
|
with (
|
|
patch("timmy.agents.base.SubAgent") as MockSubAgent,
|
|
patch("timmy.prompts.get_system_prompt", return_value="BASE"),
|
|
):
|
|
MockSubAgent.side_effect = lambda **kw: MagicMock(**kw)
|
|
a1 = loader.load_agents()
|
|
a2 = loader.load_agents(force_reload=True)
|
|
assert a1 is not a2
|
|
|
|
|
|
# ── get_agent ─────────────────────────────────────────────────────────────────
|
|
|
|
|
|
def test_get_agent_returns_agent(mock_yaml_config):
|
|
with (
|
|
patch("timmy.agents.base.SubAgent") as MockSubAgent,
|
|
patch("timmy.prompts.get_system_prompt", return_value="BASE"),
|
|
):
|
|
MockSubAgent.side_effect = lambda **kw: MagicMock(agent_id=kw["agent_id"])
|
|
agent = loader.get_agent("helper")
|
|
assert agent.agent_id == "helper"
|
|
|
|
|
|
def test_get_agent_raises_for_unknown(mock_yaml_config):
|
|
with (
|
|
patch("timmy.agents.base.SubAgent") as MockSubAgent,
|
|
patch("timmy.prompts.get_system_prompt", return_value="BASE"),
|
|
):
|
|
MockSubAgent.side_effect = lambda **kw: MagicMock(**kw)
|
|
with pytest.raises(KeyError, match="Unknown agent.*nope"):
|
|
loader.get_agent("nope")
|
|
|
|
|
|
# ── list_agents ───────────────────────────────────────────────────────────────
|
|
|
|
|
|
def test_list_agents_returns_metadata(mock_yaml_config):
|
|
result = loader.list_agents()
|
|
assert len(result) == 2
|
|
ids = {a["id"] for a in result}
|
|
assert ids == {"helper", "coder"}
|
|
|
|
|
|
def test_list_agents_includes_model_and_tools(mock_yaml_config):
|
|
result = loader.list_agents()
|
|
coder = next(a for a in result if a["id"] == "coder")
|
|
assert coder["model"] == "big-model"
|
|
assert coder["tools"] == ["python", "shell"]
|
|
assert coder["status"] == "available"
|
|
|
|
|
|
def test_list_agents_uses_defaults_for_name_and_role(mock_yaml_config):
|
|
result = loader.list_agents()
|
|
helper = next(a for a in result if a["id"] == "helper")
|
|
assert helper["name"] == "Helper"
|
|
assert helper["role"] == "general"
|
|
|
|
|
|
# ── get_routing_config ────────────────────────────────────────────────────────
|
|
|
|
|
|
def test_get_routing_config(mock_yaml_config):
|
|
routing = loader.get_routing_config()
|
|
assert routing["method"] == "pattern"
|
|
assert "coder" in routing["patterns"]
|
|
|
|
|
|
def test_get_routing_config_default_when_missing(tmp_path):
|
|
"""When no routing section exists, returns a sensible default."""
|
|
config_dir = tmp_path / "config"
|
|
config_dir.mkdir()
|
|
(config_dir / "agents.yaml").write_text("defaults: {}\nagents: {}\n")
|
|
|
|
with patch.object(loader.settings, "repo_root", str(tmp_path)):
|
|
routing = loader.get_routing_config()
|
|
assert routing == {"method": "pattern", "patterns": {}}
|
|
|
|
|
|
# ── _matches_pattern ──────────────────────────────────────────────────────────
|
|
|
|
|
|
class TestMatchesPattern:
|
|
def test_single_word_match(self):
|
|
assert loader._matches_pattern("code", "please code this")
|
|
|
|
def test_single_word_no_partial(self):
|
|
assert not loader._matches_pattern("code", "barcode scanner")
|
|
|
|
def test_multi_word_all_present(self):
|
|
assert loader._matches_pattern("fix bug", "can you fix this bug?")
|
|
|
|
def test_multi_word_any_order(self):
|
|
assert loader._matches_pattern("fix bug", "there is a bug, please fix it")
|
|
|
|
def test_multi_word_missing_one(self):
|
|
assert not loader._matches_pattern("fix bug", "fix the typo")
|
|
|
|
def test_case_insensitive(self):
|
|
assert loader._matches_pattern("Code", "CODE this")
|
|
|
|
def test_word_boundary(self):
|
|
assert not loader._matches_pattern("test", "testing in progress")
|
|
|
|
|
|
# ── route_request ─────────────────────────────────────────────────────────────
|
|
|
|
|
|
def test_route_request_matches_coder(mock_yaml_config):
|
|
assert loader.route_request("please code this feature") == "coder"
|
|
|
|
|
|
def test_route_request_matches_writer(mock_yaml_config):
|
|
assert loader.route_request("write a summary") == "writer"
|
|
|
|
|
|
def test_route_request_returns_none_when_no_match(mock_yaml_config):
|
|
assert loader.route_request("hello there") is None
|
|
|
|
|
|
def test_route_request_non_pattern_method(mock_yaml_config):
|
|
"""When routing method is not 'pattern', always returns None."""
|
|
loader._load_config()
|
|
loader._config["routing"]["method"] = "llm"
|
|
assert loader.route_request("code this") is None
|
|
|
|
|
|
# ── route_request_with_match ──────────────────────────────────────────────────
|
|
|
|
|
|
def test_route_request_with_match_returns_tuple(mock_yaml_config):
|
|
agent_id, pattern = loader.route_request_with_match("fix this bug please")
|
|
assert agent_id == "coder"
|
|
assert pattern == "fix bug"
|
|
|
|
|
|
def test_route_request_with_match_no_match(mock_yaml_config):
|
|
agent_id, pattern = loader.route_request_with_match("hello")
|
|
assert agent_id is None
|
|
assert pattern is None
|
|
|
|
|
|
def test_route_request_with_match_non_pattern_method(mock_yaml_config):
|
|
loader._load_config()
|
|
loader._config["routing"]["method"] = "llm"
|
|
agent_id, pattern = loader.route_request_with_match("code this")
|
|
assert agent_id is None
|
|
assert pattern is None
|
|
|
|
|
|
# ── reload_agents ─────────────────────────────────────────────────────────────
|
|
|
|
|
|
def test_reload_agents_clears_caches(mock_yaml_config):
|
|
with (
|
|
patch("timmy.agents.base.SubAgent") as MockSubAgent,
|
|
patch("timmy.prompts.get_system_prompt", return_value="BASE"),
|
|
):
|
|
MockSubAgent.side_effect = lambda **kw: MagicMock(**kw)
|
|
loader.load_agents()
|
|
assert loader._agents is not None
|
|
assert loader._config is not None
|
|
|
|
loader.reload_agents()
|
|
assert loader._agents is not None
|
|
assert MockSubAgent.call_count == 4 # 2 agents * 2 loads
|