forked from Zakaria/hermes-agent
Hermes-agent
This commit is contained in:
@@ -0,0 +1,265 @@
|
||||
"""Tests for agent/skill_utils.py."""
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
from agent.skill_utils import (
|
||||
extract_skill_conditions,
|
||||
get_disabled_skill_names,
|
||||
get_external_skills_dirs,
|
||||
iter_skill_index_files,
|
||||
resolve_skill_config_values,
|
||||
skill_matches_platform,
|
||||
)
|
||||
|
||||
|
||||
def test_metadata_as_dict_with_hermes():
|
||||
"""Normal case: metadata is a dict containing hermes keys."""
|
||||
frontmatter = {
|
||||
"metadata": {
|
||||
"hermes": {
|
||||
"fallback_for_toolsets": ["toolset_a"],
|
||||
"requires_toolsets": ["toolset_b"],
|
||||
"fallback_for_tools": ["tool_x"],
|
||||
"requires_tools": ["tool_y"],
|
||||
}
|
||||
}
|
||||
}
|
||||
result = extract_skill_conditions(frontmatter)
|
||||
assert result["fallback_for_toolsets"] == ["toolset_a"]
|
||||
assert result["requires_toolsets"] == ["toolset_b"]
|
||||
assert result["fallback_for_tools"] == ["tool_x"]
|
||||
assert result["requires_tools"] == ["tool_y"]
|
||||
|
||||
|
||||
def test_metadata_as_string_does_not_crash():
|
||||
"""Bug case: metadata is a non-dict truthy value (e.g. a YAML string)."""
|
||||
frontmatter = {"metadata": "some text"}
|
||||
result = extract_skill_conditions(frontmatter)
|
||||
assert result == {
|
||||
"fallback_for_toolsets": [],
|
||||
"requires_toolsets": [],
|
||||
"fallback_for_tools": [],
|
||||
"requires_tools": [],
|
||||
}
|
||||
|
||||
|
||||
def test_metadata_as_none():
|
||||
"""metadata key is present but set to null/None."""
|
||||
frontmatter = {"metadata": None}
|
||||
result = extract_skill_conditions(frontmatter)
|
||||
assert result == {
|
||||
"fallback_for_toolsets": [],
|
||||
"requires_toolsets": [],
|
||||
"fallback_for_tools": [],
|
||||
"requires_tools": [],
|
||||
}
|
||||
|
||||
|
||||
def test_metadata_missing_entirely():
|
||||
"""metadata key is absent from frontmatter."""
|
||||
frontmatter = {"name": "my-skill", "description": "Does stuff."}
|
||||
result = extract_skill_conditions(frontmatter)
|
||||
assert result == {
|
||||
"fallback_for_toolsets": [],
|
||||
"requires_toolsets": [],
|
||||
"fallback_for_tools": [],
|
||||
"requires_tools": [],
|
||||
}
|
||||
|
||||
|
||||
def test_iter_skill_index_files_prunes_dependency_dirs(tmp_path):
|
||||
real = tmp_path / "real-skill"
|
||||
real.mkdir()
|
||||
(real / "SKILL.md").write_text("---\nname: real-skill\n---\n", encoding="utf-8")
|
||||
|
||||
nested = (
|
||||
tmp_path
|
||||
/ "bring"
|
||||
/ "scripts"
|
||||
/ ".venv"
|
||||
/ "lib"
|
||||
/ "python3.13"
|
||||
/ "site-packages"
|
||||
/ "typer"
|
||||
/ ".agents"
|
||||
/ "skills"
|
||||
/ "typer"
|
||||
)
|
||||
nested.mkdir(parents=True)
|
||||
(nested / "SKILL.md").write_text("---\nname: typer\n---\n", encoding="utf-8")
|
||||
|
||||
node_module = (
|
||||
tmp_path
|
||||
/ "web-skill"
|
||||
/ "node_modules"
|
||||
/ "dep"
|
||||
/ ".agents"
|
||||
/ "skills"
|
||||
/ "dep"
|
||||
)
|
||||
node_module.mkdir(parents=True)
|
||||
(node_module / "SKILL.md").write_text("---\nname: dep\n---\n", encoding="utf-8")
|
||||
|
||||
found = list(iter_skill_index_files(tmp_path, "SKILL.md"))
|
||||
|
||||
assert found == [real / "SKILL.md"]
|
||||
|
||||
|
||||
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_skill_config_raw_cache_invalidates_on_config_edit(tmp_path, monkeypatch):
|
||||
"""Editing config.yaml should invalidate the shared raw config cache."""
|
||||
from agent import skill_utils
|
||||
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
hermes_home.mkdir()
|
||||
config_path = hermes_home / "config.yaml"
|
||||
config_path.write_text("skills:\n disabled: [old-skill]\n", encoding="utf-8")
|
||||
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
skill_utils._external_dirs_cache_clear()
|
||||
assert get_disabled_skill_names() == {"old-skill"}
|
||||
|
||||
config_path.write_text("skills:\n disabled: [new-skill]\n", encoding="utf-8")
|
||||
import os
|
||||
os.utime(config_path, None)
|
||||
|
||||
assert get_disabled_skill_names() == {"new-skill"}
|
||||
|
||||
|
||||
# ── 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_linux_skill_loads_on_termux_android_platform(self):
|
||||
# Python 3.13+ on Termux reports sys.platform == "android".
|
||||
fm = {"platforms": ["linux"]}
|
||||
with patch("agent.skill_utils.sys.platform", "android"), patch(
|
||||
"agent.skill_utils.is_termux", return_value=True
|
||||
):
|
||||
assert skill_matches_platform(fm) is True
|
||||
|
||||
def test_linux_macos_windows_skill_loads_on_termux(self):
|
||||
# The common "[linux, macos, windows]" tag used by github-*,
|
||||
# productivity, mlops, etc.
|
||||
fm = {"platforms": ["linux", "macos", "windows"]}
|
||||
with patch("agent.skill_utils.sys.platform", "android"), patch(
|
||||
"agent.skill_utils.is_termux", return_value=True
|
||||
):
|
||||
assert skill_matches_platform(fm) is True
|
||||
|
||||
def test_linux_skill_loads_on_termux_linux_platform(self):
|
||||
# Pre-3.13 Termux reports sys.platform == "linux" already — this
|
||||
# works without the Termux escape hatch but must still pass.
|
||||
fm = {"platforms": ["linux"]}
|
||||
with patch("agent.skill_utils.sys.platform", "linux"), patch(
|
||||
"agent.skill_utils.is_termux", return_value=True
|
||||
):
|
||||
assert skill_matches_platform(fm) is True
|
||||
|
||||
def test_macos_only_skill_still_excluded_on_termux(self):
|
||||
# macOS-only skills (apple-notes, imessage, ...) should NOT load
|
||||
# on Termux. The Termux fallback only widens platforms:[linux,...].
|
||||
fm = {"platforms": ["macos"]}
|
||||
with patch("agent.skill_utils.sys.platform", "android"), patch(
|
||||
"agent.skill_utils.is_termux", return_value=True
|
||||
):
|
||||
assert skill_matches_platform(fm) is False
|
||||
|
||||
def test_windows_only_skill_still_excluded_on_termux(self):
|
||||
fm = {"platforms": ["windows"]}
|
||||
with patch("agent.skill_utils.sys.platform", "android"), patch(
|
||||
"agent.skill_utils.is_termux", return_value=True
|
||||
):
|
||||
assert skill_matches_platform(fm) is False
|
||||
|
||||
def test_explicit_termux_or_android_tag_matches(self):
|
||||
# Skills can also opt in explicitly via platforms:[termux] or
|
||||
# platforms:[android] — both should match a Termux session.
|
||||
with patch("agent.skill_utils.sys.platform", "android"), patch(
|
||||
"agent.skill_utils.is_termux", return_value=True
|
||||
):
|
||||
assert skill_matches_platform({"platforms": ["termux"]}) is True
|
||||
assert skill_matches_platform({"platforms": ["android"]}) 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
|
||||
|
||||
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
|
||||
|
||||
def test_macos_skill_on_real_macos_unaffected(self):
|
||||
fm = {"platforms": ["macos"]}
|
||||
with patch("agent.skill_utils.sys.platform", "darwin"), patch(
|
||||
"agent.skill_utils.is_termux", return_value=False
|
||||
):
|
||||
assert skill_matches_platform(fm) is True
|
||||
Reference in New Issue
Block a user