hermes-agent/tests/agent/test_skill_utils.py
Teknium 6b81590c55
test: prune low-value tests suite-wide (wave 1) — 46,820 → 28,106 test functions
Systematic prune per AGENTS.md test policy, one pass over every major
test tree (gateway, hermes_cli, tools, agent, run_agent, plugins, cli,
cron, tui_gateway, honcho/openviking, root-level):

- DELETE: source-reading tests (read_text/getsource on prod files),
  change-detector tests (exact catalog counts, model-name snapshots,
  config version literals), mock-echo tests (assert a mock returns what
  it was told), assertion-free/trivial tests, near-duplicate
  parametrizations (boundaries + one representative kept), async/sync
  twin duplicates, cosmetic within-file variations.
- KEEP (mandatory): security/redaction/approval guards, message-role
  alternation invariants, prompt-caching/deterministic-call-id
  invariants, issue-number regression tests (deduped), E2E tests.
- 6 test files deleted outright (script-style/no-assert or fully
  redundant); conftest.py, fakes/, fixtures/ untouched.
- tests/acp/conftest.py added: autouse fixture stubs the live
  models.dev/GitHub/Copilot/Anthropic inventory fetches that ACP server
  tests performed on every session create — test_server.py 147s → 3.4s,
  and the tests are now genuinely hermetic.
- Sleep-based slowness shrunk where safe (codex_ttfb_watchdog,
  compression_concurrent_fork, etc.); no wall-clock assertion tightened.

Verification: full hermetic suite via scripts/run_tests.sh —
2439 files, 31,130 tests passed, 0 failed, 0 flaky retries, 315s wall
(baseline: 583s wall, 13,564s subprocess CPU).
2026-07-29 13:10:23 -07:00

304 lines
11 KiB
Python

