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. ecfcf103ad added the interrupt notice on the
prompt_toolkit Enter handler, and 55a8974c13 moved 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:
kshitijk4poor authored and kshitij committed 2026-10-20 13:09:00 +05:30
1 parent a36db282b9
commit d87e7536e0
2 files changed
+15 -9

No files matched your search

+7 -6
View File
@@ -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:
+8 -3
View File
@@ -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)