From d87e7536e042a9d0668e88d8762769db41018189 Mon Sep 17 00:00:00 2001 From: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com> Date: Sun, 18 Oct 2026 13:02:54 +0530 Subject: [PATCH] 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. --- hermes_cli/cli_commands_session_tools.py | 13 +++++++------ tests/hermes_cli/test_gateway_tools.py | 11 ++++++++--- 2 files changed, 15 insertions(+), 9 deletions(-) diff --git a/hermes_cli/cli_commands_session_tools.py b/hermes_cli/cli_commands_session_tools.py index e67a2b7a8a..ce85178e21 100644 --- a/hermes_cli/cli_commands_session_tools.py +++ b/hermes_cli/cli_commands_session_tools.py @@ -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: diff --git a/tests/hermes_cli/test_gateway_tools.py b/tests/hermes_cli/test_gateway_tools.py index 9e003f447d..06db58780e 100644 --- a/tests/hermes_cli/test_gateway_tools.py +++ b/tests/hermes_cli/test_gateway_tools.py @@ -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)