mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-11-06 05:17:15 +00:00
fix(cli): /stop typed mid-turn shows its interrupt and stop results
Symptom: /stop typed while a turn runs interrupts the turn and kills the session's background processes, but prints nothing. Idle, the same command prints "No running background processes." or the stop counts. Root cause: PR #138668.ecfcf103adadded the interrupt notice on the prompt_toolkit Enter handler, and55a8974c13moved the mid-turn cleanup onto a worker thread. Both printed with plain print(). From the Enter handler or a worker thread while the Application runs, a plain print() races the input redraw and is overwritten. Change: the interrupt notice and every _stop_background_work line go through _cprint (the module's _cp helper), as /steer, /queue, /bg and /btw already do. _cprint paints on the app's loop when it runs and falls back to a normal print otherwise, so the idle /stop output is unchanged. Why: /stop is the only one of the classic CLI's mid-turn inline commands (/model, /steer, /queue, /bg, /btw, /stop) that still printed through plain print(). The test now checks the lines reach _cprint, because capturing stdout passes for a print() the user never sees.
This commit is contained in:
1 parent
a36db282b9
commit
d87e7536e0
2 files changed
+15
-9
No files matched your search
@@ -119,7 +119,7 @@ class CLICommandsSessionToolsMixin:
|
||||
return _stop_background_work()
|
||||
if getattr(self, "agent", None):
|
||||
from agent.interrupt_compat import request_hard_interrupt
|
||||
print(f" {t('cli.tui.interrupting')}")
|
||||
_cp(f" {t('cli.tui.interrupting')}")
|
||||
request_hard_interrupt(self.agent)
|
||||
from agent.memory_provider import spawn_context_thread
|
||||
spawn_context_thread(_stop_background_work, name="cli-stop").start()
|
||||
@@ -296,7 +296,8 @@ class CLICommandsSessionToolsMixin:
|
||||
|
||||
def _stop_background_work() -> None:
|
||||
"""``/stop``'s cleanup: kill the background processes, then interrupt the background (async)
|
||||
delegations. Mid-turn it runs on a worker thread and prints its result from there."""
|
||||
delegations. Mid-turn it runs on a worker thread, so it prints through ``_cprint``: a plain
|
||||
print() from off the input loop is buried by the prompt_toolkit redraw."""
|
||||
from hermes_cli.gateway_tools import process_registry as _registry, process_view
|
||||
process_registry, view = _registry(), process_view()
|
||||
if view is not None:
|
||||
@@ -305,17 +306,17 @@ def _stop_background_work() -> None:
|
||||
# Background subagents live in their own registry, not the process registry.
|
||||
n_async = view.active_delegations() if view is not None else _probe("tools.async_delegation", "active_count", 0)
|
||||
if not running and not n_async:
|
||||
return print(f" {_t('stop.none_running')}")
|
||||
return _cp(f" {_t('stop.none_running')}")
|
||||
if running:
|
||||
print(f" {_t('stop.stopping', count=len(running))}")
|
||||
print(f" {_t('stop.stopped', count=process_registry.kill_all(source='cli.stop'))}")
|
||||
_cp(f" {_t('stop.stopping', count=len(running))}")
|
||||
_cp(f" {_t('stop.stopped', count=process_registry.kill_all(source='cli.stop'))}")
|
||||
if n_async:
|
||||
if view is not None:
|
||||
interrupted = view.interrupt_delegations() # the gateway interrupts this session's own
|
||||
else:
|
||||
from tools.async_delegation import interrupt_all
|
||||
interrupted = interrupt_all(reason='/stop')
|
||||
print(f" {_t('stop.interrupted_delegations', count=interrupted)}")
|
||||
_cp(f" {_t('stop.interrupted_delegations', count=interrupted)}")
|
||||
|
||||
|
||||
def print_session_diff(cli, result: dict, stat_only: bool) -> None:
|
||||
|
||||
@@ -133,14 +133,19 @@ def test_agents_before_the_first_turn_reads_the_opened_sessions_processes(gatewa
|
||||
assert ("process.list", {"session_id": SID}) in client.calls
|
||||
|
||||
|
||||
def test_stop_typed_mid_turn_interrupts_the_turn_then_stops_processes(gateway, monkeypatch, capsys):
|
||||
def test_stop_typed_mid_turn_interrupts_the_turn_then_stops_processes(gateway, monkeypatch):
|
||||
"""/stop typed while a turn runs is dispatched inline and interrupts it like Ctrl+C (tracker
|
||||
#134889 N4), instead of being queued until the turn it should stop has finished. Only the
|
||||
interrupt runs on the UI thread: ``kill_all`` waits out each process's stop grace period, so it
|
||||
runs on a worker and the input is back at once (Greptile 4277259986)."""
|
||||
runs on a worker and the input is back at once (Greptile 4277259986). Both its notices go
|
||||
through ``_cprint``: a plain print() from the Enter handler or the worker is buried by the
|
||||
prompt_toolkit redraw, so a mid-turn /stop printed nothing (tracker #134889 CLI-27)."""
|
||||
import threading
|
||||
from types import SimpleNamespace
|
||||
import cli as cli_module
|
||||
from cli import HermesCLI
|
||||
painted = []
|
||||
monkeypatch.setattr(cli_module, "_cprint", painted.append)
|
||||
_agent, client = gateway
|
||||
view = gateway_tools.process_view()
|
||||
assert view is not None
|
||||
@@ -168,7 +173,7 @@ def test_stop_typed_mid_turn_interrupts_the_turn_then_stops_processes(gateway, m
|
||||
release.set()
|
||||
for worker in [t for t in threading.enumerate() if t.name == "cli-stop"]:
|
||||
worker.join(5)
|
||||
out = capsys.readouterr().out
|
||||
out = "\n".join(painted)
|
||||
assert stopped.is_set() and "Interrupting agent" in out and "Stopped 1 process(es)." in out
|
||||
assert any(method == "process.stop" for method, _ in client.calls)
|
||||
|
||||
|
||||
Reference in new issue
Block a user