"""Tests for agent/skill_bundles.py — YAML-defined skill bundles.""" import os from pathlib import Path from unittest.mock import patch import pytest from agent.skill_bundles import ( _slugify, build_bundle_invocation_message, delete_bundle, get_bundle, get_skill_bundles, list_bundles, reload_bundles, resolve_bundle_command_key, save_bundle, scan_bundles, ) def _make_bundle_yaml( bundles_dir: Path, slug: str, skills: list[str], description: str = "", instruction: str = "", name: str | None = None, ) -> Path: bundles_dir.mkdir(parents=True, exist_ok=True) lines = [] if name is not None: lines.append(f"name: {name}") else: lines.append(f"name: {slug}") if description: lines.append(f"description: {description}") lines.append("skills:") for s in skills: lines.append(f" - {s}") if instruction: lines.append(f"instruction: |") for ln in instruction.splitlines(): lines.append(f" {ln}") path = bundles_dir / f"{slug}.yaml" path.write_text("\n".join(lines) + "\n") return path def _make_skill(skills_dir: Path, name: str, body: str = "Do the thing.") -> Path: skill_dir = skills_dir / name skill_dir.mkdir(parents=True, exist_ok=True) (skill_dir / "SKILL.md").write_text( f"---\nname: {name}\ndescription: Description for {name}\n---\n\n# {name}\n\n{body}\n" ) return skill_dir @pytest.fixture def bundles_env(tmp_path, monkeypatch): """Isolated bundles dir + skills dir.""" bundles_dir = tmp_path / "skill-bundles" skills_dir = tmp_path / "skills" skills_dir.mkdir() monkeypatch.setenv("HERMES_BUNDLES_DIR", str(bundles_dir)) # Patch SKILLS_DIR so skill loading hits our temp tree. import tools.skills_tool as skills_tool_module monkeypatch.setattr(skills_tool_module, "SKILLS_DIR", skills_dir) # Reset module-level cache between tests. import agent.skill_bundles as mod mod._bundles_cache = {} mod._bundles_cache_mtime = None return bundles_dir, skills_dir class TestSlugify: def test_basic(self): assert _slugify("Backend Dev") == "backend-dev" def test_underscores(self): assert _slugify("backend_dev") == "backend-dev" def test_strips_invalid_chars(self): assert _slugify("hello, world!") == "hello-world" def test_collapses_hyphens(self): assert _slugify("a--b---c") == "a-b-c" def test_empty(self): assert _slugify("") == "" assert _slugify("!!!") == "" class TestScanBundles: def test_empty_dir(self, bundles_env): bundles_dir, _ = bundles_env result = scan_bundles() assert result == {} def test_finds_bundle(self, bundles_env): bundles_dir, _ = bundles_env _make_bundle_yaml(bundles_dir, "backend", ["skill-a", "skill-b"]) result = scan_bundles() assert "/backend" in result assert result["/backend"]["name"] == "backend" assert result["/backend"]["skills"] == ["skill-a", "skill-b"] def test_skips_invalid_yaml(self, bundles_env): bundles_dir, _ = bundles_env bundles_dir.mkdir(parents=True) (bundles_dir / "broken.yaml").write_text("{not: valid yaml: [") _make_bundle_yaml(bundles_dir, "good", ["skill-a"]) result = scan_bundles() assert "/good" in result assert "/broken" not in result def test_skips_bundle_without_skills(self, bundles_env): bundles_dir, _ = bundles_env bundles_dir.mkdir(parents=True) (bundles_dir / "noskills.yaml").write_text("name: noskills\nskills: []\n") result = scan_bundles() assert "/noskills" not in result def test_duplicate_slug_first_wins(self, bundles_env): bundles_dir, _ = bundles_env # Two files normalizing to the same slug. Sort order is by filename: # 'alpha-dup.yaml' sorts before 'alpha.yaml' (`-` < `.` in ASCII), so # the first-seen file wins. _make_bundle_yaml(bundles_dir, "alpha", ["s1"], name="alpha") _make_bundle_yaml(bundles_dir, "alpha-dup", ["s2"], name="ALPHA") result = scan_bundles() assert "/alpha" in result # alpha-dup.yaml is scanned first → its skills win assert result["/alpha"]["skills"] == ["s2"] def test_uses_filename_as_fallback_name(self, bundles_env): bundles_dir, _ = bundles_env bundles_dir.mkdir(parents=True) (bundles_dir / "fallback.yaml").write_text("skills:\n - foo\n") result = scan_bundles() assert "/fallback" in result assert result["/fallback"]["name"] == "fallback" class TestGetSkillBundles: def test_returns_cache(self, bundles_env): bundles_dir, _ = bundles_env _make_bundle_yaml(bundles_dir, "a", ["s1"]) first = get_skill_bundles() # Second call should hit cache (no rescan unless mtime changed). second = get_skill_bundles() assert first is second or first == second def test_rescans_on_change(self, bundles_env): bundles_dir, _ = bundles_env _make_bundle_yaml(bundles_dir, "a", ["s1"]) assert "/a" in get_skill_bundles() # Add a second bundle and bump mtime. import time as _t _t.sleep(0.05) # ensure mtime granularity is exceeded _make_bundle_yaml(bundles_dir, "b", ["s2"]) os.utime(bundles_dir, None) result = get_skill_bundles() assert "/a" in result assert "/b" in result class TestResolveBundleCommandKey: def test_exact_match(self, bundles_env): bundles_dir, _ = bundles_env _make_bundle_yaml(bundles_dir, "my-bundle", ["s1"]) scan_bundles() assert resolve_bundle_command_key("my-bundle") == "/my-bundle" def test_underscore_alias(self, bundles_env): """Telegram converts hyphens to underscores in command names.""" bundles_dir, _ = bundles_env _make_bundle_yaml(bundles_dir, "my-bundle", ["s1"]) scan_bundles() assert resolve_bundle_command_key("my_bundle") == "/my-bundle" def test_unknown(self, bundles_env): scan_bundles() assert resolve_bundle_command_key("missing") is None def test_empty(self, bundles_env): assert resolve_bundle_command_key("") is None class TestBuildBundleInvocationMessage: def test_loads_all_skills(self, bundles_env): bundles_dir, skills_dir = bundles_env _make_skill(skills_dir, "skill-a", body="Skill A content.") _make_skill(skills_dir, "skill-b", body="Skill B content.") _make_bundle_yaml(bundles_dir, "combo", ["skill-a", "skill-b"]) scan_bundles() result = build_bundle_invocation_message("/combo") assert result is not None msg, loaded, missing = result assert set(loaded) == {"skill-a", "skill-b"} assert missing == [] assert "Skill A content." in msg assert "Skill B content." in msg assert "combo" in msg def test_skips_missing_skills(self, bundles_env): bundles_dir, skills_dir = bundles_env _make_skill(skills_dir, "skill-a") _make_bundle_yaml(bundles_dir, "combo", ["skill-a", "skill-ghost"]) scan_bundles() result = build_bundle_invocation_message("/combo") assert result is not None msg, loaded, missing = result assert loaded == ["skill-a"] assert missing == ["skill-ghost"] assert "skill-ghost" in msg # called out in header def test_unknown_bundle_returns_none(self, bundles_env): scan_bundles() assert build_bundle_invocation_message("/nope") is None def test_no_loadable_skills_returns_none(self, bundles_env): bundles_dir, _ = bundles_env _make_bundle_yaml(bundles_dir, "ghost", ["nonexistent-skill"]) scan_bundles() result = build_bundle_invocation_message("/ghost") assert result is None def test_includes_user_instruction(self, bundles_env): bundles_dir, skills_dir = bundles_env _make_skill(skills_dir, "skill-a") _make_bundle_yaml(bundles_dir, "combo", ["skill-a"]) scan_bundles() result = build_bundle_invocation_message( "/combo", user_instruction="extra context here" ) assert result is not None msg, _, _ = result assert "extra context here" in msg def test_includes_bundle_instruction(self, bundles_env): bundles_dir, skills_dir = bundles_env _make_skill(skills_dir, "skill-a") _make_bundle_yaml( bundles_dir, "combo", ["skill-a"], instruction="Always check tests first.", ) scan_bundles() result = build_bundle_invocation_message("/combo") assert result is not None msg, _, _ = result assert "Always check tests first." in msg def test_dedupes_skills(self, bundles_env): bundles_dir, skills_dir = bundles_env _make_skill(skills_dir, "skill-a") _make_bundle_yaml(bundles_dir, "combo", ["skill-a", "skill-a"]) scan_bundles() result = build_bundle_invocation_message("/combo") assert result is not None _, loaded, _ = result assert loaded == ["skill-a"] class TestSaveAndDeleteBundle: def test_save_creates_file(self, bundles_env): bundles_dir, _ = bundles_env path = save_bundle("test-bundle", ["s1", "s2"], description="d", instruction="i") assert path.exists() assert path.parent == bundles_dir content = path.read_text() assert "test-bundle" in content assert "s1" in content assert "s2" in content assert "description: d" in content def test_save_refuses_overwrite_by_default(self, bundles_env): save_bundle("dup", ["s1"]) with pytest.raises(FileExistsError): save_bundle("dup", ["s2"]) def test_save_overwrites_with_force(self, bundles_env): save_bundle("dup", ["s1"]) save_bundle("dup", ["s2"], overwrite=True) info = get_bundle("dup") assert info is not None assert info["skills"] == ["s2"] def test_save_requires_skills(self, bundles_env): with pytest.raises(ValueError): save_bundle("empty", []) def test_save_requires_name(self, bundles_env): with pytest.raises(ValueError): save_bundle("", ["s1"]) def test_delete_removes_file(self, bundles_env): bundles_dir, _ = bundles_env save_bundle("doomed", ["s1"]) assert get_bundle("doomed") is not None delete_bundle("doomed") assert get_bundle("doomed") is None def test_delete_missing_raises(self, bundles_env): with pytest.raises(FileNotFoundError): delete_bundle("ghost") class TestReloadBundles: def test_reports_added_and_removed(self, bundles_env): bundles_dir, _ = bundles_env _make_bundle_yaml(bundles_dir, "old", ["s1"]) scan_bundles() # populate cache with {old} # Mutate the disk WITHOUT going through save/delete helpers (which # would refresh the cache mid-way). reload_bundles() diffs the # in-memory cache against the freshly-scanned disk state. (bundles_dir / "old.yaml").unlink() _make_bundle_yaml(bundles_dir, "new", ["s2"]) diff = reload_bundles() added_names = {e["name"] for e in diff["added"]} removed_names = {e["name"] for e in diff["removed"]} assert "new" in added_names assert "old" in removed_names assert diff["total"] == 1 class TestListBundles: def test_sorted_by_slug(self, bundles_env): bundles_dir, _ = bundles_env _make_bundle_yaml(bundles_dir, "zebra", ["s1"]) _make_bundle_yaml(bundles_dir, "apple", ["s2"]) _make_bundle_yaml(bundles_dir, "mango", ["s3"]) scan_bundles() info_list = list_bundles() slugs = [b["slug"] for b in info_list] assert slugs == sorted(slugs)