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.
253 lines
10 KiB
Python
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)
|