"""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