"""Regression tests: slash commands must bypass the base adapter's active-session guard. When an agent is running, the base adapter's Level 1 guard in handle_message() intercepts all incoming messages and queues them as pending. Certain commands (/stop, /new, /reset, /approve, /deny, /status) must bypass this guard and be dispatched directly to the gateway runner — otherwise they are queued as user text and either: - leak into the conversation as agent input (/stop, /new), or - deadlock (/approve, /deny — agent blocks on Event.wait) These tests verify that the bypass works at the adapter level and that the safety net in _run_agent discards leaked command text. """ import asyncio import pytest from gateway.config import Platform, PlatformConfig from gateway.platforms.base import BasePlatformAdapter, MessageEvent, MessageType from gateway.session import SessionSource, build_session_key # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- class _StubAdapter(BasePlatformAdapter): """Concrete adapter with abstract methods stubbed out.""" async def connect(self): pass async def disconnect(self): pass async def send(self, chat_id, text, **kwargs): pass async def get_chat_info(self, chat_id): return {} def _make_adapter(): """Create a minimal adapter for testing the active-session guard.""" config = PlatformConfig(enabled=True, token="test-token") adapter = _StubAdapter(config, Platform.TELEGRAM) adapter._busy_text_mode = "" adapter.sent_responses = [] async def _mock_handler(event): cmd = event.get_command() return f"handled:{cmd}" if cmd else f"handled:text:{event.text}" adapter._message_handler = _mock_handler async def _mock_send_retry(chat_id, content, **kwargs): adapter.sent_responses.append(content) adapter._send_with_retry = _mock_send_retry return adapter def _make_event(text="/stop", chat_id="12345"): source = SessionSource( platform=Platform.TELEGRAM, chat_id=chat_id, chat_type="dm" ) return MessageEvent(text=text, message_type=MessageType.TEXT, source=source) def _session_key(chat_id="12345"): source = SessionSource( platform=Platform.TELEGRAM, chat_id=chat_id, chat_type="dm" ) return build_session_key(source) # --------------------------------------------------------------------------- # Tests: commands bypass Level 1 when session is active # --------------------------------------------------------------------------- class TestCommandBypassActiveSession: """Commands that must bypass the active-session guard.""" @pytest.mark.asyncio async def test_stop_bypasses_guard(self): """/stop must be dispatched directly, not queued.""" adapter = _make_adapter() sk = _session_key() adapter._active_sessions[sk] = asyncio.Event() await adapter.handle_message(_make_event("/stop")) assert sk not in adapter._pending_messages, ( "/stop was queued as a pending message instead of being dispatched" ) assert any("handled:stop" in r for r in adapter.sent_responses), ( "/stop response was not sent back to the user" ) @pytest.mark.asyncio async def test_new_bypasses_guard(self): """/new must be dispatched directly, not queued.""" adapter = _make_adapter() sk = _session_key() adapter._active_sessions[sk] = asyncio.Event() await adapter.handle_message(_make_event("/new")) assert sk not in adapter._pending_messages assert any("handled:new" in r for r in adapter.sent_responses) @pytest.mark.asyncio async def test_reset_bypasses_guard(self): """/reset (alias for /new) must be dispatched directly.""" adapter = _make_adapter() sk = _session_key() adapter._active_sessions[sk] = asyncio.Event() await adapter.handle_message(_make_event("/reset")) assert sk not in adapter._pending_messages assert any("handled:reset" in r for r in adapter.sent_responses) @pytest.mark.asyncio async def test_approve_bypasses_guard(self): """/approve must bypass (deadlock prevention).""" adapter = _make_adapter() sk = _session_key() adapter._active_sessions[sk] = asyncio.Event() await adapter.handle_message(_make_event("/approve")) assert sk not in adapter._pending_messages assert any("handled:approve" in r for r in adapter.sent_responses) @pytest.mark.asyncio async def test_deny_bypasses_guard(self): """/deny must bypass (deadlock prevention).""" adapter = _make_adapter() sk = _session_key() adapter._active_sessions[sk] = asyncio.Event() await adapter.handle_message(_make_event("/deny")) assert sk not in adapter._pending_messages assert any("handled:deny" in r for r in adapter.sent_responses) @pytest.mark.asyncio async def test_status_bypasses_guard(self): """/status must bypass so it returns a system response.""" adapter = _make_adapter() sk = _session_key() adapter._active_sessions[sk] = asyncio.Event() await adapter.handle_message(_make_event("/status")) assert sk not in adapter._pending_messages assert any("handled:status" in r for r in adapter.sent_responses) @pytest.mark.asyncio async def test_agents_bypasses_guard(self): """/agents must bypass so active-task queries don't interrupt runs.""" adapter = _make_adapter() sk = _session_key() adapter._active_sessions[sk] = asyncio.Event() await adapter.handle_message(_make_event("/agents")) assert sk not in adapter._pending_messages assert any("handled:agents" in r for r in adapter.sent_responses) @pytest.mark.asyncio async def test_tasks_alias_bypasses_guard(self): """/tasks alias must bypass active-session guard too.""" adapter = _make_adapter() sk = _session_key() adapter._active_sessions[sk] = asyncio.Event() await adapter.handle_message(_make_event("/tasks")) assert sk not in adapter._pending_messages assert any("handled:tasks" in r for r in adapter.sent_responses) @pytest.mark.asyncio async def test_background_bypasses_guard(self): """/background must bypass so it spawns a parallel task, not an interrupt.""" adapter = _make_adapter() sk = _session_key() adapter._active_sessions[sk] = asyncio.Event() await adapter.handle_message(_make_event("/background summarize HN")) assert sk not in adapter._pending_messages, ( "/background was queued as a pending message instead of being dispatched" ) assert any("handled:background" in r for r in adapter.sent_responses), ( "/background response was not sent back to the user" ) @pytest.mark.asyncio async def test_steer_bypasses_guard(self): """/steer must bypass the Level-1 active-session guard so it reaches the gateway runner's /steer handler and injects into the running agent instead of being queued as user text for the next turn. """ adapter = _make_adapter() sk = _session_key() adapter._active_sessions[sk] = asyncio.Event() await adapter.handle_message(_make_event("/steer also check auth.log")) assert sk not in adapter._pending_messages, ( "/steer was queued as a pending message instead of being dispatched" ) assert any("handled:steer" in r for r in adapter.sent_responses), ( "/steer 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.""" adapter = _make_adapter() sk = _session_key() adapter._active_sessions[sk] = asyncio.Event() await adapter.handle_message(_make_event("/queue follow up")) assert sk not in adapter._pending_messages, ( "/queue was queued as a pending message instead of being dispatched" ) assert any("handled:queue" in r for r in adapter.sent_responses), ( "/queue response was not sent back to the user" ) # --------------------------------------------------------------------------- # 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, /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"), ("/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", "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 # --------------------------------------------------------------------------- class TestNonBypassStillQueued: """Regular messages and unknown commands must be queued, not dispatched.""" @pytest.mark.asyncio async def test_regular_text_queued(self): """Plain text while agent is running must be queued as pending.""" adapter = _make_adapter() sk = _session_key() adapter._active_sessions[sk] = asyncio.Event() await adapter.handle_message(_make_event("hello world")) assert sk in adapter._pending_messages, ( "Regular text was not queued — it should be pending" ) assert len(adapter.sent_responses) == 0, ( "Regular text should not produce a direct response" ) @pytest.mark.asyncio async def test_unknown_command_queued(self): """Unknown /commands must be queued, not dispatched.""" adapter = _make_adapter() sk = _session_key() adapter._active_sessions[sk] = asyncio.Event() await adapter.handle_message(_make_event("/foobar")) assert sk in adapter._pending_messages assert len(adapter.sent_responses) == 0 @pytest.mark.asyncio async def test_file_path_not_treated_as_command(self): """A message like '/path/to/file' must not bypass the guard.""" adapter = _make_adapter() sk = _session_key() adapter._active_sessions[sk] = asyncio.Event() await adapter.handle_message(_make_event("/path/to/file.py")) assert sk in adapter._pending_messages assert len(adapter.sent_responses) == 0 # --------------------------------------------------------------------------- # Tests: no active session — commands go through normally # --------------------------------------------------------------------------- class TestNoActiveSessionNormalDispatch: """When no agent is running, messages spawn a background task normally.""" @pytest.mark.asyncio async def test_stop_when_no_session_active(self): """/stop without an active session spawns a background task (the Level 2 handler will return 'No active task').""" adapter = _make_adapter() sk = _session_key() # No active session — _active_sessions is empty assert sk not in adapter._active_sessions await adapter.handle_message(_make_event("/stop")) # Should have gone through the normal path (background task spawned) # and NOT be in _pending_messages (that's the queued-during-active path) assert sk not in adapter._pending_messages # --------------------------------------------------------------------------- # Tests: safety net in _run_agent discards command text from pending queue # --------------------------------------------------------------------------- class TestPendingCommandSafetyNet: """The safety net in gateway/run.py _run_agent must discard command text that leaks into the pending queue via interrupt_message fallback.""" def test_stop_command_detected(self): """resolve_command must recognize /stop so the safety net can discard it.""" from hermes_cli.commands import resolve_command assert resolve_command("stop") is not None assert resolve_command("stop").name == "stop" def test_new_command_detected(self): from hermes_cli.commands import resolve_command assert resolve_command("new") is not None assert resolve_command("new").name == "new" def test_reset_alias_detected(self): from hermes_cli.commands import resolve_command assert resolve_command("reset") is not None assert resolve_command("reset").name == "new" # alias def test_unknown_command_not_detected(self): from hermes_cli.commands import resolve_command assert resolve_command("foobar") is None def test_file_path_not_detected_as_command(self): """'/path/to/file' should not resolve as a command.""" from hermes_cli.commands import resolve_command # The safety net splits on whitespace and takes the first word # after stripping '/'. For '/path/to/file', that's 'path/to/file'. assert resolve_command("path/to/file") is None # --------------------------------------------------------------------------- # Tests: bypass with @botname suffix (Telegram-style) # --------------------------------------------------------------------------- class TestBypassWithBotnameSuffix: """Telegram appends @botname to commands. The bypass must still work.""" @pytest.mark.asyncio async def test_stop_with_botname(self): """/stop@MyHermesBot must bypass the guard.""" adapter = _make_adapter() sk = _session_key() adapter._active_sessions[sk] = asyncio.Event() await adapter.handle_message(_make_event("/stop@MyHermesBot")) assert sk not in adapter._pending_messages, ( "/stop@MyHermesBot was queued instead of bypassing" ) assert any("handled:stop" in r for r in adapter.sent_responses) @pytest.mark.asyncio async def test_new_with_botname(self): """/new@MyHermesBot must bypass the guard.""" adapter = _make_adapter() sk = _session_key() adapter._active_sessions[sk] = asyncio.Event() await adapter.handle_message(_make_event("/new@MyHermesBot")) assert sk not in adapter._pending_messages assert any("handled:new" in r for r in adapter.sent_responses)