"""Tests for agent/skill_utils.py."""
from unittest.mock import patch
from agent.skill_utils import (
extract_skill_config_vars,
extract_skill_conditions,
get_disabled_skill_names,
get_external_skills_dirs,
is_excluded_skill_path,
is_external_skill_path,
is_skill_support_path,
iter_skill_index_files,
parse_frontmatter,
resolve_skill_config_values,
skill_matches_platform,
skill_matches_platform_list,
)
def test_skill_config_helpers_share_raw_config_parse_cache(tmp_path, monkeypatch):
"""Repeated skill config helpers should parse config.yaml only once."""
from agent import skill_utils
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
external = tmp_path / "external-skills"
external.mkdir()
config_path = hermes_home / "config.yaml"
config_path.write_text(
f"""
skills:
disabled:
- hidden-skill
external_dirs:
- {external}
config:
wiki:
path: ~/wiki
""".strip(),
encoding="utf-8",
)
parse_count = 0
real_yaml_load = skill_utils.yaml_load
def counting_yaml_load(text):
nonlocal parse_count
parse_count += 1
return real_yaml_load(text)
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
skill_utils._external_dirs_cache_clear()
getattr(skill_utils, "_raw_config_cache_clear", lambda: None)()
monkeypatch.setattr(skill_utils, "yaml_load", counting_yaml_load)
assert get_disabled_skill_names() == {"hidden-skill"}
assert get_external_skills_dirs() == [external.resolve()]
assert resolve_skill_config_values([
{"key": "wiki.path", "description": "Wiki path"}
])["wiki.path"].endswith("/wiki")
assert parse_count == 1
def test_iter_skill_index_files_prunes_skill_support_dirs(tmp_path):
"""Archived package SKILL.md files under support dirs are not active skills."""
real = tmp_path / "umbrella"
real.mkdir()
(real / "SKILL.md").write_text("---\nname: umbrella\n---\n", encoding="utf-8")
package = real / "references" / "old-skill-package"
package.mkdir(parents=True)
(package / "SKILL.md").write_text("---\nname: old-skill\n---\n", encoding="utf-8")
(package / "DESCRIPTION.md").write_text(
"---\ndescription: archived package\n---\n", encoding="utf-8"
)
script_package = real / "scripts" / "helper-skill"
script_package.mkdir(parents=True)
(script_package / "SKILL.md").write_text("---\nname: helper\n---\n", encoding="utf-8")
found = list(iter_skill_index_files(tmp_path, "SKILL.md"))
desc_found = list(iter_skill_index_files(tmp_path, "DESCRIPTION.md"))
assert found == [real / "SKILL.md"]
assert desc_found == []
assert is_skill_support_path(package / "SKILL.md") is True
assert is_excluded_skill_path(package / "SKILL.md") is True
def test_iter_skill_index_files_keeps_support_named_categories(tmp_path):
"""A category named scripts/templates/assets/references is still valid."""
scripts_skill = tmp_path / "scripts" / "bash-helper"
scripts_skill.mkdir(parents=True)
(scripts_skill / "SKILL.md").write_text(
"---\nname: bash-helper\n---\n", encoding="utf-8"
)
templates_skill = tmp_path / "templates" / "deck-template"
templates_skill.mkdir(parents=True)
(templates_skill / "SKILL.md").write_text(
"---\nname: deck-template\n---\n", encoding="utf-8"
)
found = list(iter_skill_index_files(tmp_path, "SKILL.md"))
assert found == [scripts_skill / "SKILL.md", templates_skill / "SKILL.md"]
assert is_skill_support_path(scripts_skill / "SKILL.md") is False
assert is_excluded_skill_path(scripts_skill / "SKILL.md") is False
def test_skill_support_path_uses_explicit_discovery_root_not_cwd(tmp_path, monkeypatch):
discovery_root = tmp_path / "site-packages" / "skills"
umbrella = discovery_root / "category" / "umbrella"
nested = umbrella / "references" / "archived" / "SKILL.md"
nested.parent.mkdir(parents=True)
(umbrella / "SKILL.md").write_text("---\nname: umbrella\n---\n", encoding="utf-8")
nested.write_text("---\nname: archived\n---\n", encoding="utf-8")
elsewhere = tmp_path / "elsewhere"
elsewhere.mkdir()
monkeypatch.chdir(elsewhere)
relative = nested.relative_to(discovery_root)
assert is_skill_support_path(relative, root=discovery_root) is True
assert is_excluded_skill_path(relative, root=discovery_root) is True
# ── skill_matches_platform on Termux ──────────────────────────────────────
class TestSkillMatchesPlatformTermux:
"""Termux is Linux userland on Android. Skills tagged platforms:[linux]
must load there regardless of whether Python reports sys.platform as
"linux" (pre-3.13) or "android" (3.13+). Reported by user @LikiusInik
in May 2026 — only 3 built-in skills appeared on Termux because every
github/productivity/mlops skill is tagged platforms:[linux,macos,windows]
and sys.platform=="android" did not start with "linux".
"""
def test_no_platforms_field_matches_everywhere(self):
# Backward-compat default — skills without a platforms tag load
# on any OS, Termux included.
with patch("agent.skill_utils.sys.platform", "android"), patch(
"agent.skill_utils.is_termux", return_value=True
):
assert skill_matches_platform({}) is True
assert skill_matches_platform({"name": "foo"}) is True
def test_non_termux_android_does_not_widen(self):
# If we're somehow on a plain Android Python (not Termux), don't
# silently load Linux skills — Termux is the supported environment.
fm = {"platforms": ["linux"]}
with patch("agent.skill_utils.sys.platform", "android"), patch(
"agent.skill_utils.is_termux", return_value=False
):
assert skill_matches_platform(fm) is False
assert skill_matches_platform_list(fm["platforms"]) is False
def test_linux_skill_on_real_linux_unaffected(self):
# The non-Termux Linux path must not change.
fm = {"platforms": ["linux"]}
with patch("agent.skill_utils.sys.platform", "linux"), patch(
"agent.skill_utils.is_termux", return_value=False
):
assert skill_matches_platform(fm) is True
assert skill_matches_platform_list(fm["platforms"]) is True
class TestNormalizeSkillLookupName:
def test_relative_path_unchanged(self, tmp_path, monkeypatch):
from agent.skill_utils import normalize_skill_lookup_name
# Relative identifiers early-return before any root lookup.
assert normalize_skill_lookup_name("foo/bar") == "foo/bar"
def test_absolute_via_symlink_uses_lexical_relative_path(self, tmp_path, monkeypatch):
from agent.skill_utils import normalize_skill_lookup_name
skills_dir = tmp_path / "skills"
skills_dir.mkdir()
external = tmp_path / "external" / "my-skill"
external.mkdir(parents=True)
link = skills_dir / "my-skill"
try:
link.symlink_to(external)
except OSError:
pytest.skip("Symlinks not supported")
monkeypatch.setattr("tools.skills_tool.SKILLS_DIR", skills_dir)
assert normalize_skill_lookup_name(str(link)) == "my-skill"
# ── parse_frontmatter: UTF-8 BOM tolerance ─────────────────────────────────
class TestParseFrontmatterBOM:
"""A UTF-8 BOM (U+FEFF) on a Windows-saved SKILL.md must not defeat
frontmatter parsing.
Notepad and PowerShell ``>`` prepend a BOM when saving UTF-8;
``read_text(encoding="utf-8")`` (what ``_parse_skill_file`` uses) keeps
it, so the bytes handed to ``parse_frontmatter`` start with a BOM ahead of
the ``---`` fence. Before the fix the ``startswith("---")`` check returned
False and the whole frontmatter was silently dropped — the skill loaded
nameless, platform gating fell open, and env-var/config setup never fired.
"""
SKILL = (
"---\n"
"name: my-skill\n"
"description: Does a thing.\n"
"platforms: [macos]\n"
"metadata:\n"
" hermes:\n"
" config:\n"
" - key: my.key\n"
" description: A configured value\n"
"---\n\n"
"# My Skill\n\nBody text.\n"
)
def test_bom_frontmatter_matches_plain(self):
plain_fm, plain_body = parse_frontmatter(self.SKILL)
bom_fm, bom_body = parse_frontmatter("\ufeff" + self.SKILL)
assert bom_fm == plain_fm
assert bom_body == plain_body
assert bom_fm["name"] == "my-skill"
assert bom_fm["description"] == "Does a thing."
def test_bom_platform_gating_regression(self):
# The concrete harm: a macOS-only skill must stay hidden on non-macOS
# whether or not the file carries a BOM. Empty frontmatter (the bug)
# reads as "no platform restriction" and leaks the skill everywhere.
with patch("agent.skill_utils.sys.platform", "win32"), patch(
"agent.skill_utils.is_termux", return_value=False
):
plain_fm, _ = parse_frontmatter(self.SKILL)
bom_fm, _ = parse_frontmatter("\ufeff" + self.SKILL)
assert skill_matches_platform(plain_fm) is False
assert skill_matches_platform(bom_fm) is False
def test_real_file_read_path(self, tmp_path):
# End-to-end: write the file the way a Windows editor does (utf-8-sig
# emits a BOM), read it the way _parse_skill_file does (plain utf-8),
# and confirm the frontmatter survives the round trip.
f = tmp_path / "SKILL.md"
f.write_text(self.SKILL, encoding="utf-8-sig")
raw = f.read_text(encoding="utf-8")
assert raw.startswith("\ufeff") # BOM really is present on disk
fm, _ = parse_frontmatter(raw)
assert fm["name"] == "my-skill"
assert fm["platforms"] == ["macos"]
class TestBOMToleranceSiblingSites:
"""The BOM fix must cover every independent frontmatter parser, not just
the canonical ``parse_frontmatter`` — several modules reimplement the
``---`` fence check locally."""
SKILL = "---\nname: bom-skill\ndescription: Saved by Notepad\n---\n\n# Body\n"
def test_prompt_builder_strips_bom_frontmatter(self):
# A BOM'd context file (AGENTS.md etc.) must not leak raw
# frontmatter into the system prompt.
from agent.prompt_builder import _strip_yaml_frontmatter
out = _strip_yaml_frontmatter("\ufeff---\nfoo: bar\n---\nBody text\n")
assert out.strip() == "Body text"
def test_blueprints_split_frontmatter_bom(self):
# str.lstrip() does NOT strip U+FEFF (it is not whitespace), so the
# pre-existing lstrip() in _split_frontmatter never covered it.
from tools.blueprints import _split_frontmatter
fm = _split_frontmatter("\ufeff---\nname: bp\n---\nbody")
assert fm is not None
assert fm.get("name") == "bp"