- Skill invocation: no secret capture callback so SSH remote setup note is emitted - Patch agent.skill_utils.sys for platform checks (skill_matches_platform) - Skip CLAUDE.md priority test on Darwin (case-insensitive FS) Made-with: Cursor Co-authored-by: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com>
317 lines
11 KiB
Python
317 lines
11 KiB
Python
"""Tests for agent/skill_commands.py — skill slash command scanning and platform filtering."""
|
|
|
|
import os
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
from unittest.mock import patch
|
|
|
|
import tools.skills_tool as skills_tool_module
|
|
from agent.skill_commands import (
|
|
build_plan_path,
|
|
build_preloaded_skills_prompt,
|
|
build_skill_invocation_message,
|
|
scan_skill_commands,
|
|
)
|
|
|
|
|
|
def _make_skill(
|
|
skills_dir, name, frontmatter_extra="", body="Do the thing.", category=None
|
|
):
|
|
"""Helper to create a minimal skill directory with SKILL.md."""
|
|
if category:
|
|
skill_dir = skills_dir / category / name
|
|
else:
|
|
skill_dir = skills_dir / name
|
|
skill_dir.mkdir(parents=True, exist_ok=True)
|
|
content = f"""\
|
|
---
|
|
name: {name}
|
|
description: Description for {name}.
|
|
{frontmatter_extra}---
|
|
|
|
# {name}
|
|
|
|
{body}
|
|
"""
|
|
(skill_dir / "SKILL.md").write_text(content)
|
|
return skill_dir
|
|
|
|
|
|
class TestScanSkillCommands:
|
|
def test_finds_skills(self, tmp_path):
|
|
with patch("tools.skills_tool.SKILLS_DIR", tmp_path):
|
|
_make_skill(tmp_path, "my-skill")
|
|
result = scan_skill_commands()
|
|
assert "/my-skill" in result
|
|
assert result["/my-skill"]["name"] == "my-skill"
|
|
|
|
def test_empty_dir(self, tmp_path):
|
|
with patch("tools.skills_tool.SKILLS_DIR", tmp_path):
|
|
result = scan_skill_commands()
|
|
assert result == {}
|
|
|
|
def test_excludes_incompatible_platform(self, tmp_path):
|
|
"""macOS-only skills should not register slash commands on Linux."""
|
|
with (
|
|
patch("tools.skills_tool.SKILLS_DIR", tmp_path),
|
|
patch("agent.skill_utils.sys") as mock_sys,
|
|
):
|
|
mock_sys.platform = "linux"
|
|
_make_skill(tmp_path, "imessage", frontmatter_extra="platforms: [macos]\n")
|
|
_make_skill(tmp_path, "web-search")
|
|
result = scan_skill_commands()
|
|
assert "/web-search" in result
|
|
assert "/imessage" not in result
|
|
|
|
def test_includes_matching_platform(self, tmp_path):
|
|
"""macOS-only skills should register slash commands on macOS."""
|
|
with (
|
|
patch("tools.skills_tool.SKILLS_DIR", tmp_path),
|
|
patch("agent.skill_utils.sys") as mock_sys,
|
|
):
|
|
mock_sys.platform = "darwin"
|
|
_make_skill(tmp_path, "imessage", frontmatter_extra="platforms: [macos]\n")
|
|
result = scan_skill_commands()
|
|
assert "/imessage" in result
|
|
|
|
def test_universal_skill_on_any_platform(self, tmp_path):
|
|
"""Skills without platforms field should register on any platform."""
|
|
with (
|
|
patch("tools.skills_tool.SKILLS_DIR", tmp_path),
|
|
patch("agent.skill_utils.sys") as mock_sys,
|
|
):
|
|
mock_sys.platform = "win32"
|
|
_make_skill(tmp_path, "generic-tool")
|
|
result = scan_skill_commands()
|
|
assert "/generic-tool" in result
|
|
|
|
def test_excludes_disabled_skills(self, tmp_path):
|
|
"""Disabled skills should not register slash commands."""
|
|
with (
|
|
patch("tools.skills_tool.SKILLS_DIR", tmp_path),
|
|
patch(
|
|
"tools.skills_tool._get_disabled_skill_names",
|
|
return_value={"disabled-skill"},
|
|
),
|
|
):
|
|
_make_skill(tmp_path, "enabled-skill")
|
|
_make_skill(tmp_path, "disabled-skill")
|
|
result = scan_skill_commands()
|
|
assert "/enabled-skill" in result
|
|
assert "/disabled-skill" not in result
|
|
|
|
|
|
class TestBuildPreloadedSkillsPrompt:
|
|
def test_builds_prompt_for_multiple_named_skills(self, tmp_path):
|
|
with patch("tools.skills_tool.SKILLS_DIR", tmp_path):
|
|
_make_skill(tmp_path, "first-skill")
|
|
_make_skill(tmp_path, "second-skill")
|
|
prompt, loaded, missing = build_preloaded_skills_prompt(
|
|
["first-skill", "second-skill"]
|
|
)
|
|
|
|
assert missing == []
|
|
assert loaded == ["first-skill", "second-skill"]
|
|
assert "first-skill" in prompt
|
|
assert "second-skill" in prompt
|
|
assert "preloaded" in prompt.lower()
|
|
|
|
def test_reports_missing_named_skills(self, tmp_path):
|
|
with patch("tools.skills_tool.SKILLS_DIR", tmp_path):
|
|
_make_skill(tmp_path, "present-skill")
|
|
prompt, loaded, missing = build_preloaded_skills_prompt(
|
|
["present-skill", "missing-skill"]
|
|
)
|
|
|
|
assert "present-skill" in prompt
|
|
assert loaded == ["present-skill"]
|
|
assert missing == ["missing-skill"]
|
|
|
|
|
|
class TestBuildSkillInvocationMessage:
|
|
def test_loads_skill_by_stored_path_when_frontmatter_name_differs(self, tmp_path):
|
|
skill_dir = tmp_path / "mlops" / "audiocraft"
|
|
skill_dir.mkdir(parents=True, exist_ok=True)
|
|
(skill_dir / "SKILL.md").write_text(
|
|
"""\
|
|
---
|
|
name: audiocraft-audio-generation
|
|
description: Generate audio with AudioCraft.
|
|
---
|
|
|
|
# AudioCraft
|
|
|
|
Generate some audio.
|
|
"""
|
|
)
|
|
|
|
with patch("tools.skills_tool.SKILLS_DIR", tmp_path):
|
|
scan_skill_commands()
|
|
msg = build_skill_invocation_message("/audiocraft-audio-generation", "compose")
|
|
|
|
assert msg is not None
|
|
assert "AudioCraft" in msg
|
|
assert "compose" in msg
|
|
|
|
def test_builds_message(self, tmp_path):
|
|
with patch("tools.skills_tool.SKILLS_DIR", tmp_path):
|
|
_make_skill(tmp_path, "test-skill")
|
|
scan_skill_commands()
|
|
msg = build_skill_invocation_message("/test-skill", "do stuff")
|
|
assert msg is not None
|
|
assert "test-skill" in msg
|
|
assert "do stuff" in msg
|
|
|
|
def test_returns_none_for_unknown(self, tmp_path):
|
|
with patch("tools.skills_tool.SKILLS_DIR", tmp_path):
|
|
scan_skill_commands()
|
|
msg = build_skill_invocation_message("/nonexistent")
|
|
assert msg is None
|
|
|
|
def test_uses_shared_skill_loader_for_secure_setup(self, tmp_path, monkeypatch):
|
|
monkeypatch.delenv("TENOR_API_KEY", raising=False)
|
|
calls = []
|
|
|
|
def fake_secret_callback(var_name, prompt, metadata=None):
|
|
calls.append((var_name, prompt, metadata))
|
|
os.environ[var_name] = "stored-in-test"
|
|
return {
|
|
"success": True,
|
|
"stored_as": var_name,
|
|
"validated": False,
|
|
"skipped": False,
|
|
}
|
|
|
|
monkeypatch.setattr(
|
|
skills_tool_module,
|
|
"_secret_capture_callback",
|
|
fake_secret_callback,
|
|
raising=False,
|
|
)
|
|
|
|
with patch("tools.skills_tool.SKILLS_DIR", tmp_path):
|
|
_make_skill(
|
|
tmp_path,
|
|
"test-skill",
|
|
frontmatter_extra=(
|
|
"required_environment_variables:\n"
|
|
" - name: TENOR_API_KEY\n"
|
|
" prompt: Tenor API key\n"
|
|
),
|
|
)
|
|
scan_skill_commands()
|
|
msg = build_skill_invocation_message("/test-skill", "do stuff")
|
|
|
|
assert msg is not None
|
|
assert "test-skill" in msg
|
|
assert len(calls) == 1
|
|
assert calls[0][0] == "TENOR_API_KEY"
|
|
|
|
def test_gateway_still_loads_skill_but_returns_setup_guidance(
|
|
self, tmp_path, monkeypatch
|
|
):
|
|
monkeypatch.delenv("TENOR_API_KEY", raising=False)
|
|
|
|
def fail_if_called(var_name, prompt, metadata=None):
|
|
raise AssertionError(
|
|
"gateway flow should not try secure in-band secret capture"
|
|
)
|
|
|
|
monkeypatch.setattr(
|
|
skills_tool_module,
|
|
"_secret_capture_callback",
|
|
fail_if_called,
|
|
raising=False,
|
|
)
|
|
|
|
with patch.dict(
|
|
os.environ, {"HERMES_SESSION_PLATFORM": "telegram"}, clear=False
|
|
):
|
|
with patch("tools.skills_tool.SKILLS_DIR", tmp_path):
|
|
_make_skill(
|
|
tmp_path,
|
|
"test-skill",
|
|
frontmatter_extra=(
|
|
"required_environment_variables:\n"
|
|
" - name: TENOR_API_KEY\n"
|
|
" prompt: Tenor API key\n"
|
|
),
|
|
)
|
|
scan_skill_commands()
|
|
msg = build_skill_invocation_message("/test-skill", "do stuff")
|
|
|
|
assert msg is not None
|
|
assert "local cli" in msg.lower()
|
|
|
|
def test_preserves_remaining_remote_setup_warning(self, tmp_path, monkeypatch):
|
|
monkeypatch.setenv("TERMINAL_ENV", "ssh")
|
|
monkeypatch.delenv("TENOR_API_KEY", raising=False)
|
|
monkeypatch.setattr(
|
|
skills_tool_module,
|
|
"_secret_capture_callback",
|
|
None,
|
|
raising=False,
|
|
)
|
|
|
|
with patch("tools.skills_tool.SKILLS_DIR", tmp_path):
|
|
_make_skill(
|
|
tmp_path,
|
|
"test-skill",
|
|
frontmatter_extra=(
|
|
"required_environment_variables:\n"
|
|
" - name: TENOR_API_KEY\n"
|
|
" prompt: Tenor API key\n"
|
|
),
|
|
)
|
|
scan_skill_commands()
|
|
msg = build_skill_invocation_message("/test-skill", "do stuff")
|
|
|
|
assert msg is not None
|
|
assert "remote environment" in msg.lower()
|
|
|
|
def test_supporting_file_hint_uses_file_path_argument(self, tmp_path):
|
|
with patch("tools.skills_tool.SKILLS_DIR", tmp_path):
|
|
skill_dir = _make_skill(tmp_path, "test-skill")
|
|
references = skill_dir / "references"
|
|
references.mkdir()
|
|
(references / "api.md").write_text("reference")
|
|
scan_skill_commands()
|
|
msg = build_skill_invocation_message("/test-skill", "do stuff")
|
|
|
|
assert msg is not None
|
|
assert 'file_path="<path>"' in msg
|
|
|
|
|
|
class TestPlanSkillHelpers:
|
|
def test_build_plan_path_uses_workspace_relative_dir_and_slugifies_request(self):
|
|
path = build_plan_path(
|
|
"Implement OAuth login + refresh tokens!",
|
|
now=datetime(2026, 3, 15, 9, 30, 45),
|
|
)
|
|
|
|
assert path == Path(".hermes") / "plans" / "2026-03-15_093045-implement-oauth-login-refresh-tokens.md"
|
|
|
|
def test_plan_skill_message_can_include_runtime_save_path_note(self, tmp_path):
|
|
with patch("tools.skills_tool.SKILLS_DIR", tmp_path):
|
|
_make_skill(
|
|
tmp_path,
|
|
"plan",
|
|
body="Save plans under .hermes/plans in the active workspace and do not execute the work.",
|
|
)
|
|
scan_skill_commands()
|
|
msg = build_skill_invocation_message(
|
|
"/plan",
|
|
"Add a /plan command",
|
|
runtime_note=(
|
|
"Save the markdown plan with write_file to this exact relative path inside "
|
|
"the active workspace/backend cwd: .hermes/plans/plan.md"
|
|
),
|
|
)
|
|
|
|
assert msg is not None
|
|
assert "Save plans under $HERMES_HOME/plans" not in msg
|
|
assert ".hermes/plans" in msg
|
|
assert "Add a /plan command" in msg
|
|
assert ".hermes/plans/plan.md" in msg
|
|
assert "Runtime note:" in msg
|