hermes-agent/tests/hermes_cli/test_tui_mouse_residue_suppression.py
brooklyn! 912e6e2274
fix(tui): suppress mouse-residue leaks during Python launcher startup (#31213)
* fix(tui): suppress mouse-residue leaks during Python launcher startup

`hermes --tui …` spends ~100–300ms inside the Python launcher (lazy
imports, arg parsing, session resolution) before exec'ing the Node TUI
binary. During that window stdin is still in cooked + echo mode. If a
prior session left DEC mouse tracking asserted (or the user spammed
mouse movement while the previous session was opening), the terminal
keeps emitting `\\x1b[<…M` SGR motion reports that get echoed straight
back into the user's shell scrollback as literal `^[[<…M` text and
sit there above the TUI banner until the next clear.

The Node side already calls `resetTerminalModes()` in `entry.tsx`, but
by then the race is already lost — the bytes echoed during the Python
warmup window were committed to the scrollback before Node started.

Fix: write the mouse-tracking disable sequence at the very top of
`hermes_cli.main`, before every heavy import. The terminal stops
emitting motion events as soon as the bytes hit the wire (one TTY
round-trip), shrinking the race window from hundreds of milliseconds
to a few. `HERMES_TUI_NO_EARLY_DISABLE=1` opts out for diagnostics.

* test(tui): drop dead _reload_main, hoist import out of patch context

Addresses Copilot review on PR #31213.

The tests used to import `hermes_cli.main` inside the `patch("os.write")`
context, which Copilot pointed out is order-dependent: if the module
is already loaded (e.g. imported by a prior test in the same process),
the import is a no-op and the patch only sees the explicit
`_suppress_mouse_residue_early()` call. Either way the assertion can
flake when run alongside other tests.

Move the import to module scope — every subprocess gets a fresh
`hermes_cli.main`, whose module-level invocation is a no-op under
pytest argv. Tests then exercise `_suppress_mouse_residue_early()`
directly inside their own patch context. Also drop the unused
`_reload_main` helper.

* fix(tui): skip early mouse-disable when stdout is not a TTY

Addresses Copilot review on PR #31213.

`hermes --tui … >log` or CI capture pipes fd 1 away from the terminal.
The disable bytes can't reach the terminal in that case but would
still get written into the log file as raw CSI sequences. Guard with
`os.isatty(1)` inside the existing `try/except OSError` block so the
'never break startup' contract holds.

* docs(tui): rephrase 'raw cooked mode' as 'cooked + echo mode'

Copilot review nit on PR #31213 — the original wording was self-
contradictory. Pre-TUI stdin state is cooked + echo (kernel TTY
discipline still owns the line buffer and echoes input back). The
TUI switches it to raw mode later when Ink mounts.
2026-05-27 22:03:45 -05:00

92 lines
3.8 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Tests for the TUI-hot-path mouse-residue suppression.
The Python launcher (`hermes --tui …`) has a ~100300ms cold-start window
where stdin is still in cooked + echo mode. If a previous Hermes session
left DEC mouse-tracking asserted, any mouse motion during that window
echoes literal ``^[[<…M`` text into the user's scrollback.
`_suppress_mouse_residue_early()` writes the disable sequence to stdout
before the heavy imports so the terminal stops emitting events ASAP.
"""
from __future__ import annotations
import sys
from unittest.mock import patch
# Importing the module triggers `_suppress_mouse_residue_early()` at module
# scope. Under the test runner argv (`pytest …`) it's a no-op, but we import
# at file scope so individual tests don't race the import side-effect with
# their `patch("os.write")` context.
from hermes_cli.main import _suppress_mouse_residue_early
EXPECTED = (
b"\x1b[?1003l\x1b[?1002l\x1b[?1001l\x1b[?1000l\x1b[?9l"
b"\x1b[?1006l\x1b[?1005l\x1b[?1015l\x1b[?1016l\x1b[?2029l"
)
class TestEarlyMouseDisable:
def test_writes_disable_sequence_when_tui_flag_in_argv(self, monkeypatch):
monkeypatch.setattr(sys, "argv", ["hermes", "--tui", "-c", "abc"])
monkeypatch.delenv("HERMES_TUI", raising=False)
monkeypatch.delenv("HERMES_TUI_NO_EARLY_DISABLE", raising=False)
with patch("os.isatty", return_value=True), patch("os.write") as mock_write:
_suppress_mouse_residue_early()
mock_write.assert_called_once_with(1, EXPECTED)
def test_writes_disable_sequence_when_hermes_tui_env_set(self, monkeypatch):
monkeypatch.setattr(sys, "argv", ["hermes"])
monkeypatch.setenv("HERMES_TUI", "1")
monkeypatch.delenv("HERMES_TUI_NO_EARLY_DISABLE", raising=False)
with patch("os.isatty", return_value=True), patch("os.write") as mock_write:
_suppress_mouse_residue_early()
mock_write.assert_called_once_with(1, EXPECTED)
def test_no_op_on_non_tui_invocation(self, monkeypatch):
monkeypatch.setattr(sys, "argv", ["hermes", "--version"])
monkeypatch.delenv("HERMES_TUI", raising=False)
monkeypatch.delenv("HERMES_TUI_NO_EARLY_DISABLE", raising=False)
with patch("os.write") as mock_write:
_suppress_mouse_residue_early()
mock_write.assert_not_called()
def test_respects_diagnostic_escape_hatch(self, monkeypatch):
monkeypatch.setattr(sys, "argv", ["hermes", "--tui"])
monkeypatch.delenv("HERMES_TUI", raising=False)
monkeypatch.setenv("HERMES_TUI_NO_EARLY_DISABLE", "1")
with patch("os.write") as mock_write:
_suppress_mouse_residue_early()
mock_write.assert_not_called()
def test_skips_when_stdout_is_not_a_tty(self, monkeypatch):
# `hermes --tui … >log` or CI capture: pipe is fd 1, not a TTY. The
# bytes can't reach a terminal and would just pollute the log.
monkeypatch.setattr(sys, "argv", ["hermes", "--tui"])
monkeypatch.delenv("HERMES_TUI", raising=False)
monkeypatch.delenv("HERMES_TUI_NO_EARLY_DISABLE", raising=False)
with patch("os.isatty", return_value=False), patch("os.write") as mock_write:
_suppress_mouse_residue_early()
mock_write.assert_not_called()
def test_oserror_is_swallowed(self, monkeypatch):
monkeypatch.setattr(sys, "argv", ["hermes", "--tui"])
monkeypatch.delenv("HERMES_TUI", raising=False)
monkeypatch.delenv("HERMES_TUI_NO_EARLY_DISABLE", raising=False)
def boom(*_a, **_k):
raise OSError("stdout closed")
with patch("os.isatty", return_value=True), patch("os.write", side_effect=boom):
# Must not propagate — startup hot path can never break.
_suppress_mouse_residue_early()