Files
hermes-agent/tests/hermes_cli/test_process_notification_display.py
Brooklyn Nicholson 8cb4fdc925 fix(process): heartbeats wake the agent only on new output, and never as a user bubble
A `terminal(background=true, heartbeat=N)` tick queued a notification every N seconds
whether or not the process had printed anything, and every queued event costs the owning
session a full model turn. On Desktop and the TUI that turn painted the wake as a user
bubble ("[Background process ... heartbeat #9 ... (no new output since the last
heartbeat)]") followed by the model's "Still running normally." — over and over, for a
process whose row on the status stack already said it was running — and while the wake
held the session's turn, the user's own prompt sat queued behind it.

- `ProcessRegistry._emit_heartbeat` skips a tick with no new output. The sequence counts
  delivered beats only; the "(no new output)" placeholder in the formatter is gone.
- TUI/Desktop type heartbeat rows `display_kind: hidden` (the kind both clients and the
  transcript preview already honour); the CLI paints a one-line receipt and persists the
  row hidden, so reopening the session in Desktop shows only the agent's reply.
- Desktop hydration drops heartbeat rows persisted by older backends the same way.
- `display.background_process_notifications: off` is honored by the TUI/Desktop poller and
  the CLI drain, not just the messaging gateway. `off` mutes process-driven wakes only:
  a finished `delegate_task(background=true)` still lands.

Supersedes #123123 (cherry-picked; scoped so `off` keeps subagent results) and #119202
(cherry-picked; `heartbeat: 0` is schema-valid so models that materialize every field
stop tripping the foreground guard).
2026-09-26 22:19:53 -05:00

131 lines
7.6 KiB
Python

