From 51ca5759946666aeeced20b0f731f3fac630b39b Mon Sep 17 00:00:00 2001 From: Teknium Date: Wed, 22 Apr 2026 15:01:50 -0700 Subject: [PATCH] feat(gateway): expose plugin slash commands natively on all platforms + decision-capable command hook MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plugin slash commands now surface as first-class commands in every gateway enumerator — Discord native slash picker, Telegram BotCommand menu, Slack /hermes subcommand map — without a separate per-platform plugin API. The existing 'command:' gateway hook gains a decision protocol via HookRegistry.emit_collect(): handlers that return a dict with {'decision': 'deny'|'handled'|'rewrite'|'allow'} can intercept slash command dispatch before core handling runs, unifying what would otherwise have been a parallel 'pre_gateway_command' hook surface. Changes: - gateway/hooks.py: add HookRegistry.emit_collect() that fires the same handler set as emit() but collects non-None return values. Backward compatible — fire-and-forget telemetry hooks still work via emit(). - hermes_cli/plugins.py: add optional 'args_hint' param to register_command() so plugins can opt into argument-aware native UI registration (Discord arg picker, future platforms). - hermes_cli/commands.py: add _iter_plugin_command_entries() helper and merge plugin commands into telegram_bot_commands() and slack_subcommand_map(). New is_gateway_known_command() recognizes both built-in and plugin commands so the gateway hook fires for either. - gateway/platforms/discord.py: extract _build_auto_slash_command helper from the COMMAND_REGISTRY auto-register loop and reuse it for plugin-registered commands. Built-in name conflicts are skipped. - gateway/run.py: before normal slash dispatch, call emit_collect on command: and honor deny/handled/rewrite/allow decisions. Hook now fires for plugin commands too. - scripts/release.py: AUTHOR_MAP entry for @Magaav. - Tests: emit_collect semantics, plugin command surfacing per platform, decision protocol (deny/handled/rewrite/allow + non-dict tolerance), Discord plugin auto-registration + conflict skipping, is_gateway_known_command. Salvaged from #14131 (@Magaav). Original PR added a parallel 'pre_gateway_command' hook and a platform-keyed plugin command registry; this re-implementation reuses the existing 'command:' hook and treats plugin commands as platform-agnostic so the same capability reaches Telegram and Slack without new API surface. Co-authored-by: Magaav <73175452+Magaav@users.noreply.github.com> --- gateway/hooks.py | 55 ++++- gateway/platforms/discord.py | 99 ++++++--- gateway/run.py | 76 +++++-- hermes_cli/commands.py | 65 ++++++ hermes_cli/plugins.py | 9 + scripts/release.py | 1 + tests/gateway/test_discord_slash_commands.py | 83 ++++++++ tests/gateway/test_hooks.py | 96 +++++++++ tests/gateway/test_unknown_command.py | 209 ++++++++++++++++++- tests/hermes_cli/test_commands.py | 116 ++++++++++ tests/hermes_cli/test_plugins.py | 27 +++ 11 files changed, 778 insertions(+), 58 deletions(-) diff --git a/gateway/hooks.py b/gateway/hooks.py index c50394b20..374e5b25f 100644 --- a/gateway/hooks.py +++ b/gateway/hooks.py @@ -135,9 +135,22 @@ class HookRegistry: except Exception as e: print(f"[hooks] Error loading hook {hook_dir.name}: {e}", flush=True) + def _resolve_handlers(self, event_type: str) -> List[Callable]: + """Return all handlers that should fire for ``event_type``. + + Exact matches fire first, followed by wildcard matches (e.g. + ``command:*`` matches ``command:reset``). + """ + handlers = list(self._handlers.get(event_type, [])) + if ":" in event_type: + base = event_type.split(":")[0] + wildcard_key = f"{base}:*" + handlers.extend(self._handlers.get(wildcard_key, [])) + return handlers + async def emit(self, event_type: str, context: Optional[Dict[str, Any]] = None) -> None: """ - Fire all handlers registered for an event. + Fire all handlers registered for an event, discarding return values. Supports wildcard matching: handlers registered for "command:*" will fire for any "command:..." event. Handlers registered for a base type @@ -151,16 +164,7 @@ class HookRegistry: if context is None: context = {} - # Collect handlers: exact match + wildcard match - handlers = list(self._handlers.get(event_type, [])) - - # Check for wildcard patterns (e.g., "command:*" matches "command:reset") - if ":" in event_type: - base = event_type.split(":")[0] - wildcard_key = f"{base}:*" - handlers.extend(self._handlers.get(wildcard_key, [])) - - for fn in handlers: + for fn in self._resolve_handlers(event_type): try: result = fn(event_type, context) # Support both sync and async handlers @@ -168,3 +172,32 @@ class HookRegistry: await result except Exception as e: print(f"[hooks] Error in handler for '{event_type}': {e}", flush=True) + + async def emit_collect( + self, + event_type: str, + context: Optional[Dict[str, Any]] = None, + ) -> List[Any]: + """Fire handlers and return their non-None return values in order. + + Like :meth:`emit` but captures each handler's return value. Used for + decision-style hooks (e.g. ``command:`` policies that want to + allow/deny/rewrite the command before normal dispatch). + + Exceptions from individual handlers are logged but do not abort the + remaining handlers. + """ + if context is None: + context = {} + + results: List[Any] = [] + for fn in self._resolve_handlers(event_type): + try: + result = fn(event_type, context) + if asyncio.iscoroutine(result): + result = await result + if result is not None: + results.append(result) + except Exception as e: + print(f"[hooks] Error in handler for '{event_type}': {e}", flush=True) + return results diff --git a/gateway/platforms/discord.py b/gateway/platforms/discord.py index d43e18d73..9857b8ffd 100644 --- a/gateway/platforms/discord.py +++ b/gateway/platforms/discord.py @@ -2129,10 +2129,42 @@ class DiscordAdapter(BasePlatformAdapter): # This ensures new commands added to COMMAND_REGISTRY in # hermes_cli/commands.py automatically appear as Discord slash # commands without needing a manual entry here. + def _build_auto_slash_command(_name: str, _description: str, _args_hint: str = ""): + """Build a discord.app_commands.Command that proxies to _run_simple_slash.""" + discord_name = _name.lower()[:32] + desc = (_description or f"Run /{_name}")[:100] + has_args = bool(_args_hint) + + if has_args: + def _make_args_handler(__name: str, __hint: str): + @discord.app_commands.describe(args=f"Arguments: {__hint}"[:100]) + async def _handler(interaction: discord.Interaction, args: str = ""): + await self._run_simple_slash( + interaction, f"/{__name} {args}".strip() + ) + _handler.__name__ = f"auto_slash_{__name.replace('-', '_')}" + return _handler + + handler = _make_args_handler(_name, _args_hint) + else: + def _make_simple_handler(__name: str): + async def _handler(interaction: discord.Interaction): + await self._run_simple_slash(interaction, f"/{__name}") + _handler.__name__ = f"auto_slash_{__name.replace('-', '_')}" + return _handler + + handler = _make_simple_handler(_name) + + return discord.app_commands.Command( + name=discord_name, + description=desc, + callback=handler, + ) + + already_registered: set[str] = set() try: from hermes_cli.commands import COMMAND_REGISTRY, _is_gateway_available, _resolve_config_gates - already_registered = set() try: already_registered = {cmd.name for cmd in tree.get_commands()} except Exception: @@ -2147,38 +2179,10 @@ class DiscordAdapter(BasePlatformAdapter): discord_name = cmd_def.name.lower()[:32] if discord_name in already_registered: continue - # Skip aliases that overlap with already-registered names - # (aliases for explicitly registered commands are handled above). - desc = (cmd_def.description or f"Run /{cmd_def.name}")[:100] - has_args = bool(cmd_def.args_hint) - - if has_args: - # Command takes optional arguments — create handler with - # an optional ``args`` string parameter. - def _make_args_handler(_name: str, _hint: str): - @discord.app_commands.describe(args=f"Arguments: {_hint}"[:100]) - async def _handler(interaction: discord.Interaction, args: str = ""): - await self._run_simple_slash( - interaction, f"/{_name} {args}".strip() - ) - _handler.__name__ = f"auto_slash_{_name.replace('-', '_')}" - return _handler - - handler = _make_args_handler(cmd_def.name, cmd_def.args_hint) - else: - # Parameterless command. - def _make_simple_handler(_name: str): - async def _handler(interaction: discord.Interaction): - await self._run_simple_slash(interaction, f"/{_name}") - _handler.__name__ = f"auto_slash_{_name.replace('-', '_')}" - return _handler - - handler = _make_simple_handler(cmd_def.name) - - auto_cmd = discord.app_commands.Command( - name=discord_name, - description=desc, - callback=handler, + auto_cmd = _build_auto_slash_command( + cmd_def.name, + cmd_def.description, + cmd_def.args_hint, ) try: tree.add_command(auto_cmd) @@ -2195,6 +2199,35 @@ class DiscordAdapter(BasePlatformAdapter): except Exception as e: logger.warning("Discord auto-register from COMMAND_REGISTRY failed: %s", e) + # ── Plugin-registered slash commands ── + # Plugins register via PluginContext.register_command(); we mirror + # those into Discord's native slash picker so users get the same + # autocomplete UX as for built-in commands. No per-platform plugin + # API needed — plugin commands are platform-agnostic. + try: + from hermes_cli.commands import _iter_plugin_command_entries + + for plugin_name, plugin_desc, plugin_args_hint in _iter_plugin_command_entries(): + discord_name = plugin_name.lower()[:32] + if discord_name in already_registered: + continue + auto_cmd = _build_auto_slash_command( + plugin_name, + plugin_desc, + plugin_args_hint, + ) + try: + tree.add_command(auto_cmd) + already_registered.add(discord_name) + except Exception: + # Silently skip commands that fail registration (e.g. + # name conflict with a subcommand group). + pass + except Exception as e: + logger.warning( + "Discord auto-register from plugin commands failed: %s", e + ) + # Register skills under a single /skill command group with category # subcommand groups. This uses 1 top-level slot instead of N, # supporting up to 25 categories × 25 skills = 625 skills. diff --git a/gateway/run.py b/gateway/run.py index 51c9c529f..3b6fa718d 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -3485,23 +3485,73 @@ class GatewayRunner: # Check for commands command = event.get_command() - - # Emit command:* hook for any recognized slash command. - # GATEWAY_KNOWN_COMMANDS is derived from the central COMMAND_REGISTRY - # in hermes_cli/commands.py — no hardcoded set to maintain here. - from hermes_cli.commands import GATEWAY_KNOWN_COMMANDS, resolve_command as _resolve_cmd - if command and command in GATEWAY_KNOWN_COMMANDS: - await self.hooks.emit(f"command:{command}", { - "platform": source.platform.value if source.platform else "", - "user_id": source.user_id, - "command": command, - "args": event.get_command_args().strip(), - }) - # Resolve aliases to canonical name so dispatch only checks canonicals. + from hermes_cli.commands import ( + GATEWAY_KNOWN_COMMANDS, + is_gateway_known_command, + resolve_command as _resolve_cmd, + ) + + # Resolve aliases to canonical name so dispatch and hook names + # don't depend on the exact alias the user typed. _cmd_def = _resolve_cmd(command) if command else None canonical = _cmd_def.name if _cmd_def else command + # Fire the ``command:`` hook for any recognized slash + # command — built-in OR plugin-registered. Handlers can return a + # dict with ``{"decision": "deny" | "handled" | "rewrite", ...}`` + # to intercept dispatch before core handling runs. This replaces + # the previous fire-and-forget emit(): return values are now + # honored, but handlers that return nothing behave exactly as + # before (telemetry-style hooks keep working). + if command and is_gateway_known_command(canonical): + raw_args = event.get_command_args().strip() + hook_ctx = { + "platform": source.platform.value if source.platform else "", + "user_id": source.user_id, + "command": canonical, + "raw_command": command, + "args": raw_args, + "raw_args": raw_args, + } + try: + hook_results = await self.hooks.emit_collect( + f"command:{canonical}", hook_ctx + ) + except Exception as _hook_err: + logger.debug( + "command:%s hook dispatch failed (non-fatal): %s", + canonical, _hook_err, + ) + hook_results = [] + + for hook_result in hook_results: + if not isinstance(hook_result, dict): + continue + decision = str(hook_result.get("decision", "")).strip().lower() + if not decision or decision == "allow": + continue + if decision == "deny": + message = hook_result.get("message") + if isinstance(message, str) and message: + return message + return f"Command `/{command}` was blocked by a hook." + if decision == "handled": + message = hook_result.get("message") + return message if isinstance(message, str) and message else None + if decision == "rewrite": + new_command = str( + hook_result.get("command_name", "") + ).strip().lstrip("/") + if not new_command: + continue + new_args = str(hook_result.get("raw_args", "")).strip() + event.text = f"/{new_command} {new_args}".strip() + command = event.get_command() + _cmd_def = _resolve_cmd(command) if command else None + canonical = _cmd_def.name if _cmd_def else command + break + if canonical == "new": return await self._handle_reset_command(event) diff --git a/hermes_cli/commands.py b/hermes_cli/commands.py index 8b43a351f..87d73af58 100644 --- a/hermes_cli/commands.py +++ b/hermes_cli/commands.py @@ -260,6 +260,26 @@ GATEWAY_KNOWN_COMMANDS: frozenset[str] = frozenset( ) +def is_gateway_known_command(name: str | None) -> bool: + """Return True if ``name`` resolves to a gateway-dispatchable slash command. + + This covers both built-in commands (``GATEWAY_KNOWN_COMMANDS`` derived + from ``COMMAND_REGISTRY``) and plugin-registered commands, which are + looked up lazily so importing this module never forces plugin + discovery. Gateway code uses this to decide whether to emit + ``command:`` hooks — plugin commands get the same lifecycle + events as built-ins. + """ + if not name: + return False + if name in GATEWAY_KNOWN_COMMANDS: + return True + for plugin_name, _description, _args_hint in _iter_plugin_command_entries(): + if plugin_name == name: + return True + return False + + # Commands with explicit Level-2 running-agent handlers in gateway/run.py. # Listed here for introspection / tests; semantically a subset of # "all resolvable commands" — which is the real bypass set (see @@ -371,12 +391,47 @@ def gateway_help_lines() -> list[str]: return lines +def _iter_plugin_command_entries() -> list[tuple[str, str, str]]: + """Yield (name, description, args_hint) tuples for all plugin slash commands. + + Plugin commands are registered via + :func:`hermes_cli.plugins.PluginContext.register_command`. They behave + like ``CommandDef`` entries for gateway surfacing: they appear in the + Telegram command menu, in Slack's ``/hermes`` subcommand mapping, and + (via :func:`gateway.platforms.discord._register_slash_commands`) in + Discord's native slash command picker. + + Lookup is lazy so importing this module never forces plugin discovery + (which can trigger filesystem scans and environment-dependent + behavior). + """ + try: + from hermes_cli.plugins import get_plugin_commands + except Exception: + return [] + try: + commands = get_plugin_commands() or {} + except Exception: + return [] + entries: list[tuple[str, str, str]] = [] + for name, meta in commands.items(): + if not isinstance(name, str) or not isinstance(meta, dict): + continue + description = str(meta.get("description") or f"Run /{name}") + args_hint = str(meta.get("args_hint") or "").strip() + entries.append((name, description, args_hint)) + return entries + + def telegram_bot_commands() -> list[tuple[str, str]]: """Return (command_name, description) pairs for Telegram setMyCommands. Telegram command names cannot contain hyphens, so they are replaced with underscores. Aliases are skipped -- Telegram shows one menu entry per canonical command. + + Plugin-registered slash commands are included so plugins get native + autocomplete in Telegram without touching core code. """ overrides = _resolve_config_gates() result: list[tuple[str, str]] = [] @@ -386,6 +441,10 @@ def telegram_bot_commands() -> list[tuple[str, str]]: tg_name = _sanitize_telegram_name(cmd.name) if tg_name: result.append((tg_name, cmd.description)) + for name, description, _args_hint in _iter_plugin_command_entries(): + tg_name = _sanitize_telegram_name(name) + if tg_name: + result.append((tg_name, description)) return result @@ -750,6 +809,9 @@ def slack_subcommand_map() -> dict[str, str]: Maps both canonical names and aliases so /hermes bg do stuff works the same as /hermes background do stuff. + + Plugin-registered slash commands are included so ``/hermes `` + routes through the plugin handler. """ overrides = _resolve_config_gates() mapping: dict[str, str] = {} @@ -759,6 +821,9 @@ def slack_subcommand_map() -> dict[str, str]: mapping[cmd.name] = f"/{cmd.name}" for alias in cmd.aliases: mapping[alias] = f"/{alias}" + for name, _description, _args_hint in _iter_plugin_command_entries(): + if name not in mapping: + mapping[name] = f"/{name}" return mapping diff --git a/hermes_cli/plugins.py b/hermes_cli/plugins.py index 3dd7af823..2dc1b50ea 100644 --- a/hermes_cli/plugins.py +++ b/hermes_cli/plugins.py @@ -283,6 +283,7 @@ class PluginContext: name: str, handler: Callable, description: str = "", + args_hint: str = "", ) -> None: """Register a slash command (e.g. ``/lcm``) available in CLI and gateway sessions. @@ -293,6 +294,13 @@ class PluginContext: terminal commands), this registers in-session slash commands that users invoke during a conversation. + ``args_hint`` is an optional short string (e.g. ``""`` or + ``"dias:7 formato:json"``) used by gateway adapters to surface the + command with an argument field — for example Discord's native slash + command picker. Plugin commands without ``args_hint`` register as + parameterless in Discord and still accept trailing text when invoked + as free-form chat. + Names conflicting with built-in commands are rejected with a warning. """ clean = name.lower().strip().lstrip("/").replace(" ", "-") @@ -320,6 +328,7 @@ class PluginContext: "handler": handler, "description": description or "Plugin command", "plugin": self.manifest.name, + "args_hint": (args_hint or "").strip(), } logger.debug("Plugin %s registered command: /%s", self.manifest.name, clean) diff --git a/scripts/release.py b/scripts/release.py index 1bf56a0ed..f76e3f59c 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -182,6 +182,7 @@ AUTHOR_MAP = { "adavyasharma@gmail.com": "adavyas", "acaayush1111@gmail.com": "aayushchaudhary", "jason@outland.art": "jasonoutland", + "73175452+Magaav@users.noreply.github.com": "Magaav", "mrflu1918@proton.me": "SPANISHFLU", "morganemoss@gmai.com": "mormio", "kopjop926@gmail.com": "cesareth", diff --git a/tests/gateway/test_discord_slash_commands.py b/tests/gateway/test_discord_slash_commands.py index 1c3ec2625..7e1f5d4a8 100644 --- a/tests/gateway/test_discord_slash_commands.py +++ b/tests/gateway/test_discord_slash_commands.py @@ -199,6 +199,89 @@ async def test_auto_registered_command_with_args(adapter): ) +@pytest.mark.asyncio +async def test_auto_registers_plugin_commands_for_discord(adapter): + """Plugin slash commands should appear as native Discord app commands.""" + adapter._run_simple_slash = AsyncMock() + + with patch( + "hermes_cli.plugins.get_plugin_commands", + return_value={ + "metricas": { + "handler": lambda _a: "ok", + "description": "Metrics dashboard", + "args_hint": "dias:7 formato:json", + "plugin": "metrics-plugin", + } + }, + ): + adapter._register_slash_commands() + + tree_names = set(adapter._client.tree.commands.keys()) + assert "metricas" in tree_names + + metricas_cmd = adapter._client.tree.commands["metricas"] + interaction = SimpleNamespace() + await metricas_cmd.callback(interaction, args="dias:7 formato:json") + adapter._run_simple_slash.assert_awaited_once_with( + interaction, "/metricas dias:7 formato:json" + ) + + +@pytest.mark.asyncio +async def test_auto_registered_plugin_command_without_args_hint(adapter): + """Plugin commands without args_hint should register as parameterless.""" + adapter._run_simple_slash = AsyncMock() + + with patch( + "hermes_cli.plugins.get_plugin_commands", + return_value={ + "ping": { + "handler": lambda _a: "pong", + "description": "Ping the plugin", + "args_hint": "", + "plugin": "ping-plugin", + } + }, + ): + adapter._register_slash_commands() + + assert "ping" in adapter._client.tree.commands + ping_cmd = adapter._client.tree.commands["ping"] + interaction = SimpleNamespace() + await ping_cmd.callback(interaction) + adapter._run_simple_slash.assert_awaited_once_with(interaction, "/ping") + + +@pytest.mark.asyncio +async def test_plugin_command_name_conflict_skipped(adapter): + """A plugin command that collides with a built-in must not override it.""" + adapter._run_simple_slash = AsyncMock() + + with patch( + "hermes_cli.plugins.get_plugin_commands", + return_value={ + "status": { + "handler": lambda _a: "plugin-status", + "description": "Plugin status", + "args_hint": "", + "plugin": "shadow-plugin", + } + }, + ): + adapter._register_slash_commands() + + # Built-ins are registered via @tree.command as plain functions. A + # plugin-registered override would install a _FakeCommand instance + # (has .callback) via tree.add_command. If the conflict-skip logic + # fires, the slot remains a bare function. + status_entry = adapter._client.tree.commands["status"] + assert callable(status_entry) and not hasattr(status_entry, "callback"), ( + "plugin registration overrode the built-in /status command — " + "the already_registered skip must prevent this" + ) + + # ------------------------------------------------------------------ # _handle_thread_create_slash — success, session dispatch, failure # ------------------------------------------------------------------ diff --git a/tests/gateway/test_hooks.py b/tests/gateway/test_hooks.py index 1301aebae..ac9e51919 100644 --- a/tests/gateway/test_hooks.py +++ b/tests/gateway/test_hooks.py @@ -220,3 +220,99 @@ class TestEmit: await reg.emit("agent:start") # no context arg assert captured[0] == {} + + +class TestEmitCollect: + """Tests for emit_collect() — returns handler return values for decision-style hooks.""" + + @pytest.mark.asyncio + async def test_collects_sync_return_values(self): + reg = HookRegistry() + reg._handlers["command:status"] = [ + lambda _e, _c: {"decision": "allow"}, + lambda _e, _c: {"decision": "deny", "message": "nope"}, + ] + + results = await reg.emit_collect("command:status", {}) + + assert results == [ + {"decision": "allow"}, + {"decision": "deny", "message": "nope"}, + ] + + @pytest.mark.asyncio + async def test_collects_async_return_values(self): + reg = HookRegistry() + + async def _async_handler(_event_type, _ctx): + return {"decision": "handled", "message": "done"} + + reg._handlers["command:ping"] = [_async_handler] + + results = await reg.emit_collect("command:ping", {}) + + assert results == [{"decision": "handled", "message": "done"}] + + @pytest.mark.asyncio + async def test_drops_none_return_values(self): + reg = HookRegistry() + reg._handlers["command:x"] = [ + lambda _e, _c: None, # fire-and-forget, returns nothing + lambda _e, _c: {"decision": "deny"}, + lambda _e, _c: None, + ] + + results = await reg.emit_collect("command:x", {}) + + assert results == [{"decision": "deny"}] + + @pytest.mark.asyncio + async def test_handler_exception_does_not_abort_chain(self): + reg = HookRegistry() + + def _raises(_e, _c): + raise ValueError("boom") + + reg._handlers["command:x"] = [ + _raises, + lambda _e, _c: {"decision": "allow"}, + ] + + results = await reg.emit_collect("command:x", {}) + + # First handler's exception is swallowed; second handler's value still collected. + assert results == [{"decision": "allow"}] + + @pytest.mark.asyncio + async def test_wildcard_match_also_collected(self): + reg = HookRegistry() + reg._handlers["command:*"] = [lambda _e, _c: {"decision": "allow"}] + reg._handlers["command:reset"] = [lambda _e, _c: {"decision": "deny"}] + + results = await reg.emit_collect("command:reset", {}) + + # Exact match fires first, then wildcard. + assert results == [{"decision": "deny"}, {"decision": "allow"}] + + @pytest.mark.asyncio + async def test_no_handlers_returns_empty_list(self): + reg = HookRegistry() + + results = await reg.emit_collect("unknown:event", {}) + + assert results == [] + + @pytest.mark.asyncio + async def test_default_context(self): + reg = HookRegistry() + captured = [] + + def _handler(event_type, context): + captured.append((event_type, context)) + return None + + reg._handlers["agent:start"] = [_handler] + + await reg.emit_collect("agent:start") # no context arg + + assert captured == [("agent:start", {})] diff --git a/tests/gateway/test_unknown_command.py b/tests/gateway/test_unknown_command.py index 4c644cb73..114134496 100644 --- a/tests/gateway/test_unknown_command.py +++ b/tests/gateway/test_unknown_command.py @@ -41,7 +41,11 @@ def _make_runner(): adapter.send = AsyncMock() runner.adapters = {Platform.TELEGRAM: adapter} runner._voice_mode = {} - runner.hooks = SimpleNamespace(emit=AsyncMock(), loaded_hooks=False) + runner.hooks = SimpleNamespace( + emit=AsyncMock(), + emit_collect=AsyncMock(return_value=[]), + loaded_hooks=False, + ) session_entry = SessionEntry( session_key=build_session_key(_make_source()), @@ -164,3 +168,206 @@ async def test_underscored_alias_for_hyphenated_builtin_not_flagged(monkeypatch) # Whatever /reload_mcp returns, it must not be the unknown-command guard. if result is not None: assert "Unknown command" not in result + + +# ------------------------------------------------------------------ +# command: decision hook — deny / handled / rewrite +# ------------------------------------------------------------------ + +@pytest.mark.asyncio +async def test_command_hook_can_deny_before_dispatch(monkeypatch): + """A handler returning {"decision": "deny"} blocks a slash command early.""" + import gateway.run as gateway_run + + runner = _make_runner() + runner._run_agent = AsyncMock( + side_effect=AssertionError("denied slash command leaked to the agent") + ) + runner._handle_status_command = AsyncMock( + side_effect=AssertionError("denied slash command reached its handler") + ) + runner.hooks.emit_collect = AsyncMock( + return_value=[{"decision": "deny", "message": "Blocked by ACL"}] + ) + + monkeypatch.setattr( + gateway_run, "_resolve_runtime_agent_kwargs", lambda: {"api_key": "***"} + ) + + result = await runner._handle_message(_make_event("/status")) + + assert result == "Blocked by ACL" + runner._run_agent.assert_not_called() + # The emit_collect call should use the canonical command name. + call_args = runner.hooks.emit_collect.await_args + assert call_args.args[0] == "command:status" + + +@pytest.mark.asyncio +async def test_command_hook_deny_without_message_uses_default(monkeypatch): + """A deny decision with no message falls back to a generic blocked string.""" + import gateway.run as gateway_run + + runner = _make_runner() + runner._handle_status_command = AsyncMock( + side_effect=AssertionError("denied slash command reached its handler") + ) + runner.hooks.emit_collect = AsyncMock(return_value=[{"decision": "deny"}]) + + monkeypatch.setattr( + gateway_run, "_resolve_runtime_agent_kwargs", lambda: {"api_key": "***"} + ) + + result = await runner._handle_message(_make_event("/status")) + + assert result is not None + assert "blocked" in result.lower() + + +@pytest.mark.asyncio +async def test_command_hook_can_mark_command_as_handled(monkeypatch): + """A handled decision short-circuits dispatch cleanly with a custom reply.""" + import gateway.run as gateway_run + + runner = _make_runner() + runner._handle_status_command = AsyncMock( + side_effect=AssertionError("handled slash command reached its handler") + ) + runner.hooks.emit_collect = AsyncMock( + return_value=[{"decision": "handled", "message": "Already handled upstream"}] + ) + + monkeypatch.setattr( + gateway_run, "_resolve_runtime_agent_kwargs", lambda: {"api_key": "***"} + ) + + result = await runner._handle_message(_make_event("/status")) + + assert result == "Already handled upstream" + + +@pytest.mark.asyncio +async def test_command_hook_allow_decision_is_passthrough(monkeypatch): + """A handler returning {"decision": "allow"} must NOT prevent normal dispatch.""" + import gateway.run as gateway_run + + runner = _make_runner() + runner._handle_status_command = AsyncMock(return_value="status: ok") + runner.hooks.emit_collect = AsyncMock( + return_value=[{"decision": "allow"}] + ) + + monkeypatch.setattr( + gateway_run, "_resolve_runtime_agent_kwargs", lambda: {"api_key": "***"} + ) + + result = await runner._handle_message(_make_event("/status")) + + assert result == "status: ok" + runner._handle_status_command.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_command_hook_non_dict_return_values_ignored(monkeypatch): + """Hook return values that aren't dicts must not break dispatch.""" + import gateway.run as gateway_run + + runner = _make_runner() + runner._handle_status_command = AsyncMock(return_value="status: ok") + runner.hooks.emit_collect = AsyncMock( + return_value=["some string", 42, None, {}] + ) + + monkeypatch.setattr( + gateway_run, "_resolve_runtime_agent_kwargs", lambda: {"api_key": "***"} + ) + + result = await runner._handle_message(_make_event("/status")) + + assert result == "status: ok" + + +@pytest.mark.asyncio +async def test_command_hook_fires_for_plugin_registered_command(monkeypatch): + """Plugin-registered slash commands should also trigger command: hooks.""" + import gateway.run as gateway_run + + runner = _make_runner() + runner._run_agent = AsyncMock( + side_effect=AssertionError("plugin command leaked to the agent") + ) + runner.hooks.emit_collect = AsyncMock( + return_value=[{"decision": "handled", "message": "intercepted"}] + ) + + monkeypatch.setattr( + gateway_run, "_resolve_runtime_agent_kwargs", lambda: {"api_key": "***"} + ) + # Stub plugin command lookup so is_gateway_known_command() recognizes /metricas. + from hermes_cli import plugins as _plugins_mod + + monkeypatch.setattr( + _plugins_mod, + "get_plugin_commands", + lambda: {"metricas": {"description": "Metrics", "args_hint": "dias:7"}}, + ) + + result = await runner._handle_message(_make_event("/metricas dias:7")) + + assert result == "intercepted" + # Hook event name uses the plugin command as canonical. + call_args = runner.hooks.emit_collect.await_args + assert call_args.args[0] == "command:metricas" + # Args are passed through in both "args" and "raw_args" keys. + ctx = call_args.args[1] + assert ctx["raw_args"] == "dias:7" + + +@pytest.mark.asyncio +async def test_command_hook_rewrite_routes_to_plugin(monkeypatch): + """A rewrite decision should re-resolve the command and route to the new one.""" + import gateway.run as gateway_run + + runner = _make_runner() + runner._run_agent = AsyncMock( + side_effect=AssertionError("rewritten command leaked to the agent") + ) + + call_log = [] + + async def _emit_collect(event_type, ctx): + call_log.append(event_type) + if event_type == "command:status": + return [ + { + "decision": "rewrite", + "command_name": "metricas", + "raw_args": "dias:7", + } + ] + return [] + + runner.hooks.emit_collect = AsyncMock(side_effect=_emit_collect) + + monkeypatch.setattr( + gateway_run, "_resolve_runtime_agent_kwargs", lambda: {"api_key": "***"} + ) + from hermes_cli import plugins as _plugins_mod + + monkeypatch.setattr( + _plugins_mod, + "get_plugin_commands", + lambda: {"metricas": {"description": "Metrics", "args_hint": "dias:7"}}, + ) + monkeypatch.setattr( + _plugins_mod, + "get_plugin_command_handler", + lambda name: (lambda args: f"metrics {args}") if name == "metricas" else None, + ) + + result = await runner._handle_message(_make_event("/status")) + + assert result == "metrics dias:7" + # First emit_collect fires on the original command; after rewrite the + # dispatcher does NOT re-fire for the new command (one decision per turn). + assert call_log == ["command:status"] diff --git a/tests/hermes_cli/test_commands.py b/tests/hermes_cli/test_commands.py index 49e114aef..a27f99661 100644 --- a/tests/hermes_cli/test_commands.py +++ b/tests/hermes_cli/test_commands.py @@ -1208,3 +1208,119 @@ class TestDiscordSkillCommandsByCategory: assert "axolotl" in names assert "vllm" in names assert len(uncategorized) == 0 + + +# --------------------------------------------------------------------------- +# Plugin slash command integration +# --------------------------------------------------------------------------- + +class TestPluginCommandEnumeration: + """Plugin commands registered via ctx.register_command() must be surfaced + by every gateway enumerator (Telegram menu, Slack subcommand map, etc.). + """ + + def _patch_plugin_commands(self, monkeypatch, commands): + """Monkeypatch hermes_cli.plugins.get_plugin_commands() to a fixed dict.""" + from hermes_cli import plugins as _plugins_mod + + monkeypatch.setattr( + _plugins_mod, "get_plugin_commands", lambda: dict(commands) + ) + + def test_plugin_command_appears_in_telegram_menu(self, monkeypatch): + """/metricas registered by a plugin must appear in Telegram BotCommand menu.""" + self._patch_plugin_commands(monkeypatch, { + "metricas": { + "handler": lambda _a: "ok", + "description": "Metrics dashboard", + "args_hint": "dias:7", + "plugin": "metrics-plugin", + } + }) + names = {name for name, _desc in telegram_bot_commands()} + assert "metricas" in names + + def test_plugin_command_appears_in_slack_subcommand_map(self, monkeypatch): + """/hermes metricas must route through the Slack subcommand map.""" + self._patch_plugin_commands(monkeypatch, { + "metricas": { + "handler": lambda _a: "ok", + "description": "Metrics", + "args_hint": "", + "plugin": "metrics-plugin", + } + }) + mapping = slack_subcommand_map() + assert mapping.get("metricas") == "/metricas" + + def test_plugin_command_does_not_shadow_builtin_in_slack(self, monkeypatch): + """If a plugin registers a name that collides with a built-in, the built-in mapping wins.""" + self._patch_plugin_commands(monkeypatch, { + "status": { + "handler": lambda _a: "plugin-status", + "description": "Plugin status", + "args_hint": "", + "plugin": "shadow-plugin", + } + }) + mapping = slack_subcommand_map() + # Built-in /status must still be present and not overwritten. + assert mapping.get("status") == "/status" + + def test_plugin_command_with_hyphens_sanitized_for_telegram(self, monkeypatch): + """Plugin names containing hyphens must be underscore-normalized for Telegram.""" + self._patch_plugin_commands(monkeypatch, { + "my-plugin-cmd": { + "handler": lambda _a: "ok", + "description": "desc", + "args_hint": "", + "plugin": "p", + } + }) + names = {name for name, _desc in telegram_bot_commands()} + assert "my_plugin_cmd" in names + assert "my-plugin-cmd" not in names + + def test_is_gateway_known_command_recognizes_plugin_commands(self, monkeypatch): + """is_gateway_known_command() must return True for plugin commands.""" + from hermes_cli.commands import is_gateway_known_command + + self._patch_plugin_commands(monkeypatch, { + "metricas": { + "handler": lambda _a: "ok", + "description": "Metrics", + "args_hint": "", + "plugin": "p", + } + }) + assert is_gateway_known_command("metricas") is True + assert is_gateway_known_command("definitely-not-registered") is False + + def test_is_gateway_known_command_still_recognizes_builtins(self, monkeypatch): + """Built-in commands must remain known even when plugin discovery fails.""" + from hermes_cli import plugins as _plugins_mod + from hermes_cli.commands import is_gateway_known_command + + def _boom(): + raise RuntimeError("plugin system down") + + monkeypatch.setattr(_plugins_mod, "get_plugin_commands", _boom) + + assert is_gateway_known_command("status") is True + assert is_gateway_known_command(None) is False + assert is_gateway_known_command("") is False + + def test_plugin_enumerator_handles_missing_plugin_manager(self, monkeypatch): + """Enumerators must never raise when plugin discovery raises.""" + from hermes_cli import plugins as _plugins_mod + + def _boom(): + raise RuntimeError("plugin system down") + + monkeypatch.setattr(_plugins_mod, "get_plugin_commands", _boom) + + # Both calls should succeed and just return the built-in set. + tg_names = {name for name, _desc in telegram_bot_commands()} + slack_names = set(slack_subcommand_map()) + assert "status" in tg_names + assert "status" in slack_names diff --git a/tests/hermes_cli/test_plugins.py b/tests/hermes_cli/test_plugins.py index 04d056771..2455547de 100644 --- a/tests/hermes_cli/test_plugins.py +++ b/tests/hermes_cli/test_plugins.py @@ -787,6 +787,33 @@ class TestPluginCommands: assert entry["handler"] is handler assert entry["description"] == "My custom command" assert entry["plugin"] == "test-plugin" + # args_hint defaults to empty string when not passed. + assert entry["args_hint"] == "" + + def test_register_command_with_args_hint(self): + """args_hint is stored and surfaced for gateway-native UI registration.""" + mgr = PluginManager() + manifest = PluginManifest(name="test-plugin", source="user") + ctx = PluginContext(manifest, mgr) + + ctx.register_command( + "metricas", + lambda a: a, + description="Metrics dashboard", + args_hint="dias:7 formato:json", + ) + + entry = mgr._plugin_commands["metricas"] + assert entry["args_hint"] == "dias:7 formato:json" + + def test_register_command_args_hint_whitespace_trimmed(self): + """args_hint leading/trailing whitespace is stripped.""" + mgr = PluginManager() + manifest = PluginManifest(name="test-plugin", source="user") + ctx = PluginContext(manifest, mgr) + + ctx.register_command("foo", lambda a: a, args_hint=" ") + assert mgr._plugin_commands["foo"]["args_hint"] == "" def test_register_command_normalizes_name(self): """Names are lowercased, stripped, and leading slashes removed."""