fix(gateway): slash commands never interrupt a running agent (#12334)
Any recognized slash command now bypasses the Level-1 active-session guard instead of queueing + interrupting. A mid-run /model (or /reasoning, /voice, /insights, /title, /resume, /retry, /undo, /compress, /usage, /provider, /reload-mcp, /sethome, /reset) used to interrupt the agent AND get silently discarded by the slash-command safety net — zero-char response, dropped tool calls. Root cause: - Discord registers 41 native slash commands via tree.command(). - Only 14 were in ACTIVE_SESSION_BYPASS_COMMANDS. - The other ~15 user-facing ones fell through base.py:handle_message to the busy-session handler, which calls running_agent.interrupt() AND queues the text. - After the aborted run, gateway/run.py:9912 correctly identifies the queued text as a slash command and discards it — but the damage (interrupt + zero-char response) already happened. Fix: - should_bypass_active_session() now returns True for any resolvable slash command. ACTIVE_SESSION_BYPASS_COMMANDS stays as the subset with dedicated Level-2 handlers (documentation + tests). - gateway/run.py adds a catch-all after the dedicated handlers that returns a user-visible "agent busy — wait or /stop first" response for any other resolvable command. - Unknown text / file-path-like messages are unchanged — they still queue. Also: - gateway/platforms/discord.py logs the invoker identity on every slash command (user id + name + channel + guild) so future ghost-command reports can be triaged without guessing. Tests: - 15 new parametrized cases in test_command_bypass_active_session.py cover every previously-broken Discord slash command. - Existing tests for /stop, /new, /approve, /deny, /help, /status, /agents, /background, /steer, /update, /queue still pass. - test_steer.py's ACTIVE_SESSION_BYPASS_COMMANDS check still passes. Fixes #5057. Related: #6252, #10370, #4665.
This commit is contained in:
@@ -1933,6 +1933,24 @@ class DiscordAdapter(BasePlatformAdapter):
|
||||
the "thinking..." indicator is replaced with that text; otherwise it
|
||||
is deleted so the channel isn't cluttered.
|
||||
"""
|
||||
# Log the invoker so ghost-command reports can be triaged. Discord
|
||||
# native slash invocations are always user-initiated (no bot can fire
|
||||
# them), but mobile autocomplete / keyboard shortcuts / other users
|
||||
# in the same channel are easy to miss in post-mortems.
|
||||
try:
|
||||
_user = interaction.user
|
||||
_chan_id = getattr(interaction.channel, "id", None) or getattr(interaction, "channel_id", None)
|
||||
logger.info(
|
||||
"[Discord] slash '%s' invoked by user=%s id=%s channel=%s guild=%s",
|
||||
command_text,
|
||||
getattr(_user, "name", "?"),
|
||||
getattr(_user, "id", "?"),
|
||||
_chan_id,
|
||||
getattr(interaction, "guild_id", None),
|
||||
)
|
||||
except Exception:
|
||||
pass # logging must never block command dispatch
|
||||
|
||||
await interaction.response.defer(ephemeral=True)
|
||||
event = self._build_slash_event(interaction, command_text)
|
||||
await self.handle_message(event)
|
||||
|
||||
@@ -2987,8 +2987,8 @@ class GatewayRunner:
|
||||
|
||||
# Resolve the command once for all early-intercept checks below.
|
||||
from hermes_cli.commands import (
|
||||
ACTIVE_SESSION_BYPASS_COMMANDS as _DEDICATED_HANDLERS,
|
||||
resolve_command as _resolve_cmd_inner,
|
||||
should_bypass_active_session as _should_bypass_active_inner,
|
||||
)
|
||||
_evt_cmd = event.get_command()
|
||||
_cmd_def_inner = _resolve_cmd_inner(_evt_cmd) if _evt_cmd else None
|
||||
@@ -3123,11 +3123,9 @@ class GatewayRunner:
|
||||
if _cmd_def_inner and _cmd_def_inner.name == "background":
|
||||
return await self._handle_background_command(event)
|
||||
|
||||
# Gateway-handled info/control commands must never fall through to
|
||||
# the interrupt path. If they are queued as pending text, the
|
||||
# slash-command safety net discards them before the user sees any
|
||||
# response.
|
||||
if _cmd_def_inner and _should_bypass_active_inner(_cmd_def_inner.name):
|
||||
# Gateway-handled info/control commands with dedicated
|
||||
# running-agent handlers.
|
||||
if _cmd_def_inner and _cmd_def_inner.name in _DEDICATED_HANDLERS:
|
||||
if _cmd_def_inner.name == "help":
|
||||
return await self._handle_help_command(event)
|
||||
if _cmd_def_inner.name == "commands":
|
||||
@@ -3137,6 +3135,21 @@ class GatewayRunner:
|
||||
if _cmd_def_inner.name == "update":
|
||||
return await self._handle_update_command(event)
|
||||
|
||||
# Catch-all: any other recognized slash command reached the
|
||||
# running-agent guard. Reject gracefully rather than falling
|
||||
# through to interrupt + discard. Without this, commands
|
||||
# like /model, /reasoning, /voice, /insights, /title,
|
||||
# /resume, /retry, /undo, /compress, /usage, /provider,
|
||||
# /reload-mcp, /sethome, /reset (all registered as Discord
|
||||
# slash commands) would interrupt the agent AND get
|
||||
# silently discarded by the slash-command safety net,
|
||||
# producing a zero-char response. See #5057, #6252, #10370.
|
||||
if _cmd_def_inner:
|
||||
return (
|
||||
f"⏳ Agent is running — `/{_cmd_def_inner.name}` can't run "
|
||||
f"mid-turn. Wait for the current response or `/stop` first."
|
||||
)
|
||||
|
||||
if event.message_type == MessageType.PHOTO:
|
||||
logger.debug("PRIORITY photo follow-up for session %s — queueing without interrupt", _quick_key[:20])
|
||||
adapter = self.adapters.get(source.platform)
|
||||
|
||||
Reference in New Issue
Block a user