/ tests / agent / test_skill_commands_reload.py
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          )