fix(gateway): move quick-command dispatch before built-in handlers

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).
This commit is contained in:
Hermes Agent 2026-05-03 22:02:24 +10:00 committed by Teknium
parent c857592558
commit 74c997d985
2 changed files with 46 additions and 1 deletions

View file

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

View file

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