mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-25 00:51:20 +00:00
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:
parent
41560192c4
commit
632a807a3e
4 changed files with 137 additions and 13 deletions
|
|
@ -268,6 +268,82 @@ class TestCommandBypassActiveSession:
|
|||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests: non-bypass-set commands (no dedicated Level-2 handler) also bypass
|
||||
# instead of interrupting + being discarded. Regression for the Discord
|
||||
# ghost-slash-command bug where /model, /reasoning, /voice, /insights, /title,
|
||||
# /resume, /retry, /undo, /compress, /usage, /provider, /reload-mcp,
|
||||
# /sethome, /reset silently interrupted the running agent.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestAllResolvableCommandsBypassGuard:
|
||||
"""Every recognized slash command must bypass the Level-1 active-session
|
||||
guard. Without this, commands the user fires mid-run interrupt the agent
|
||||
AND get silently discarded by the slash-command safety net (zero-char
|
||||
response)."""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"command_text,canonical",
|
||||
[
|
||||
("/model claude-sonnet-4", "model"),
|
||||
("/model", "model"),
|
||||
("/reasoning high", "reasoning"),
|
||||
("/personality default", "personality"),
|
||||
("/voice on", "voice"),
|
||||
("/insights 7", "insights"),
|
||||
("/title my session", "title"),
|
||||
("/resume yesterday", "resume"),
|
||||
("/retry", "retry"),
|
||||
("/undo", "undo"),
|
||||
("/compress", "compress"),
|
||||
("/usage", "usage"),
|
||||
("/provider", "provider"),
|
||||
("/reload-mcp", "reload-mcp"),
|
||||
("/sethome", "sethome"),
|
||||
],
|
||||
)
|
||||
@pytest.mark.asyncio
|
||||
async def test_command_bypasses_guard(self, command_text, canonical):
|
||||
"""Any resolvable slash command bypasses instead of being queued."""
|
||||
adapter = _make_adapter()
|
||||
sk = _session_key()
|
||||
adapter._active_sessions[sk] = asyncio.Event()
|
||||
|
||||
await adapter.handle_message(_make_event(command_text))
|
||||
|
||||
assert sk not in adapter._pending_messages, (
|
||||
f"{command_text} was queued as pending — it should bypass the guard"
|
||||
)
|
||||
assert len(adapter.sent_responses) > 0, (
|
||||
f"{command_text} produced no response — it should be dispatched, "
|
||||
"not silently discarded"
|
||||
)
|
||||
|
||||
def test_should_bypass_returns_true_for_every_registered_command(self):
|
||||
"""Spot-check: the commands previously-broken on Discord all bypass."""
|
||||
from hermes_cli.commands import should_bypass_active_session
|
||||
|
||||
for cmd in (
|
||||
"model", "reasoning", "personality", "voice", "insights", "title",
|
||||
"resume", "retry", "undo", "compress", "usage", "provider",
|
||||
"reload-mcp", "sethome", "reset",
|
||||
):
|
||||
assert should_bypass_active_session(cmd) is True, (
|
||||
f"/{cmd} must bypass the active-session guard"
|
||||
)
|
||||
|
||||
def test_should_bypass_returns_false_for_unknown(self):
|
||||
"""Unknown words don't bypass — they get queued as user text."""
|
||||
from hermes_cli.commands import should_bypass_active_session
|
||||
|
||||
assert should_bypass_active_session("foobar") is False
|
||||
assert should_bypass_active_session(None) is False
|
||||
assert should_bypass_active_session("") is False
|
||||
# A file path split on whitespace: '/path/to/file.py' -> 'path/to/file.py'
|
||||
assert should_bypass_active_session("path/to/file.py") is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests: non-bypass messages still get queued
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue