fix(tui): autonomous background process completion notifications (#26071) (#26327)

* feat(process-registry): add format_process_notification shared helper

* feat(process-registry): add drain_notifications method

* refactor(cli): use shared drain_notifications and format_process_notification

* feat(tui): add background notification poller for completion_queue

* feat(tui): wire notification poller into session init/finalize

* refactor(tui): add post-turn drain using shared helper as safety net
This commit is contained in:
Siddharth Balyan 2026-05-15 19:31:00 +05:30 committed by GitHub
parent db84a78e61
commit d5416284f1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 486 additions and 55 deletions

View file

@ -4649,3 +4649,158 @@ def test_config_show_displays_nested_max_turns(monkeypatch):
)
assert ["Max Turns", "120"] in agent_rows
def test_notification_poller_delivers_completion(monkeypatch):
"""Poller picks up completion events and triggers agent turns."""
from tools.process_registry import process_registry
turns = []
emitted = []
class _Agent:
def run_conversation(self, prompt, conversation_history=None, stream_callback=None):
turns.append(prompt)
return {
"final_response": "ok",
"messages": [{"role": "assistant", "content": "ok"}],
}
class _ImmediateThread:
def __init__(self, target=None, daemon=None):
self._target = target
def start(self):
self._target()
sess = _session(agent=_Agent())
server._sessions["sid_poll"] = sess
monkeypatch.setattr(server.threading, "Thread", _ImmediateThread)
monkeypatch.setattr(server, "_emit", lambda *a, **kw: emitted.append(a))
monkeypatch.setattr(server, "make_stream_renderer", lambda cols: None)
monkeypatch.setattr(server, "render_message", lambda raw, cols: None)
# Clear queue
while not process_registry.completion_queue.empty():
process_registry.completion_queue.get_nowait()
process_registry._completion_consumed.discard("proc_poller_test")
stop = threading.Event()
# Put event on queue, then immediately signal stop so the poller
# runs exactly one iteration.
process_registry.completion_queue.put({
"type": "completion",
"session_id": "proc_poller_test",
"command": "echo hello",
"exit_code": 0,
"output": "hello",
})
stop.set()
try:
server._notification_poller_loop(stop, "sid_poll", sess)
# Should have emitted a status.update with kind=process
status_calls = [a for a in emitted if a[0] == "status.update"]
assert len(status_calls) >= 1
assert status_calls[0][2]["kind"] == "process"
# Should have triggered an agent turn
assert len(turns) == 1
assert "[IMPORTANT: Background process proc_poller_test completed" in turns[0]
finally:
server._sessions.pop("sid_poll", None)
while not process_registry.completion_queue.empty():
process_registry.completion_queue.get_nowait()
def test_notification_poller_skips_consumed(monkeypatch):
"""Already-consumed completions are not dispatched by the poller."""
from tools.process_registry import process_registry
turns = []
class _Agent:
def run_conversation(self, prompt, conversation_history=None, stream_callback=None):
turns.append(prompt)
return {"final_response": "ok", "messages": []}
class _ImmediateThread:
def __init__(self, target=None, daemon=None):
self._target = target
def start(self):
self._target()
sess = _session(agent=_Agent())
server._sessions["sid_skip"] = sess
monkeypatch.setattr(server.threading, "Thread", _ImmediateThread)
monkeypatch.setattr(server, "_emit", lambda *a, **kw: None)
monkeypatch.setattr(server, "make_stream_renderer", lambda cols: None)
monkeypatch.setattr(server, "render_message", lambda raw, cols: None)
while not process_registry.completion_queue.empty():
process_registry.completion_queue.get_nowait()
process_registry._completion_consumed.add("proc_already_done")
process_registry.completion_queue.put({
"type": "completion",
"session_id": "proc_already_done",
"command": "echo x",
"exit_code": 0,
"output": "x",
})
stop = threading.Event()
stop.set()
try:
server._notification_poller_loop(stop, "sid_skip", sess)
assert len(turns) == 0
finally:
server._sessions.pop("sid_skip", None)
process_registry._completion_consumed.discard("proc_already_done")
while not process_registry.completion_queue.empty():
process_registry.completion_queue.get_nowait()
def test_notification_poller_requeues_when_busy(monkeypatch):
"""When the agent is busy, the poller requeues the event."""
from tools.process_registry import process_registry
emitted = []
sess = _session(running=True) # agent is busy
server._sessions["sid_busy"] = sess
monkeypatch.setattr(server, "_emit", lambda *a, **kw: emitted.append(a))
while not process_registry.completion_queue.empty():
process_registry.completion_queue.get_nowait()
process_registry._completion_consumed.discard("proc_busy_test")
evt = {
"type": "completion",
"session_id": "proc_busy_test",
"command": "make build",
"exit_code": 0,
"output": "ok",
}
process_registry.completion_queue.put(evt)
stop = threading.Event()
stop.set()
try:
server._notification_poller_loop(stop, "sid_busy", sess)
# Status update was emitted (user sees it)
status_calls = [a for a in emitted if a[0] == "status.update"]
assert len(status_calls) == 1
# Event was requeued (agent was busy, no turn triggered)
assert not process_registry.completion_queue.empty()
requeued = process_registry.completion_queue.get_nowait()
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()

