feat(gateway): expose plugin slash commands natively on all platforms + decision-capable command hook

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:<name>' 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:<canonical> 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:<name>'
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>
This commit is contained in:
Teknium 2026-04-22 15:01:50 -07:00 committed by Teknium
parent c96a548bde
commit 51ca575994
11 changed files with 778 additions and 58 deletions

View file

@ -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:<name>`` 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

View file

@ -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.

View file

@ -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:<canonical>`` 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)

View file

@ -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:<name>`` 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 <plugin-cmd>``
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

View file

@ -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. ``"<file>"`` 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)

View file

@ -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",

View file

@ -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
# ------------------------------------------------------------------

View file

@ -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", {})]

View file

@ -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:<name> 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:<name> 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"]

View file

@ -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

View file

@ -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=" <file> ")
assert mgr._plugin_commands["foo"]["args_hint"] == "<file>"
def test_register_command_normalizes_name(self):
"""Names are lowercased, stripped, and leading slashes removed."""