/ tests / hermes_cli / test_commands.py
test_commands.py
   1  """Tests for the central command registry and autocomplete."""
   2  
   3  from prompt_toolkit.completion import CompleteEvent
   4  from prompt_toolkit.document import Document
   5  
   6  from hermes_cli.commands import (
   7      COMMAND_REGISTRY,
   8      COMMANDS,
   9      COMMANDS_BY_CATEGORY,
  10      CommandDef,
  11      GATEWAY_KNOWN_COMMANDS,
  12      SUBCOMMANDS,
  13      SlashCommandAutoSuggest,
  14      SlashCommandCompleter,
  15      _CMD_NAME_LIMIT,
  16      _SLACK_RESERVED_COMMANDS,
  17      _TG_NAME_LIMIT,
  18      _clamp_command_names,
  19      _clamp_telegram_names,
  20      _sanitize_telegram_name,
  21      discord_skill_commands,
  22      gateway_help_lines,
  23      resolve_command,
  24      slack_app_manifest,
  25      slack_native_slashes,
  26      slack_subcommand_map,
  27      telegram_bot_commands,
  28      telegram_menu_commands,
  29  )
  30  
  31  
  32  def _completions(completer: SlashCommandCompleter, text: str):
  33      return list(
  34          completer.get_completions(
  35              Document(text=text),
  36              CompleteEvent(completion_requested=True),
  37          )
  38      )
  39  
  40  
  41  # ---------------------------------------------------------------------------
  42  # CommandDef registry tests
  43  # ---------------------------------------------------------------------------
  44  
  45  class TestCommandRegistry:
  46      def test_registry_is_nonempty(self):
  47          assert len(COMMAND_REGISTRY) > 30
  48  
  49      def test_every_entry_is_commanddef(self):
  50          for entry in COMMAND_REGISTRY:
  51              assert isinstance(entry, CommandDef), f"Unexpected type: {type(entry)}"
  52  
  53      def test_no_duplicate_canonical_names(self):
  54          names = [cmd.name for cmd in COMMAND_REGISTRY]
  55          assert len(names) == len(set(names)), f"Duplicate names: {[n for n in names if names.count(n) > 1]}"
  56  
  57      def test_no_alias_collides_with_canonical_name(self):
  58          """An alias must not shadow another command's canonical name."""
  59          canonical_names = {cmd.name for cmd in COMMAND_REGISTRY}
  60          for cmd in COMMAND_REGISTRY:
  61              for alias in cmd.aliases:
  62                  if alias in canonical_names:
  63                      # reset -> new is intentional (reset IS an alias for new)
  64                      target = next(c for c in COMMAND_REGISTRY if c.name == alias)
  65                      # This should only happen if the alias points to the same entry
  66                      assert resolve_command(alias).name == cmd.name or alias == cmd.name, \
  67                          f"Alias '{alias}' of '{cmd.name}' shadows canonical '{target.name}'"
  68  
  69      def test_every_entry_has_valid_category(self):
  70          valid_categories = {"Session", "Configuration", "Tools & Skills", "Info", "Exit"}
  71          for cmd in COMMAND_REGISTRY:
  72              assert cmd.category in valid_categories, f"{cmd.name} has invalid category '{cmd.category}'"
  73  
  74      def test_reasoning_subcommands_are_in_logical_order(self):
  75          reasoning = next(cmd for cmd in COMMAND_REGISTRY if cmd.name == "reasoning")
  76          assert reasoning.subcommands[:6] == (
  77              "none",
  78              "minimal",
  79              "low",
  80              "medium",
  81              "high",
  82              "xhigh",
  83          )
  84  
  85      def test_cli_only_and_gateway_only_are_mutually_exclusive(self):
  86          for cmd in COMMAND_REGISTRY:
  87              assert not (cmd.cli_only and cmd.gateway_only), \
  88                  f"{cmd.name} cannot be both cli_only and gateway_only"
  89  
  90  
  91  # ---------------------------------------------------------------------------
  92  # resolve_command tests
  93  # ---------------------------------------------------------------------------
  94  
  95  class TestResolveCommand:
  96      def test_canonical_name_resolves(self):
  97          assert resolve_command("help").name == "help"
  98          assert resolve_command("background").name == "background"
  99          assert resolve_command("copy").name == "copy"
 100          assert resolve_command("agents").name == "agents"
 101  
 102      def test_alias_resolves_to_canonical(self):
 103          assert resolve_command("bg").name == "background"
 104          assert resolve_command("reset").name == "new"
 105          assert resolve_command("q").name == "queue"
 106          assert resolve_command("exit").name == "quit"
 107          assert resolve_command("gateway").name == "platforms"
 108          assert resolve_command("set-home").name == "sethome"
 109          assert resolve_command("reload_mcp").name == "reload-mcp"
 110          assert resolve_command("tasks").name == "agents"
 111  
 112      def test_leading_slash_stripped(self):
 113          assert resolve_command("/help").name == "help"
 114          assert resolve_command("/bg").name == "background"
 115  
 116      def test_unknown_returns_none(self):
 117          assert resolve_command("nonexistent") is None
 118          assert resolve_command("") is None
 119  
 120  
 121  # ---------------------------------------------------------------------------
 122  # Derived dicts (backwards compat)
 123  # ---------------------------------------------------------------------------
 124  
 125  class TestDerivedDicts:
 126      def test_commands_dict_excludes_gateway_only(self):
 127          """gateway_only commands should NOT appear in the CLI COMMANDS dict."""
 128          for cmd in COMMAND_REGISTRY:
 129              if cmd.gateway_only:
 130                  assert f"/{cmd.name}" not in COMMANDS, \
 131                      f"gateway_only command /{cmd.name} should not be in COMMANDS"
 132  
 133      def test_commands_dict_includes_all_cli_commands(self):
 134          for cmd in COMMAND_REGISTRY:
 135              if not cmd.gateway_only:
 136                  assert f"/{cmd.name}" in COMMANDS, \
 137                      f"/{cmd.name} missing from COMMANDS dict"
 138  
 139      def test_commands_dict_includes_aliases(self):
 140          assert "/bg" in COMMANDS
 141          assert "/reset" in COMMANDS
 142          assert "/q" in COMMANDS
 143          assert "/exit" in COMMANDS
 144          assert "/reload_mcp" in COMMANDS
 145          assert "/gateway" in COMMANDS
 146  
 147      def test_commands_by_category_covers_all_categories(self):
 148          registry_categories = {cmd.category for cmd in COMMAND_REGISTRY if not cmd.gateway_only}
 149          assert set(COMMANDS_BY_CATEGORY.keys()) == registry_categories
 150  
 151      def test_every_command_has_nonempty_description(self):
 152          for cmd, desc in COMMANDS.items():
 153              assert isinstance(desc, str) and len(desc) > 0, f"{cmd} has empty description"
 154  
 155  
 156  # ---------------------------------------------------------------------------
 157  # Gateway helpers
 158  # ---------------------------------------------------------------------------
 159  
 160  class TestGatewayKnownCommands:
 161      def test_excludes_cli_only_without_config_gate(self):
 162          for cmd in COMMAND_REGISTRY:
 163              if cmd.cli_only and not cmd.gateway_config_gate:
 164                  assert cmd.name not in GATEWAY_KNOWN_COMMANDS, \
 165                      f"cli_only command '{cmd.name}' should not be in GATEWAY_KNOWN_COMMANDS"
 166  
 167      def test_includes_config_gated_cli_only(self):
 168          """Commands with gateway_config_gate are always in GATEWAY_KNOWN_COMMANDS."""
 169          for cmd in COMMAND_REGISTRY:
 170              if cmd.gateway_config_gate:
 171                  assert cmd.name in GATEWAY_KNOWN_COMMANDS, \
 172                      f"config-gated command '{cmd.name}' should be in GATEWAY_KNOWN_COMMANDS"
 173  
 174      def test_includes_gateway_commands(self):
 175          for cmd in COMMAND_REGISTRY:
 176              if not cmd.cli_only:
 177                  assert cmd.name in GATEWAY_KNOWN_COMMANDS
 178                  for alias in cmd.aliases:
 179                      assert alias in GATEWAY_KNOWN_COMMANDS
 180  
 181      def test_bg_alias_in_gateway(self):
 182          assert "bg" in GATEWAY_KNOWN_COMMANDS
 183          assert "background" in GATEWAY_KNOWN_COMMANDS
 184  
 185      def test_is_frozenset(self):
 186          assert isinstance(GATEWAY_KNOWN_COMMANDS, frozenset)
 187  
 188  
 189  class TestGatewayHelpLines:
 190      def test_returns_nonempty_list(self):
 191          lines = gateway_help_lines()
 192          assert len(lines) > 10
 193  
 194      def test_excludes_cli_only_commands_without_config_gate(self):
 195          import re
 196          lines = gateway_help_lines()
 197          joined = "\n".join(lines)
 198          for cmd in COMMAND_REGISTRY:
 199              if cmd.cli_only and not cmd.gateway_config_gate:
 200                  # Word-boundary match so `/reload` doesn't match `/reload-mcp`
 201                  pattern = rf'`/{re.escape(cmd.name)}(?![-_\w])'
 202                  assert not re.search(pattern, joined), \
 203                      f"cli_only command /{cmd.name} should not be in gateway help"
 204  
 205      def test_includes_alias_note_for_bg(self):
 206          lines = gateway_help_lines()
 207          bg_line = [l for l in lines if "/background" in l]
 208          assert len(bg_line) == 1
 209          assert "/bg" in bg_line[0]
 210  
 211  
 212  class TestTelegramBotCommands:
 213      def test_returns_list_of_tuples(self):
 214          cmds = telegram_bot_commands()
 215          assert len(cmds) > 10
 216          for name, desc in cmds:
 217              assert isinstance(name, str)
 218              assert isinstance(desc, str)
 219  
 220      def test_no_hyphens_in_command_names(self):
 221          """Telegram does not support hyphens in command names."""
 222          for name, _ in telegram_bot_commands():
 223              assert "-" not in name, f"Telegram command '{name}' contains a hyphen"
 224  
 225      def test_all_names_valid_telegram_chars(self):
 226          """Telegram requires: lowercase a-z, 0-9, underscores only."""
 227          import re
 228          tg_valid = re.compile(r"^[a-z0-9_]+$")
 229          for name, _ in telegram_bot_commands():
 230              assert tg_valid.match(name), f"Invalid Telegram command name: {name!r}"
 231  
 232      def test_excludes_cli_only_without_config_gate(self):
 233          names = {name for name, _ in telegram_bot_commands()}
 234          for cmd in COMMAND_REGISTRY:
 235              if cmd.cli_only and not cmd.gateway_config_gate:
 236                  tg_name = cmd.name.replace("-", "_")
 237                  assert tg_name not in names
 238  
 239      def test_excludes_commands_with_required_args(self):
 240          names = {name for name, _ in telegram_bot_commands()}
 241          assert "background" not in names
 242          assert "queue" not in names
 243          assert "steer" not in names
 244          assert "background" in GATEWAY_KNOWN_COMMANDS
 245  
 246  
 247  class TestSlackSubcommandMap:
 248      def test_returns_dict(self):
 249          mapping = slack_subcommand_map()
 250          assert isinstance(mapping, dict)
 251          assert len(mapping) > 10
 252  
 253      def test_values_are_slash_prefixed(self):
 254          for key, val in slack_subcommand_map().items():
 255              assert val.startswith("/"), f"Slack mapping for '{key}' should start with /"
 256  
 257      def test_includes_aliases(self):
 258          mapping = slack_subcommand_map()
 259          assert "bg" in mapping
 260          assert "reset" in mapping
 261  
 262      def test_excludes_cli_only_without_config_gate(self):
 263          mapping = slack_subcommand_map()
 264          for cmd in COMMAND_REGISTRY:
 265              if cmd.cli_only and not cmd.gateway_config_gate:
 266                  assert cmd.name not in mapping
 267  
 268  
 269  class TestSlackNativeSlashes:
 270      """Slack native slash command generation — used to register every
 271      COMMAND_REGISTRY entry as a first-class Slack slash, matching Discord
 272      and Telegram."""
 273  
 274      def test_returns_triples(self):
 275          slashes = slack_native_slashes()
 276          assert len(slashes) >= 10
 277          for entry in slashes:
 278              assert isinstance(entry, tuple) and len(entry) == 3
 279              name, desc, hint = entry
 280              assert isinstance(name, str) and name
 281              assert isinstance(desc, str)
 282              assert isinstance(hint, str)
 283  
 284      def test_hermes_catchall_is_first(self):
 285          """``/hermes`` must be reserved as the first slot so the legacy
 286          ``/hermes <subcommand>`` form keeps working after we add new
 287          commands and hit the 50-slash cap."""
 288          slashes = slack_native_slashes()
 289          assert slashes[0][0] == "hermes"
 290  
 291      def test_names_respect_slack_limits(self):
 292          for name, _desc, _hint in slack_native_slashes():
 293              # Slack: lowercase a-z, 0-9, hyphens, underscores; max 32 chars
 294              assert len(name) <= 32, f"slash {name!r} exceeds 32 chars"
 295              assert name == name.lower()
 296              for ch in name:
 297                  assert ch.isalnum() or ch in "-_", f"invalid char {ch!r} in {name!r}"
 298  
 299      def test_under_fifty_command_cap(self):
 300          """Slack allows at most 50 slash commands per app."""
 301          assert len(slack_native_slashes()) <= 50
 302  
 303      def test_unique_names(self):
 304          names = [n for n, _d, _h in slack_native_slashes()]
 305          assert len(names) == len(set(names)), "duplicate Slack slash names"
 306  
 307      def test_includes_canonical_commands(self):
 308          names = {n for n, _d, _h in slack_native_slashes()}
 309          # Sample of gateway-available canonical commands
 310          for expected in ("new", "stop", "background", "model", "help"):
 311              assert expected in names, f"missing canonical /{expected}"
 312  
 313      def test_excludes_slack_reserved_commands(self):
 314          """Slack built-in commands (e.g. /status, /me, /join) cannot be
 315          registered by apps and must be excluded from the manifest.
 316          Users can still reach them via /hermes <command>."""
 317          names = {n for n, _d, _h in slack_native_slashes()}
 318          for reserved in _SLACK_RESERVED_COMMANDS:
 319              assert reserved not in names, (
 320                  f"/{reserved} is a Slack built-in and must not appear in the manifest"
 321              )
 322  
 323      def test_includes_aliases_as_first_class_slashes(self):
 324          """Aliases (/btw, /bg, /reset, /q) must be registered as standalone
 325          slashes — this is the whole point of native-slashes parity."""
 326          names = {n for n, _d, _h in slack_native_slashes()}
 327          assert "btw" in names
 328          assert "bg" in names
 329          assert "reset" in names
 330          assert "q" in names
 331  
 332      def test_telegram_parity(self):
 333          """Every Telegram bot command must be registerable on Slack too.
 334  
 335          This catches the old behavior where Slack users couldn't invoke
 336          commands like /btw natively. If a future command surfaces on
 337          Telegram but not Slack (because of Slack's 50-slash cap), this
 338          test fails loudly so we can curate the list rather than silently
 339          dropping parity.
 340  
 341          Slack-reserved built-in commands (e.g. /status) are excluded
 342          from parity checks since they cannot be registered on Slack.
 343          """
 344          slack_names = {n for n, _d, _h in slack_native_slashes()}
 345          tg_names = {n for n, _d in telegram_bot_commands()}
 346          # Some Telegram names have underscores where Slack uses hyphens
 347          # (e.g. set_home vs sethome). Normalize both sides for comparison.
 348          def _norm(s: str) -> str:
 349              return s.replace("-", "_").replace("__", "_").strip("_")
 350  
 351          slack_norm = {_norm(n) for n in slack_names}
 352          tg_norm = {_norm(n) for n in tg_names}
 353          reserved_norm = {_norm(n) for n in _SLACK_RESERVED_COMMANDS}
 354          missing = (tg_norm - slack_norm) - reserved_norm
 355          assert not missing, (
 356              f"commands on Telegram but missing from Slack native slashes: {sorted(missing)}"
 357          )
 358  
 359  
 360  class TestSlackAppManifest:
 361      """Generated Slack app manifest (used by `hermes slack manifest`)."""
 362  
 363      def test_returns_dict(self):
 364          m = slack_app_manifest()
 365          assert isinstance(m, dict)
 366          assert "features" in m
 367          assert "slash_commands" in m["features"]
 368  
 369      def test_each_slash_has_required_fields(self):
 370          m = slack_app_manifest()
 371          for entry in m["features"]["slash_commands"]:
 372              assert entry["command"].startswith("/")
 373              assert "description" in entry
 374              assert "url" in entry
 375              # should_escape must be present (Slack defaults to True which
 376              # HTML-escapes args — we want the raw text)
 377              assert "should_escape" in entry
 378  
 379      def test_btw_is_in_manifest(self):
 380          """Regression: /btw must be a native Slack slash, not just a
 381          /hermes subcommand."""
 382          m = slack_app_manifest()
 383          commands = [c["command"] for c in m["features"]["slash_commands"]]
 384          assert "/btw" in commands
 385  
 386      def test_custom_request_url(self):
 387          m = slack_app_manifest(request_url="https://example.com/slack")
 388          for entry in m["features"]["slash_commands"]:
 389              assert entry["url"] == "https://example.com/slack"
 390  
 391  
 392  # ---------------------------------------------------------------------------
 393  # Config-gated gateway commands
 394  # ---------------------------------------------------------------------------
 395  
 396  class TestGatewayConfigGate:
 397      """Tests for the gateway_config_gate mechanism on CommandDef."""
 398  
 399      def test_verbose_has_config_gate(self):
 400          cmd = resolve_command("verbose")
 401          assert cmd is not None
 402          assert cmd.cli_only is True
 403          assert cmd.gateway_config_gate == "display.tool_progress_command"
 404  
 405      def test_verbose_in_gateway_known_commands(self):
 406          """Config-gated commands are always recognized by the gateway."""
 407          assert "verbose" in GATEWAY_KNOWN_COMMANDS
 408  
 409      def test_config_gate_excluded_from_help_when_off(self, tmp_path, monkeypatch):
 410          """When the config gate is falsy, the command should not appear in help."""
 411          # Write a config with the gate off (default)
 412          config_file = tmp_path / "config.yaml"
 413          config_file.write_text("display:\n  tool_progress_command: false\n")
 414          monkeypatch.setenv("HERMES_HOME", str(tmp_path))
 415  
 416          lines = gateway_help_lines()
 417          joined = "\n".join(lines)
 418          assert "`/verbose" not in joined
 419  
 420      def test_config_gate_included_in_help_when_on(self, tmp_path, monkeypatch):
 421          """When the config gate is truthy, the command should appear in help."""
 422          config_file = tmp_path / "config.yaml"
 423          config_file.write_text("display:\n  tool_progress_command: true\n")
 424          monkeypatch.setenv("HERMES_HOME", str(tmp_path))
 425  
 426          lines = gateway_help_lines()
 427          joined = "\n".join(lines)
 428          assert "`/verbose" in joined
 429  
 430      def test_config_gate_quoted_false_stays_disabled_everywhere(self, tmp_path, monkeypatch):
 431          """Quoted false must not enable config-gated gateway commands."""
 432          config_file = tmp_path / "config.yaml"
 433          config_file.write_text('display:\n  tool_progress_command: "false"\n')
 434          monkeypatch.setenv("HERMES_HOME", str(tmp_path))
 435  
 436          lines = gateway_help_lines()
 437          joined = "\n".join(lines)
 438          names = {name for name, _ in telegram_bot_commands()}
 439          mapping = slack_subcommand_map()
 440  
 441          assert "`/verbose" not in joined
 442          assert "verbose" not in names
 443          assert "verbose" not in mapping
 444  
 445      def test_config_gate_excluded_from_telegram_when_off(self, tmp_path, monkeypatch):
 446          config_file = tmp_path / "config.yaml"
 447          config_file.write_text("display:\n  tool_progress_command: false\n")
 448          monkeypatch.setenv("HERMES_HOME", str(tmp_path))
 449  
 450          names = {name for name, _ in telegram_bot_commands()}
 451          assert "verbose" not in names
 452  
 453      def test_config_gate_included_in_telegram_when_on(self, tmp_path, monkeypatch):
 454          config_file = tmp_path / "config.yaml"
 455          config_file.write_text("display:\n  tool_progress_command: true\n")
 456          monkeypatch.setenv("HERMES_HOME", str(tmp_path))
 457  
 458          names = {name for name, _ in telegram_bot_commands()}
 459          assert "verbose" in names
 460  
 461      def test_config_gate_excluded_from_slack_when_off(self, tmp_path, monkeypatch):
 462          config_file = tmp_path / "config.yaml"
 463          config_file.write_text("display:\n  tool_progress_command: false\n")
 464          monkeypatch.setenv("HERMES_HOME", str(tmp_path))
 465  
 466          mapping = slack_subcommand_map()
 467          assert "verbose" not in mapping
 468  
 469      def test_config_gate_included_in_slack_when_on(self, tmp_path, monkeypatch):
 470          config_file = tmp_path / "config.yaml"
 471          config_file.write_text("display:\n  tool_progress_command: true\n")
 472          monkeypatch.setenv("HERMES_HOME", str(tmp_path))
 473  
 474          mapping = slack_subcommand_map()
 475          assert "verbose" in mapping
 476  
 477  
 478  # ---------------------------------------------------------------------------
 479  # Autocomplete (SlashCommandCompleter)
 480  # ---------------------------------------------------------------------------
 481  
 482  class TestSlashCommandCompleter:
 483      # -- basic prefix completion -----------------------------------------
 484  
 485      def test_builtin_prefix_completion_uses_shared_registry(self):
 486          completions = _completions(SlashCommandCompleter(), "/re")
 487          texts = {item.text for item in completions}
 488  
 489          assert "reset" in texts
 490          assert "retry" in texts
 491          assert "reload-mcp" in texts
 492  
 493      def test_builtin_completion_display_meta_shows_description(self):
 494          completions = _completions(SlashCommandCompleter(), "/help")
 495          assert len(completions) == 1
 496          assert completions[0].display_meta_text == "Show available commands"
 497  
 498      # -- exact-match trailing space --------------------------------------
 499  
 500      def test_exact_match_completion_adds_trailing_space(self):
 501          completions = _completions(SlashCommandCompleter(), "/help")
 502  
 503          assert [item.text for item in completions] == ["help "]
 504  
 505      def test_partial_match_does_not_add_trailing_space(self):
 506          completions = _completions(SlashCommandCompleter(), "/hel")
 507  
 508          assert [item.text for item in completions] == ["help"]
 509  
 510      # -- non-slash input returns nothing ---------------------------------
 511  
 512      def test_no_completions_for_non_slash_input(self):
 513          assert _completions(SlashCommandCompleter(), "help") == []
 514  
 515      def test_no_completions_for_empty_input(self):
 516          assert _completions(SlashCommandCompleter(), "") == []
 517  
 518      # -- skill commands via provider ------------------------------------
 519  
 520      def test_skill_commands_are_completed_from_provider(self):
 521          completer = SlashCommandCompleter(
 522              skill_commands_provider=lambda: {
 523                  "/gif-search": {"description": "Search for GIFs across providers"},
 524              }
 525          )
 526  
 527          completions = _completions(completer, "/gif")
 528  
 529          assert len(completions) == 1
 530          assert completions[0].text == "gif-search"
 531          assert completions[0].display_text == "/gif-search"
 532          assert completions[0].display_meta_text == "⚡ Search for GIFs across providers"
 533  
 534      def test_skill_exact_match_adds_trailing_space(self):
 535          completer = SlashCommandCompleter(
 536              skill_commands_provider=lambda: {
 537                  "/gif-search": {"description": "Search for GIFs"},
 538              }
 539          )
 540  
 541          completions = _completions(completer, "/gif-search")
 542  
 543          assert len(completions) == 1
 544          assert completions[0].text == "gif-search "
 545  
 546      def test_no_skill_provider_means_no_skill_completions(self):
 547          """Default (None) provider should not blow up or add completions."""
 548          completer = SlashCommandCompleter()
 549          completions = _completions(completer, "/gif")
 550          # /gif doesn't match any builtin command
 551          assert completions == []
 552  
 553      def test_skill_provider_exception_is_swallowed(self):
 554          """A broken provider should not crash autocomplete."""
 555          completer = SlashCommandCompleter(
 556              skill_commands_provider=lambda: (_ for _ in ()).throw(RuntimeError("boom")),
 557          )
 558          # Should return builtin matches only, no crash
 559          completions = _completions(completer, "/he")
 560          texts = {item.text for item in completions}
 561          assert "help" in texts
 562  
 563      def test_skill_description_truncated_at_50_chars(self):
 564          long_desc = "A" * 80
 565          completer = SlashCommandCompleter(
 566              skill_commands_provider=lambda: {
 567                  "/long-skill": {"description": long_desc},
 568              }
 569          )
 570          completions = _completions(completer, "/long")
 571          assert len(completions) == 1
 572          meta = completions[0].display_meta_text
 573          # "⚡ " prefix + 50 chars + "..."
 574          assert meta == f"⚡ {'A' * 50}..."
 575  
 576      def test_skill_missing_description_uses_fallback(self):
 577          completer = SlashCommandCompleter(
 578              skill_commands_provider=lambda: {
 579                  "/no-desc": {},
 580              }
 581          )
 582          completions = _completions(completer, "/no-desc")
 583          assert len(completions) == 1
 584          assert "Skill command" in completions[0].display_meta_text
 585  
 586  
 587  # ── SUBCOMMANDS extraction ──────────────────────────────────────────────
 588  
 589  
 590  class TestSubcommands:
 591      def test_explicit_subcommands_extracted(self):
 592          """Commands with explicit subcommands on CommandDef are extracted."""
 593          assert "/skills" in SUBCOMMANDS
 594          assert "install" in SUBCOMMANDS["/skills"]
 595  
 596      def test_reasoning_has_subcommands(self):
 597          assert "/reasoning" in SUBCOMMANDS
 598          subs = SUBCOMMANDS["/reasoning"]
 599          assert "high" in subs
 600          assert "show" in subs
 601          assert "hide" in subs
 602  
 603      def test_fast_has_subcommands(self):
 604          assert "/fast" in SUBCOMMANDS
 605          subs = SUBCOMMANDS["/fast"]
 606          assert "fast" in subs
 607          assert "normal" in subs
 608          assert "status" in subs
 609  
 610      def test_voice_has_subcommands(self):
 611          assert "/voice" in SUBCOMMANDS
 612          assert "on" in SUBCOMMANDS["/voice"]
 613          assert "off" in SUBCOMMANDS["/voice"]
 614  
 615      def test_cron_has_subcommands(self):
 616          assert "/cron" in SUBCOMMANDS
 617          assert "list" in SUBCOMMANDS["/cron"]
 618          assert "add" in SUBCOMMANDS["/cron"]
 619  
 620      def test_commands_without_subcommands_not_in_dict(self):
 621          """Plain commands should not appear in SUBCOMMANDS."""
 622          assert "/help" not in SUBCOMMANDS
 623          assert "/quit" not in SUBCOMMANDS
 624          assert "/clear" not in SUBCOMMANDS
 625  
 626  
 627  # ── Subcommand tab completion ───────────────────────────────────────────
 628  
 629  
 630  class TestSubcommandCompletion:
 631      def test_subcommand_completion_after_space(self):
 632          """Typing '/reasoning ' then Tab should show subcommands."""
 633          completions = _completions(SlashCommandCompleter(), "/reasoning ")
 634          texts = {c.text for c in completions}
 635          assert "high" in texts
 636          assert "show" in texts
 637  
 638      def test_fast_subcommand_completion_after_space(self):
 639          completions = _completions(SlashCommandCompleter(), "/fast ")
 640          texts = {c.text for c in completions}
 641          assert "fast" in texts
 642          assert "normal" in texts
 643  
 644      def test_fast_command_filtered_out_when_unavailable(self):
 645          completions = _completions(
 646              SlashCommandCompleter(command_filter=lambda cmd: cmd != "/fast"),
 647              "/fa",
 648          )
 649          texts = {c.text for c in completions}
 650          assert "fast" not in texts
 651  
 652      def test_subcommand_prefix_filters(self):
 653          """Typing '/reasoning sh' should only show 'show'."""
 654          completions = _completions(SlashCommandCompleter(), "/reasoning sh")
 655          texts = {c.text for c in completions}
 656          assert texts == {"show"}
 657  
 658      def test_subcommand_exact_match_suppressed(self):
 659          """Typing the full subcommand shouldn't re-suggest it."""
 660          completions = _completions(SlashCommandCompleter(), "/reasoning show")
 661          texts = {c.text for c in completions}
 662          assert "show" not in texts
 663  
 664      def test_no_subcommands_for_plain_command(self):
 665          """Commands without subcommands yield nothing after space."""
 666          completions = _completions(SlashCommandCompleter(), "/help ")
 667          assert completions == []
 668  
 669  
 670  # ── Ghost text (SlashCommandAutoSuggest) ────────────────────────────────
 671  
 672  
 673  def _suggestion(text: str, completer=None) -> str | None:
 674      """Get ghost text suggestion for given input."""
 675      suggest = SlashCommandAutoSuggest(completer=completer)
 676      doc = Document(text=text)
 677  
 678      class FakeBuffer:
 679          pass
 680  
 681      result = suggest.get_suggestion(FakeBuffer(), doc)
 682      return result.text if result else None
 683  
 684  
 685  class TestGhostText:
 686      def test_command_name_suggestion(self):
 687          """/he → 'lp'"""
 688          assert _suggestion("/he") == "lp"
 689  
 690      def test_command_name_suggestion_reasoning(self):
 691          """/rea → 'soning'"""
 692          assert _suggestion("/rea") == "soning"
 693  
 694      def test_no_suggestion_for_complete_command(self):
 695          assert _suggestion("/help") is None
 696  
 697      def test_subcommand_suggestion(self):
 698          """/reasoning h → 'igh'"""
 699          assert _suggestion("/reasoning h") == "igh"
 700  
 701      def test_subcommand_suggestion_show(self):
 702          """/reasoning sh → 'ow'"""
 703          assert _suggestion("/reasoning sh") == "ow"
 704  
 705      def test_fast_subcommand_suggestion(self):
 706          assert _suggestion("/fast f") == "ast"
 707  
 708      def test_fast_subcommand_suggestion_hidden_when_filtered(self):
 709          completer = SlashCommandCompleter(command_filter=lambda cmd: cmd != "/fast")
 710          assert _suggestion("/fa", completer=completer) is None
 711  
 712      def test_no_suggestion_for_non_slash(self):
 713          assert _suggestion("hello") is None
 714  
 715  
 716  # ---------------------------------------------------------------------------
 717  # Telegram command name sanitization
 718  # ---------------------------------------------------------------------------
 719  
 720  
 721  class TestSanitizeTelegramName:
 722      """Tests for _sanitize_telegram_name() — Telegram requires [a-z0-9_] only."""
 723  
 724      def test_hyphens_replaced_with_underscores(self):
 725          assert _sanitize_telegram_name("my-skill-name") == "my_skill_name"
 726  
 727      def test_plus_sign_stripped(self):
 728          """Regression: skill name 'Jellyfin + Jellystat 24h Summary'."""
 729          assert _sanitize_telegram_name("jellyfin-+-jellystat-24h-summary") == "jellyfin_jellystat_24h_summary"
 730  
 731      def test_slash_stripped(self):
 732          """Regression: skill name 'Sonarr v3/v4 API Integration'."""
 733          assert _sanitize_telegram_name("sonarr-v3/v4-api-integration") == "sonarr_v3v4_api_integration"
 734  
 735      def test_uppercase_lowercased(self):
 736          assert _sanitize_telegram_name("MyCommand") == "mycommand"
 737  
 738      def test_dots_and_special_chars_stripped(self):
 739          assert _sanitize_telegram_name("skill.v2@beta!") == "skillv2beta"
 740  
 741      def test_consecutive_underscores_collapsed(self):
 742          assert _sanitize_telegram_name("a---b") == "a_b"
 743          assert _sanitize_telegram_name("a-+-b") == "a_b"
 744  
 745      def test_leading_trailing_underscores_stripped(self):
 746          assert _sanitize_telegram_name("-leading") == "leading"
 747          assert _sanitize_telegram_name("trailing-") == "trailing"
 748          assert _sanitize_telegram_name("-both-") == "both"
 749  
 750      def test_digits_preserved(self):
 751          assert _sanitize_telegram_name("skill-24h") == "skill_24h"
 752  
 753      def test_empty_after_sanitization(self):
 754          assert _sanitize_telegram_name("+++") == ""
 755  
 756      def test_spaces_only_becomes_empty(self):
 757          assert _sanitize_telegram_name("   ") == ""
 758  
 759      def test_already_valid(self):
 760          assert _sanitize_telegram_name("valid_name_123") == "valid_name_123"
 761  
 762  
 763  # ---------------------------------------------------------------------------
 764  # Telegram command name clamping (32-char limit)
 765  # ---------------------------------------------------------------------------
 766  
 767  
 768  class TestClampTelegramNames:
 769      """Tests for _clamp_telegram_names() — 32-char enforcement + collision."""
 770  
 771      def test_short_names_unchanged(self):
 772          entries = [("help", "Show help"), ("status", "Show status")]
 773          result = _clamp_telegram_names(entries, set())
 774          assert result == entries
 775  
 776      def test_long_name_truncated(self):
 777          long = "a" * 40
 778          result = _clamp_telegram_names([(long, "desc")], set())
 779          assert len(result) == 1
 780          assert result[0][0] == "a" * _TG_NAME_LIMIT
 781          assert result[0][1] == "desc"
 782  
 783      def test_collision_with_reserved_gets_digit_suffix(self):
 784          # The truncated form collides with a reserved name
 785          prefix = "x" * _TG_NAME_LIMIT
 786          long_name = "x" * 40
 787          result = _clamp_telegram_names([(long_name, "d")], reserved={prefix})
 788          assert len(result) == 1
 789          name = result[0][0]
 790          assert len(name) == _TG_NAME_LIMIT
 791          assert name == "x" * (_TG_NAME_LIMIT - 1) + "0"
 792  
 793      def test_collision_between_entries_gets_incrementing_digits(self):
 794          # Two long names that truncate to the same 32-char prefix
 795          base = "y" * 40
 796          entries = [(base + "_alpha", "d1"), (base + "_beta", "d2")]
 797          result = _clamp_telegram_names(entries, set())
 798          assert len(result) == 2
 799          assert result[0][0] == "y" * _TG_NAME_LIMIT
 800          assert result[1][0] == "y" * (_TG_NAME_LIMIT - 1) + "0"
 801  
 802      def test_collision_with_reserved_and_entries_skips_taken_digits(self):
 803          prefix = "z" * _TG_NAME_LIMIT
 804          digit0 = "z" * (_TG_NAME_LIMIT - 1) + "0"
 805          # Reserve both the plain truncation and digit-0
 806          reserved = {prefix, digit0}
 807          long_name = "z" * 50
 808          result = _clamp_telegram_names([(long_name, "d")], reserved)
 809          assert len(result) == 1
 810          assert result[0][0] == "z" * (_TG_NAME_LIMIT - 1) + "1"
 811  
 812      def test_all_digits_exhausted_drops_entry(self):
 813          prefix = "w" * _TG_NAME_LIMIT
 814          # Reserve the plain truncation + all 10 digit slots
 815          reserved = {prefix} | {"w" * (_TG_NAME_LIMIT - 1) + str(d) for d in range(10)}
 816          long_name = "w" * 50
 817          result = _clamp_telegram_names([(long_name, "d")], reserved)
 818          assert result == []
 819  
 820      def test_exact_32_chars_not_truncated(self):
 821          name = "a" * _TG_NAME_LIMIT
 822          result = _clamp_telegram_names([(name, "desc")], set())
 823          assert result[0][0] == name
 824  
 825      def test_duplicate_short_name_deduplicated(self):
 826          entries = [("foo", "d1"), ("foo", "d2")]
 827          result = _clamp_telegram_names(entries, set())
 828          assert len(result) == 1
 829          assert result[0] == ("foo", "d1")
 830  
 831  
 832  class TestClampCommandNamesTriples:
 833      """Tests for _clamp_command_names with 3-tuples (name, desc, cmd_key).
 834  
 835      Skill entries pass through _clamp_command_names as 3-tuples so the
 836      original cmd_key survives name truncation.  Before the fix in PR #18951,
 837      the code stripped cmd_key into a side-dict keyed by the *original*
 838      (name, desc) pair — after truncation the lookup key no longer matched,
 839      silently losing the cmd_key.
 840      """
 841  
 842      def test_short_triple_preserved(self):
 843          entries = [("skill", "A skill", "/skill")]
 844          result = _clamp_command_names(entries, set())
 845          assert result == [("skill", "A skill", "/skill")]
 846  
 847      def test_long_name_preserves_cmd_key(self):
 848          long = "a" * 50
 849          cmd_key = f"/{long}"
 850          result = _clamp_command_names([(long, "desc", cmd_key)], set())
 851          assert len(result) == 1
 852          name, desc, key = result[0]
 853          assert len(name) == _CMD_NAME_LIMIT
 854          assert key == cmd_key, "cmd_key must survive name clamping"
 855  
 856      def test_collision_preserves_cmd_key(self):
 857          prefix = "x" * _CMD_NAME_LIMIT
 858          long = "x" * 50
 859          result = _clamp_command_names(
 860              [(long, "desc", "/long-skill")], reserved={prefix},
 861          )
 862          assert len(result) == 1
 863          name, _desc, key = result[0]
 864          assert name == "x" * (_CMD_NAME_LIMIT - 1) + "0"
 865          assert key == "/long-skill"
 866  
 867      def test_multiple_long_names_preserve_respective_keys(self):
 868          base = "y" * 40
 869          entries = [
 870              (base + "_alpha", "d1", "/alpha-skill"),
 871              (base + "_beta", "d2", "/beta-skill"),
 872          ]
 873          result = _clamp_command_names(entries, set())
 874          assert len(result) == 2
 875          assert result[0][2] == "/alpha-skill"
 876          assert result[1][2] == "/beta-skill"
 877  
 878      def test_backward_compat_with_pairs(self):
 879          """Legacy 2-tuple callers (Telegram) must still work."""
 880          entries = [("help", "Show help"), ("status", "Show status")]
 881          result = _clamp_command_names(entries, set())
 882          assert result == entries
 883  
 884  
 885  class TestDiscordSkillCmdKeyDispatch:
 886      """Integration: discord_skill_commands preserves cmd_key for long names.
 887  
 888      This tests the full pipeline: skill_commands → _collect_gateway_skill_entries
 889      → _clamp_command_names → returned triples, verifying that skills with names
 890      exceeding Discord's 32-char limit still have their original cmd_key for
 891      dispatch.
 892      """
 893  
 894      def test_long_skill_name_retains_cmd_key(self, tmp_path, monkeypatch):
 895          from unittest.mock import patch
 896  
 897          long_name = "this-is-a-very-long-skill-name-that-exceeds-limit"
 898          cmd_key = f"/{long_name}"
 899          fake_skills_dir = tmp_path / "skills"
 900          fake_skills_dir.mkdir(exist_ok=True)
 901          # Use resolved path — macOS /var → /private/var symlink
 902          # causes SKILLS_DIR.resolve() to differ from tmp_path.
 903          resolved_dir = str(fake_skills_dir.resolve())
 904  
 905          fake_cmds = {
 906              cmd_key: {
 907                  "name": long_name,
 908                  "description": "A skill with a long name",
 909                  "skill_md_path": f"{resolved_dir}/{long_name}/SKILL.md",
 910                  "skill_dir": f"{resolved_dir}/{long_name}",
 911              },
 912          }
 913  
 914          with patch("agent.skill_commands.get_skill_commands", return_value=fake_cmds), \
 915               patch("tools.skills_tool.SKILLS_DIR", fake_skills_dir), \
 916               patch("agent.skill_utils.get_external_skills_dirs", return_value=[]):
 917              entries, hidden = discord_skill_commands(
 918                  max_slots=100, reserved_names=set(),
 919              )
 920  
 921          assert len(entries) == 1
 922          name, desc, key = entries[0]
 923          assert len(name) <= _CMD_NAME_LIMIT, "Name should be clamped to 32 chars"
 924          assert key == cmd_key, (
 925              f"cmd_key must be the original /{long_name}, got {key!r}"
 926          )
 927  
 928  
 929  class TestTelegramMenuCommands:
 930      """Integration: telegram_menu_commands enforces the 32-char limit."""
 931  
 932      def test_all_names_within_limit(self):
 933          menu, _ = telegram_menu_commands(max_commands=100)
 934          for name, _desc in menu:
 935              assert 1 <= len(name) <= _TG_NAME_LIMIT, (
 936                  f"Command '{name}' is {len(name)} chars (limit {_TG_NAME_LIMIT})"
 937              )
 938  
 939      def test_includes_plugin_commands_via_lazy_discovery(self, tmp_path, monkeypatch):
 940          """Telegram menu generation should discover plugin slash commands on first access."""
 941          from unittest.mock import patch
 942          import hermes_cli.plugins as plugins_mod
 943  
 944          plugin_dir = tmp_path / "plugins" / "cmd-plugin"
 945          plugin_dir.mkdir(parents=True, exist_ok=True)
 946          (plugin_dir / "plugin.yaml").write_text(
 947              "name: cmd-plugin\nversion: 0.1.0\ndescription: Test plugin\n"
 948          )
 949          (plugin_dir / "__init__.py").write_text(
 950              "def register(ctx):\n"
 951              "    ctx.register_command('lcm', lambda args: 'ok', description='LCM status and diagnostics')\n"
 952          )
 953          # Opt-in: plugins are opt-in by default, so enable in config.yaml
 954          (tmp_path / "config.yaml").write_text(
 955              "plugins:\n  enabled:\n    - cmd-plugin\n"
 956          )
 957          monkeypatch.setenv("HERMES_HOME", str(tmp_path))
 958  
 959          with patch.object(plugins_mod, "_plugin_manager", None):
 960              menu, _ = telegram_menu_commands(max_commands=100)
 961  
 962          menu_names = {name for name, _ in menu}
 963          assert "lcm" in menu_names
 964  
 965      def test_excludes_telegram_disabled_skills(self, tmp_path, monkeypatch):
 966          """Skills disabled for telegram should not appear in the menu."""
 967          from unittest.mock import patch, MagicMock
 968  
 969          # Set up a config with a telegram-specific disabled list
 970          config_file = tmp_path / "config.yaml"
 971          config_file.write_text(
 972              "skills:\n"
 973              "  platform_disabled:\n"
 974              "    telegram:\n"
 975              "      - my-disabled-skill\n"
 976          )
 977          monkeypatch.setenv("HERMES_HOME", str(tmp_path))
 978  
 979          # Mock get_skill_commands to return two skills
 980          fake_skills_dir = str(tmp_path / "skills")
 981          fake_cmds = {
 982              "/my-disabled-skill": {
 983                  "name": "my-disabled-skill",
 984                  "description": "Should be hidden",
 985                  "skill_md_path": f"{fake_skills_dir}/my-disabled-skill/SKILL.md",
 986                  "skill_dir": f"{fake_skills_dir}/my-disabled-skill",
 987              },
 988              "/my-enabled-skill": {
 989                  "name": "my-enabled-skill",
 990                  "description": "Should be visible",
 991                  "skill_md_path": f"{fake_skills_dir}/my-enabled-skill/SKILL.md",
 992                  "skill_dir": f"{fake_skills_dir}/my-enabled-skill",
 993              },
 994          }
 995          with (
 996              patch("agent.skill_commands.get_skill_commands", return_value=fake_cmds),
 997              patch("tools.skills_tool.SKILLS_DIR", tmp_path / "skills"),
 998          ):
 999              (tmp_path / "skills").mkdir(exist_ok=True)
