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:
@@ -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
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@@ -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", {})]
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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."""
|
||||
|
||||
Reference in New Issue
Block a user