Files
hermes-agent/tests/hermes_cli/test_terminal_notify.py
T
kshitijk4poor ac2359cd35 fix(cli): turn-end notifications no longer paint the pet's kitty frame as base64
Symptom (Ghostty, display.pet on, display.bell_on_complete on): at the end of a turn the
input line fills with several rows of base64 and a stale copy of the status bar + pet
stays above the response panel.

Root cause: _ring_bell runs on the agent thread and terminal_notify wrote the OSC 9 /
OSC 777 sequence through its own open("/dev/tty") (or sys.stdout). The prompt_toolkit
loop thread may at that moment be mid-write of a 12 KB kitty APC pet frame, which the tty
drains ~1 KB at a time. The second writer splices into the frame; the foreign ESC aborts
the APC and the terminal paints the remainder of the payload as text at the input cursor.
The wrapped garbage scrolls the screen, so the panel that follows is printed against a
stale cursor position and the old chrome survives above it.

Change: when the CLI's Application is running, _ring_bell hands "\a" + the notification
sequence to the app loop (_run_on_app_loop -> _write_terminal_sequence), serializing it
behind the renderer and the after_render frame writer. terminal_notify.notify() keeps the
/dev/tty path for callers without a running app; the sequence builder is split out as
notification_sequence().

Verification: pty A/B with the real Application + after_render frame writer, 400 rings
vs 91 frames — base: 3 leaks (4,324 base64 chars painted); fixed: 0 leaks, 400/400
notifications delivered, 0 aborted frames.
2026-09-15 14:52:26 +05:30

97 lines
3.5 KiB
Python

"""display.bell_on_prompt / bell_on_complete also drive OSC 9 + Warp OSC 777 via _ring_bell."""
import io
import json
import sys
import pytest
from cli import HermesCLI
from hermes_cli import terminal_notify
_WARP_OK = {
"TERM_PROGRAM": "WarpTerminal",
"WARP_CLI_AGENT_PROTOCOL_VERSION": "1",
"WARP_CLIENT_VERSION": "v0.2026.08.01.00.00.stable_01",
}
def _ring(monkeypatch, *, flag_on, env, **kwargs):
for key in _WARP_OK:
monkeypatch.delenv(key, raising=False)
for key, value in env.items():
monkeypatch.setenv(key, value)
written = []
monkeypatch.setattr(terminal_notify, "_write_tty", written.append)
cli = HermesCLI.__new__(HermesCLI)
cli.bell_on_prompt = flag_on
cli.session_id = "sess-1"
cli._ring_bell(prompt=True, **kwargs)
return "".join(written)
def test_osc9_body_emitted_and_sanitized_only_when_flag_on(monkeypatch):
out = _ring(monkeypatch, flag_on=True, env={}, context="approval\x1b\x07\x00\x7f!")
assert out == "\x1b]9;Hermes: approval!\x07"
assert _ring(monkeypatch, flag_on=False, env={}, context="approval") == ""
def test_warp_osc777_only_under_supported_warp_build(monkeypatch):
out = _ring(monkeypatch, flag_on=True, env=_WARP_OK, context="approval", detail="rm -rf build")
prefix = "\x1b]777;notify;warp://cli-agent;"
assert out.count(prefix) == 1
payload = json.loads(out.split(prefix, 1)[1].rstrip("\x07"))
assert payload["agent"] == "hermes"
assert payload["event"] == "permission_request"
assert payload["summary"] == "rm -rf build"
assert payload["session_id"] == "sess-1"
assert payload["v"] == 1
# Broken build (advertises the protocol var but can't render) → OSC 9 only.
broken = dict(_WARP_OK, WARP_CLIENT_VERSION="v0.2026.03.25.08.24.stable_05")
assert prefix not in _ring(monkeypatch, flag_on=True, env=broken, context="approval")
# Not Warp at all → OSC 9 only.
not_warp = dict(_WARP_OK, TERM_PROGRAM="ghostty")
assert prefix not in _ring(monkeypatch, flag_on=True, env=not_warp, context="approval")
def test_running_app_gets_bell_and_osc9_on_its_loop_never_a_second_tty_writer(monkeypatch):
"""With the prompt_toolkit app live, the notification must reach the tty through the app's
output ON THE APP LOOP. A parallel /dev/tty or sys.stdout write from the agent thread splices
into an in-flight kitty pet frame and the terminal paints the frame's base64 as text."""
for key in _WARP_OK:
monkeypatch.delenv(key, raising=False)
monkeypatch.setattr(terminal_notify, "_write_tty", lambda seq: pytest.fail(f"stray tty write: {seq!r}"))
fake_stdout = io.StringIO()
monkeypatch.setattr(sys, "stdout", fake_stdout)
class _Output:
raw = []
def write_raw(self, data):
self.raw.append(data)
def flush(self):
self.raw.append("<flush>")
class _Loop:
queued = []
def call_soon_threadsafe(self, fn):
self.queued.append(fn)
class _App:
_is_running = True
loop = _Loop()
output = _Output()
cli = HermesCLI.__new__(HermesCLI)
cli.bell_on_complete = True
cli.session_id = "sess-1"
cli._app = _App()
cli._ring_bell(context="turn complete")
# Nothing touched the tty from the calling thread; the write is queued for the loop.
assert _Output.raw == [] and fake_stdout.getvalue() == ""
assert len(_Loop.queued) == 1
_Loop.queued[0]()
assert _Output.raw == ["\a\x1b]9;Hermes: turn complete\x07", "<flush>"]