From 597d2230eaeb5eb14fb4a99c4fdf8d221007181d Mon Sep 17 00:00:00 2001 From: Samuel Odio Date: Sat, 29 Aug 2026 05:57:51 -0700 Subject: [PATCH] refactor(gateway): share plain command dispatch --- gateway/run.py | 114 +++++------------- .../test_gateway_command_dispatch_minimal.py | 23 ++++ website/docs/reference/slash-commands.md | 6 +- .../current/reference/slash-commands.md | 8 +- 4 files changed, 60 insertions(+), 91 deletions(-) diff --git a/gateway/run.py b/gateway/run.py index 93bf4160d9..00b62aeb3e 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -17153,6 +17153,32 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew "moa": "Agent is running — wait or /stop first, then run /moa.", } + def _gateway_plain_command_handlers(self): + """Return ordinary slash handlers shared by idle and busy dispatch.""" + return { + "status": self._handle_status_command, + "context": self._handle_context_command, + "restart": self._handle_restart_command, + "approve": self._handle_approve_command, + "deny": self._handle_deny_command, + "pause": self._handle_pause_command, + "agents": self._handle_agents_command, + "bg": self._handle_background_command, + "btw": self._handle_btw_command, + "kanban": self._handle_kanban_command, + "subgoal": self._handle_subgoal_command, + "heartbeat": self._handle_heartbeat_command, + "busy": self._handle_busy_command, + "yolo": self._handle_yolo_command, + "verbose": self._handle_verbose_command, + "footer": self._handle_footer_command, + "help": self._handle_help_command, + "commands": self._handle_commands_command, + "profile": self._handle_profile_command, + "update": self._handle_update_command, + "version": self._handle_version_command, + } + async def _dispatch_busy_slash_command( self, event: MessageEvent, cmd_def, quick_key: str, source, ): @@ -17193,29 +17219,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew return reject_text if policy in ("dispatch", "interrupt_then_dispatch"): - plain = { - "status": self._handle_status_command, - "context": self._handle_context_command, - "restart": self._handle_restart_command, - "approve": self._handle_approve_command, - "deny": self._handle_deny_command, - "pause": self._handle_pause_command, - "agents": self._handle_agents_command, - "bg": self._handle_background_command, - "btw": self._handle_btw_command, - "kanban": self._handle_kanban_command, - "subgoal": self._handle_subgoal_command, - "heartbeat": self._handle_heartbeat_command, - "busy": self._handle_busy_command, - "yolo": self._handle_yolo_command, - "verbose": self._handle_verbose_command, - "footer": self._handle_footer_command, - "help": self._handle_help_command, - "commands": self._handle_commands_command, - "profile": self._handle_profile_command, - "update": self._handle_update_command, - "version": self._handle_version_command, - }.get(name) + plain = self._gateway_plain_command_handlers().get(name) if plain is not None: return await plain(event) logger.warning( @@ -18319,8 +18323,9 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew canonical = _cmd_def.name if _cmd_def else command break - if canonical == "pause": - return await self._handle_pause_command(event) + plain_handler = self._gateway_plain_command_handlers().get(canonical) + if plain_handler is not None: + return await plain_handler(event) if canonical == "new": if await asyncio.to_thread(self._is_telegram_topic_root_lobby, source): @@ -18341,42 +18346,21 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew if canonical == "topic": return await self._handle_topic_command(event) - if canonical == "help": - return await self._handle_help_command(event) - if canonical == "start": logger.info("Ignoring /start platform ping for session %s", _quick_key) return "" - if canonical == "commands": - return await self._handle_commands_command(event) - - if canonical == "profile": - return await self._handle_profile_command(event) - if canonical == "whoami": return await self._handle_whoami_command(event) - if canonical == "status": - return await self._handle_status_command(event) - if canonical == "egress": from hermes_cli.proxy_cli import format_status_text return format_status_text() - if canonical == "context": - return await self._handle_context_command(event) - - if canonical == "agents": - return await self._handle_agents_command(event) - if canonical == "platform": return await self._handle_platform_command(event) - if canonical == "restart": - return await self._handle_restart_command(event) - if canonical == "stop": return await self._handle_stop_command(event) @@ -18448,21 +18432,9 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew if canonical == "fast": return await self._handle_fast_command(event) - if canonical == "verbose": - return await self._handle_verbose_command(event) - - if canonical == "footer": - return await self._handle_footer_command(event) - - if canonical == "yolo": - return await self._handle_yolo_command(event) - if canonical == "approvals": return await self._handle_approvals_command(event) - if canonical == "busy": - return await self._handle_busy_command(event) - if canonical == "model": return await self._handle_model_command(event) @@ -18472,9 +18444,6 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew if canonical == "personality": return await self._handle_personality_command(event) - if canonical == "kanban": - return await self._handle_kanban_command(event) - if canonical == "suggestions": return await self._handle_suggestions_command(event) @@ -18559,18 +18528,6 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew if canonical == "bundles": return await self._handle_bundles_command(event) - if canonical == "approve": - return await self._handle_approve_command(event) - - if canonical == "deny": - return await self._handle_deny_command(event) - - if canonical == "update": - return await self._handle_update_command(event) - - if canonical == "version": - return await self._handle_version_command(event) - if canonical == "debug": return await self._handle_debug_command(event) @@ -18592,12 +18549,6 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew if canonical == "diff": return await self._handle_diff_command(event) - if canonical == "bg": - return await self._handle_background_command(event) - - if canonical == "btw": - return await self._handle_btw_command(event) - if canonical == "queue": queue_payload = event.get_command_args().strip() if not queue_payload: @@ -18628,8 +18579,6 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew if canonical == "loop": return await self._handle_loop_command(event) - if canonical == "heartbeat": - return await self._handle_heartbeat_command(event) if canonical == "refine": return await self._handle_refine_command(event) if canonical == "review": @@ -18671,9 +18620,6 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew except Exception: return "Failed to prepare MoA turn." - if canonical == "subgoal": - return await self._handle_subgoal_command(event) - if canonical == "voice": return await self._handle_voice_command(event) diff --git a/tests/gateway/test_gateway_command_dispatch_minimal.py b/tests/gateway/test_gateway_command_dispatch_minimal.py index 9f3f5ee961..8682b37bd7 100644 --- a/tests/gateway/test_gateway_command_dispatch_minimal.py +++ b/tests/gateway/test_gateway_command_dispatch_minimal.py @@ -129,3 +129,26 @@ async def test_idle_queue_sends_payload_as_next_turn(command_text): assert runner._running_agents == {} +@pytest.mark.asyncio +async def test_plain_handler_map_drives_idle_and_busy_dispatch(): + """Ordinary handlers have one lookup seam for idle and busy sessions.""" + from hermes_cli.commands import resolve_command + + runner, _adapter = _make_runner() + handler = AsyncMock(side_effect=["idle", "busy"]) + runner._gateway_plain_command_handlers = lambda: {"busy": handler} + idle_event = _make_event("/busy status") + + assert await runner._handle_message(idle_event) == "idle" + + busy_event = _make_event("/busy status") + cmd_def = resolve_command("busy") + assert cmd_def is not None + assert await runner._dispatch_busy_slash_command( + busy_event, + cmd_def, + build_session_key(busy_event.source), + busy_event.source, + ) == "busy" + assert handler.await_count == 2 + diff --git a/website/docs/reference/slash-commands.md b/website/docs/reference/slash-commands.md index ba43b6e212..608eb80b15 100644 --- a/website/docs/reference/slash-commands.md +++ b/website/docs/reference/slash-commands.md @@ -92,7 +92,7 @@ Type `/` in the CLI to open the autocomplete menu. Built-in commands are case-in | `/yolo` | Toggle YOLO mode — skip all dangerous command approval prompts. | | `/approvals [manual\|smart\|off]` | Show or set the persistent dangerous-command approval mode. | | `/footer [on\|off\|status]` | Toggle the gateway runtime-metadata footer on final replies (shows model, context %, and cwd). | -| `/busy [queue\|steer\|interrupt\|status]` | CLI-only: control what pressing Enter does while Hermes is working — queue the new message, steer mid-turn, or interrupt immediately. | +| `/busy [queue\|steer\|interrupt\|status]` | Control what happens when you message while Hermes is working — queue the new message, steer mid-turn, or interrupt immediately. Works in the CLI and messaging gateway. | | `/indicator [kaomoji\|emoji\|unicode\|ascii]` | CLI-only: pick the TUI busy-indicator style. | | `/timestamps [on\|off\|status]` | CLI-only: toggle `[HH:MM]` timestamps on messages and in `/history`. | | `/wake [on\|off\|status]` | CLI-only: toggle the "Hey Hermes" wake word listener. | @@ -292,12 +292,12 @@ The messaging gateway supports the following built-in commands inside Telegram, ## Notes -- `/skin`, `/snapshot`, `/export`, `/import`, `/reload`, `/tools`, `/toolsets`, `/browser`, `/config`, `/cron`, `/platforms`, `/paste`, `/image`, `/statusbar`, `/battery`, `/focus`, `/plugins`, `/busy`, `/indicator`, `/wake`, `/journey`, `/redraw`, `/clear`, `/history`, `/save`, `/copy`, `/handoff`, `/prompt`, `/pet`, `/hatch`, `/timestamps`, `/subscription`, and `/quit` are **CLI-only** commands. +- `/skin`, `/snapshot`, `/export`, `/import`, `/reload`, `/tools`, `/toolsets`, `/browser`, `/config`, `/cron`, `/platforms`, `/paste`, `/image`, `/statusbar`, `/battery`, `/focus`, `/plugins`, `/indicator`, `/wake`, `/journey`, `/redraw`, `/clear`, `/history`, `/save`, `/copy`, `/handoff`, `/prompt`, `/pet`, `/hatch`, `/timestamps`, `/subscription`, and `/quit` are **CLI-only** commands. - `/skills` is **CLI-only for search/browse/install**; its write-approval review subcommands (`pending`, `approve`, `reject`, `diff`, `approval`) also work on messaging platforms when `skills.write_approval` is on. `/memory` works on **both** surfaces. - `/verbose` is **CLI-only by default**, but can be enabled for messaging platforms by setting `display.tool_progress_command: true` in `config.yaml`. When enabled, it cycles the `display.tool_progress` mode and saves to config. - `/focus` and `/verbose` share one suppression path (`display.tool_progress`), so they can never contradict each other: `/focus on` pins tool progress to `off` and stashes your mode under `display.focus_saved_tool_progress`; `/focus off` restores it; cycling `/verbose` while focus is on takes the mode back and clears the focus badge. Focus view is display-only — it never changes conversation history, the system prompt, or anything sent to the model, so it has zero prompt-cache impact. - `/sethome`, `/restart`, `/approve`, `/deny`, `/topic`, `/platform`, and `/commands` are **messaging-only** commands. -- `/status`, `/egress`, `/version`, `/whoami`, `/bg`, `/btw`, `/queue`, `/steer`, `/voice`, `/reload-mcp`, `/reload-skills`, `/rollback`, `/diff`, `/debug`, `/fast`, `/approvals`, `/footer`, `/curator`, `/kanban`, `/topup`, `/suggestions`, `/blueprint`, `/learn`, `/init`, `/sessions`, and `/yolo` work in **both** the CLI and the messaging gateway. +- `/status`, `/egress`, `/version`, `/whoami`, `/bg`, `/btw`, `/queue`, `/steer`, `/voice`, `/reload-mcp`, `/reload-skills`, `/rollback`, `/diff`, `/debug`, `/fast`, `/approvals`, `/busy`, `/footer`, `/curator`, `/kanban`, `/topup`, `/suggestions`, `/blueprint`, `/learn`, `/init`, `/sessions`, and `/yolo` work in **both** the CLI and the messaging gateway. - `/voice join`, `/voice channel`, and `/voice leave` are only meaningful on Discord. - In the TUI, `/sessions` shows live sessions in the current TUI process. Use `/resume [name]` or `hermes --tui --resume ` for saved or closed transcripts. diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/reference/slash-commands.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/reference/slash-commands.md index 4af5a9f45c..f40215a40c 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/reference/slash-commands.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/reference/slash-commands.md @@ -77,7 +77,7 @@ Hermes 有两个斜杠命令入口,均由 `hermes_cli/commands.py` 中的中 | `/voice [on\|off\|tts\|status]` | 切换 CLI 语音模式和语音播放。录音使用 `voice.record_key`(默认:`Ctrl+B`)。 | | `/yolo` | 切换 YOLO 模式——跳过所有危险命令审批提示。 | | `/footer [on\|off\|status]` | 切换最终回复中的 gateway 运行时元数据页脚(显示模型、工具调用次数、耗时)。 | -| `/busy [queue\|steer\|interrupt\|status]` | 仅限 CLI:控制 Hermes 工作时按下 Enter 的行为——将新消息加入队列、中途引导,或立即中断。 | +| `/busy [queue\|steer\|interrupt\|status]` | 控制 Hermes 工作时发送消息的行为——将新消息加入队列、中途引导,或立即中断。支持 CLI 和消息 gateway。 | | `/indicator [kaomoji\|emoji\|unicode\|ascii]` | 仅限 CLI:选择 TUI 忙碌指示器样式。 | ### 工具与 Skill @@ -247,11 +247,11 @@ hermes config set model.aliases.grok x-ai/grok-4 ## 注意事项 -- `/skin`、`/snapshot`、`/reload`、`/tools`、`/toolsets`、`/browser`、`/config`、`/cron`、`/platforms`、`/paste`、`/image`、`/statusbar`、`/plugins`、`/busy`、`/indicator`、`/redraw`、`/clear`、`/history`、`/save`、`/copy`、`/handoff`、`/billing` 和 `/quit` 是**仅限 CLI** 的命令。 +- `/skin`、`/snapshot`、`/reload`、`/tools`、`/toolsets`、`/browser`、`/config`、`/cron`、`/platforms`、`/paste`、`/image`、`/statusbar`、`/plugins`、`/indicator`、`/redraw`、`/clear`、`/history`、`/save`、`/copy`、`/handoff`、`/billing` 和 `/quit` 是**仅限 CLI** 的命令。 - `/skills` **仅在搜索/浏览/安装时属于 CLI-only**;其写入审批子命令(`pending`、`approve`、`reject`、`diff`、`approval`)在 `skills.write_approval` 开启时也可在消息平台使用。`/memory` 可在**两个表面**使用。 - `/verbose` **默认仅限 CLI**,但可通过在 `config.yaml` 中设置 `display.tool_progress_command: true` 为消息平台启用。启用后,它会循环切换 `display.tool_progress` 模式并保存到配置。 - `/sethome`、`/update`、`/restart`、`/approve`、`/deny`、`/topic`、`/platform` 和 `/commands` 是**仅限消息平台**的命令。 -- `/status`、`/version`、`/bg`、`/btw`、`/queue`、`/steer`、`/voice`、`/reload-mcp`、`/reload-skills`、`/rollback`、`/debug`、`/fast`、`/footer`、`/curator`、`/kanban`、`/credits`、`/suggestions`、`/blueprint`、`/sessions` 和 `/yolo` 在 **CLI 和消息 gateway 中均可使用**。 +- `/status`、`/version`、`/bg`、`/btw`、`/queue`、`/steer`、`/voice`、`/reload-mcp`、`/reload-skills`、`/rollback`、`/debug`、`/fast`、`/busy`、`/footer`、`/curator`、`/kanban`、`/credits`、`/suggestions`、`/blueprint`、`/sessions` 和 `/yolo` 在 **CLI 和消息 gateway 中均可使用**。 - `/voice join`、`/voice channel` 和 `/voice leave` 仅在 Discord 上有意义。 ## 破坏性命令的确认提示 @@ -269,4 +269,4 @@ CLI 在执行会丢弃未保存会话状态的斜杠命令前会提示确认。 **内联跳过:** 追加 `now`、`--yes` 或 `-y` 可为单次调用绕过弹窗——例如 `/reset now`、`/new --yes my-session`、`/clear -y`、`/undo -y`。适用于弹窗在你的终端无法正常渲染的情况(见 [issue #30768](https://github.com/NousResearch/hermes-agent/issues/30768),原生 Windows PowerShell)或对 CLI 进行脚本化操作时。 -在 `~/.hermes/config.yaml` 中设置 `approvals.destructive_slash_confirm: false` 可全局禁用提示;设置回 `true` 可重新启用。背景说明见 [安全——破坏性斜杠命令确认](../user-guide/security.md#dangerous-command-approval)。 \ No newline at end of file +在 `~/.hermes/config.yaml` 中设置 `approvals.destructive_slash_confirm: false` 可全局禁用提示;设置回 `true` 可重新启用。背景说明见 [安全——破坏性斜杠命令确认](../user-guide/security.md#dangerous-command-approval)。