mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-25 00:51:20 +00:00
refactor: centralize slash command registry (#1603)
* refactor: centralize slash command registry Replace 7+ scattered command definition sites with a single CommandDef registry in hermes_cli/commands.py. All downstream consumers now derive from this registry: - CLI process_command() resolves aliases via resolve_command() - Gateway _known_commands uses GATEWAY_KNOWN_COMMANDS frozenset - Gateway help text generated by gateway_help_lines() - Telegram BotCommands generated by telegram_bot_commands() - Slack subcommand map generated by slack_subcommand_map() Adding a command or alias is now a one-line change to COMMAND_REGISTRY instead of touching 6+ files. Bugfixes included: - Telegram now registers /rollback, /background (were missing) - Slack now has /voice, /update, /reload-mcp (were missing) - Gateway duplicate 'reasoning' dispatch (dead code) removed - Gateway help text can no longer drift from CLI help Backwards-compatible: COMMANDS and COMMANDS_BY_CATEGORY dicts are rebuilt from the registry, so existing imports work unchanged. * docs: update developer docs for centralized command registry Update AGENTS.md with full 'Slash Command Registry' and 'Adding a Slash Command' sections covering CommandDef fields, registry helpers, and the one-line alias workflow. Also update: - CONTRIBUTING.md: commands.py description - website/docs/reference/slash-commands.md: reference central registry - docs/plans/centralize-command-registry.md: mark COMPLETED - plans/checkpoint-rollback.md: reference new pattern - hermes-agent-dev skill: architecture table * chore: remove stale plan docs
This commit is contained in:
parent
b798062501
commit
46176c8029
14 changed files with 571 additions and 802 deletions
|
|
@ -1,20 +1,20 @@
|
|||
"""Tests for shared slash command definitions and autocomplete."""
|
||||
"""Tests for the central command registry and autocomplete."""
|
||||
|
||||
from prompt_toolkit.completion import CompleteEvent
|
||||
from prompt_toolkit.document import Document
|
||||
|
||||
from hermes_cli.commands import COMMANDS, SlashCommandCompleter
|
||||
|
||||
|
||||
# All commands that must be present in the shared COMMANDS dict.
|
||||
EXPECTED_COMMANDS = {
|
||||
"/help", "/tools", "/toolsets", "/model", "/provider", "/prompt",
|
||||
"/personality", "/clear", "/history", "/new", "/reset", "/retry",
|
||||
"/undo", "/save", "/config", "/cron", "/skills", "/platforms",
|
||||
"/verbose", "/reasoning", "/compress", "/title", "/usage", "/insights", "/paste",
|
||||
"/reload-mcp", "/rollback", "/stop", "/background", "/bg", "/skin", "/voice", "/browser", "/quit",
|
||||
"/plugins",
|
||||
}
|
||||
from hermes_cli.commands import (
|
||||
COMMAND_REGISTRY,
|
||||
COMMANDS,
|
||||
COMMANDS_BY_CATEGORY,
|
||||
CommandDef,
|
||||
GATEWAY_KNOWN_COMMANDS,
|
||||
SlashCommandCompleter,
|
||||
gateway_help_lines,
|
||||
resolve_command,
|
||||
slack_subcommand_map,
|
||||
telegram_bot_commands,
|
||||
)
|
||||
|
||||
|
||||
def _completions(completer: SlashCommandCompleter, text: str):
|
||||
|
|
@ -26,21 +26,200 @@ def _completions(completer: SlashCommandCompleter, text: str):
|
|||
)
|
||||
|
||||
|
||||
class TestCommands:
|
||||
def test_shared_commands_include_cli_specific_entries(self):
|
||||
"""Entries that previously only existed in cli.py are now in the shared dict."""
|
||||
assert COMMANDS["/paste"] == "Check clipboard for an image and attach it"
|
||||
assert COMMANDS["/reload-mcp"] == "Reload MCP servers from config.yaml"
|
||||
# ---------------------------------------------------------------------------
|
||||
# CommandDef registry tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_all_expected_commands_present(self):
|
||||
"""Regression guard — every known command must appear in the shared dict."""
|
||||
assert set(COMMANDS.keys()) == EXPECTED_COMMANDS
|
||||
class TestCommandRegistry:
|
||||
def test_registry_is_nonempty(self):
|
||||
assert len(COMMAND_REGISTRY) > 30
|
||||
|
||||
def test_every_entry_is_commanddef(self):
|
||||
for entry in COMMAND_REGISTRY:
|
||||
assert isinstance(entry, CommandDef), f"Unexpected type: {type(entry)}"
|
||||
|
||||
def test_no_duplicate_canonical_names(self):
|
||||
names = [cmd.name for cmd in COMMAND_REGISTRY]
|
||||
assert len(names) == len(set(names)), f"Duplicate names: {[n for n in names if names.count(n) > 1]}"
|
||||
|
||||
def test_no_alias_collides_with_canonical_name(self):
|
||||
"""An alias must not shadow another command's canonical name."""
|
||||
canonical_names = {cmd.name for cmd in COMMAND_REGISTRY}
|
||||
for cmd in COMMAND_REGISTRY:
|
||||
for alias in cmd.aliases:
|
||||
if alias in canonical_names:
|
||||
# reset -> new is intentional (reset IS an alias for new)
|
||||
target = next(c for c in COMMAND_REGISTRY if c.name == alias)
|
||||
# This should only happen if the alias points to the same entry
|
||||
assert resolve_command(alias).name == cmd.name or alias == cmd.name, \
|
||||
f"Alias '{alias}' of '{cmd.name}' shadows canonical '{target.name}'"
|
||||
|
||||
def test_every_entry_has_valid_category(self):
|
||||
valid_categories = {"Session", "Configuration", "Tools & Skills", "Info", "Exit"}
|
||||
for cmd in COMMAND_REGISTRY:
|
||||
assert cmd.category in valid_categories, f"{cmd.name} has invalid category '{cmd.category}'"
|
||||
|
||||
def test_cli_only_and_gateway_only_are_mutually_exclusive(self):
|
||||
for cmd in COMMAND_REGISTRY:
|
||||
assert not (cmd.cli_only and cmd.gateway_only), \
|
||||
f"{cmd.name} cannot be both cli_only and gateway_only"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# resolve_command tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestResolveCommand:
|
||||
def test_canonical_name_resolves(self):
|
||||
assert resolve_command("help").name == "help"
|
||||
assert resolve_command("background").name == "background"
|
||||
|
||||
def test_alias_resolves_to_canonical(self):
|
||||
assert resolve_command("bg").name == "background"
|
||||
assert resolve_command("reset").name == "new"
|
||||
assert resolve_command("q").name == "quit"
|
||||
assert resolve_command("exit").name == "quit"
|
||||
assert resolve_command("gateway").name == "platforms"
|
||||
assert resolve_command("set-home").name == "sethome"
|
||||
assert resolve_command("reload_mcp").name == "reload-mcp"
|
||||
|
||||
def test_leading_slash_stripped(self):
|
||||
assert resolve_command("/help").name == "help"
|
||||
assert resolve_command("/bg").name == "background"
|
||||
|
||||
def test_unknown_returns_none(self):
|
||||
assert resolve_command("nonexistent") is None
|
||||
assert resolve_command("") is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Derived dicts (backwards compat)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestDerivedDicts:
|
||||
def test_commands_dict_excludes_gateway_only(self):
|
||||
"""gateway_only commands should NOT appear in the CLI COMMANDS dict."""
|
||||
for cmd in COMMAND_REGISTRY:
|
||||
if cmd.gateway_only:
|
||||
assert f"/{cmd.name}" not in COMMANDS, \
|
||||
f"gateway_only command /{cmd.name} should not be in COMMANDS"
|
||||
|
||||
def test_commands_dict_includes_all_cli_commands(self):
|
||||
for cmd in COMMAND_REGISTRY:
|
||||
if not cmd.gateway_only:
|
||||
assert f"/{cmd.name}" in COMMANDS, \
|
||||
f"/{cmd.name} missing from COMMANDS dict"
|
||||
|
||||
def test_commands_dict_includes_aliases(self):
|
||||
assert "/bg" in COMMANDS
|
||||
assert "/reset" in COMMANDS
|
||||
assert "/q" in COMMANDS
|
||||
assert "/exit" in COMMANDS
|
||||
assert "/reload_mcp" in COMMANDS
|
||||
assert "/gateway" in COMMANDS
|
||||
|
||||
def test_commands_by_category_covers_all_categories(self):
|
||||
registry_categories = {cmd.category for cmd in COMMAND_REGISTRY if not cmd.gateway_only}
|
||||
assert set(COMMANDS_BY_CATEGORY.keys()) == registry_categories
|
||||
|
||||
def test_every_command_has_nonempty_description(self):
|
||||
for cmd, desc in COMMANDS.items():
|
||||
assert isinstance(desc, str) and len(desc) > 0, f"{cmd} has empty description"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Gateway helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestGatewayKnownCommands:
|
||||
def test_excludes_cli_only(self):
|
||||
for cmd in COMMAND_REGISTRY:
|
||||
if cmd.cli_only:
|
||||
assert cmd.name not in GATEWAY_KNOWN_COMMANDS, \
|
||||
f"cli_only command '{cmd.name}' should not be in GATEWAY_KNOWN_COMMANDS"
|
||||
|
||||
def test_includes_gateway_commands(self):
|
||||
for cmd in COMMAND_REGISTRY:
|
||||
if not cmd.cli_only:
|
||||
assert cmd.name in GATEWAY_KNOWN_COMMANDS
|
||||
for alias in cmd.aliases:
|
||||
assert alias in GATEWAY_KNOWN_COMMANDS
|
||||
|
||||
def test_bg_alias_in_gateway(self):
|
||||
assert "bg" in GATEWAY_KNOWN_COMMANDS
|
||||
assert "background" in GATEWAY_KNOWN_COMMANDS
|
||||
|
||||
def test_is_frozenset(self):
|
||||
assert isinstance(GATEWAY_KNOWN_COMMANDS, frozenset)
|
||||
|
||||
|
||||
class TestGatewayHelpLines:
|
||||
def test_returns_nonempty_list(self):
|
||||
lines = gateway_help_lines()
|
||||
assert len(lines) > 10
|
||||
|
||||
def test_excludes_cli_only_commands(self):
|
||||
lines = gateway_help_lines()
|
||||
joined = "\n".join(lines)
|
||||
for cmd in COMMAND_REGISTRY:
|
||||
if cmd.cli_only:
|
||||
assert f"`/{cmd.name}" not in joined, \
|
||||
f"cli_only command /{cmd.name} should not be in gateway help"
|
||||
|
||||
def test_includes_alias_note_for_bg(self):
|
||||
lines = gateway_help_lines()
|
||||
bg_line = [l for l in lines if "/background" in l]
|
||||
assert len(bg_line) == 1
|
||||
assert "/bg" in bg_line[0]
|
||||
|
||||
|
||||
class TestTelegramBotCommands:
|
||||
def test_returns_list_of_tuples(self):
|
||||
cmds = telegram_bot_commands()
|
||||
assert len(cmds) > 10
|
||||
for name, desc in cmds:
|
||||
assert isinstance(name, str)
|
||||
assert isinstance(desc, str)
|
||||
|
||||
def test_no_hyphens_in_command_names(self):
|
||||
"""Telegram does not support hyphens in command names."""
|
||||
for name, _ in telegram_bot_commands():
|
||||
assert "-" not in name, f"Telegram command '{name}' contains a hyphen"
|
||||
|
||||
def test_excludes_cli_only(self):
|
||||
names = {name for name, _ in telegram_bot_commands()}
|
||||
for cmd in COMMAND_REGISTRY:
|
||||
if cmd.cli_only:
|
||||
tg_name = cmd.name.replace("-", "_")
|
||||
assert tg_name not in names
|
||||
|
||||
|
||||
class TestSlackSubcommandMap:
|
||||
def test_returns_dict(self):
|
||||
mapping = slack_subcommand_map()
|
||||
assert isinstance(mapping, dict)
|
||||
assert len(mapping) > 10
|
||||
|
||||
def test_values_are_slash_prefixed(self):
|
||||
for key, val in slack_subcommand_map().items():
|
||||
assert val.startswith("/"), f"Slack mapping for '{key}' should start with /"
|
||||
|
||||
def test_includes_aliases(self):
|
||||
mapping = slack_subcommand_map()
|
||||
assert "bg" in mapping
|
||||
assert "reset" in mapping
|
||||
|
||||
def test_excludes_cli_only(self):
|
||||
mapping = slack_subcommand_map()
|
||||
for cmd in COMMAND_REGISTRY:
|
||||
if cmd.cli_only:
|
||||
assert cmd.name not in mapping
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Autocomplete (SlashCommandCompleter)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestSlashCommandCompleter:
|
||||
# -- basic prefix completion -----------------------------------------
|
||||
|
||||
|
|
@ -55,7 +234,7 @@ class TestSlashCommandCompleter:
|
|||
def test_builtin_completion_display_meta_shows_description(self):
|
||||
completions = _completions(SlashCommandCompleter(), "/help")
|
||||
assert len(completions) == 1
|
||||
assert completions[0].display_meta_text == "Show this help message"
|
||||
assert completions[0].display_meta_text == "Show available commands"
|
||||
|
||||
# -- exact-match trailing space --------------------------------------
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue