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