test_skill_commands_reload.py
1 """Tests for ``agent.skill_commands.reload_skills``. 2 3 Covers the helper that powers ``/reload-skills`` (CLI + gateway slash command). 4 The helper rescans the skills directory and returns a diff of what changed. 5 It does NOT invalidate the skills system-prompt cache — skills are invoked 6 at runtime via ``/skill-name``, ``skills_list``, or ``skill_view`` and don't 7 need to live in the system prompt. 8 9 ``added`` and ``removed`` are lists of ``{"name": str, "description": str}`` 10 dicts. Descriptions are truncated to 60 chars. 11 """ 12 13 import shutil 14 import tempfile 15 import textwrap 16 from pathlib import Path 17 18 import pytest 19 20 21 def _write_skill(skills_dir: Path, name: str, description: str = "") -> Path: 22 skill_dir = skills_dir / name 23 skill_dir.mkdir(parents=True, exist_ok=True) 24 (skill_dir / "SKILL.md").write_text( 25 textwrap.dedent( 26 f"""\ 27 --- 28 name: {name} 29 description: {description or f'{name} skill'} 30 --- 31 body 32 """ 33 ) 34 ) 35 return skill_dir 36 37 38 @pytest.fixture 39 def hermes_home(monkeypatch): 40 """Isolate HERMES_HOME for ``reload_skills`` tests. 41 42 Rather than popping cache-bearing modules from ``sys.modules`` (which 43 races against pytest-xdist's parallel workers), we monkeypatch the 44 module-level ``HERMES_HOME`` / ``SKILLS_DIR`` constants in place so the 45 isolation is local to this fixture's scope. 46 """ 47 td = tempfile.mkdtemp(prefix="hermes-reload-skills-") 48 monkeypatch.setenv("HERMES_HOME", td) 49 home = Path(td) 50 (home / "skills").mkdir(parents=True, exist_ok=True) 51 52 # Import lazily (inside fixture) so the modules are already resident, 53 # then redirect their captured paths at the new temp dir. 54 import tools.skills_tool as _st 55 import agent.skill_commands as _sc 56 57 monkeypatch.setattr(_st, "HERMES_HOME", home, raising=False) 58 monkeypatch.setattr(_st, "SKILLS_DIR", home / "skills", raising=False) 59 # Reset the in-process slash-command cache so each test starts from zero. 60 monkeypatch.setattr(_sc, "_skill_commands", {}, raising=False) 61 62 yield home 63 64 shutil.rmtree(td, ignore_errors=True) 65 66 67 class TestReloadSkillsHelper: 68 """``agent.skill_commands.reload_skills``.""" 69 70 def test_returns_expected_keys(self, hermes_home): 71 from agent.skill_commands import reload_skills 72 73 result = reload_skills() 74 assert set(result) == {"added", "removed", "unchanged", "total", "commands"} 75 assert result["total"] == 0 76 assert result["added"] == [] 77 assert result["removed"] == [] 78 79 def test_detects_newly_added_skill_with_description(self, hermes_home): 80 from agent.skill_commands import reload_skills, get_skill_commands 81 82 # Prime the cache so subsequent diff is meaningful 83 get_skill_commands() 84 85 _write_skill(hermes_home / "skills", "demo", "a demo skill") 86 result = reload_skills() 87 88 assert result["added"] == [{"name": "demo", "description": "a demo skill"}] 89 assert result["removed"] == [] 90 assert result["total"] == 1 91 assert result["commands"] == 1 92 93 def test_detects_removed_skill_carries_description(self, hermes_home): 94 from agent.skill_commands import reload_skills 95 96 skill_dir = _write_skill(hermes_home / "skills", "demo", "soon to be gone") 97 # First reload: demo present 98 first = reload_skills() 99 assert first["total"] == 1 100 assert first["added"] == [{"name": "demo", "description": "soon to be gone"}] 101 102 # Remove and reload — the description must survive the removal diff 103 # (we cached it from the pre-rescan snapshot). 104 shutil.rmtree(skill_dir) 105 second = reload_skills() 106 107 assert second["removed"] == [{"name": "demo", "description": "soon to be gone"}] 108 assert second["added"] == [] 109 assert second["total"] == 0 110 111 def test_description_passes_through_verbatim(self, hermes_home): 112 """``description`` must be the full SKILL.md frontmatter string — no 113 truncation. The system prompt renders skills as 114 `` - name: description`` without a length cap, and the reload 115 note mirrors that format, so truncating here would make the diff 116 render differently from the original catalog.""" 117 from agent.skill_commands import reload_skills, get_skill_commands 118 119 get_skill_commands() # prime 120 long_desc = "x" * 200 121 _write_skill(hermes_home / "skills", "longdesc", long_desc) 122 123 result = reload_skills() 124 assert len(result["added"]) == 1 125 assert result["added"][0]["description"] == long_desc 126 127 def test_unchanged_skills_appear_in_unchanged_list(self, hermes_home): 128 from agent.skill_commands import reload_skills, get_skill_commands 129 130 _write_skill(hermes_home / "skills", "alpha") 131 # Prime cache 132 get_skill_commands() 133 134 # Call reload again with no FS changes 135 result = reload_skills() 136 assert "alpha" in result["unchanged"] 137 assert result["added"] == [] 138 assert result["removed"] == [] 139 140 def test_does_not_invalidate_prompt_cache_snapshot(self, hermes_home): 141 """reload_skills must NOT delete the skills prompt-cache snapshot. 142 143 Skills are called at runtime — the system prompt doesn't need to 144 mention them for the model to use them — so reloading them should 145 preserve prefix caching. 146 """ 147 from agent.prompt_builder import _skills_prompt_snapshot_path 148 from agent.skill_commands import reload_skills 149 150 snapshot = _skills_prompt_snapshot_path() 151 snapshot.parent.mkdir(parents=True, exist_ok=True) 152 snapshot.write_text("{}") 153 assert snapshot.exists() 154 155 reload_skills() 156 157 assert snapshot.exists(), ( 158 "prompt cache snapshot should be preserved — skills don't live " 159 "in the system prompt so there's no reason to invalidate it" 160 )