"""Background-process completions paint a compact title while the model keeps the raw wall."""
import queue
import threading
from types import SimpleNamespace
from unittest.mock import Mock
import pytest
from cli import HermesCLI
from tools.process_registry_notifications import (
PROCESS_COMPLETE_DISPLAY_KIND, format_process_notification, process_completion_display_text)
from tui_gateway import server
def _registry(events):
return SimpleNamespace(
drain_notifications=lambda **kw: [(e, format_process_notification(e)) for e in events],
completion_queue=queue.Queue(), is_completion_consumed=lambda sid: False)
def _event(sid, exit_code, command="cd /tmp && bash long-build.sh"):
return {"type": "completion", "session_id": sid, "session_key": "display-session", "command": command,
"exit_code": exit_code, "completion_reason": "exited", "output": "web tsc=0\nSECRET_OUTPUT_LINE"}
@pytest.mark.parametrize("mode,expect_injected", [("off", False), ("concise", True), ("all", True)])
def test_drain_honors_background_process_notifications_off(monkeypatch, mode, expect_injected):
"""``off`` consumes completion events without injecting a REPL input (no spurious post-Ctrl+C
turn, #123114); every other documented mode keeps injecting, and the durable claim/ack chain
runs in all modes so an ``off`` session never leaves pending rows to replay on restart."""
events = [_event("proc_off", 0)]
cli = HermesCLI.__new__(HermesCLI)
cli.session_id = "display-session"
cli._pending_input = queue.Queue()
monkeypatch.setattr("tools.process_registry.process_registry", _registry(events))
acked = []
monkeypatch.setattr("tools.async_delegation.claim_event_delivery", lambda *a: "claimed")
monkeypatch.setattr("tools.async_delegation.complete_event_delivery", lambda *a: acked.append(a))
monkeypatch.setattr("cli.CLI_CONFIG", {"display": {"background_process_notifications": mode}})
cli._drain_process_notifications("cli-post-turn")
assert acked, "events must still be claimed and acknowledged under every mode"
assert cli._pending_input.empty() is (not expect_injected)
if expect_injected:
assert cli._pending_input.get_nowait() is not None
def test_off_mutes_process_wakes_but_subagent_results_still_land(monkeypatch):
"""``off`` is about background PROCESSES: a finished ``delegate_task(background=true)`` is a
result the user asked for, never a notification to opt out of."""
delegation = {"type": "async_delegation", "delegation_id": "d1", "session_key": "display-session",
"results": [{"status": "completed", "summary": "done", "goal": "audit"}]}
events = [_event("proc_a", 0), {**_event("proc_b", 0), "type": "heartbeat", "seq": 1, "output": "line"}, delegation]
cli = HermesCLI.__new__(HermesCLI)
cli.session_id = "display-session"
cli._pending_input = queue.Queue()
monkeypatch.setattr("tools.process_registry.process_registry", _registry(events))
monkeypatch.setattr("tools.async_delegation.claim_event_delivery", lambda *a: "claimed")
monkeypatch.setattr("tools.async_delegation.complete_event_delivery", lambda *a: None)
monkeypatch.setattr("cli.CLI_CONFIG", {"display": {"background_process_notifications": "off"}})
cli._drain_process_notifications("cli-post-turn")
queued = cli._pending_input.get_nowait()
assert cli._pending_input.empty()
assert queued.display_kind == "async_delegation_complete"
def test_heartbeat_wake_paints_one_line_and_persists_hidden(monkeypatch):
"""The model gets the output delta; the human gets a one-line receipt, and the persisted row is
typed ``hidden`` so Desktop/TUI never paint the wake as a user bubble."""
from tools.process_registry_notifications import HEARTBEAT_DISPLAY_KIND, heartbeat_display_text
beat = {**_event("proc_hb", None), "type": "heartbeat", "seq": 3, "elapsed": 422, "interval": 60,
"output": "web tsc=0\nSECRET_OUTPUT_LINE"}
cli = HermesCLI.__new__(HermesCLI)
cli.session_id = "display-session"
cli._pending_input = queue.Queue()
monkeypatch.setattr("tools.process_registry.process_registry", _registry([beat]))
monkeypatch.setattr("tools.async_delegation.claim_event_delivery", lambda *a: "claimed")
monkeypatch.setattr("tools.async_delegation.complete_event_delivery", lambda *a: None)
cli._drain_process_notifications("cli-idle")
queued = cli._pending_input.get_nowait()
assert queued == format_process_notification(beat)
assert queued.display_kind == HEARTBEAT_DISPLAY_KIND == "hidden"
assert queued.display_text == heartbeat_display_text(beat)
assert "SECRET_OUTPUT_LINE" not in queued.display_text and "long-build.sh" in queued.display_text
def test_process_completion_display_keeps_payload_separate_across_surfaces(monkeypatch, capsys, tmp_path):
events = [_event("proc_1", 0)]
payload = format_process_notification(events[0])
cli = HermesCLI.__new__(HermesCLI)
cli.session_id = "display-session"
cli._pending_input = queue.Queue()
registry = _registry(events)
monkeypatch.setattr("tools.process_registry.process_registry", registry)
monkeypatch.setattr("tools.async_delegation.claim_event_delivery", lambda *a: "claimed")
monkeypatch.setattr("tools.async_delegation.complete_event_delivery", lambda *a: None)
cli._drain_process_notifications("cli-idle")
for attr, value in {"_pending_resume_sessions": [], "_typed_voice_stop": lambda t: False,
"handle_bang_shell": lambda t: False, "_turn_summary_begin": lambda: None,
"_tui_after_turn": lambda: None, "_app": SimpleNamespace(invalidate=lambda: None),
"chat": Mock()}.items():
setattr(cli, attr, value)
cli._tui_process_one_input(cli._pending_input.get_nowait())
visible = capsys.readouterr().out
expected = process_completion_display_text(events)
assert "long-build.sh" in expected and expected in visible
assert "[IMPORTANT" not in visible and "SECRET_OUTPUT_LINE" not in visible
queued = cli.chat.call_args.args[0]
assert queued == payload # the model still receives the full notification
cli.conversation_history = []
cli.agent = SimpleNamespace(run_conversation=Mock(return_value={}))
cli._chat_stage_user_message(cli.agent, queued)
staged = cli.conversation_history[-1]
assert staged["content"] == payload and type(staged["content"]) is str
assert staged["display_kind"] == PROCESS_COMPLETE_DISPLAY_KIND
assert staged["display_metadata"]["display_text"] == expected
from hermes_cli.cli_agent_setup_mixin import _collect_resume_entries
entries, _, _ = _collect_resume_entries(cli.conversation_history, {}, lambda text: text)
assert entries == [("event", expected)]
# TUI gateway: the status line and the persisted turn carry the same compact title.
emitted, submitted = [], []
monkeypatch.setattr(server, "_emit", lambda *args: emitted.append(args))
monkeypatch.setattr(server, "_notif_submit", lambda *args, **kw: submitted.append((args, kw)))
monkeypatch.setattr(server, "_notif_claim_turn", lambda session: True)
session = {"session_key": "display-session", "history_lock": threading.RLock()}
server._notif_handle_ready("ui-session", session, events, set(), registry, format_process_notification, None,
owned=True)
assert emitted[0][2] == {"kind": "process", "text": expected}
(_rid, _sid, _session, text, _what), kwargs = submitted[0]
assert text == payload
assert kwargs["display_kind"] == PROCESS_COMPLETE_DISPLAY_KIND
assert kwargs["display_metadata"] == {"display_text": expected}