* feat: config-gated /verbose command for messaging gateway Add gateway_config_gate field to CommandDef, allowing cli_only commands to be conditionally available in the gateway based on a config value. - CommandDef gains gateway_config_gate: str | None — a config dotpath that, when truthy, overrides cli_only for gateway surfaces - /verbose uses gateway_config_gate='display.tool_progress_command' - Default is off (cli_only behavior preserved) - When enabled, /verbose cycles tool_progress mode (off/new/all/verbose) in the gateway, saving to config.yaml — same cycle as the CLI - Gateway helpers (help, telegram menus, slack mapping) dynamically check config to include/exclude config-gated commands - GATEWAY_KNOWN_COMMANDS always includes config-gated commands so the gateway recognizes them and can respond appropriately - Handles YAML 1.1 bool coercion (bare 'off' parses as False) - 8 new tests for the config gate mechanism + gateway handler * docs: document gateway_config_gate and /verbose messaging support - AGENTS.md: add gateway_config_gate to CommandDef fields - slash-commands.md: note /verbose can be enabled for messaging, update Notes - configuration.md: add tool_progress_command to display section + usage note - cli.md: cross-link to config docs for messaging enablement - messaging/index.md: show tool_progress_command in config snippet - plugins.md: add gateway_config_gate to register_command parameter table
507 lines
20 KiB
Python
507 lines
20 KiB
Python
"""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 (
|
|
COMMAND_REGISTRY,
|
|
COMMANDS,
|
|
COMMANDS_BY_CATEGORY,
|
|
CommandDef,
|
|
GATEWAY_KNOWN_COMMANDS,
|
|
SUBCOMMANDS,
|
|
SlashCommandAutoSuggest,
|
|
SlashCommandCompleter,
|
|
gateway_help_lines,
|
|
resolve_command,
|
|
slack_subcommand_map,
|
|
telegram_bot_commands,
|
|
)
|
|
|
|
|
|
def _completions(completer: SlashCommandCompleter, text: str):
|
|
return list(
|
|
completer.get_completions(
|
|
Document(text=text),
|
|
CompleteEvent(completion_requested=True),
|
|
)
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# CommandDef registry tests
|
|
# ---------------------------------------------------------------------------
|
|
|
|
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_without_config_gate(self):
|
|
for cmd in COMMAND_REGISTRY:
|
|
if cmd.cli_only and not cmd.gateway_config_gate:
|
|
assert cmd.name not in GATEWAY_KNOWN_COMMANDS, \
|
|
f"cli_only command '{cmd.name}' should not be in GATEWAY_KNOWN_COMMANDS"
|
|
|
|
def test_includes_config_gated_cli_only(self):
|
|
"""Commands with gateway_config_gate are always in GATEWAY_KNOWN_COMMANDS."""
|
|
for cmd in COMMAND_REGISTRY:
|
|
if cmd.gateway_config_gate:
|
|
assert cmd.name in GATEWAY_KNOWN_COMMANDS, \
|
|
f"config-gated command '{cmd.name}' should 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_without_config_gate(self):
|
|
lines = gateway_help_lines()
|
|
joined = "\n".join(lines)
|
|
for cmd in COMMAND_REGISTRY:
|
|
if cmd.cli_only and not cmd.gateway_config_gate:
|
|
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_without_config_gate(self):
|
|
names = {name for name, _ in telegram_bot_commands()}
|
|
for cmd in COMMAND_REGISTRY:
|
|
if cmd.cli_only and not cmd.gateway_config_gate:
|
|
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_without_config_gate(self):
|
|
mapping = slack_subcommand_map()
|
|
for cmd in COMMAND_REGISTRY:
|
|
if cmd.cli_only and not cmd.gateway_config_gate:
|
|
assert cmd.name not in mapping
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Config-gated gateway commands
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestGatewayConfigGate:
|
|
"""Tests for the gateway_config_gate mechanism on CommandDef."""
|
|
|
|
def test_verbose_has_config_gate(self):
|
|
cmd = resolve_command("verbose")
|
|
assert cmd is not None
|
|
assert cmd.cli_only is True
|
|
assert cmd.gateway_config_gate == "display.tool_progress_command"
|
|
|
|
def test_verbose_in_gateway_known_commands(self):
|
|
"""Config-gated commands are always recognized by the gateway."""
|
|
assert "verbose" in GATEWAY_KNOWN_COMMANDS
|
|
|
|
def test_config_gate_excluded_from_help_when_off(self, tmp_path, monkeypatch):
|
|
"""When the config gate is falsy, the command should not appear in help."""
|
|
# Write a config with the gate off (default)
|
|
config_file = tmp_path / "config.yaml"
|
|
config_file.write_text("display:\n tool_progress_command: false\n")
|
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
|
|
|
lines = gateway_help_lines()
|
|
joined = "\n".join(lines)
|
|
assert "`/verbose" not in joined
|
|
|
|
def test_config_gate_included_in_help_when_on(self, tmp_path, monkeypatch):
|
|
"""When the config gate is truthy, the command should appear in help."""
|
|
config_file = tmp_path / "config.yaml"
|
|
config_file.write_text("display:\n tool_progress_command: true\n")
|
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
|
|
|
lines = gateway_help_lines()
|
|
joined = "\n".join(lines)
|
|
assert "`/verbose" in joined
|
|
|
|
def test_config_gate_excluded_from_telegram_when_off(self, tmp_path, monkeypatch):
|
|
config_file = tmp_path / "config.yaml"
|
|
config_file.write_text("display:\n tool_progress_command: false\n")
|
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
|
|
|
names = {name for name, _ in telegram_bot_commands()}
|
|
assert "verbose" not in names
|
|
|
|
def test_config_gate_included_in_telegram_when_on(self, tmp_path, monkeypatch):
|
|
config_file = tmp_path / "config.yaml"
|
|
config_file.write_text("display:\n tool_progress_command: true\n")
|
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
|
|
|
names = {name for name, _ in telegram_bot_commands()}
|
|
assert "verbose" in names
|
|
|
|
def test_config_gate_excluded_from_slack_when_off(self, tmp_path, monkeypatch):
|
|
config_file = tmp_path / "config.yaml"
|
|
config_file.write_text("display:\n tool_progress_command: false\n")
|
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
|
|
|
mapping = slack_subcommand_map()
|
|
assert "verbose" not in mapping
|
|
|
|
def test_config_gate_included_in_slack_when_on(self, tmp_path, monkeypatch):
|
|
config_file = tmp_path / "config.yaml"
|
|
config_file.write_text("display:\n tool_progress_command: true\n")
|
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
|
|
|
mapping = slack_subcommand_map()
|
|
assert "verbose" in mapping
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Autocomplete (SlashCommandCompleter)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestSlashCommandCompleter:
|
|
# -- basic prefix completion -----------------------------------------
|
|
|
|
def test_builtin_prefix_completion_uses_shared_registry(self):
|
|
completions = _completions(SlashCommandCompleter(), "/re")
|
|
texts = {item.text for item in completions}
|
|
|
|
assert "reset" in texts
|
|
assert "retry" in texts
|
|
assert "reload-mcp" in texts
|
|
|
|
def test_builtin_completion_display_meta_shows_description(self):
|
|
completions = _completions(SlashCommandCompleter(), "/help")
|
|
assert len(completions) == 1
|
|
assert completions[0].display_meta_text == "Show available commands"
|
|
|
|
# -- exact-match trailing space --------------------------------------
|
|
|
|
def test_exact_match_completion_adds_trailing_space(self):
|
|
completions = _completions(SlashCommandCompleter(), "/help")
|
|
|
|
assert [item.text for item in completions] == ["help "]
|
|
|
|
def test_partial_match_does_not_add_trailing_space(self):
|
|
completions = _completions(SlashCommandCompleter(), "/hel")
|
|
|
|
assert [item.text for item in completions] == ["help"]
|
|
|
|
# -- non-slash input returns nothing ---------------------------------
|
|
|
|
def test_no_completions_for_non_slash_input(self):
|
|
assert _completions(SlashCommandCompleter(), "help") == []
|
|
|
|
def test_no_completions_for_empty_input(self):
|
|
assert _completions(SlashCommandCompleter(), "") == []
|
|
|
|
# -- skill commands via provider ------------------------------------
|
|
|
|
def test_skill_commands_are_completed_from_provider(self):
|
|
completer = SlashCommandCompleter(
|
|
skill_commands_provider=lambda: {
|
|
"/gif-search": {"description": "Search for GIFs across providers"},
|
|
}
|
|
)
|
|
|
|
completions = _completions(completer, "/gif")
|
|
|
|
assert len(completions) == 1
|
|
assert completions[0].text == "gif-search"
|
|
assert completions[0].display_text == "/gif-search"
|
|
assert completions[0].display_meta_text == "⚡ Search for GIFs across providers"
|
|
|
|
def test_skill_exact_match_adds_trailing_space(self):
|
|
completer = SlashCommandCompleter(
|
|
skill_commands_provider=lambda: {
|
|
"/gif-search": {"description": "Search for GIFs"},
|
|
}
|
|
)
|
|
|
|
completions = _completions(completer, "/gif-search")
|
|
|
|
assert len(completions) == 1
|
|
assert completions[0].text == "gif-search "
|
|
|
|
def test_no_skill_provider_means_no_skill_completions(self):
|
|
"""Default (None) provider should not blow up or add completions."""
|
|
completer = SlashCommandCompleter()
|
|
completions = _completions(completer, "/gif")
|
|
# /gif doesn't match any builtin command
|
|
assert completions == []
|
|
|
|
def test_skill_provider_exception_is_swallowed(self):
|
|
"""A broken provider should not crash autocomplete."""
|
|
completer = SlashCommandCompleter(
|
|
skill_commands_provider=lambda: (_ for _ in ()).throw(RuntimeError("boom")),
|
|
)
|
|
# Should return builtin matches only, no crash
|
|
completions = _completions(completer, "/he")
|
|
texts = {item.text for item in completions}
|
|
assert "help" in texts
|
|
|
|
def test_skill_description_truncated_at_50_chars(self):
|
|
long_desc = "A" * 80
|
|
completer = SlashCommandCompleter(
|
|
skill_commands_provider=lambda: {
|
|
"/long-skill": {"description": long_desc},
|
|
}
|
|
)
|
|
completions = _completions(completer, "/long")
|
|
assert len(completions) == 1
|
|
meta = completions[0].display_meta_text
|
|
# "⚡ " prefix + 50 chars + "..."
|
|
assert meta == f"⚡ {'A' * 50}..."
|
|
|
|
def test_skill_missing_description_uses_fallback(self):
|
|
completer = SlashCommandCompleter(
|
|
skill_commands_provider=lambda: {
|
|
"/no-desc": {},
|
|
}
|
|
)
|
|
completions = _completions(completer, "/no-desc")
|
|
assert len(completions) == 1
|
|
assert "Skill command" in completions[0].display_meta_text
|
|
|
|
|
|
# ── SUBCOMMANDS extraction ──────────────────────────────────────────────
|
|
|
|
|
|
class TestSubcommands:
|
|
def test_explicit_subcommands_extracted(self):
|
|
"""Commands with explicit subcommands on CommandDef are extracted."""
|
|
assert "/prompt" in SUBCOMMANDS
|
|
assert "clear" in SUBCOMMANDS["/prompt"]
|
|
|
|
def test_reasoning_has_subcommands(self):
|
|
assert "/reasoning" in SUBCOMMANDS
|
|
subs = SUBCOMMANDS["/reasoning"]
|
|
assert "high" in subs
|
|
assert "show" in subs
|
|
assert "hide" in subs
|
|
|
|
def test_voice_has_subcommands(self):
|
|
assert "/voice" in SUBCOMMANDS
|
|
assert "on" in SUBCOMMANDS["/voice"]
|
|
assert "off" in SUBCOMMANDS["/voice"]
|
|
|
|
def test_cron_has_subcommands(self):
|
|
assert "/cron" in SUBCOMMANDS
|
|
assert "list" in SUBCOMMANDS["/cron"]
|
|
assert "add" in SUBCOMMANDS["/cron"]
|
|
|
|
def test_commands_without_subcommands_not_in_dict(self):
|
|
"""Plain commands should not appear in SUBCOMMANDS."""
|
|
assert "/help" not in SUBCOMMANDS
|
|
assert "/quit" not in SUBCOMMANDS
|
|
assert "/clear" not in SUBCOMMANDS
|
|
|
|
|
|
# ── Subcommand tab completion ───────────────────────────────────────────
|
|
|
|
|
|
class TestSubcommandCompletion:
|
|
def test_subcommand_completion_after_space(self):
|
|
"""Typing '/reasoning ' then Tab should show subcommands."""
|
|
completions = _completions(SlashCommandCompleter(), "/reasoning ")
|
|
texts = {c.text for c in completions}
|
|
assert "high" in texts
|
|
assert "show" in texts
|
|
|
|
def test_subcommand_prefix_filters(self):
|
|
"""Typing '/reasoning sh' should only show 'show'."""
|
|
completions = _completions(SlashCommandCompleter(), "/reasoning sh")
|
|
texts = {c.text for c in completions}
|
|
assert texts == {"show"}
|
|
|
|
def test_subcommand_exact_match_suppressed(self):
|
|
"""Typing the full subcommand shouldn't re-suggest it."""
|
|
completions = _completions(SlashCommandCompleter(), "/reasoning show")
|
|
texts = {c.text for c in completions}
|
|
assert "show" not in texts
|
|
|
|
def test_no_subcommands_for_plain_command(self):
|
|
"""Commands without subcommands yield nothing after space."""
|
|
completions = _completions(SlashCommandCompleter(), "/help ")
|
|
assert completions == []
|
|
|
|
|
|
# ── Ghost text (SlashCommandAutoSuggest) ────────────────────────────────
|
|
|
|
|
|
def _suggestion(text: str, completer=None) -> str | None:
|
|
"""Get ghost text suggestion for given input."""
|
|
suggest = SlashCommandAutoSuggest(completer=completer)
|
|
doc = Document(text=text)
|
|
|
|
class FakeBuffer:
|
|
pass
|
|
|
|
result = suggest.get_suggestion(FakeBuffer(), doc)
|
|
return result.text if result else None
|
|
|
|
|
|
class TestGhostText:
|
|
def test_command_name_suggestion(self):
|
|
"""/he → 'lp'"""
|
|
assert _suggestion("/he") == "lp"
|
|
|
|
def test_command_name_suggestion_reasoning(self):
|
|
"""/rea → 'soning'"""
|
|
assert _suggestion("/rea") == "soning"
|
|
|
|
def test_no_suggestion_for_complete_command(self):
|
|
assert _suggestion("/help") is None
|
|
|
|
def test_subcommand_suggestion(self):
|
|
"""/reasoning h → 'igh'"""
|
|
assert _suggestion("/reasoning h") == "igh"
|
|
|
|
def test_subcommand_suggestion_show(self):
|
|
"""/reasoning sh → 'ow'"""
|
|
assert _suggestion("/reasoning sh") == "ow"
|
|
|
|
def test_no_suggestion_for_non_slash(self):
|
|
assert _suggestion("hello") is None
|