Files
hermes-agent/tests/gateway/test_bundles_command.py
Teknium b5c1fe78aa feat(skills): add skill bundles — alias /<name> loads multiple skills (#28373)
Skill bundles are tiny YAML files in ~/.hermes/skill-bundles/ that
group several skills under one slash command. Invoking /<bundle-name>
from any surface (CLI, TUI, dashboard, any gateway platform) loads
every referenced skill into a single combined user message.

Use cases:
- /backend-dev → loads github-code-review + test-driven-development
  + github-pr-workflow as one bundle.
- /research → loads several research skills together.
- Team task profiles shared via dotfiles.

Behavior:
- Bundles take precedence over individual skills when slugs collide.
- Missing skills are skipped with a note, not fatal.
- No system-prompt mutation — bundles generate a fresh user message
  at invocation time, the same way /<skill> does. Prompt cache stays
  intact.
- Works in CLI dispatch, gateway dispatch, autocomplete (CLI + TUI),
  /help display.

Schema (~/.hermes/skill-bundles/<slug>.yaml):
    name: backend-dev
    description: Backend feature work.
    skills:
      - github-code-review
      - test-driven-development
    instruction: |
      Optional extra guidance prepended to the loaded skills.

New module: agent/skill_bundles.py — load, scan, resolve, build
invocation message, save, delete. yaml.safe_load only; broken
bundles log a warning and are skipped, never raise.

New CLI subcommand: hermes bundles {list,show,create,delete,reload}.
Implementation in hermes_cli/bundles.py; wired in hermes_cli/main.py.
'bundles' added to _BUILTIN_SUBCOMMANDS so plugin discovery skips it.

New in-session slash command: /bundles lists installed bundles in
both CLI and gateway. /<bundle-name> dispatch added to CLI (cli.py)
and gateway (gateway/run.py) before the existing /<skill-name> path.

Autocomplete: SlashCommandCompleter gained an optional
skill_bundles_provider parameter that defaults to None — the prompt
shows '▣ <description> (N skills)' for bundles vs '' for skills.

Tests:
- tests/agent/test_skill_bundles.py — 33 tests covering slugify,
  scan/cache freshness, resolve (including underscore→hyphen
  Telegram alias), build_bundle_invocation_message (loading, missing
  skills, user/bundle instruction injection, dedup), save/delete,
  reload diff, list sort.
- tests/hermes_cli/test_bundles.py — 8 tests for the CLI
  subcommand (create/list/show/delete/reload, --force, missing
  bundle errors).
- tests/gateway/test_bundles_command.py — 4 tests for the gateway
  handler and bundle resolution priority.

Live E2E: verified subprocess invocations of hermes bundles
{list,create,show,reload,delete} round-trip correctly against an
isolated HERMES_HOME.

Docs:
- website/docs/user-guide/features/skills.md — new 'Skill Bundles'
  section with quick example, YAML schema, management commands,
  behavior notes.
- website/docs/reference/cli-commands.md — 'hermes bundles' added to
  the top-level command table and given its own subcommand section.
2026-05-18 21:38:05 -07:00

116 lines
3.8 KiB
Python

"""Tests for the ``/bundles`` gateway slash command handler.
Verifies that:
- ``_handle_bundles_command`` returns useful text when no bundles are
installed and when several are.
- Bundle dispatch in ``_handle_message`` rewrites ``event.text`` to the
combined skill content when the user types ``/<bundle-slug>``.
The actual ``/<bundle-slug>`` → combined-message build is tested in
``tests/agent/test_skill_bundles.py``; this file only checks the gateway
glue (handler wiring, dispatch ordering, event.text rewrite).
"""
import asyncio
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock
import pytest
from gateway.config import GatewayConfig, Platform, PlatformConfig
from gateway.platforms.base import MessageEvent
from gateway.session import SessionSource
def _make_source() -> SessionSource:
return SessionSource(
platform=Platform.TELEGRAM,
user_id="u1",
chat_id="c1",
user_name="tester",
chat_type="dm",
)
def _make_event(text: str) -> MessageEvent:
return MessageEvent(text=text, source=_make_source(), message_id="m1")
def _make_runner():
from gateway.run import GatewayRunner
runner = object.__new__(GatewayRunner)
runner.config = GatewayConfig(
platforms={Platform.TELEGRAM: PlatformConfig(enabled=True, token="***")}
)
adapter = MagicMock()
adapter.send = AsyncMock()
runner.adapters = {Platform.TELEGRAM: adapter}
runner.hooks = SimpleNamespace(
emit=AsyncMock(),
emit_collect=AsyncMock(return_value=[]),
loaded_hooks=False,
)
return runner
@pytest.fixture
def bundles_env(tmp_path, monkeypatch):
bundles_dir = tmp_path / "skill-bundles"
skills_dir = tmp_path / "skills"
skills_dir.mkdir()
monkeypatch.setenv("HERMES_BUNDLES_DIR", str(bundles_dir))
import tools.skills_tool as skills_tool_module
monkeypatch.setattr(skills_tool_module, "SKILLS_DIR", skills_dir)
import agent.skill_bundles as mod
mod._bundles_cache = {}
mod._bundles_cache_mtime = None
return bundles_dir, skills_dir
def _make_skill(skills_dir, name, body="content"):
sd = skills_dir / name
sd.mkdir(parents=True, exist_ok=True)
(sd / "SKILL.md").write_text(
f"---\nname: {name}\ndescription: desc {name}\n---\n\n# {name}\n\n{body}\n"
)
def _make_bundle(bundles_dir, slug, skills):
bundles_dir.mkdir(parents=True, exist_ok=True)
(bundles_dir / f"{slug}.yaml").write_text(
f"name: {slug}\nskills:\n" + "\n".join(f" - {s}" for s in skills) + "\n"
)
class TestHandleBundlesCommand:
def test_empty(self, bundles_env):
runner = _make_runner()
result = asyncio.run(runner._handle_bundles_command(_make_event("/bundles")))
assert "No skill bundles" in result
def test_with_bundles(self, bundles_env):
bundles_dir, _ = bundles_env
_make_bundle(bundles_dir, "research", ["alpha", "beta"])
runner = _make_runner()
result = asyncio.run(runner._handle_bundles_command(_make_event("/bundles")))
assert "research" in result
assert "/research" in result
assert "2 skills" in result
class TestBundleResolutionPriority:
"""Verify resolve_bundle_command_key picks bundles over skills."""
def test_bundle_resolves(self, bundles_env):
bundles_dir, _ = bundles_env
_make_bundle(bundles_dir, "research", ["alpha"])
from agent.skill_bundles import resolve_bundle_command_key
assert resolve_bundle_command_key("research") == "/research"
def test_underscore_alias(self, bundles_env):
bundles_dir, _ = bundles_env
_make_bundle(bundles_dir, "my-bundle", ["alpha"])
from agent.skill_bundles import resolve_bundle_command_key
assert resolve_bundle_command_key("my_bundle") == "/my-bundle"