1000              menu, hidden = telegram_menu_commands(max_commands=100)
1001  
1002          menu_names = {n for n, _ in menu}
1003          assert "my_enabled_skill" in menu_names
1004          assert "my_disabled_skill" not in menu_names
1005  
1006      def test_external_dir_skills_included_in_telegram_menu(self, tmp_path, monkeypatch):
1007          """External skills (``skills.external_dirs``) must appear in the Telegram menu.
1008  
1009          Regression test for #8110 — external skills were visible to the
1010          agent and CLI but silently excluded from gateway slash menus
1011          because ``_collect_gateway_skill_entries`` only accepted skills
1012          whose path started with ``SKILLS_DIR``.
1013  
1014          Also verifies the trailing-slash boundary: a directory that
1015          simply shares a prefix with a configured ``external_dirs`` entry
1016          (``/tmp/my-skills-extra`` vs ``/tmp/my-skills``) must NOT be
1017          admitted.
1018          """
1019          from unittest.mock import patch
1020  
1021          local_dir = tmp_path / "skills"
1022          local_dir.mkdir()
1023          external_dir = tmp_path / "my-skills"
1024          external_dir.mkdir()
1025          lookalike_dir = tmp_path / "my-skills-extra"
1026          lookalike_dir.mkdir()
1027  
1028          monkeypatch.setenv("HERMES_HOME", str(tmp_path))
1029          (tmp_path / "config.yaml").write_text(
1030              f"skills:\n  external_dirs:\n    - {external_dir}\n"
1031          )
1032  
1033          fake_cmds = {
1034              "/local-one": {
1035                  "name": "local-one",
1036                  "description": "Local",
1037                  "skill_md_path": f"{local_dir}/local-one/SKILL.md",
1038                  "skill_dir": f"{local_dir}/local-one",
1039              },
1040              "/morning-briefing": {
1041                  "name": "morning-briefing",
1042                  "description": "External skill",
1043                  "skill_md_path": f"{external_dir}/morning-briefing/SKILL.md",
1044                  "skill_dir": f"{external_dir}/morning-briefing",
1045              },
1046              "/lookalike-skill": {
1047                  "name": "lookalike-skill",
1048                  "description": "Lives in a sibling dir that shares a prefix",
1049                  "skill_md_path": f"{lookalike_dir}/lookalike-skill/SKILL.md",
1050                  "skill_dir": f"{lookalike_dir}/lookalike-skill",
1051              },
1052          }
1053  
1054          with (
1055              patch("agent.skill_commands.get_skill_commands", return_value=fake_cmds),
1056              patch("tools.skills_tool.SKILLS_DIR", local_dir),
1057              patch(
1058                  "agent.skill_utils.get_external_skills_dirs",
1059                  return_value=[external_dir],
1060              ),
1061          ):
1062              menu, _ = telegram_menu_commands(max_commands=100)
1063  
1064          menu_names = {n for n, _ in menu}
1065          assert "local_one" in menu_names, "local skill must appear"
1066          assert "morning_briefing" in menu_names, (
1067              "external skill from skills.external_dirs must appear (fixes #8110)"
1068          )
1069          assert "lookalike_skill" not in menu_names, (
1070              "prefix-match sibling directories must not be admitted"
1071          )
1072  
1073      def test_special_chars_in_skill_names_sanitized(self, tmp_path, monkeypatch):
1074          """Skills with +, /, or other special chars produce valid Telegram names."""
1075          from unittest.mock import patch
1076          import re
1077  
1078          monkeypatch.setenv("HERMES_HOME", str(tmp_path))
1079  
1080          fake_skills_dir = str(tmp_path / "skills")
1081          fake_cmds = {
1082              "/jellyfin-+-jellystat-24h-summary": {
1083                  "name": "Jellyfin + Jellystat 24h Summary",
1084                  "description": "Test",
1085                  "skill_md_path": f"{fake_skills_dir}/jellyfin/SKILL.md",
1086                  "skill_dir": f"{fake_skills_dir}/jellyfin",
1087              },
1088              "/sonarr-v3/v4-api": {
1089                  "name": "Sonarr v3/v4 API",
1090                  "description": "Test",
1091                  "skill_md_path": f"{fake_skills_dir}/sonarr/SKILL.md",
1092                  "skill_dir": f"{fake_skills_dir}/sonarr",
1093              },
1094          }
1095          with (
1096              patch("agent.skill_commands.get_skill_commands", return_value=fake_cmds),
1097              patch("tools.skills_tool.SKILLS_DIR", tmp_path / "skills"),
1098          ):
1099              (tmp_path / "skills").mkdir(exist_ok=True)
1100              menu, _ = telegram_menu_commands(max_commands=100)
1101  
1102          # Every name must match Telegram's [a-z0-9_] requirement
1103          tg_valid = re.compile(r"^[a-z0-9_]+$")
1104          for name, _ in menu:
1105              assert tg_valid.match(name), f"Invalid Telegram command name: {name!r}"
1106  
1107      def test_empty_sanitized_names_excluded(self, tmp_path, monkeypatch):
1108          """Skills whose names sanitize to empty string are silently dropped."""
1109          from unittest.mock import patch
1110  
1111          monkeypatch.setenv("HERMES_HOME", str(tmp_path))
1112  
1113          fake_skills_dir = str(tmp_path / "skills")
1114          fake_cmds = {
1115              "/+++": {
1116                  "name": "+++",
1117                  "description": "All special chars",
1118                  "skill_md_path": f"{fake_skills_dir}/bad/SKILL.md",
1119                  "skill_dir": f"{fake_skills_dir}/bad",
1120              },
1121              "/valid-skill": {
1122                  "name": "valid-skill",
1123                  "description": "Normal skill",
1124                  "skill_md_path": f"{fake_skills_dir}/valid/SKILL.md",
1125                  "skill_dir": f"{fake_skills_dir}/valid",
1126              },
1127          }
1128          with (
1129              patch("agent.skill_commands.get_skill_commands", return_value=fake_cmds),
1130              patch("tools.skills_tool.SKILLS_DIR", tmp_path / "skills"),
1131          ):
1132              (tmp_path / "skills").mkdir(exist_ok=True)
1133              menu, _ = telegram_menu_commands(max_commands=100)
1134  
1135          menu_names = {n for n, _ in menu}
1136          # The valid skill should be present, the empty one should not
1137          assert "valid_skill" in menu_names
1138          # No empty string in menu names
1139          assert "" not in menu_names
1140  
1141  
1142  # ---------------------------------------------------------------------------
1143  # Backward-compat aliases
1144  # ---------------------------------------------------------------------------
1145  
1146  class TestBackwardCompatAliases:
1147      """The renamed constants/functions still exist under the old names."""
1148  
1149      def test_tg_name_limit_alias(self):
1150          assert _TG_NAME_LIMIT == _CMD_NAME_LIMIT == 32
1151  
1152      def test_clamp_telegram_names_is_clamp_command_names(self):
1153          assert _clamp_telegram_names is _clamp_command_names
1154  
1155  
1156  # ---------------------------------------------------------------------------
1157  # Discord skill command registration
1158  # ---------------------------------------------------------------------------
1159  
1160  class TestDiscordSkillCommands:
1161      """Tests for discord_skill_commands() — centralized skill registration."""
1162  
1163      def test_returns_skill_entries(self, tmp_path, monkeypatch):
1164          """Skills under SKILLS_DIR (not .hub) should be returned."""
1165          from unittest.mock import patch
1166  
1167          fake_skills_dir = str(tmp_path / "skills")
1168          fake_cmds = {
1169              "/gif-search": {
1170                  "name": "gif-search",
1171                  "description": "Search for GIFs",
1172                  "skill_md_path": f"{fake_skills_dir}/gif-search/SKILL.md",
1173                  "skill_dir": f"{fake_skills_dir}/gif-search",
1174              },
1175              "/code-review": {
1176                  "name": "code-review",
1177                  "description": "Review code changes",
1178                  "skill_md_path": f"{fake_skills_dir}/code-review/SKILL.md",
1179                  "skill_dir": f"{fake_skills_dir}/code-review",
1180              },
1181          }
1182          monkeypatch.setenv("HERMES_HOME", str(tmp_path))
1183          (tmp_path / "skills").mkdir(exist_ok=True)
1184          with (
1185              patch("agent.skill_commands.get_skill_commands", return_value=fake_cmds),
1186              patch("tools.skills_tool.SKILLS_DIR", tmp_path / "skills"),
1187          ):
1188              entries, hidden = discord_skill_commands(
1189                  max_slots=50, reserved_names=set(),
1190              )
1191  
1192          names = {n for n, _d, _k in entries}
1193          assert "gif-search" in names
1194          assert "code-review" in names
1195          assert hidden == 0
1196          # Verify cmd_key is preserved for handler callbacks
1197          keys = {k for _n, _d, k in entries}
1198          assert "/gif-search" in keys
1199          assert "/code-review" in keys
1200  
1201      def test_names_allow_hyphens(self, tmp_path, monkeypatch):
1202          """Discord names should keep hyphens (unlike Telegram's _ sanitization)."""
1203          from unittest.mock import patch
1204  
1205          fake_skills_dir = str(tmp_path / "skills")
1206          fake_cmds = {
1207              "/my-cool-skill": {
1208                  "name": "my-cool-skill",
1209                  "description": "A cool skill",
1210                  "skill_md_path": f"{fake_skills_dir}/my-cool-skill/SKILL.md",
1211                  "skill_dir": f"{fake_skills_dir}/my-cool-skill",
1212              },
1213          }
1214          monkeypatch.setenv("HERMES_HOME", str(tmp_path))
1215          (tmp_path / "skills").mkdir(exist_ok=True)
1216          with (
1217              patch("agent.skill_commands.get_skill_commands", return_value=fake_cmds),
1218              patch("tools.skills_tool.SKILLS_DIR", tmp_path / "skills"),
1219          ):
1220              entries, _ = discord_skill_commands(
1221                  max_slots=50, reserved_names=set(),
1222              )
1223  
1224          assert entries[0][0] == "my-cool-skill"  # hyphens preserved
1225  
1226      def test_cap_enforcement(self, tmp_path, monkeypatch):
1227          """Entries beyond max_slots should be hidden."""
1228          from unittest.mock import patch
1229  
1230          fake_skills_dir = str(tmp_path / "skills")
1231          fake_cmds = {
1232              f"/skill-{i:03d}": {
1233                  "name": f"skill-{i:03d}",
1234                  "description": f"Skill {i}",
1235                  "skill_md_path": f"{fake_skills_dir}/skill-{i:03d}/SKILL.md",
1236                  "skill_dir": f"{fake_skills_dir}/skill-{i:03d}",
1237              }
1238              for i in range(20)
1239          }
1240          monkeypatch.setenv("HERMES_HOME", str(tmp_path))
1241          (tmp_path / "skills").mkdir(exist_ok=True)
1242          with (
1243              patch("agent.skill_commands.get_skill_commands", return_value=fake_cmds),
1244              patch("tools.skills_tool.SKILLS_DIR", tmp_path / "skills"),
1245          ):
1246              entries, hidden = discord_skill_commands(
1247                  max_slots=5, reserved_names=set(),
1248              )
1249  
1250          assert len(entries) == 5
1251          assert hidden == 15
1252  
1253      def test_excludes_discord_disabled_skills(self, tmp_path, monkeypatch):
1254          """Skills disabled for discord should not appear."""
1255          from unittest.mock import patch
1256  
1257          config_file = tmp_path / "config.yaml"
1258          config_file.write_text(
1259              "skills:\n"
1260              "  platform_disabled:\n"
1261              "    discord:\n"
1262              "      - secret-skill\n"
1263          )
1264          monkeypatch.setenv("HERMES_HOME", str(tmp_path))
1265  
1266          fake_skills_dir = str(tmp_path / "skills")
1267          fake_cmds = {
1268              "/secret-skill": {
1269                  "name": "secret-skill",
1270                  "description": "Should not appear",
1271                  "skill_md_path": f"{fake_skills_dir}/secret-skill/SKILL.md",
1272                  "skill_dir": f"{fake_skills_dir}/secret-skill",
1273              },
1274              "/public-skill": {
1275                  "name": "public-skill",
1276                  "description": "Should appear",
1277                  "skill_md_path": f"{fake_skills_dir}/public-skill/SKILL.md",
1278                  "skill_dir": f"{fake_skills_dir}/public-skill",
1279              },
1280          }
1281          (tmp_path / "skills").mkdir(exist_ok=True)
1282          with (
1283              patch("agent.skill_commands.get_skill_commands", return_value=fake_cmds),
1284              patch("tools.skills_tool.SKILLS_DIR", tmp_path / "skills"),
1285          ):
1286              entries, _ = discord_skill_commands(
1287                  max_slots=50, reserved_names=set(),
1288              )
1289  
1290          names = {n for n, _d, _k in entries}
1291          assert "secret-skill" not in names
1292          assert "public-skill" in names
1293  
1294      def test_reserved_names_not_overwritten(self, tmp_path, monkeypatch):
1295          """Skills whose names collide with built-in commands should be skipped."""
1296          from unittest.mock import patch
1297  
1298          fake_skills_dir = str(tmp_path / "skills")
1299          fake_cmds = {
1300              "/status": {
1301                  "name": "status",
1302                  "description": "Skill that collides with built-in",
1303                  "skill_md_path": f"{fake_skills_dir}/status/SKILL.md",
1304                  "skill_dir": f"{fake_skills_dir}/status",
1305              },
1306          }
1307          monkeypatch.setenv("HERMES_HOME", str(tmp_path))
1308          (tmp_path / "skills").mkdir(exist_ok=True)
1309          with (
1310              patch("agent.skill_commands.get_skill_commands", return_value=fake_cmds),
1311              patch("tools.skills_tool.SKILLS_DIR", tmp_path / "skills"),
1312          ):
1313              entries, _ = discord_skill_commands(
1314                  max_slots=50, reserved_names={"status"},
1315              )
1316  
1317          names = {n for n, _d, _k in entries}
1318          assert "status" not in names
1319  
1320      def test_description_truncated_at_100_chars(self, tmp_path, monkeypatch):
1321          """Descriptions exceeding 100 chars should be truncated."""
1322          from unittest.mock import patch
1323  
1324          fake_skills_dir = str(tmp_path / "skills")
1325          long_desc = "x" * 150
1326          fake_cmds = {
1327              "/verbose-skill": {
1328                  "name": "verbose-skill",
1329                  "description": long_desc,
1330                  "skill_md_path": f"{fake_skills_dir}/verbose-skill/SKILL.md",
1331                  "skill_dir": f"{fake_skills_dir}/verbose-skill",
1332              },
1333          }
1334          monkeypatch.setenv("HERMES_HOME", str(tmp_path))
1335          (tmp_path / "skills").mkdir(exist_ok=True)
1336          with (
1337              patch("agent.skill_commands.get_skill_commands", return_value=fake_cmds),
1338              patch("tools.skills_tool.SKILLS_DIR", tmp_path / "skills"),
1339          ):
1340              entries, _ = discord_skill_commands(
1341                  max_slots=50, reserved_names=set(),
1342              )
1343  
1344          assert len(entries[0][1]) == 100
1345          assert entries[0][1].endswith("...")
1346  
1347      def test_all_names_within_32_chars(self, tmp_path, monkeypatch):
1348          """All returned names must respect the 32-char Discord limit."""
1349          from unittest.mock import patch
1350  
1351          fake_skills_dir = str(tmp_path / "skills")
1352          long_name = "a" * 50
1353          fake_cmds = {
1354              f"/{long_name}": {
1355                  "name": long_name,
1356                  "description": "Long name skill",
1357                  "skill_md_path": f"{fake_skills_dir}/{long_name}/SKILL.md",
1358                  "skill_dir": f"{fake_skills_dir}/{long_name}",
1359              },
1360          }
1361          monkeypatch.setenv("HERMES_HOME", str(tmp_path))
1362          (tmp_path / "skills").mkdir(exist_ok=True)
1363          with (
1364              patch("agent.skill_commands.get_skill_commands", return_value=fake_cmds),
1365              patch("tools.skills_tool.SKILLS_DIR", tmp_path / "skills"),
1366          ):
1367              entries, _ = discord_skill_commands(
1368                  max_slots=50, reserved_names=set(),
1369              )
1370  
1371          for name, _d, _k in entries:
1372              assert len(name) <= _CMD_NAME_LIMIT, (
1373                  f"Name '{name}' is {len(name)} chars (limit {_CMD_NAME_LIMIT})"
1374              )
1375  
1376  
1377  # ---------------------------------------------------------------------------
1378  # Discord skill commands grouped by category
1379  # ---------------------------------------------------------------------------
1380  
1381  from hermes_cli.commands import discord_skill_commands_by_category  # noqa: E402
1382  
1383  
1384  class TestDiscordSkillCommandsByCategory:
1385      """Tests for discord_skill_commands_by_category() — /skill group registration."""
1386  
1387      def test_groups_skills_by_category(self, tmp_path, monkeypatch):
1388          """Skills nested 2+ levels deep should be grouped by top-level category."""
1389          from unittest.mock import patch
1390  
1391          fake_skills_dir = str(tmp_path / "skills")
1392          # Create the directory structure so resolve() works
1393          for p in [
1394              "skills/creative/ascii-art",
1395              "skills/creative/excalidraw",
1396              "skills/media/gif-search",
1397          ]:
1398              (tmp_path / p).mkdir(parents=True, exist_ok=True)
1399              (tmp_path / p / "SKILL.md").write_text("---\nname: test\n---\n")
1400  
1401          fake_cmds = {
1402              "/ascii-art": {
1403                  "name": "ascii-art",
1404                  "description": "Generate ASCII art",
1405                  "skill_md_path": f"{fake_skills_dir}/creative/ascii-art/SKILL.md",
1406              },
1407              "/excalidraw": {
1408                  "name": "excalidraw",
1409                  "description": "Hand-drawn diagrams",
1410                  "skill_md_path": f"{fake_skills_dir}/creative/excalidraw/SKILL.md",
1411              },
1412              "/gif-search": {
1413                  "name": "gif-search",
1414                  "description": "Search for GIFs",
1415                  "skill_md_path": f"{fake_skills_dir}/media/gif-search/SKILL.md",
1416              },
1417          }
1418          monkeypatch.setenv("HERMES_HOME", str(tmp_path))
1419          with (
1420              patch("agent.skill_commands.get_skill_commands", return_value=fake_cmds),
1421              patch("tools.skills_tool.SKILLS_DIR", tmp_path / "skills"),
1422          ):
1423              categories, uncategorized, hidden = discord_skill_commands_by_category(
1424                  reserved_names=set(),
1425              )
1426  
1427          assert "creative" in categories
1428          assert "media" in categories
1429          assert len(categories["creative"]) == 2
1430          assert len(categories["media"]) == 1
1431          assert uncategorized == []
1432          assert hidden == 0
1433  
1434      def test_root_level_skills_are_uncategorized(self, tmp_path, monkeypatch):
1435          """Skills directly under SKILLS_DIR (only 1 path component) → uncategorized."""
1436          from unittest.mock import patch
1437  
1438          fake_skills_dir = str(tmp_path / "skills")
1439          (tmp_path / "skills" / "dogfood").mkdir(parents=True, exist_ok=True)
1440          (tmp_path / "skills" / "dogfood" / "SKILL.md").write_text("")
1441  
1442          fake_cmds = {
1443              "/dogfood": {
1444                  "name": "dogfood",
1445                  "description": "QA testing",
1446                  "skill_md_path": f"{fake_skills_dir}/dogfood/SKILL.md",
1447              },
1448          }
1449          monkeypatch.setenv("HERMES_HOME", str(tmp_path))
1450          with (
1451              patch("agent.skill_commands.get_skill_commands", return_value=fake_cmds),
1452              patch("tools.skills_tool.SKILLS_DIR", tmp_path / "skills"),
1453          ):
1454              categories, uncategorized, hidden = discord_skill_commands_by_category(
1455                  reserved_names=set(),
1456              )
1457  
1458          assert categories == {}
1459          assert len(uncategorized) == 1
1460          assert uncategorized[0][0] == "dogfood"
1461  
1462      def test_hub_skills_excluded(self, tmp_path, monkeypatch):
1463          """Skills under .hub should be excluded."""
1464          from unittest.mock import patch
1465  
1466          fake_skills_dir = str(tmp_path / "skills")
1467          (tmp_path / "skills" / ".hub" / "some-skill").mkdir(parents=True, exist_ok=True)
1468          (tmp_path / "skills" / ".hub" / "some-skill" / "SKILL.md").write_text("")
1469  
1470          fake_cmds = {
1471              "/some-skill": {
1472                  "name": "some-skill",
1473                  "description": "Hub skill",
1474                  "skill_md_path": f"{fake_skills_dir}/.hub/some-skill/SKILL.md",
1475              },
1476          }
1477          monkeypatch.setenv("HERMES_HOME", str(tmp_path))
1478          with (
1479              patch("agent.skill_commands.get_skill_commands", return_value=fake_cmds),
1480              patch("tools.skills_tool.SKILLS_DIR", tmp_path / "skills"),
1481          ):
1482              categories, uncategorized, hidden = discord_skill_commands_by_category(
1483                  reserved_names=set(),
1484              )
1485  
1486          assert categories == {}
1487          assert uncategorized == []
1488  
1489      def test_deep_nested_skills_use_top_category(self, tmp_path, monkeypatch):
1490          """Skills like mlops/training/axolotl should group under 'mlops'."""
1491          from unittest.mock import patch
1492  
1493          fake_skills_dir = str(tmp_path / "skills")
1494          (tmp_path / "skills" / "mlops" / "training" / "axolotl").mkdir(parents=True, exist_ok=True)
1495          (tmp_path / "skills" / "mlops" / "training" / "axolotl" / "SKILL.md").write_text("")
1496          (tmp_path / "skills" / "mlops" / "inference" / "vllm").mkdir(parents=True, exist_ok=True)
1497          (tmp_path / "skills" / "mlops" / "inference" / "vllm" / "SKILL.md").write_text("")
1498  
1499          fake_cmds = {
1500              "/axolotl": {
1501                  "name": "axolotl",
1502                  "description": "Fine-tuning with Axolotl",
1503                  "skill_md_path": f"{fake_skills_dir}/mlops/training/axolotl/SKILL.md",
1504              },
1505              "/vllm": {
1506                  "name": "vllm",
1507                  "description": "vLLM inference",
1508                  "skill_md_path": f"{fake_skills_dir}/mlops/inference/vllm/SKILL.md",
1509              },
1510          }
1511          monkeypatch.setenv("HERMES_HOME", str(tmp_path))
1512          with (
1513              patch("agent.skill_commands.get_skill_commands", return_value=fake_cmds),
1514              patch("tools.skills_tool.SKILLS_DIR", tmp_path / "skills"),
1515          ):
1516              categories, uncategorized, hidden = discord_skill_commands_by_category(
1517                  reserved_names=set(),
1518              )
1519  
1520          # Both should be under 'mlops' regardless of sub-category
1521          assert "mlops" in categories
1522          names = {n for n, _d, _k in categories["mlops"]}
1523          assert "axolotl" in names
1524          assert "vllm" in names
1525          assert len(uncategorized) == 0
1526  
1527      def test_no_legacy_25x25_cap(self, tmp_path, monkeypatch):
1528          """The old nested-layout caps (25 groups × 25 skills/group) are gone.
1529  
1530          The live caller flattens categories into a single autocomplete list,
1531          which Discord fetches dynamically — the per-command 8KB payload
1532          concern from the old nested layout (#11321, #10259) no longer applies.
1533          Guards against accidentally re-introducing the caps, which would
1534          silently drop skills in the 26th+ alphabetical category (the exact
1535          failure mode users were hitting with 29 category dirs on real
1536          installs).
1537          """
1538          from unittest.mock import patch
1539  
1540          fake_skills_dir = str(tmp_path / "skills")
1541  
1542          # Build 30 categories (> old _MAX_GROUPS=25) each with 30 skills
1543          # (> old _MAX_PER_GROUP=25).
1544          fake_cmds = {}
1545          for c in range(30):
1546              cat = f"cat{c:02d}"  # cat00, cat01, ..., cat29 — 30 categories
1547              for s in range(30):
1548                  name = f"skill-{c:02d}-{s:02d}"
1549                  skill_subdir = tmp_path / "skills" / cat / name
1550                  skill_subdir.mkdir(parents=True, exist_ok=True)
1551                  (skill_subdir / "SKILL.md").write_text("---\nname: x\n---\n")
1552                  fake_cmds[f"/{name}"] = {
1553                      "name": name,
1554                      "description": f"Category {cat} skill {s}",
1555                      "skill_md_path": f"{fake_skills_dir}/{cat}/{name}/SKILL.md",
1556                  }
1557  
1558          monkeypatch.setenv("HERMES_HOME", str(tmp_path))
1559          with (
1560              patch("agent.skill_commands.get_skill_commands", return_value=fake_cmds),
1561              patch("tools.skills_tool.SKILLS_DIR", tmp_path / "skills"),
1562          ):
1563              categories, uncategorized, hidden = discord_skill_commands_by_category(
1564                  reserved_names=set(),
1565              )
1566  
1567          # Every category should be present — no 25-group cap
1568          assert len(categories) == 30, (
1569              f"expected all 30 categories, got {len(categories)} "
1570              f"(cap from old nested layout must be removed)"
1571          )
1572          # Every skill in every category must be present — no 25-per-group cap
1573          for cat_name, entries in categories.items():
1574              assert len(entries) == 30, (
1575                  f"category {cat_name}: expected 30 skills, got {len(entries)} "
1576                  f"(cap from old nested layout must be removed)"
1577              )
1578          # Nothing should be reported hidden for the cap reason (the only
1579          # legitimate hidden reason now is name clamp collisions, which
1580          # don't happen here since all names are unique).
1581          assert hidden == 0
1582  
1583      def test_external_dirs_skills_included(self, tmp_path, monkeypatch):
1584          """Skills in ``skills.external_dirs`` must appear in /skill autocomplete.
1585  
1586          #18741 fixed this for the flat ``discord_skill_commands`` collector
1587          but left ``discord_skill_commands_by_category`` (the live caller for
1588          Discord's ``/skill`` command) still filtering by
1589          ``SKILLS_DIR`` prefix only. Regression guard that both collectors
1590          now accept external-dir skills.
1591          """
1592          from unittest.mock import patch
1593  
1594          local_skills_dir = tmp_path / "local-skills"
1595          external_dir = tmp_path / "external-skills"
1596  
1597          (local_skills_dir / "creative" / "local-skill").mkdir(parents=True)
1598          (local_skills_dir / "creative" / "local-skill" / "SKILL.md").write_text("")
1599  
1600          (external_dir / "mlops" / "external-skill").mkdir(parents=True)
1601          (external_dir / "mlops" / "external-skill" / "SKILL.md").write_text("")
1602  
1603          fake_cmds = {
1604              "/local-skill": {
1605                  "name": "local-skill",
1606                  "description": "Local",
1607                  "skill_md_path": str(local_skills_dir / "creative" / "local-skill" / "SKILL.md"),
1608              },
1609              "/external-skill": {
1610                  "name": "external-skill",
1611                  "description": "External",
1612                  "skill_md_path": str(external_dir / "mlops" / "external-skill" / "SKILL.md"),
1613              },
1614          }
1615          monkeypatch.setenv("HERMES_HOME", str(tmp_path))
1616          with (
1617              patch("agent.skill_commands.get_skill_commands", return_value=fake_cmds),
1618              patch("tools.skills_tool.SKILLS_DIR", local_skills_dir),
1619              patch(
1620                  "agent.skill_utils.get_external_skills_dirs",
1621                  return_value=[external_dir],
1622              ),
1623          ):
1624              categories, uncategorized, hidden = discord_skill_commands_by_category(
1625                  reserved_names=set(),
1626              )
1627  
1628          # Local skill → grouped under "creative"
1629          assert "creative" in categories
1630          assert any(n == "local-skill" for n, _d, _k in categories["creative"])
1631          # External skill → grouped under its own top-level dir "mlops"
1632          assert "mlops" in categories, (
1633              "external-dir skills must be included — the old SKILLS_DIR-only "
1634              "prefix check was broken for by_category (completes #18741)"
1635          )
1636          assert any(n == "external-skill" for n, _d, _k in categories["mlops"])
1637          assert uncategorized == []
1638          assert hidden == 0
1639  
1640  
1641  # ---------------------------------------------------------------------------
1642  # Plugin slash command integration
1643  # ---------------------------------------------------------------------------
1644  
1645  class TestPluginCommandEnumeration:
1646      """Plugin commands registered via ctx.register_command() must be surfaced
1647      by every gateway enumerator (Telegram menu, Slack subcommand map, etc.).
1648      """
1649  
1650      def _patch_plugin_commands(self, monkeypatch, commands):
1651          """Monkeypatch hermes_cli.plugins.get_plugin_commands() to a fixed dict."""
1652          from hermes_cli import plugins as _plugins_mod
1653  
1654          monkeypatch.setattr(
1655              _plugins_mod, "get_plugin_commands", lambda: dict(commands)
1656          )
1657  
1658      def test_plugin_command_appears_in_telegram_menu(self, monkeypatch):
1659          """/metricas registered by a plugin must appear in Telegram BotCommand menu."""
1660          self._patch_plugin_commands(monkeypatch, {
1661              "metricas": {
1662                  "handler": lambda _a: "ok",
1663                  "description": "Metrics dashboard",
1664                  "args_hint": "dias:7",
1665                  "plugin": "metrics-plugin",
1666              }
1667          })
1668          names = {name for name, _desc in telegram_bot_commands()}
1669          assert "metricas" in names
1670  
1671      def test_plugin_command_with_required_args_excluded_from_telegram_menu(self, monkeypatch):
1672          """Telegram BotCommand selections cannot supply required arguments."""
1673          self._patch_plugin_commands(monkeypatch, {
1674              "background-job": {
1675                  "handler": lambda _a: "ok",
1676                  "description": "Run a background job",
1677                  "args_hint": "<prompt>",
1678                  "plugin": "jobs-plugin",
1679              }
1680          })
1681          names = {name for name, _desc in telegram_bot_commands()}
1682          assert "background_job" not in names
1683  
1684      def test_plugin_command_appears_in_slack_subcommand_map(self, monkeypatch):
1685          """/hermes metricas must route through the Slack subcommand map."""
1686          self._patch_plugin_commands(monkeypatch, {
1687              "metricas": {
1688                  "handler": lambda _a: "ok",
1689                  "description": "Metrics",
1690                  "args_hint": "",
1691                  "plugin": "metrics-plugin",
1692              }
1693          })
1694          mapping = slack_subcommand_map()
1695          assert mapping.get("metricas") == "/metricas"
1696  
1697      def test_plugin_command_does_not_shadow_builtin_in_slack(self, monkeypatch):
1698          """If a plugin registers a name that collides with a built-in, the built-in mapping wins."""
1699          self._patch_plugin_commands(monkeypatch, {
1700              "status": {
1701                  "handler": lambda _a: "plugin-status",
1702                  "description": "Plugin status",
1703                  "args_hint": "",
1704                  "plugin": "shadow-plugin",
1705              }
1706          })
1707          mapping = slack_subcommand_map()
1708          # Built-in /status must still be present and not overwritten.
1709          assert mapping.get("status") == "/status"
1710  
1711      def test_plugin_command_with_hyphens_sanitized_for_telegram(self, monkeypatch):
1712          """Plugin names containing hyphens must be underscore-normalized for Telegram."""
1713          self._patch_plugin_commands(monkeypatch, {
1714              "my-plugin-cmd": {
1715                  "handler": lambda _a: "ok",
1716                  "description": "desc",
1717                  "args_hint": "",
1718                  "plugin": "p",
1719              }
1720          })
1721          names = {name for name, _desc in telegram_bot_commands()}
1722          assert "my_plugin_cmd" in names
1723          assert "my-plugin-cmd" not in names
1724  
1725      def test_is_gateway_known_command_recognizes_plugin_commands(self, monkeypatch):
1726          """is_gateway_known_command() must return True for plugin commands."""
1727          from hermes_cli.commands import is_gateway_known_command
1728  
1729          self._patch_plugin_commands(monkeypatch, {
1730              "metricas": {
1731                  "handler": lambda _a: "ok",
1732                  "description": "Metrics",
1733                  "args_hint": "",
1734                  "plugin": "p",
1735              }
1736          })
1737          assert is_gateway_known_command("metricas") is True
1738          assert is_gateway_known_command("definitely-not-registered") is False
1739  
1740      def test_is_gateway_known_command_still_recognizes_builtins(self, monkeypatch):
1741          """Built-in commands must remain known even when plugin discovery fails."""
1742          from hermes_cli import plugins as _plugins_mod
1743          from hermes_cli.commands import is_gateway_known_command
1744  
1745          def _boom():
1746              raise RuntimeError("plugin system down")
1747  
1748          monkeypatch.setattr(_plugins_mod, "get_plugin_commands", _boom)
1749  
1750          assert is_gateway_known_command("status") is True
1751          assert is_gateway_known_command(None) is False
1752          assert is_gateway_known_command("") is False
1753  
1754      def test_plugin_enumerator_handles_missing_plugin_manager(self, monkeypatch):
1755          """Enumerators must never raise when plugin discovery raises."""
1756          from hermes_cli import plugins as _plugins_mod
1757  
1758          def _boom():
1759              raise RuntimeError("plugin system down")
1760  
1761          monkeypatch.setattr(_plugins_mod, "get_plugin_commands", _boom)
1762  
1763          # Both calls should succeed and just return the built-in set.
1764          tg_names = {name for name, _desc in telegram_bot_commands()}
1765          slack_names = set(slack_subcommand_map())
1766          assert "status" in tg_names
1767          assert "status" in slack_names