diff --git a/gateway/platforms/base.py b/gateway/platforms/base.py index 28615a006f3d9..8f8b0e7405572 100644 --- a/gateway/platforms/base.py +++ b/gateway/platforms/base.py @@ -1356,7 +1356,20 @@ async def handle_message(self, event: MessageEvent) -> None: # session lifecycle and its cleanup races with the running task # (see PR #4926). cmd = event.get_command() - if cmd in ("approve", "deny", "status", "stop", "new", "reset", "background"): + if cmd in ( + # Session control + "approve", "deny", "status", "stop", "new", "reset", + "background", "bg", "queue", "q", + # Execute immediately (info/config — no agent interaction needed) + "help", "commands", "profile", "provider", + "usage", "insights", "sethome", "set-home", + "voice", "yolo", "btw", + # Reject with message (needs idle agent) + "model", "retry", "undo", "title", "branch", "fork", + "compress", "rollback", "resume", + "reasoning", "fast", "personality", + "update", "reload-mcp", "reload_mcp", + ): logger.debug( "[%s] Command '/%s' bypassing active-session guard for %s", self.name, cmd, session_key, diff --git a/gateway/run.py b/gateway/run.py index 659ba8013697e..2291b8b1451dd 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -2034,6 +2034,16 @@ async def _handle_message(self, event: MessageEvent) -> Optional[str]: if _cmd_def_inner and _cmd_def_inner.name == "model": return "Agent is running — wait or /stop first, then switch models." + # Commands that require an idle agent — reject with a helpful message. + _REJECT_IF_RUNNING = frozenset({ + "retry", "undo", "title", "branch", + "compress", "rollback", "resume", + "reasoning", "fast", "personality", + "update", "reload-mcp", + }) + if _cmd_def_inner and _cmd_def_inner.name in _REJECT_IF_RUNNING: + return "Agent is running — wait or /stop first." + # /approve and /deny must bypass the running-agent interrupt path. # The agent thread is blocked on a threading.Event inside # tools/approval.py — sending an interrupt won't unblock it. diff --git a/tests/gateway/test_command_bypass_active_session.py b/tests/gateway/test_command_bypass_active_session.py index 318b14dd825ef..5dd225f95659f 100644 --- a/tests/gateway/test_command_bypass_active_session.py +++ b/tests/gateway/test_command_bypass_active_session.py @@ -327,3 +327,68 @@ async def test_new_with_botname(self): assert sk not in adapter._pending_messages assert any("handled:new" in r for r in adapter.sent_responses) + + +# --------------------------------------------------------------------------- +# Tests: commands that should execute immediately (bypass guard) +# --------------------------------------------------------------------------- + + +class TestExecuteImmediatelyCommands: + """Commands that should bypass and execute even while agent is running.""" + + EXEC_IMMEDIATE = [ + "help", "commands", "profile", "provider", + "usage", "insights", "sethome", "voice", + "yolo", "btw", + ] + + @pytest.mark.asyncio + @pytest.mark.parametrize("cmd", EXEC_IMMEDIATE) + async def test_exec_immediate_bypasses_guard(self, cmd): + """Each command must be dispatched directly, not queued.""" + adapter = _make_adapter() + sk = _session_key() + adapter._active_sessions[sk] = asyncio.Event() + + await adapter.handle_message(_make_event(f"/{cmd}")) + + assert sk not in adapter._pending_messages, ( + f"/{cmd} was queued as pending instead of being dispatched" + ) + assert any(f"handled:{cmd}" in r for r in adapter.sent_responses), ( + f"/{cmd} response was not sent back to the user" + ) + + +# --------------------------------------------------------------------------- +# Tests: commands that should bypass and return 'agent running' rejection +# --------------------------------------------------------------------------- + + +class TestRejectWithMessageCommands: + """Commands that should bypass and return 'agent running' rejection.""" + + REJECT_COMMANDS = [ + "retry", "undo", "title", "branch", "fork", + "compress", "rollback", "resume", + "reasoning", "fast", "personality", + "update", "reload-mcp", + ] + + @pytest.mark.asyncio + @pytest.mark.parametrize("cmd", REJECT_COMMANDS) + async def test_reject_command_bypasses_guard(self, cmd): + """Each reject command must be dispatched directly, not queued.""" + adapter = _make_adapter() + sk = _session_key() + adapter._active_sessions[sk] = asyncio.Event() + + await adapter.handle_message(_make_event(f"/{cmd}")) + + assert sk not in adapter._pending_messages, ( + f"/{cmd} was queued as pending instead of being dispatched" + ) + assert any(f"handled:{cmd}" in r for r in adapter.sent_responses), ( + f"/{cmd} response was not sent back to the user" + )