From 3dee0634c1436635fbcf51c87a7f32b4101a41b9 Mon Sep 17 00:00:00 2001 From: MaxFreedomPollard <272618364+MaxFreedomPollard@users.noreply.github.com> Date: Fri, 31 Jul 2026 02:26:06 -0400 Subject: [PATCH] fix(cli): dispatch /background inline instead of queuing it behind the turn /background (/bg, /btw) exists to start independent work while the current turn keeps running. Typed while the agent was busy it went into _pending_input like ordinary input, and process_loop is blocked inside self.chat() for the whole run, so the background task only started once the foreground turn had finished. That is the one moment it was not needed. /steer had the identical problem and was fixed the same way, by dispatching inline on the UI thread. The command's own CommandDef already declares busy_policy="dispatch"; the gateway honours that, the classic CLI never consulted it. The foreground turn is untouched: no interrupt, no steer, and ordinary non-slash input keeps following the configured busy-input behaviour. --- cli.py | 49 ++++++++ tests/cli/test_cli_background_busy_path.py | 132 +++++++++++++++++++++ 2 files changed, 181 insertions(+) create mode 100644 tests/cli/test_cli_background_busy_path.py diff --git a/cli.py b/cli.py index 2f584b8cdb3d0..489068c783107 100644 --- a/cli.py +++ b/cli.py @@ -9595,6 +9595,37 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin): except Exception: return False + def _should_handle_background_command_inline( + self, text: str, has_images: bool = False + ) -> bool: + """Return True when /background should be dispatched while the agent runs. + + Same queue problem /steer had. ``/background`` (``/bg``, ``/btw``) + exists to start independent work *without* waiting for the current + turn, but a slash command typed while the agent is busy goes into + ``_pending_input``, and ``process_loop`` is blocked inside + ``self.chat()`` for the whole run. The background task therefore only + starts once the foreground turn has finished, which is the one moment + it was not needed. + + The command's own ``CommandDef`` already declares + ``busy_policy="dispatch"``; the gateway honours that, the classic CLI + never consulted it. Dispatching inline on the UI thread starts the + background session immediately and leaves the foreground turn running + untouched: no interrupt, no steer. + """ + if not text or has_images or not _looks_like_slash_command(text): + return False + if not getattr(self, "_agent_running", False): + return False + try: + from hermes_cli.commands import resolve_command + base = text.split(None, 1)[0].lower().lstrip('/') + cmd = resolve_command(base) + return bool(cmd and cmd.name == "background") + except Exception: + return False + def _output_console(self): """Use prompt_toolkit-safe Rich rendering once the TUI is live.""" if getattr(self, "_app", None): @@ -15202,6 +15233,24 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin): event.app.invalidate() return + # Same treatment for /background (/bg, /btw) while the agent is + # running. Queuing it defeats the entire point of the command: + # process_loop is blocked inside self.chat(), so the background + # task would only start once the foreground turn it was meant to + # run alongside has already finished (#75221). The foreground + # turn is left alone: no interrupt, no steer. + if self._should_handle_background_command_inline( + text, has_images=has_images + ): + self.process_command(text) + event.app.current_buffer.reset(append_to_history=True) + # Repaint for the same reason as the /steer branch above: + # process_command() prints through patch_stdout and never + # invalidates the app, so the submitted text can linger in + # the input area looking unsent. + event.app.invalidate() + return + # Snapshot and clear attached images images = list(self._attached_images) self._attached_images.clear() diff --git a/tests/cli/test_cli_background_busy_path.py b/tests/cli/test_cli_background_busy_path.py new file mode 100644 index 0000000000000..96e627761d6b7 --- /dev/null +++ b/tests/cli/test_cli_background_busy_path.py @@ -0,0 +1,132 @@ +"""Regression tests for classic-CLI mid-run /background dispatch. + +Background +---------- +``/background`` (``/bg``, ``/btw``) exists to start independent work while +the current turn keeps running. Typed while the agent was busy it went into +``self._pending_input`` like ordinary input, and ``process_loop`` is blocked +inside ``self.chat()`` for the whole run, so the background task only started +once the foreground turn had finished. That is the one moment it was not +needed (#75221). + +``/steer`` had the identical problem and was fixed by dispatching inline on +the UI thread; the command's own ``CommandDef`` already declares +``busy_policy="dispatch"``, which the gateway honours and the classic CLI +never consulted. + +These tests exercise the detector without starting a prompt_toolkit app, +mirroring tests/cli/test_cli_steer_busy_path.py. +""" + +from __future__ import annotations + +import importlib +import sys +from unittest.mock import MagicMock, patch + + +def _make_cli(): + """Create a HermesCLI instance with prompt_toolkit stubbed out.""" + _clean_config = { + "model": { + "default": "anthropic/claude-opus-4.6", + "base_url": "https://openrouter.ai/api/v1", + "provider": "auto", + }, + "display": {"compact": False, "tool_progress": "all"}, + "agent": {}, + "terminal": {"env_type": "local"}, + } + clean_env = {"LLM_MODEL": "", "HERMES_MAX_ITERATIONS": ""} + prompt_toolkit_stubs = { + "prompt_toolkit": MagicMock(), + "prompt_toolkit.history": MagicMock(), + "prompt_toolkit.styles": MagicMock(), + "prompt_toolkit.patch_stdout": MagicMock(), + "prompt_toolkit.application": MagicMock(), + "prompt_toolkit.layout": MagicMock(), + "prompt_toolkit.layout.processors": MagicMock(), + "prompt_toolkit.filters": MagicMock(), + "prompt_toolkit.layout.dimension": MagicMock(), + "prompt_toolkit.layout.menus": MagicMock(), + "prompt_toolkit.widgets": MagicMock(), + "prompt_toolkit.key_binding": MagicMock(), + "prompt_toolkit.completion": MagicMock(), + "prompt_toolkit.formatted_text": MagicMock(), + "prompt_toolkit.auto_suggest": MagicMock(), + } + with patch.dict(sys.modules, prompt_toolkit_stubs), patch.dict( + "os.environ", clean_env, clear=False + ): + import cli as _cli_mod + + _cli_mod = importlib.reload(_cli_mod) + with patch.object(_cli_mod, "get_tool_definitions", return_value=[]), patch.dict( + _cli_mod.__dict__, {"CLI_CONFIG": _clean_config} + ): + return _cli_mod.HermesCLI() + + +class TestBackgroundInlineDetector: + def test_detects_background_when_agent_running(self): + cli = _make_cli() + cli._agent_running = True + assert cli._should_handle_background_command_inline( + "/background inspect the test failures" + ) is True + + def test_detects_both_aliases(self): + cli = _make_cli() + cli._agent_running = True + assert cli._should_handle_background_command_inline("/bg do work") is True + assert cli._should_handle_background_command_inline("/btw do work") is True + + def test_ignores_background_when_agent_idle(self): + """Idle input falls through to the normal process_loop dispatch.""" + cli = _make_cli() + cli._agent_running = False + assert cli._should_handle_background_command_inline("/bg do work") is False + + def test_ignores_non_slash_input(self): + cli = _make_cli() + cli._agent_running = True + assert cli._should_handle_background_command_inline("bg without slash") is False + assert cli._should_handle_background_command_inline("") is False + + def test_ignores_other_slash_commands(self): + cli = _make_cli() + cli._agent_running = True + assert cli._should_handle_background_command_inline("/steer hello") is False + assert cli._should_handle_background_command_inline("/queue hello") is False + assert cli._should_handle_background_command_inline("/stop") is False + + def test_ignores_background_with_attached_images(self): + """Image payloads take the normal path.""" + cli = _make_cli() + cli._agent_running = True + assert cli._should_handle_background_command_inline( + "/bg look at this", has_images=True + ) is False + + def test_case_and_whitespace_tolerant(self): + cli = _make_cli() + cli._agent_running = True + assert cli._should_handle_background_command_inline("/BG do work") is True + + +class TestBackgroundBusyPolicyContract: + """The registry already declares the intent this detector implements.""" + + def test_background_declares_dispatch_while_busy(self): + from hermes_cli.commands import resolve_command + + cmd = resolve_command("background") + assert cmd is not None + assert cmd.busy_policy == "dispatch" + + def test_aliases_resolve_to_background(self): + from hermes_cli.commands import resolve_command + + for alias in ("bg", "btw"): + cmd = resolve_command(alias) + assert cmd is not None and cmd.name == "background"