diff --git a/cli.py b/cli.py index c90b0a294dd..911a4ca1ed9 100644 --- a/cli.py +++ b/cli.py @@ -11550,6 +11550,51 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin): except Exception: pass + def _clear_active_overlays_for_interrupt(self) -> None: + """Drain and clear every input-blocking overlay left by an interrupted agent. + + approval/clarify/sudo/secret prompts each block a worker thread on a + ``response_queue.get()``. When the agent is interrupted the worker + thread is torn down, but the overlay's state dict stays set — leaving + the CLI input gated (``read_only`` condition + keypress filter) with no + thread servicing the prompt. The result is a frozen terminal until the + prompt's own timeout expires. Push a terminal value onto each queue so + any still-blocked thread unblocks cleanly, then nil the state out and + restore the user's pre-modal draft (#14026). + + Safe default per prompt: approval -> "deny", clarify/sudo/secret -> + cancel (None / empty). Each step is wrapped so a dead queue can't + prevent clearing the others. + """ + if self._approval_state: + try: + self._approval_state["response_queue"].put("deny") + except Exception: + pass + self._approval_state = None + if self._clarify_state: + try: + self._clarify_state["response_queue"].put( + "The user cancelled. Use your best judgement to proceed." + ) + except Exception: + pass + self._clarify_state = None + self._clarify_freetext = False + if self._sudo_state: + try: + self._sudo_state["response_queue"].put("") + except Exception: + pass + self._sudo_state = None + self._sudo_deadline = 0 + self._restore_modal_input_snapshot() + if self._secret_state: + try: + self._cancel_secret_capture() + except Exception: + self._secret_state = None + def _submit_secret_response(self, value: str) -> None: if not self._secret_state: return @@ -11926,6 +11971,12 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin): if stop_event is not None: stop_event.set() self.agent.interrupt(interrupt_msg) + # Clear any active overlay states the interrupted agent + # left behind. approval/clarify/sudo/secret prompts gate + # input (read_only condition + keypress filter) until + # explicitly reset — without this the CLI freezes after + # an interrupt until the prompt's own timeout expires (#14026). + self._clear_active_overlays_for_interrupt() # Debug: log to file (stdout may be devnull from redirect_stdout) try: _dbg = _hermes_home / "interrupt_debug.log" @@ -13253,50 +13304,42 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin): event.app.invalidate() return - # Cancel sudo prompt - if self._sudo_state: - self._sudo_state["response_queue"].put("") - self._sudo_state = None - event.app.invalidate() - return - - # Cancel secret prompt - if self._secret_state: - self._cancel_secret_capture() - event.app.current_buffer.reset() - event.app.invalidate() - return - - # Cancel approval prompt (deny) - if self._approval_state: - self._approval_state["response_queue"].put("deny") - self._approval_state = None - event.app.invalidate() - return - - # Cancel slash confirmation prompt + # Cancel slash confirmation prompt (foreground UI, not an + # agent-blocking overlay — cancel and stop here). if self._slash_confirm_state: self._submit_slash_confirm_response("cancel") event.app.current_buffer.reset() event.app.invalidate() return - # Cancel /model picker + # Cancel /model picker (foreground UI — cancel and stop here). if self._model_picker_state: self._close_model_picker() event.app.current_buffer.reset() event.app.invalidate() return - # Cancel clarify prompt - if self._clarify_state: - self._clarify_state["response_queue"].put( - "The user cancelled. Use your best judgement to proceed." - ) - self._clarify_state = None - self._clarify_freetext = False + # Clear all agent-blocking overlays (approval/clarify/sudo/secret) + # in one shot. We do NOT return after clearing — we fall through so + # that if the agent is also running we fire the interrupt on the same + # Ctrl+C press. This fixes the case where a stale/orphaned overlay + # (left behind by a previous interrupt) consumes the press without + # ever reaching the agent-interrupt branch, leaving the chat frozen + # (#14026). + _overlay_cleared = bool( + self._sudo_state + or self._secret_state + or self._approval_state + or self._clarify_state + ) + if _overlay_cleared: + self._clear_active_overlays_for_interrupt() event.app.current_buffer.reset() event.app.invalidate() + + # If we only cleared overlays and the agent is NOT running, stop here + # (don't fall through to the interrupt/exit path). + if _overlay_cleared and not (self._agent_running and self.agent): return if self._agent_running and self.agent: @@ -13353,50 +13396,35 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin): event.app.invalidate() return - # Cancel sudo prompt - if self._sudo_state: - self._sudo_state["response_queue"].put("") - self._sudo_state = None - event.app.invalidate() - return - - # Cancel secret prompt - if self._secret_state: - self._cancel_secret_capture() - event.app.current_buffer.reset() - event.app.invalidate() - return - - # Cancel approval prompt (deny) - if self._approval_state: - self._approval_state["response_queue"].put("deny") - self._approval_state = None - event.app.invalidate() - return - - # Cancel slash confirmation prompt + # Cancel slash confirmation prompt (foreground UI — cancel and stop). if self._slash_confirm_state: self._submit_slash_confirm_response("cancel") event.app.current_buffer.reset() event.app.invalidate() return - # Cancel /model picker + # Cancel /model picker (foreground UI — cancel and stop). if self._model_picker_state: self._close_model_picker() event.app.current_buffer.reset() event.app.invalidate() return - # Cancel clarify prompt - if self._clarify_state: - self._clarify_state["response_queue"].put( - "The user cancelled. Use your best judgement to proceed." - ) - self._clarify_state = None - self._clarify_freetext = False + # Clear all agent-blocking overlays in one shot, then fall through to + # the agent-interrupt branch so a single Ctrl+Q both clears a stale + # overlay and interrupts a still-running agent (#14026). + _overlay_cleared = bool( + self._sudo_state + or self._secret_state + or self._approval_state + or self._clarify_state + ) + if _overlay_cleared: + self._clear_active_overlays_for_interrupt() event.app.current_buffer.reset() event.app.invalidate() + + if _overlay_cleared and not (self._agent_running and self.agent): return if self._agent_running and self.agent: diff --git a/scripts/release.py b/scripts/release.py index 92a903a6a3e..53e3e5b2a69 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -54,6 +54,7 @@ AUTHOR_MAP = { "znding04@gmail.com": "znding04", # PR #15487 salvage (distinguish OpenRouter upstream 429 from account 429; upstream_rate_limit failover reason) "zkowkmdx@sharklasers.com": "nnnet", # PR #25142 salvage (stop STT-failure chatter poisoning the LLM prompt; drop hardcoded English notice) "vladimsmirnoff33@gmail.com": "londo161", # PR #15795 salvage (redact status --all API keys; tolerate dict/str compression message shape) + "neo.assistant2026@gmail.com": "neo-2026", # PR #14026 salvage (clear input-blocking overlays on interrupt so the CLI doesn't freeze; #13618) "cypher@augmentl.com": "Nickperillo", # PR #8008 salvage (Discord channel-name matching + flush pending sends on shutdown) "tenoryang@outlook.com": "MarioYounger", # PR #9028 salvage (bash/sh heredoc approval, NFKC homograph fold, execute_code CREDS/BEARER/APIKEY env filter) "peet.wannasarnmetha@gmail.com": "peetwan", # PR #51841 salvage (loopback ws-ping tuning + token-frame coalescing + loop heartbeat; #48445/#50005) diff --git a/tests/cli/test_cli_approval_ui.py b/tests/cli/test_cli_approval_ui.py index 0366ed628d9..8e1d4e327da 100644 --- a/tests/cli/test_cli_approval_ui.py +++ b/tests/cli/test_cli_approval_ui.py @@ -644,3 +644,96 @@ class TestPersistPromptSummary: assert "Clarify" in summary assert "Pick a path?" in summary assert "B" in summary + + +class TestClearOverlaysForInterrupt: + """Regression tests for #14026 — interrupting a running agent must clear + every input-blocking overlay (approval/clarify/sudo/secret) so the CLI + isn't left frozen with no thread servicing the prompt.""" + + def _make_cli(self): + cli = _make_cli_stub() + # Attributes the helper touches that the base stub doesn't set. + cli._clarify_state = None + cli._clarify_freetext = False + cli._secret_state = None + cli._secret_deadline = 0 + cli._paint_now = MagicMock() + return cli + + def test_clears_all_four_overlays_and_unblocks_queues(self): + cli = self._make_cli() + approval_q = queue.Queue() + clarify_q = queue.Queue() + sudo_q = queue.Queue() + secret_q = queue.Queue() + cli._approval_state = {"response_queue": approval_q} + cli._clarify_state = {"response_queue": clarify_q} + cli._clarify_freetext = True + cli._sudo_state = {"response_queue": sudo_q, "timeout": 60} + cli._sudo_deadline = 99999.0 + cli._secret_state = {"response_queue": secret_q, "var_name": "X"} + + cli._clear_active_overlays_for_interrupt() + + # All states nilled out. + assert cli._approval_state is None + assert cli._clarify_state is None + assert cli._clarify_freetext is False + assert cli._sudo_state is None + assert cli._sudo_deadline == 0 + assert cli._secret_state is None + + # Each blocked thread would have received a terminal value. + assert approval_q.get_nowait() == "deny" + assert clarify_q.get_nowait() # cancellation sentinel string + assert sudo_q.get_nowait() == "" + assert secret_q.get_nowait() == "" + + def test_noop_when_no_overlays_active(self): + cli = self._make_cli() + cli._clear_active_overlays_for_interrupt() + assert cli._approval_state is None + assert cli._clarify_state is None + assert cli._sudo_state is None + assert cli._secret_state is None + + def test_dead_queue_does_not_block_clearing_others(self): + """A queue that raises on put() must not prevent the remaining + overlays from being cleared.""" + cli = self._make_cli() + + class _DeadQueue: + def put(self, *_a, **_k): + raise RuntimeError("queue gone") + + clarify_q = queue.Queue() + cli._approval_state = {"response_queue": _DeadQueue()} + cli._clarify_state = {"response_queue": clarify_q} + + cli._clear_active_overlays_for_interrupt() + + assert cli._approval_state is None # cleared despite dead queue + assert cli._clarify_state is None + assert clarify_q.get_nowait() + + def test_interrupt_unblocks_thread_blocked_on_approval(self): + """End-to-end: a worker blocked on the approval queue unblocks when the + interrupt helper drains it.""" + cli = self._make_cli() + approval_q = queue.Queue() + cli._approval_state = {"response_queue": approval_q} + result = {} + + def _worker(): + result["value"] = approval_q.get(timeout=2) + + t = threading.Thread(target=_worker, daemon=True) + t.start() + time.sleep(0.05) + cli._clear_active_overlays_for_interrupt() + t.join(timeout=2) + + assert not t.is_alive(), "worker thread never unblocked" + assert result["value"] == "deny" +