fix(tui_gateway): dedup re-queued process notifications flooding TUI
_ notification_poller_loop_ re-emits status.update every cycle when a background process completes while the session is busy. The same completion event gets re-queued and re-emitted to the TUI every few ms, flooding the transcript with duplicate lines. Add _notification_event_dedup_key(evt) that returns a tuple identity for each notification event. Only emit status.update on first sight per identity: - completions: (sid, type) — one-shot per process session - watch_match: (sid, type, command, pattern, output, ...) - watch_overflow/disabled: (sid, type, command, message, ...) The dedup key design was refined from an initial sid:type approach after @lordbuffcloud identified that distinct watch_match events (READY vs DONE) for the same process would be incorrectly collapsed. Tests from @tymrtn cover distinct watch matches, exact replay dedup, and completion one-shot behavior. Co-authored-by: tymrtn <ty@tmrtn.com>
This commit is contained in:
committed by
Teknium
co-authored by
tymrtn
parent
2f0c8e90e6
commit
e7a7872a87
@@ -5474,6 +5474,8 @@ def test_notification_poller_requeues_when_busy(monkeypatch):
|
||||
assert requeued["session_id"] == "proc_busy_test"
|
||||
finally:
|
||||
server._sessions.pop("sid_busy", None)
|
||||
while not process_registry.completion_queue.empty():
|
||||
process_registry.completion_queue.get_nowait()
|
||||
|
||||
|
||||
def test_session_save_writes_under_hermes_home_with_system_prompt(monkeypatch, tmp_path):
|
||||
@@ -5533,3 +5535,95 @@ def test_session_save_writes_under_hermes_home_with_system_prompt(monkeypatch, t
|
||||
assert payload["session_start"] == "2026-01-01T12:00:00"
|
||||
assert payload["system_prompt"] == "You are Hermes."
|
||||
assert payload["messages"] == history
|
||||
|
||||
|
||||
def test_notification_event_dedup_key_preserves_distinct_watch_matches():
|
||||
"""Watch-match identity includes match content, not just session/type."""
|
||||
base = {
|
||||
"type": "watch_match",
|
||||
"session_id": "proc_watch",
|
||||
"command": "tail -f app.log",
|
||||
"pattern": "READY",
|
||||
"output": "READY on port 8000",
|
||||
"suppressed": 0,
|
||||
}
|
||||
|
||||
identical = dict(base)
|
||||
distinct_output = {**base, "output": "READY on port 9000"}
|
||||
distinct_pattern = {**base, "pattern": "MIGRATION_DONE"}
|
||||
|
||||
base_key = server._notification_event_dedup_key(base)
|
||||
assert server._notification_event_dedup_key(identical) == base_key
|
||||
assert server._notification_event_dedup_key(distinct_output) != base_key
|
||||
assert server._notification_event_dedup_key(distinct_pattern) != base_key
|
||||
|
||||
|
||||
def test_notification_poller_emits_distinct_watch_matches_once(monkeypatch):
|
||||
"""Distinct watch matches from one process emit; exact replay is deduped."""
|
||||
from tools.process_registry import process_registry
|
||||
|
||||
turns = []
|
||||
emitted = []
|
||||
|
||||
def _fake_run_prompt_submit(rid, sid, session, text):
|
||||
turns.append(text)
|
||||
with session["history_lock"]:
|
||||
session["running"] = False
|
||||
|
||||
sess = _session()
|
||||
server._sessions["sid_watch_dedup"] = sess
|
||||
monkeypatch.setattr(server, "_emit", lambda *a, **kw: emitted.append(a))
|
||||
monkeypatch.setattr(server, "_run_prompt_submit", _fake_run_prompt_submit)
|
||||
|
||||
while not process_registry.completion_queue.empty():
|
||||
process_registry.completion_queue.get_nowait()
|
||||
|
||||
base = {
|
||||
"type": "watch_match",
|
||||
"session_id": "proc_watch_dedup",
|
||||
"command": "tail -f app.log",
|
||||
"pattern": "READY",
|
||||
"output": "READY on port 8000",
|
||||
"suppressed": 0,
|
||||
}
|
||||
process_registry.completion_queue.put(base)
|
||||
process_registry.completion_queue.put({**base, "output": "READY on port 9000"})
|
||||
process_registry.completion_queue.put(dict(base))
|
||||
|
||||
stop = threading.Event()
|
||||
stop.set()
|
||||
|
||||
try:
|
||||
server._notification_poller_loop(stop, "sid_watch_dedup", sess)
|
||||
status_calls = [a for a in emitted if a[0] == "status.update"]
|
||||
assert len(status_calls) == 2
|
||||
status_text = "\n".join(call[2]["text"] for call in status_calls)
|
||||
assert "READY on port 8000" in status_text
|
||||
assert "READY on port 9000" in status_text
|
||||
assert len(turns) == 3
|
||||
finally:
|
||||
server._sessions.pop("sid_watch_dedup", None)
|
||||
while not process_registry.completion_queue.empty():
|
||||
process_registry.completion_queue.get_nowait()
|
||||
|
||||
|
||||
def test_notification_event_dedup_key_keeps_completions_one_shot():
|
||||
"""Completion identity remains process-session scoped to avoid floods."""
|
||||
first = {
|
||||
"type": "completion",
|
||||
"session_id": "proc_done",
|
||||
"command": "make build",
|
||||
"exit_code": 0,
|
||||
"output": "first output",
|
||||
}
|
||||
replay = {
|
||||
"type": "completion",
|
||||
"session_id": "proc_done",
|
||||
"command": "make build --again",
|
||||
"exit_code": 1,
|
||||
"output": "different output should not change completion key",
|
||||
}
|
||||
|
||||
assert server._notification_event_dedup_key(first) == server._notification_event_dedup_key(
|
||||
replay
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user