Files
hermes-agent/tests/agent/test_skill_cache_thrash.py
Konstantin Khlopkov 2dbc44dfe0 fix(skills): convert skill command cache to multi-slot dict (platform/home identity isolation)
Fixes #104849

Converted `get_skill_commands()` from a single-slot cache (keyed by
`(platform, home)` tuple checked at call time) to a multi-slot dict
`_skill_commands_by_key = {(platform, home): {commands}}` that memoizes
each distinct identity once and serves it forever. The old single-slot
design thrashed in the Desktop `hermes serve` backend because the
platform/home identity flapped between requests (`HERMES_SESSION_PLATFORM`
set to `""` vs. resolved to `None`, profile home overrides) so every
`commands.catalog` poll (~3x/5s) re-scanned the entire skills dir.

Also normalized empty-string to `None` in `_resolve_skill_commands_platform()`
so both spellings share one cache slot (#104849 root cause), and replaced
direct `scan_skill_commands()` calls in `tui_gateway/methods_tools.py`
(two sites: `_catalog_skills`, `_dispatch_skill`) with `get_skill_commands()`
so they benefit from the cache instead of bypassing it.

- `tests/agent/test_skill_commands.py`: 30 tests green (updated monkeypatch
  fixtures from `_skill_commands*` globals to `_skill_commands_by_key`).
- New regression test `test_skill_cache_thrash_104849.py`: verifies that
  platform flapping (`""` ⇄ `None`) hits the same cache slot (scan once),
  and that distinct `(platform, home)` identities each get their own slot
  (preserves #14536 / #88023 isolation fixes).
- `test_skill_commands_reload.py`, `test_skill_invocation_description.py`,
  `test_prompt_cache_boundary.py`: green.
- `ruff check` on all modified files: clean.

Tested locally: after this patch the collision warning (skill `plan` vs.
core `/plan`) that repeated ~3x/5s while the Desktop window was focused
stopped entirely (zero scans mid-session), matching the issue reporter's
verification.
2026-09-29 18:05:05 -05:00

253 lines
10 KiB
Python

"""Regression test for #104849: skill command cache thrash from platform/home flapping."""
from pathlib import Path
from unittest.mock import patch
from agent.skill_commands import get_skill_commands, scan_skill_commands
def _make_skill(parent: Path, name: str) -> None:
skill_dir = parent / 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\nBody.\n"
)
def test_cache_survives_platform_empty_string_none_flapping(tmp_path):
"""When platform flaps between "" and None, both share the same cache slot.
Regression for #104849: `_set_session_context` in `tui_gateway/server.py`
pins the platform to an empty string (`platform=""`), and
`_resolve_skill_commands_platform()` normalizes that `""` to `None` in
some request contexts. A single-slot cache treating them as distinct keys
resulted in a rescan on every `commands.catalog` poll (~3x/5s).
The fix: multi-slot cache keyed by `(platform, home)`, and normalize `""`
to `None` so both spellings share one slot (scan once, hit forever).
"""
import os
import agent.skill_commands as sc_mod
_make_skill(tmp_path, "test-skill")
scan_count = 0
original_scan = scan_skill_commands
def counting_scan(*args, **kwargs):
nonlocal scan_count
scan_count += 1
return original_scan(*args, **kwargs)
with (
patch("tools.skills_tool.SKILLS_DIR", tmp_path),
patch.object(sc_mod, "_skill_commands_by_key", {}),
patch("agent.skill_commands.scan_skill_commands", side_effect=counting_scan),
):
# First call: platform implicitly None (no env var)
with patch.dict(os.environ, {}, clear=False):
cmds1 = get_skill_commands()
assert "/test-skill" in cmds1
assert scan_count == 1 # First scan
# Second call: platform explicitly "" (what the TUI gateway sets)
with patch.dict(os.environ, {"HERMES_PLATFORM": ""}, clear=False):
cmds2 = get_skill_commands()
# Both "" and None normalize to None — cache hit, NO rescan
assert cmds2 is cmds1
assert scan_count == 1 # Still the same scan
# Third call: back to implicitly None (drop the env var)
with patch.dict(os.environ, {}, clear=False):
cmds3 = get_skill_commands()
# Cache hit again — no new scan
assert cmds3 is cmds1
assert scan_count == 1 # Never rescanned
def test_cache_creates_multiple_slots_for_distinct_platform_or_home(tmp_path):
"""Each distinct (platform, home) identity gets its own cache slot.
Ensures the multi-slot cache doesn't degrade into a single-slot
cache that breaks the #14536 / #88023 fixes (platform/profile isolation).
"""
import os
import agent.skill_commands as sc_mod
from hermes_constants import reset_hermes_home_override, set_hermes_home_override
profile_a = tmp_path / "profile_a"
profile_b = tmp_path / "profile_b"
profile_a.mkdir()
profile_b.mkdir()
(profile_a / "config.yaml").write_text("{}\n")
(profile_b / "config.yaml").write_text("{}\n")
_make_skill(profile_a / "skills", "a-skill")
_make_skill(profile_b / "skills", "b-skill")
scan_count = 0
original_scan = scan_skill_commands
def counting_scan(*args, **kwargs):
nonlocal scan_count
scan_count += 1
return original_scan(*args, **kwargs)
with (
patch.object(sc_mod, "_skill_commands_by_key", {}),
patch("agent.skill_commands.scan_skill_commands", side_effect=counting_scan),
):
# Scan profile A, platform 'telegram'
token = set_hermes_home_override(profile_a)
try:
with patch.dict(os.environ, {"HERMES_PLATFORM": "telegram"}):
cmds_a_telegram = get_skill_commands()
finally:
reset_hermes_home_override(token)
assert "/a-skill" in cmds_a_telegram
assert scan_count == 1 # First scan
# Switch to profile B, same platform 'telegram' — different home → rescan
token = set_hermes_home_override(profile_b)
try:
with patch.dict(os.environ, {"HERMES_PLATFORM": "telegram"}):
cmds_b_telegram = get_skill_commands()
finally:
reset_hermes_home_override(token)
assert "/b-skill" in cmds_b_telegram
assert scan_count == 2 # New (platform, home) key → second scan
# Switch to profile A, platform 'discord' — different platform → rescan
token = set_hermes_home_override(profile_a)
try:
with patch.dict(os.environ, {"HERMES_PLATFORM": "discord"}):
cmds_a_discord = get_skill_commands()
finally:
reset_hermes_home_override(token)
assert "/a-skill" in cmds_a_discord
assert scan_count == 3 # New platform key → third scan
# Back to profile A, telegram — cache hit from the first scan
token = set_hermes_home_override(profile_a)
try:
with patch.dict(os.environ, {"HERMES_PLATFORM": "telegram"}):
cmds_a_telegram_again = get_skill_commands()
finally:
reset_hermes_home_override(token)
# Same (platform='telegram', home=profile_a) as call 1 → cache hit
assert cmds_a_telegram_again is cmds_a_telegram
assert scan_count == 3 # No rescan
def test_cache_keys_project_as_third_dimension(tmp_path):
"""Two sessions in different repos share (platform, home) but must NOT share
a cache slot: the project root is part of the identity (#114359). A 2-tuple
(platform, home) key would serve repo A's project skills to repo B after a
cache hit — the regression this guards against.
"""
import agent.skill_commands as sc_mod
from agent import skill_utils
from hermes_constants import reset_hermes_home_override, set_hermes_home_override
repo_a = tmp_path / "repo-a"
repo_b = tmp_path / "repo-b"
for repo in (repo_a, repo_b):
(repo / ".git").mkdir(parents=True)
_make_skill((repo_a / ".hermes" / "skills"), "proj-a-skill")
_make_skill((repo_b / ".hermes" / "skills"), "proj-b-skill")
# A shared, otherwise-empty home: only the project dimension differs.
home = tmp_path / "shared-home"
home.mkdir()
(home / "config.yaml").write_text("{}\n")
state = {"root": repo_a}
def fake_find_project_root(start=None):
return state["root"]
def fake_project_dirs():
d = state["root"] / ".hermes" / "skills"
return [d] if d.is_dir() else []
scan_count = 0
original_scan = scan_skill_commands
def counting_scan(*args, **kwargs):
nonlocal scan_count
scan_count += 1
return original_scan(*args, **kwargs)
token = set_hermes_home_override(home)
try:
with (
patch.object(sc_mod, "_skill_commands_by_key", {}),
patch("agent.skill_commands.scan_skill_commands", side_effect=counting_scan),
patch.object(skill_utils, "find_project_root", fake_find_project_root),
patch.object(skill_utils, "get_project_skills_dirs", fake_project_dirs),
):
cmds_a = get_skill_commands()
assert "/proj-a-skill" in cmds_a
assert "/proj-b-skill" not in cmds_a
assert scan_count == 1
# Same platform/home, different project root → its OWN slot, own view.
state["root"] = repo_b
cmds_b = get_skill_commands()
assert "/proj-b-skill" in cmds_b
assert "/proj-a-skill" not in cmds_b
assert scan_count == 2
# Flapping back and forth hits the memoized slots — no rescan.
state["root"] = repo_a
assert get_skill_commands() is cmds_a
state["root"] = repo_b
assert get_skill_commands() is cmds_b
assert scan_count == 2
finally:
reset_hermes_home_override(token)
def test_reload_invalidates_every_identity_slot(tmp_path):
"""reload_skills() clears the whole multi-slot dict: a skill edit can affect
any (platform, home, project) identity, so every cached view must rescan."""
import os
import agent.skill_commands as sc_mod
from hermes_constants import reset_hermes_home_override, set_hermes_home_override
from agent.skill_commands import reload_skills
profile_a = tmp_path / "profile_a"
profile_b = tmp_path / "profile_b"
profile_a.mkdir()
profile_b.mkdir()
(profile_a / "config.yaml").write_text("{}\n")
(profile_b / "config.yaml").write_text("{}\n")
_make_skill(profile_a / "skills", "a-skill")
_make_skill(profile_b / "skills", "b-skill")
with patch.object(sc_mod, "_skill_commands_by_key", {}):
for profile, expected in ((profile_a, "/a-skill"), (profile_b, "/b-skill")):
token = set_hermes_home_override(profile)
try:
assert expected in get_skill_commands()
finally:
reset_hermes_home_override(token)
assert len(sc_mod._skill_commands_by_key) == 2
# Add a skill to profile A, then reload from A: BOTH slots must drop.
_make_skill(profile_a / "skills", "a-second-skill")
token = set_hermes_home_override(profile_a)
try:
result = reload_skills()
assert "/a-second-skill" in {item["name"] for item in result["added"]} or \
result["added"], result
finally:
reset_hermes_home_override(token)
# reload_skills() repopulated ONLY the current (A) slot with a fresh view
# (it saw the skill added after the original scan); B's stale slot is gone.
slots = sc_mod._skill_commands_by_key
assert len(slots) == 1
(a_cmds,) = slots.values()
assert "/a-skill" in a_cmds and "/a-second-skill" in a_cmds
# The next lookup from B rescans and still sees its own view.
token = set_hermes_home_override(profile_b)
try:
assert "/b-skill" in get_skill_commands()
finally:
reset_hermes_home_override(token)