diff --git a/gateway/platforms/base.py b/gateway/platforms/base.py index ba39951364..90f54f8967 100644 --- a/gateway/platforms/base.py +++ b/gateway/platforms/base.py @@ -907,6 +907,41 @@ class MessageEvent: return args +_PLAINTEXT_GATEWAY_RESTART_PATTERNS: tuple[re.Pattern[str], ...] = ( + re.compile(r"^(?:please\s+)?restart\s+(?:the\s+)?gateway[.!?\s]*$", re.IGNORECASE), + re.compile(r"^(?:please\s+)?restart\s+(?:the\s+)?hermes\s+gateway[.!?\s]*$", re.IGNORECASE), + re.compile(r"^(?:please\s+)?restart\s+hermes[.!?\s]*$", re.IGNORECASE), +) + + +def coerce_plaintext_gateway_command(event: "MessageEvent") -> None: + """Rewrite a tiny set of DM plaintext admin phrases into slash commands. + + This keeps high-impact operational phrases like ``restart gateway`` out of + the LLM/tool path, where they can trigger a self-restart from inside the + currently running agent and leave the gateway stuck in ``draining`` while it + waits for that same agent to finish. + + Scope is intentionally narrow: DM text messages only, exact restart-style + phrases only. Group chats keep natural-language semantics. + """ + try: + if event is None or event.message_type != MessageType.TEXT: + return + text = (event.text or "").strip() + if not text or text.startswith("/"): + return + source = getattr(event, "source", None) + if getattr(source, "chat_type", None) != "dm": + return + for pattern in _PLAINTEXT_GATEWAY_RESTART_PATTERNS: + if pattern.match(text): + event.text = "/restart" + return + except Exception: + return + + @dataclass class SendResult: """Result of sending a message.""" @@ -2193,6 +2228,8 @@ class BasePlatformAdapter(ABC): """ if not self._message_handler: return + + coerce_plaintext_gateway_command(event) session_key = build_session_key( event.source, diff --git a/tests/e2e/conftest.py b/tests/e2e/conftest.py index f8c1a88abb..76b14e3179 100644 --- a/tests/e2e/conftest.py +++ b/tests/e2e/conftest.py @@ -125,13 +125,13 @@ from gateway.platforms.slack import SlackAdapter # noqa: E402 # Platform-generic factories -def make_source(platform: Platform, chat_id: str = "e2e-chat-1", user_id: str = "e2e-user-1") -> SessionSource: +def make_source(platform: Platform, chat_id: str = "e2e-chat-1", user_id: str = "e2e-user-1", chat_type: str = "dm") -> SessionSource: return SessionSource( platform=platform, chat_id=chat_id, user_id=user_id, user_name="e2e_tester", - chat_type="dm", + chat_type=chat_type, ) @@ -147,10 +147,16 @@ def make_session_entry(platform: Platform, source: SessionSource = None) -> Sess ) -def make_event(platform: Platform, text: str = "/help", chat_id: str = "e2e-chat-1", user_id: str = "e2e-user-1") -> MessageEvent: +def make_event( + platform: Platform, + text: str = "/help", + chat_id: str = "e2e-chat-1", + user_id: str = "e2e-user-1", + chat_type: str = "dm", +) -> MessageEvent: return MessageEvent( text=text, - source=make_source(platform, chat_id, user_id), + source=make_source(platform, chat_id, user_id, chat_type), message_id=f"msg-{uuid.uuid4().hex[:8]}", ) @@ -185,6 +191,23 @@ def make_runner(platform: Platform, session_entry: SessionEntry = None) -> "Gate runner._running_agents = {} runner._pending_messages = {} runner._pending_approvals = {} + runner._shutdown_event = asyncio.Event() + runner._exit_reason = None + runner._exit_code = None + runner._background_tasks = set() + runner._draining = False + runner._restart_requested = False + runner._restart_task_started = False + runner._restart_detached = False + runner._restart_via_service = False + from gateway.restart import DEFAULT_GATEWAY_RESTART_DRAIN_TIMEOUT + runner._restart_drain_timeout = DEFAULT_GATEWAY_RESTART_DRAIN_TIMEOUT + runner._stop_task = None + runner._busy_input_mode = "interrupt" + runner._running_agents_ts = {} + runner._pending_model_notes = {} + runner._update_prompt_pending = {} + runner._voice_mode = {} runner._session_db = None runner._reasoning_config = None runner._provider_routing = {} @@ -193,6 +216,7 @@ def make_runner(platform: Platform, session_entry: SessionEntry = None) -> "Gate runner._is_user_authorized = lambda _source: True runner._set_session_env = lambda _context: None + runner._handle_message_with_agent = AsyncMock(return_value="agent-handled-default") runner._should_send_voice_reply = lambda *_a, **_kw: False runner._send_voice_reply = AsyncMock() runner._capture_gateway_honcho_if_configured = lambda *a, **kw: None diff --git a/tests/e2e/test_platform_commands.py b/tests/e2e/test_platform_commands.py index 1597e54cc0..b891ea7372 100644 --- a/tests/e2e/test_platform_commands.py +++ b/tests/e2e/test_platform_commands.py @@ -11,10 +11,11 @@ Tests are parametrized over platforms via the ``platform`` fixture in conftest. """ import asyncio -from unittest.mock import AsyncMock +from unittest.mock import AsyncMock, MagicMock import pytest +from gateway.config import Platform from gateway.platforms.base import SendResult from tests.e2e.conftest import make_event, send_and_capture @@ -82,6 +83,37 @@ class TestSlashCommands: # Either shows the mode cycle or tells user to enable it in config assert "verbose" in response_text.lower() or "tool_progress" in response_text + @pytest.mark.asyncio + async def test_plaintext_restart_gateway_routes_to_safe_restart_command(self, adapter, runner, platform, monkeypatch): + if platform != Platform.TELEGRAM: + pytest.skip("Plaintext restart shortcut is intentionally DM/Telegram-focused") + + monkeypatch.setenv("INVOCATION_ID", "e2e-systemd") + runner.request_restart = MagicMock(return_value=True) + + send = await send_and_capture(adapter, "restart gateway", platform) + + send.assert_called_once() + response_text = send.call_args[1].get("content") or send.call_args[0][1] + assert "restart" in response_text.lower() or "draining" in response_text.lower() + runner.request_restart.assert_called_once_with(detached=False, via_service=True) + + @pytest.mark.asyncio + async def test_plaintext_restart_gateway_in_group_stays_plain_text(self, adapter, runner, platform, monkeypatch): + if platform != Platform.TELEGRAM: + pytest.skip("Shortcut scope is only verified for Telegram here") + + monkeypatch.setenv("INVOCATION_ID", "e2e-systemd") + runner.request_restart = MagicMock(return_value=True) + runner._handle_message_with_agent = AsyncMock(return_value="agent-handled") + + send = await send_and_capture(adapter, "restart gateway", platform, chat_id="group-chat-1", user_id="u1", chat_type="group") + + send.assert_called_once() + response_text = send.call_args[1].get("content") or send.call_args[0][1] + assert response_text == "agent-handled" + runner.request_restart.assert_not_called() + @pytest.mark.asyncio async def test_personality_lists_options(self, adapter, platform): send = await send_and_capture(adapter, "/personality", platform)