mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-18 04:41:56 +00:00
* 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:
parent
db84a78e61
commit
d5416284f1
5 changed files with 486 additions and 55 deletions
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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 == []
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue