mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-29 06:31:32 +00:00
* 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.
92 lines
3.8 KiB
Python
92 lines
3.8 KiB
Python
"""Tests for the TUI-hot-path mouse-residue suppression.
|
||
|
||
The Python launcher (`hermes --tui …`) has a ~100–300ms 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()
|