View file

@ -865,3 +865,138 @@ class TestProcessToolHandler:
from tools.process_registry import _handle_process
result = json.loads(_handle_process({"action": "unknown_action"}))
assert "error" in result
# =========================================================================
# format_process_notification + drain_notifications (shared helpers)
# =========================================================================
from tools.process_registry import format_process_notification
def test_format_completion_event():
evt = {
"type": "completion",
"session_id": "proc_abc",
"command": "sleep 5",
"exit_code": 0,
"output": "done",
}
result = format_process_notification(evt)
assert "[IMPORTANT: Background process proc_abc completed" in result
assert "exit code 0" in result
assert "Command: sleep 5" in result
assert "Output:\ndone]" in result
def test_format_watch_match_event():
evt = {
"type": "watch_match",
"session_id": "proc_xyz",
"command": "tail -f log",
"pattern": "ERROR",
"output": "ERROR: disk full",
"suppressed": 0,
}
result = format_process_notification(evt)
assert 'watch pattern "ERROR"' in result
assert "Matched output:\nERROR: disk full" in result
def test_format_watch_match_with_suppressed():
evt = {
"type": "watch_match",
"session_id": "proc_xyz",
"command": "tail -f log",
"pattern": "WARN",
"output": "WARN: low mem",
"suppressed": 3,
}
result = format_process_notification(evt)
assert "3 earlier matches were suppressed" in result
def test_format_watch_disabled_event():
evt = {
"type": "watch_disabled",
"message": "Watch disabled for proc_xyz: too many matches",
}
result = format_process_notification(evt)
assert "[IMPORTANT: Watch disabled for proc_xyz" in result
def test_format_returns_none_for_empty_event():
evt = {}
result = format_process_notification(evt)
assert result is not None
assert "unknown" in result
def test_drain_notifications_returns_pending_events():
from tools.process_registry import process_registry
while not process_registry.completion_queue.empty():
process_registry.completion_queue.get_nowait()
process_registry.completion_queue.put({
"type": "completion",
"session_id": "proc_drain1",
"command": "echo hi",
"exit_code": 0,
"output": "hi",
})
process_registry.completion_queue.put({
"type": "watch_match",
"session_id": "proc_drain2",
"command": "tail -f x",
"pattern": "ERR",
"output": "ERR found",
"suppressed": 0,
})
try:
results = process_registry.drain_notifications()
assert len(results) == 2
assert results[0][0]["session_id"] == "proc_drain1"
assert "proc_drain1 completed" in results[0][1]
assert results[1][0]["session_id"] == "proc_drain2"
assert "watch pattern" in results[1][1]
finally:
while not process_registry.completion_queue.empty():
process_registry.completion_queue.get_nowait()
process_registry._completion_consumed.discard("proc_drain1")
process_registry._completion_consumed.discard("proc_drain2")
def test_drain_notifications_skips_consumed():
from tools.process_registry import process_registry
while not process_registry.completion_queue.empty():
process_registry.completion_queue.get_nowait()
process_registry._completion_consumed.add("proc_consumed")
process_registry.completion_queue.put({
"type": "completion",
"session_id": "proc_consumed",
"command": "echo done",
"exit_code": 0,
"output": "done",
})
try:
results = process_registry.drain_notifications()
assert len(results) == 0
finally:
process_registry._completion_consumed.discard("proc_consumed")
while not process_registry.completion_queue.empty():
process_registry.completion_queue.get_nowait()
def test_drain_notifications_empty_queue():
from tools.process_registry import process_registry
while not process_registry.completion_queue.empty():
process_registry.completion_queue.get_nowait()
results = process_registry.drain_notifications()
assert results == []