From 74c997d9851581f169b35e33a633255f40bcbc6b Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Sun, 3 May 2026 22:02:24 +1000 Subject: [PATCH] fix(gateway): move quick-command dispatch before built-in handlers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Quick commands of type "alias" that target built-in slash commands (e.g. /h -> /model) were processed too late in _handle_message — after the if-canonical=="model" checks. This meant alias expansion never reached the target handler and fell through to the LLM as raw text. Two fixes: 1. Move the quick_commands block before built-in dispatch so alias targets (like /model) hit the correct handler after expansion. 2. Extract bare command name from target_command via .split()[0] to feed _resolve_cmd() correctly (was using the full arg-string). --- gateway/run.py | 24 +++++++++++++++++++++++- tests/e2e/test_platform_commands.py | 23 +++++++++++++++++++++++ 2 files changed, 46 insertions(+), 1 deletion(-) diff --git a/gateway/run.py b/gateway/run.py index d4f2ba8d25..f023b0d349 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -5138,6 +5138,28 @@ class GatewayRunner: _cmd_def = _resolve_cmd(command) if command else None canonical = _cmd_def.name if _cmd_def else command + # Expand alias quick commands before built-in dispatch so targets like + # /model openai/gpt-5.5 --provider openrouter reach the /model handler. + # Preserve built-in precedence; aliases only need early handling when + # the typed command is not already known. + if command and _cmd_def is None: + if isinstance(self.config, dict): + quick_commands = self.config.get("quick_commands", {}) or {} + else: + quick_commands = getattr(self.config, "quick_commands", {}) or {} + if isinstance(quick_commands, dict) and command in quick_commands: + qcmd = quick_commands[command] + if qcmd.get("type") == "alias": + target = qcmd.get("target", "").strip() + if target: + target = target if target.startswith("/") else f"/{target}" + target_command = target.lstrip("/") + user_args = event.get_command_args().strip() + event.text = f"{target} {user_args}".strip() + command = target_command.split()[0] if target_command else target_command + _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", ...}`` @@ -5351,7 +5373,7 @@ class GatewayRunner: target_command = target.lstrip("/") user_args = event.get_command_args().strip() event.text = f"{target} {user_args}".strip() - command = target_command + command = target_command.split()[0] if target_command else target_command # Fall through to normal command dispatch below else: return f"Quick command '/{command}' has no target defined." diff --git a/tests/e2e/test_platform_commands.py b/tests/e2e/test_platform_commands.py index b891ea7372..4924eed6a9 100644 --- a/tests/e2e/test_platform_commands.py +++ b/tests/e2e/test_platform_commands.py @@ -138,6 +138,29 @@ class TestSlashCommands: response_text = send.call_args[1].get("content") or send.call_args[0][1] assert "compress" in response_text.lower() or "context" in response_text.lower() + @pytest.mark.asyncio + async def test_quick_command_alias_targets_builtin_command_with_args( + self, adapter, runner, platform + ): + """Alias targets with args must reach the built-in command handler.""" + runner.config.quick_commands = { + "s": {"type": "alias", "target": "/status extra-arg"} + } + async def _handle_status(event): + assert event.get_command_args() == "extra-arg" + return "status via alias" + + runner._handle_status_command = AsyncMock(side_effect=_handle_status) + + send = await send_and_capture(adapter, "/s", platform) + + send.assert_called_once() + response_text = send.call_args[1].get("content") or send.call_args[0][1] + assert response_text == "status via alias" + runner._handle_status_command.assert_awaited_once() + runner._handle_message_with_agent.assert_not_awaited() + + class TestSessionLifecycle: """Verify session state changes across command sequences."""