feat(tui_gateway): emit notification.show on background-process completion (Option B)

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 "<cmd> exited
<code>", level info/warn by exit code, kind process.complete, key proc:<id>) 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).
This commit is contained in:
alt-glitch 2026-06-14 08:55:29 +05:30
parent 8e853e3ff8
commit 21c64f90aa
2 changed files with 84 additions and 0 deletions

View file

@ -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"

View file

@ -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: