diff --git a/gateway/run.py b/gateway/run.py index 461a56fe8b..42a6b82f98 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -3469,6 +3469,30 @@ class GatewayRunner: _update_prompts.pop(_quick_key, None) label = response_text if len(response_text) <= 20 else response_text[:20] + "…" return f"✓ Sent `{label}` to the update process." + # Recognized slash command during a pending update prompt: + # unblock the detached update subprocess by writing a blank + # response so ``_gateway_prompt`` returns the prompt's default + # (typically a safe "n" / skip) and exits cleanly instead of + # blocking on stdin until the 30-minute watcher timeout. + # The slash command then falls through to normal dispatch. + if _recognized_cmd: + response_path = _hermes_home / ".update_response" + try: + tmp = response_path.with_suffix(".tmp") + tmp.write_text("") + tmp.replace(response_path) + logger.info( + "Recognized /%s during pending update prompt for %s; " + "cancelled prompt with default and dispatching command", + _recognized_cmd, + _quick_key, + ) + except OSError as e: + logger.warning( + "Failed to write cancel response for pending update prompt: %s", + e, + ) + _update_prompts.pop(_quick_key, None) # PRIORITY handling when an agent is already running for this session. # Default behavior is to interrupt immediately so user text/stop messages diff --git a/tests/gateway/test_update_streaming.py b/tests/gateway/test_update_streaming.py index f082d9fe98..1020ea6c46 100644 --- a/tests/gateway/test_update_streaming.py +++ b/tests/gateway/test_update_streaming.py @@ -491,7 +491,13 @@ class TestUpdatePromptInterception: @pytest.mark.asyncio async def test_recognized_slash_command_bypasses_pending_update_prompt(self, tmp_path): - """Known slash commands must dispatch normally instead of being consumed.""" + """Known slash commands must dispatch normally instead of being consumed. + + The update subprocess is still blocked on stdin waiting for + ``.update_response``, so the gateway writes a blank response to + unblock it (``_gateway_prompt`` returns the prompt's default on + empty) before falling through to normal command dispatch. + """ runner = _make_runner() hermes_home = tmp_path / "hermes" hermes_home.mkdir() @@ -508,8 +514,37 @@ class TestUpdatePromptInterception: assert result == "reset ok" runner._handle_reset_command.assert_awaited_once_with(event) - assert not (hermes_home / ".update_response").exists() - assert runner._update_prompt_pending[session_key] is True + # .update_response was written (empty) to unblock the update + # subprocess; _gateway_prompt will read "", strip to "", and + # return the prompt's default. + response_path = hermes_home / ".update_response" + assert response_path.exists() + assert response_path.read_text() == "" + # Pending flag is cleared so stray future input won't be + # re-intercepted for a prompt that is no longer outstanding. + assert session_key not in runner._update_prompt_pending + + @pytest.mark.asyncio + async def test_unrecognized_slash_command_still_consumed_as_response(self, tmp_path): + """Unknown /foo is written verbatim to .update_response (legacy behavior).""" + runner = _make_runner() + hermes_home = tmp_path / "hermes" + hermes_home.mkdir() + + event = _make_event(text="/foobarbaz", chat_id="67890") + session_key = "agent:main:telegram:dm:67890" + runner._update_prompt_pending[session_key] = True + runner._is_user_authorized = MagicMock(return_value=True) + runner._session_key_for_source = MagicMock(return_value=session_key) + + with patch("gateway.run._hermes_home", hermes_home): + result = await runner._handle_message(event) + + response_path = hermes_home / ".update_response" + assert response_path.exists() + assert response_path.read_text() == "/foobarbaz" + assert "Sent" in (result or "") + assert session_key not in runner._update_prompt_pending @pytest.mark.asyncio async def test_normal_message_when_no_prompt_pending(self, tmp_path):