| """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, |
| resolve_skill_command_key, |
| 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 |
|
|
|
|
| def _symlink_category(skills_dir: Path, linked_root: Path, category: str) -> Path: |
| """Create a category symlink under skills_dir pointing outside the tree.""" |
| external_category = linked_root / category |
| external_category.mkdir(parents=True, exist_ok=True) |
| symlink_path = skills_dir / category |
| try: |
| symlink_path.symlink_to(external_category, target_is_directory=True) |
| except (OSError, NotImplementedError) as exc: |
| pytest.skip(f"symlinks unavailable in test environment: {exc}") |
| return external_category |
|
|
|
|
| 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 |
|
|
| def test_finds_skills_in_symlinked_category_dir(self, tmp_path): |
| external_root = tmp_path / "repo" |
| skills_root = tmp_path / "skills" |
| skills_root.mkdir() |
|
|
| external_category = _symlink_category(skills_root, external_root, "linked") |
| _make_skill(external_category.parent, "knowledge-brain", category="linked") |
|
|
| with patch("tools.skills_tool.SKILLS_DIR", skills_root): |
| result = scan_skill_commands() |
|
|
| assert "/knowledge-brain" in result |
| assert result["/knowledge-brain"]["name"] == "knowledge-brain" |
|
|
|
|
| def test_special_chars_stripped_from_cmd_key(self, tmp_path): |
| """Skill names with +, /, or other special chars produce clean cmd keys.""" |
| with patch("tools.skills_tool.SKILLS_DIR", tmp_path): |
| |
| skill_dir = tmp_path / "jellyfin-plus" |
| skill_dir.mkdir() |
| (skill_dir / "SKILL.md").write_text( |
| "---\nname: Jellyfin + Jellystat 24h Summary\n" |
| "description: Test skill\n---\n\nBody.\n" |
| ) |
| result = scan_skill_commands() |
| |
| assert "/jellyfin-jellystat-24h-summary" in result |
| |
| assert "/jellyfin-+-jellystat-24h-summary" not in result |
|
|
| def test_allspecial_name_skipped(self, tmp_path): |
| """Skill with name consisting only of special chars is silently skipped.""" |
| with patch("tools.skills_tool.SKILLS_DIR", tmp_path): |
| skill_dir = tmp_path / "bad-name" |
| skill_dir.mkdir() |
| (skill_dir / "SKILL.md").write_text( |
| "---\nname: +++\ndescription: Bad skill\n---\n\nBody.\n" |
| ) |
| result = scan_skill_commands() |
| |
| assert "/" not in result |
| assert result == {} |
|
|
| def test_slash_in_name_stripped_from_cmd_key(self, tmp_path): |
| """Skill names with / chars produce clean cmd keys.""" |
| with patch("tools.skills_tool.SKILLS_DIR", tmp_path): |
| skill_dir = tmp_path / "sonarr-api" |
| skill_dir.mkdir() |
| (skill_dir / "SKILL.md").write_text( |
| "---\nname: Sonarr v3/v4 API\n" |
| "description: Test skill\n---\n\nBody.\n" |
| ) |
| result = scan_skill_commands() |
| assert "/sonarr-v3v4-api" in result |
| assert any("/" in k[1:] for k in result) is False |
|
|
|
|
| class TestResolveSkillCommandKey: |
| """Telegram bot-command names disallow hyphens, so the menu registers |
| skills with hyphens swapped for underscores. When Telegram autocomplete |
| sends the underscored form back, we need to find the hyphenated key. |
| """ |
|
|
| def test_hyphenated_form_matches_directly(self, tmp_path): |
| with patch("tools.skills_tool.SKILLS_DIR", tmp_path): |
| _make_skill(tmp_path, "claude-code") |
| scan_skill_commands() |
| assert resolve_skill_command_key("claude-code") == "/claude-code" |
|
|
| def test_underscore_form_resolves_to_hyphenated_skill(self, tmp_path): |
| """/claude_code from Telegram autocomplete must resolve to /claude-code.""" |
| with patch("tools.skills_tool.SKILLS_DIR", tmp_path): |
| _make_skill(tmp_path, "claude-code") |
| scan_skill_commands() |
| assert resolve_skill_command_key("claude_code") == "/claude-code" |
|
|
| def test_single_word_command_resolves(self, tmp_path): |
| with patch("tools.skills_tool.SKILLS_DIR", tmp_path): |
| _make_skill(tmp_path, "investigate") |
| scan_skill_commands() |
| assert resolve_skill_command_key("investigate") == "/investigate" |
|
|
| def test_unknown_command_returns_none(self, tmp_path): |
| with patch("tools.skills_tool.SKILLS_DIR", tmp_path): |
| _make_skill(tmp_path, "claude-code") |
| scan_skill_commands() |
| assert resolve_skill_command_key("does_not_exist") is None |
| assert resolve_skill_command_key("does-not-exist") is None |
|
|
| def test_empty_command_returns_none(self, tmp_path): |
| with patch("tools.skills_tool.SKILLS_DIR", tmp_path): |
| scan_skill_commands() |
| assert resolve_skill_command_key("") is None |
|
|
| def test_hyphenated_command_is_not_mangled(self, tmp_path): |
| """A user-typed /foo-bar (hyphen) must not trigger the underscore fallback.""" |
| with patch("tools.skills_tool.SKILLS_DIR", tmp_path): |
| _make_skill(tmp_path, "foo-bar") |
| scan_skill_commands() |
| assert resolve_skill_command_key("foo-bar") == "/foo-bar" |
| |
| assert resolve_skill_command_key("foo_bar") == "/foo-bar" |
|
|
|
|
| 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 |
|
|
|
|
| class TestSkillDirectoryHeader: |
| """The activation message must expose the absolute skill directory and |
| explain how to resolve relative paths, so skills with bundled scripts |
| don't force the agent into a second ``skill_view()`` round-trip.""" |
|
|
| def test_header_contains_absolute_skill_dir(self, tmp_path): |
| with patch("tools.skills_tool.SKILLS_DIR", tmp_path): |
| skill_dir = _make_skill(tmp_path, "abs-dir-skill") |
| scan_skill_commands() |
| msg = build_skill_invocation_message("/abs-dir-skill", "go") |
|
|
| assert msg is not None |
| assert f"[Skill directory: {skill_dir}]" in msg |
| assert "Resolve any relative paths" in msg |
|
|
| def test_supporting_files_shown_with_absolute_paths(self, tmp_path): |
| with patch("tools.skills_tool.SKILLS_DIR", tmp_path): |
| skill_dir = _make_skill(tmp_path, "scripted-skill") |
| (skill_dir / "scripts").mkdir() |
| (skill_dir / "scripts" / "run.js").write_text("console.log('hi')") |
| scan_skill_commands() |
| msg = build_skill_invocation_message("/scripted-skill") |
|
|
| assert msg is not None |
| |
| |
| |
| assert "scripts/run.js" in msg |
| assert str(skill_dir / "scripts" / "run.js") in msg |
| assert f"node {skill_dir}/scripts/foo.js" in msg |
|
|
|
|
| class TestTemplateVarSubstitution: |
| """``${HERMES_SKILL_DIR}`` and ``${HERMES_SESSION_ID}`` in SKILL.md body |
| are replaced before the agent sees the content.""" |
|
|
| def test_substitutes_skill_dir(self, tmp_path): |
| with patch("tools.skills_tool.SKILLS_DIR", tmp_path): |
| skill_dir = _make_skill( |
| tmp_path, |
| "templated", |
| body="Run: node ${HERMES_SKILL_DIR}/scripts/foo.js", |
| ) |
| scan_skill_commands() |
| msg = build_skill_invocation_message("/templated") |
|
|
| assert msg is not None |
| assert f"node {skill_dir}/scripts/foo.js" in msg |
| |
| assert "${HERMES_SKILL_DIR}" not in msg.split("[Skill directory:")[0] |
|
|
| def test_substitutes_session_id_when_available(self, tmp_path): |
| with patch("tools.skills_tool.SKILLS_DIR", tmp_path): |
| _make_skill( |
| tmp_path, |
| "sess-templated", |
| body="Session: ${HERMES_SESSION_ID}", |
| ) |
| scan_skill_commands() |
| msg = build_skill_invocation_message( |
| "/sess-templated", task_id="abc-123" |
| ) |
|
|
| assert msg is not None |
| assert "Session: abc-123" in msg |
|
|
| def test_leaves_session_id_token_when_missing(self, tmp_path): |
| with patch("tools.skills_tool.SKILLS_DIR", tmp_path): |
| _make_skill( |
| tmp_path, |
| "sess-missing", |
| body="Session: ${HERMES_SESSION_ID}", |
| ) |
| scan_skill_commands() |
| msg = build_skill_invocation_message("/sess-missing", task_id=None) |
|
|
| assert msg is not None |
| |
| assert "Session: ${HERMES_SESSION_ID}" in msg |
|
|
| def test_disable_template_vars_via_config(self, tmp_path): |
| with ( |
| patch("tools.skills_tool.SKILLS_DIR", tmp_path), |
| patch( |
| "agent.skill_commands._load_skills_config", |
| return_value={"template_vars": False}, |
| ), |
| ): |
| _make_skill( |
| tmp_path, |
| "no-sub", |
| body="Run: node ${HERMES_SKILL_DIR}/scripts/foo.js", |
| ) |
| scan_skill_commands() |
| msg = build_skill_invocation_message("/no-sub") |
|
|
| assert msg is not None |
| |
| assert "${HERMES_SKILL_DIR}/scripts/foo.js" in msg |
|
|
|
|
| class TestInlineShellExpansion: |
| """Inline ``!`cmd`` snippets in SKILL.md run before the agent sees the |
| content — but only when the user has opted in via config.""" |
|
|
| def test_inline_shell_is_off_by_default(self, tmp_path): |
| with patch("tools.skills_tool.SKILLS_DIR", tmp_path): |
| _make_skill( |
| tmp_path, |
| "dyn-default-off", |
| body="Today is !`echo INLINE_RAN`.", |
| ) |
| scan_skill_commands() |
| msg = build_skill_invocation_message("/dyn-default-off") |
|
|
| assert msg is not None |
| |
| assert "!`echo INLINE_RAN`" in msg |
| assert "Today is INLINE_RAN." not in msg |
|
|
| def test_inline_shell_runs_when_enabled(self, tmp_path): |
| with ( |
| patch("tools.skills_tool.SKILLS_DIR", tmp_path), |
| patch( |
| "agent.skill_commands._load_skills_config", |
| return_value={"template_vars": True, "inline_shell": True, |
| "inline_shell_timeout": 5}, |
| ), |
| ): |
| _make_skill( |
| tmp_path, |
| "dyn-on", |
| body="Marker: !`echo INLINE_RAN`.", |
| ) |
| scan_skill_commands() |
| msg = build_skill_invocation_message("/dyn-on") |
|
|
| assert msg is not None |
| assert "Marker: INLINE_RAN." in msg |
| assert "!`echo INLINE_RAN`" not in msg |
|
|
| def test_inline_shell_runs_in_skill_directory(self, tmp_path): |
| """Inline snippets get the skill dir as CWD so relative paths work.""" |
| with ( |
| patch("tools.skills_tool.SKILLS_DIR", tmp_path), |
| patch( |
| "agent.skill_commands._load_skills_config", |
| return_value={"template_vars": True, "inline_shell": True, |
| "inline_shell_timeout": 5}, |
| ), |
| ): |
| skill_dir = _make_skill( |
| tmp_path, |
| "dyn-cwd", |
| body="Here: !`pwd`", |
| ) |
| scan_skill_commands() |
| msg = build_skill_invocation_message("/dyn-cwd") |
|
|
| assert msg is not None |
| assert f"Here: {skill_dir}" in msg |
|
|
| def test_inline_shell_timeout_does_not_break_message(self, tmp_path): |
| with ( |
| patch("tools.skills_tool.SKILLS_DIR", tmp_path), |
| patch( |
| "agent.skill_commands._load_skills_config", |
| return_value={"template_vars": True, "inline_shell": True, |
| "inline_shell_timeout": 1}, |
| ), |
| ): |
| _make_skill( |
| tmp_path, |
| "dyn-slow", |
| body="Slow: !`sleep 5 && printf DYN_MARKER`", |
| ) |
| scan_skill_commands() |
| msg = build_skill_invocation_message("/dyn-slow") |
|
|
| assert msg is not None |
| |
| |
| assert "inline-shell timeout" in msg |
| |
| |
| assert "DYN_MARKER" not in msg.replace("sleep 5 && printf DYN_MARKER", "") |
|
|