Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 14 additions & 1 deletion gateway/platforms/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
10 changes: 10 additions & 0 deletions gateway/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
65 changes: 65 additions & 0 deletions tests/gateway/test_command_bypass_active_session.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)