diff --git a/gateway/platforms/base.py b/gateway/platforms/base.py index e6dedfda49..b54e12c3c1 100644 --- a/gateway/platforms/base.py +++ b/gateway/platforms/base.py @@ -1579,20 +1579,9 @@ class BasePlatformAdapter(ABC): # session lifecycle and its cleanup races with the running task # (see PR #4926). cmd = event.get_command() - if cmd in ( - "approve", - "deny", - "status", - "agents", - "tasks", - "stop", - "new", - "reset", - "background", - "restart", - "queue", - "q", - ): + from hermes_cli.commands import should_bypass_active_session + + if should_bypass_active_session(cmd): 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 ea747321f9..da78f5e9e3 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -2952,7 +2952,10 @@ class GatewayRunner: return await self._handle_status_command(event) # Resolve the command once for all early-intercept checks below. - from hermes_cli.commands import resolve_command as _resolve_cmd_inner + from hermes_cli.commands import ( + 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 @@ -3038,6 +3041,20 @@ 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): + if _cmd_def_inner.name == "help": + return await self._handle_help_command(event) + if _cmd_def_inner.name == "commands": + return await self._handle_commands_command(event) + if _cmd_def_inner.name == "profile": + return await self._handle_profile_command(event) + if _cmd_def_inner.name == "update": + return await self._handle_update_command(event) + 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) diff --git a/hermes_cli/commands.py b/hermes_cli/commands.py index 2c6cf75004..ce257b0d7c 100644 --- a/hermes_cli/commands.py +++ b/hermes_cli/commands.py @@ -258,6 +258,35 @@ GATEWAY_KNOWN_COMMANDS: frozenset[str] = frozenset( ) +# Commands that must never be queued behind an active gateway session. +# These are explicit control/info commands handled by the gateway itself; +# if they get queued as pending text, the safety net in gateway.run will +# discard them before they ever reach the user. +ACTIVE_SESSION_BYPASS_COMMANDS: frozenset[str] = frozenset( + { + "agents", + "approve", + "background", + "commands", + "deny", + "help", + "new", + "profile", + "queue", + "restart", + "status", + "stop", + "update", + } +) + + +def should_bypass_active_session(command_name: str | None) -> bool: + """Return True when a slash command must bypass active-session queuing.""" + cmd = resolve_command(command_name) if command_name else None + return bool(cmd and cmd.name in ACTIVE_SESSION_BYPASS_COMMANDS) + + def _resolve_config_gates() -> set[str]: """Return canonical names of commands whose ``gateway_config_gate`` is truthy. diff --git a/tests/gateway/test_command_bypass_active_session.py b/tests/gateway/test_command_bypass_active_session.py index d8098005c4..10ff062126 100644 --- a/tests/gateway/test_command_bypass_active_session.py +++ b/tests/gateway/test_command_bypass_active_session.py @@ -200,6 +200,38 @@ class TestCommandBypassActiveSession: "/background response was not sent back to the user" ) + @pytest.mark.asyncio + async def test_help_bypasses_guard(self): + """/help must bypass so it is not silently dropped as pending slash text.""" + adapter = _make_adapter() + sk = _session_key() + adapter._active_sessions[sk] = asyncio.Event() + + await adapter.handle_message(_make_event("/help")) + + assert sk not in adapter._pending_messages, ( + "/help was queued as a pending message instead of being dispatched" + ) + assert any("handled:help" in r for r in adapter.sent_responses), ( + "/help response was not sent back to the user" + ) + + @pytest.mark.asyncio + async def test_update_bypasses_guard(self): + """/update must bypass so it is not discarded by the pending-command safety net.""" + adapter = _make_adapter() + sk = _session_key() + adapter._active_sessions[sk] = asyncio.Event() + + await adapter.handle_message(_make_event("/update")) + + assert sk not in adapter._pending_messages, ( + "/update was queued as a pending message instead of being dispatched" + ) + assert any("handled:update" in r for r in adapter.sent_responses), ( + "/update response was not sent back to the user" + ) + @pytest.mark.asyncio async def test_queue_bypasses_guard(self): """/queue must bypass so it can queue without interrupting.""" diff --git a/tests/gateway/test_session_race_guard.py b/tests/gateway/test_session_race_guard.py index d7eeff5c1e..8c26abec59 100644 --- a/tests/gateway/test_session_race_guard.py +++ b/tests/gateway/test_session_race_guard.py @@ -288,6 +288,38 @@ async def test_command_messages_do_not_leave_sentinel(): ) +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("command_text", "handler_attr", "handler_result"), + [ + ("/help", "_handle_help_command", "Help text"), + ("/commands", "_handle_commands_command", "Commands text"), + ("/update", "_handle_update_command", "Update text"), + ("/profile", "_handle_profile_command", "Profile text"), + ], +) +async def test_active_session_bypass_commands_dispatch_without_interrupt( + command_text, + handler_attr, + handler_result, +): + """Gateway-handled bypass commands must return directly while an agent runs.""" + runner = _make_runner() + event = _make_event(text=command_text) + session_key = build_session_key(event.source) + + fake_agent = MagicMock() + fake_agent.get_activity_summary.return_value = {"seconds_since_activity": 0} + runner._running_agents[session_key] = fake_agent + setattr(runner, handler_attr, AsyncMock(return_value=handler_result)) + + result = await runner._handle_message(event) + + assert result == handler_result + fake_agent.interrupt.assert_not_called() + assert session_key not in runner.adapters[Platform.TELEGRAM]._pending_messages + + # ------------------------------------------------------------------ # Test 6: /stop during sentinel force-cleans and unlocks session # ------------------------------------------------------------------