Files
nousresearch--hermes-agent/tests/cli/test_terminal_interrupt_recovery.py
wehub-resource-sync b4fbd6fe9f
Deploy Site / deploy-vercel (push) Has been skipped
Deploy Site / deploy-docs (push) Has been skipped
Build Skills Index / build-index (push) Has been skipped
CI / Deny unrelated histories (push) Has been skipped
CI / Detect affected areas (push) Successful in 27m35s
CI / OSV scan (push) Failing after 4s
CI / Build&Test Docker image (push) Successful in 9s
CI / Supply-chain scan (push) Has been skipped
CI / Lint Docker scripts (push) Failing after 5m13s
CI / Check contributors (push) Failing after 12m8s
CI / Docs Site (push) Failing after 12m8s
CI / TypeScript (push) Failing after 12m8s
CI / Python lints (push) Failing after 12m9s
CI / Python tests (push) Failing after 12m9s
CI / Check uv.lock (push) Failing after 23m22s
CI / CI timing report (push) Has been cancelled
Build Skills Index / trigger-deploy (push) Has been cancelled
CI / All required checks pass (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 11:56:03 +08:00

113 lines
4.2 KiB
Python

"""Regression tests for #33271: terminal recovery after interrupt.
When the user interrupts a running agent turn by typing a new message,
prompt_toolkit may have an in-flight ``CSI 6n`` cursor-position query whose
reply (``ESC[<row>;<col>R``) arrives on stdin after the input parser has torn
down. The reply then leaks as literal text (``^[[19;1R``) and the VT100 parser
can stall, accepting no further keystrokes — the terminal appears frozen.
The recovery path lives in ``HermesCLI._recover_terminal_after_interrupt()``,
which is invoked from ``process_loop``'s ``finally`` block only when
``self._last_turn_interrupted`` is set. It must:
1. Drain stray escape bytes from the OS input buffer (``flush_stdin``).
2. Force a clean prompt_toolkit renderer redraw (``_force_full_redraw``).
These tests exercise the real method (not a re-implementation of its logic),
and assert that the finally block actually wires it in behind the interrupt
guard.
"""
import inspect
import re
from unittest.mock import MagicMock, patch
import pytest
import cli as cli_mod
from cli import HermesCLI
@pytest.fixture
def bare_cli():
"""A HermesCLI with no __init__ — we only exercise the recovery helper."""
return object.__new__(HermesCLI)
class TestRecoverTerminalAfterInterrupt:
"""Directly exercise HermesCLI._recover_terminal_after_interrupt()."""
def test_drains_stdin_then_redraws(self, bare_cli):
"""Happy path: flush_stdin runs, then a full redraw is forced."""
bare_cli._force_full_redraw = MagicMock()
with patch("hermes_cli.curses_ui.flush_stdin") as mock_flush:
bare_cli._recover_terminal_after_interrupt()
mock_flush.assert_called_once()
bare_cli._force_full_redraw.assert_called_once()
def test_redraw_still_runs_when_flush_fails(self, bare_cli):
"""A flush_stdin failure (no TTY, non-POSIX) must not skip the redraw.
The two recovery steps are independent — losing the stdin drain must
never leave the renderer un-repainted.
"""
bare_cli._force_full_redraw = MagicMock()
with patch(
"hermes_cli.curses_ui.flush_stdin", side_effect=OSError("no tty")
):
bare_cli._recover_terminal_after_interrupt() # must not raise
bare_cli._force_full_redraw.assert_called_once()
def test_flush_runs_before_redraw(self, bare_cli):
"""Order matters: drain stray bytes first so they don't arrive mid-redraw."""
events = []
bare_cli._force_full_redraw = MagicMock(
side_effect=lambda: events.append("redraw")
)
with patch(
"hermes_cli.curses_ui.flush_stdin",
side_effect=lambda: events.append("flush"),
):
bare_cli._recover_terminal_after_interrupt()
assert events == ["flush", "redraw"]
def test_flush_stdin_is_tty_gated(self):
"""The real flush_stdin is a no-op on non-TTY stdin (piped/redirected).
Under pytest stdin is not a TTY, so this must return cleanly without
touching termios.
"""
from hermes_cli.curses_ui import flush_stdin
flush_stdin() # must not raise in a non-TTY test environment
class TestFinallyBlockWiring:
"""The recovery helper is only useful if process_loop actually calls it.
These guard against the helper silently becoming dead code (the fix being
present but never invoked), which a unit test of the helper alone can't
catch.
"""
def test_recovery_is_invoked_behind_interrupt_guard(self):
src = inspect.getsource(HermesCLI.run)
# The recovery call must be gated on _last_turn_interrupted so it only
# fires after an actual interrupt, not on every normal turn.
guard = re.search(
r"if self\._last_turn_interrupted:\s*\n\s*"
r"self\._recover_terminal_after_interrupt\(\)",
src,
)
assert guard, (
"process_loop's finally block must call "
"_recover_terminal_after_interrupt() guarded by "
"self._last_turn_interrupted"
)
def test_recovery_helper_exists(self):
assert hasattr(HermesCLI, "_recover_terminal_after_interrupt")
assert callable(HermesCLI._recover_terminal_after_interrupt)