From 21c64f90aa4631b7e186e6f8f04e5358236bb5c9 Mon Sep 17 00:00:00 2001 From: alt-glitch Date: Sun, 14 Jun 2026 08:55:29 +0530 Subject: [PATCH] feat(tui_gateway): emit notification.show on background-process completion (Option B) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A notify_on_complete background process (the agent's terminal tool, proc_*) only reached the TUI as the AGENT'S NARRATION — the completion is fed to the model as a synthetic prompt (_run_prompt_submit) and the gateway emitted only message.start + the reply, never the completion itself. So the OpenTUI card mechanism had nothing to render and the synthetic turn read as a context-less line. Additive fix (glitch-approved relaxation of the no-core rule for this one emit): new _emit_process_completion_card() fires a `notification.show` (text " exited ", level info/warn by exit code, kind process.complete, key proc:) at the two completion-delivery sites, just before the agent turn. The OpenTUI engine renders it as a distinct inline card (P1) + an OSC ping; Ink treats it as a notice. Completion events only (watch matches skipped); call-site dedup → one card per exit. No existing behavior changes — the agent turn still happens. Gateway suite green (321 passed incl. 5 new TestProcessCompletionCard cases). --- tests/test_tui_gateway_server.py | 48 ++++++++++++++++++++++++++++++++ tui_gateway/server.py | 36 ++++++++++++++++++++++++ 2 files changed, 84 insertions(+) diff --git a/tests/test_tui_gateway_server.py b/tests/test_tui_gateway_server.py index 176dc837047..ae50b730b8d 100644 --- a/tests/test_tui_gateway_server.py +++ b/tests/test_tui_gateway_server.py @@ -7856,3 +7856,51 @@ def test_reap_idle_sessions_closes_only_evictable(monkeypatch): assert closed == [("stale", "idle_timeout")] finally: server._sessions.clear() + + +class TestProcessCompletionCard: + """_emit_process_completion_card surfaces a background-process completion to + the TUI as a notification.show card (Option B, glitch 2026-06-14) — in + addition to the agent turn the completion triggers.""" + + @staticmethod + def _capture(monkeypatch): + emitted: list = [] + monkeypatch.setattr(server, "_emit", lambda event, sid, payload=None: emitted.append((event, sid, payload))) + return emitted + + def test_completion_exit_zero_is_an_info_card(self, monkeypatch): + emitted = self._capture(monkeypatch) + server._emit_process_completion_card( + "s1", {"type": "completion", "session_id": "proc_1", "command": "sleep 20 && echo hi", "exit_code": 0} + ) + assert len(emitted) == 1 + event, sid, payload = emitted[0] + assert event == "notification.show" + assert sid == "s1" + assert payload["text"] == "sleep 20 && echo hi exited 0" + assert payload["level"] == "info" + assert payload["kind"] == "process.complete" + assert payload["key"] == "proc:proc_1" + + def test_nonzero_exit_is_a_warn_card(self, monkeypatch): + emitted = self._capture(monkeypatch) + server._emit_process_completion_card("s1", {"type": "completion", "command": "build", "exit_code": 1, "session_id": "p2"}) + assert emitted[0][2]["level"] == "warn" + assert emitted[0][2]["text"] == "build exited 1" + + def test_watch_match_is_not_carded(self, monkeypatch): + emitted = self._capture(monkeypatch) + server._emit_process_completion_card("s1", {"type": "watch_match", "command": "tail -f log"}) + assert emitted == [] + + def test_long_command_is_truncated(self, monkeypatch): + emitted = self._capture(monkeypatch) + server._emit_process_completion_card("s1", {"type": "completion", "command": "x" * 100, "exit_code": 0, "session_id": "p3"}) + assert "…" in emitted[0][2]["text"] + assert len(emitted[0][2]["text"]) < 80 + + def test_missing_exit_code_says_finished(self, monkeypatch): + emitted = self._capture(monkeypatch) + server._emit_process_completion_card("s1", {"type": "completion", "command": "daemon", "session_id": "p4"}) + assert emitted[0][2]["text"] == "daemon finished" diff --git a/tui_gateway/server.py b/tui_gateway/server.py index aa4a599849b..5320c1990f7 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -5607,6 +5607,39 @@ def _notification_event_dedup_key(evt: dict) -> tuple: return (evt_sid, evt_type) +def _emit_process_completion_card(sid: str, evt: dict) -> None: + """Surface a background-process COMPLETION to the TUI as a notification card, + in ADDITION to the agent turn it triggers. A bare `notify_on_complete` exit + otherwise reaches the TUI only as the agent's narration (the completion is fed + to the model as a synthetic prompt, never sent to the UI). Emitting a + ``notification.show`` lets the OpenTUI engine render a distinct inline card so + the user actually sees the process finish; the Ink engine treats it as a + notice. Additive — no existing behaviour changes. Completion events only + (watch matches aren't terminal); the dedup at the call sites ensures one card + per completion. (glitch 2026-06-14)""" + if evt.get("type", "completion") != "completion": + return + cmd = str(evt.get("command") or "process").strip().replace("\n", " ") + if len(cmd) > 60: + cmd = cmd[:59] + "…" + code = evt.get("exit_code") + if code is None: + text, level = f"{cmd} finished", "info" + else: + text = f"{cmd} exited {code}" + level = "info" if code == 0 else "warn" + _emit( + "notification.show", + sid, + { + "text": text, + "kind": "process.complete", + "level": level, + "key": f"proc:{evt.get('session_id', '')}", + }, + ) + + def _notification_poller_loop( stop_event: threading.Event, sid: str, session: dict ) -> None: @@ -5665,6 +5698,7 @@ def _notification_poller_loop( rid = f"__notif__{int(time.time() * 1000)}" try: + _emit_process_completion_card(sid, evt) _emit("message.start", sid) _run_prompt_submit(rid, sid, session, text) except Exception as exc: @@ -5708,6 +5742,7 @@ def _notification_poller_loop( rid = f"__notif__{int(time.time() * 1000)}" try: + _emit_process_completion_card(sid, evt) _emit("message.start", sid) _run_prompt_submit(rid, sid, session, text) except Exception as exc: @@ -6148,6 +6183,7 @@ def _run_prompt_submit(rid, sid: str, session: dict, text: Any) -> None: break session["running"] = True try: + _emit_process_completion_card(sid, _evt) _emit("message.start", sid) _run_prompt_submit(rid, sid, session, synth) except Exception as _n_exc: