From 6fc5823d94519c2ac1ced640d39d14abb4861d2f Mon Sep 17 00:00:00 2001 From: briandevans <252620095+briandevans@users.noreply.github.com> Date: Tue, 26 May 2026 03:16:17 -0700 Subject: [PATCH 1/2] fix(cli): dispatch /agents inline while agent is running (#32477) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Typing /agents (or its /tasks alias) during an active agent turn does nothing in classic CLI: the slash command goes through _pending_input, which process_loop only drains after self.chat() returns. By the time the queued command is pulled, the user has been staring at a quiet screen for the entire delegation chain — defeating the introspection command's only purpose. Mirror the inline-dispatch pattern PR #25011 introduced for /steer: when the detector sees /agents while the agent is running, call process_command directly on the UI thread. _handle_agents_command is read-only (reads process_registry + a couple of CLI attrs) and _cprint already routes thread-unsafe output through prompt_toolkit's run_in_terminal, so UI-thread dispatch is safe. Fixes #32477 --- cli.py | 35 ++++++ tests/cli/test_cli_agents_busy_path.py | 145 +++++++++++++++++++++++++ 2 files changed, 180 insertions(+) create mode 100644 tests/cli/test_cli_agents_busy_path.py diff --git a/cli.py b/cli.py index 6a66595d300f5..a9b52f5ef447c 100644 --- a/cli.py +++ b/cli.py @@ -7819,6 +7819,30 @@ def _should_handle_steer_command_inline(self, text: str, has_images: bool = Fals except Exception: return False + def _should_handle_agents_command_inline(self, text: str, has_images: bool = False) -> bool: + """Return True when /agents (alias /tasks) should be dispatched inline while busy. + + Same constraint as /steer: while the agent is running, ``process_loop`` is + blocked inside ``self.chat()`` and never drains ``_pending_input`` until + the run completes. /agents is a read-only introspection command — the + whole point is to monitor in-flight delegations — so queueing makes the + command effectively silent during exactly the window it exists for. + ``_handle_agents_command`` only reads ``process_registry`` plus a couple + of CLI attributes and emits through ``_cprint`` (which routes through + ``run_in_terminal``), so it's safe to dispatch on the UI thread. + """ + 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 == "agents") + except Exception: + return False + def _output_console(self): """Use prompt_toolkit-safe Rich rendering once the TUI is live.""" if getattr(self, "_app", None): @@ -12704,6 +12728,17 @@ def handle_enter(event): event.app.current_buffer.reset(append_to_history=True) return + # Handle /agents (alias /tasks) immediately when the agent is + # running — same deadlock as /steer above. This command is the + # in-flight delegation monitor, so queueing it for after the run + # makes it useless: the user sees "nothing happens" until the + # whole delegation chain finishes. See #32477. + if self._should_handle_agents_command_inline(text, has_images=has_images): + _cprint(f"\n⚙️ {text}") + self.process_command(text) + event.app.current_buffer.reset(append_to_history=True) + return + # Snapshot and clear attached images images = list(self._attached_images) self._attached_images.clear() diff --git a/tests/cli/test_cli_agents_busy_path.py b/tests/cli/test_cli_agents_busy_path.py new file mode 100644 index 0000000000000..8d2fa29cec27f --- /dev/null +++ b/tests/cli/test_cli_agents_busy_path.py @@ -0,0 +1,145 @@ +"""Regression tests for classic-CLI mid-run /agents (alias /tasks) dispatch. + +Background +---------- +/agents (and its /tasks alias) is the in-flight introspection command — +users type it precisely because they want to monitor delegations *while* +the agent loop is running. Without the inline-dispatch path the keystroke +flows through ``_pending_input`` to ``process_loop``, which is blocked +inside ``self.chat()`` for the duration of the run. The command therefore +sits silently in the queue and never executes until the whole delegation +chain finishes — from the user's perspective "nothing happens" (#32477). + +The fix mirrors the existing /steer pattern: detect the command on the UI +thread inside ``handle_enter`` and call ``process_command`` directly, +since ``_handle_agents_command`` is read-only and ``_cprint`` already +routes thread-unsafe output through ``run_in_terminal``. +""" + +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 TestAgentsInlineDetector: + """_should_handle_agents_command_inline gates the busy-path fast dispatch.""" + + def test_detects_agents_when_agent_running(self): + cli = _make_cli() + cli._agent_running = True + assert cli._should_handle_agents_command_inline("/agents") is True + + def test_detects_tasks_alias_when_agent_running(self): + """/tasks is an alias for /agents — both should dispatch inline.""" + cli = _make_cli() + cli._agent_running = True + assert cli._should_handle_agents_command_inline("/tasks") is True + + def test_ignores_agents_when_agent_idle(self): + """Idle-path /agents should fall through to the normal process_loop + dispatch — the queue isn't blocked, so the inline shortcut is + unnecessary and would just duplicate work.""" + cli = _make_cli() + cli._agent_running = False + assert cli._should_handle_agents_command_inline("/agents") is False + assert cli._should_handle_agents_command_inline("/tasks") is False + + def test_ignores_non_slash_input(self): + cli = _make_cli() + cli._agent_running = True + assert cli._should_handle_agents_command_inline("agents") is False + assert cli._should_handle_agents_command_inline("") is False + + def test_ignores_other_slash_commands(self): + cli = _make_cli() + cli._agent_running = True + assert cli._should_handle_agents_command_inline("/queue hello") is False + assert cli._should_handle_agents_command_inline("/stop") is False + assert cli._should_handle_agents_command_inline("/help") is False + assert cli._should_handle_agents_command_inline("/steer focus") is False + + def test_ignores_agents_with_attached_images(self): + """Image payloads take the normal path; /agents takes no args.""" + cli = _make_cli() + cli._agent_running = True + assert cli._should_handle_agents_command_inline("/agents", has_images=True) is False + + +class TestAgentsBusyPathDispatch: + """When the detector fires, /agents dispatch through process_command must + reach _handle_agents_command directly rather than being queued.""" + + def test_process_command_routes_to_agents_handler(self): + """With _agent_running=True, /agents calls _handle_agents_command and + does NOT enqueue onto _pending_input.""" + cli = _make_cli() + cli._agent_running = True + cli._pending_input = MagicMock() + cli._handle_agents_command = MagicMock() + + cli.process_command("/agents") + + cli._handle_agents_command.assert_called_once() + cli._pending_input.put.assert_not_called() + + def test_tasks_alias_routes_to_agents_handler(self): + """/tasks resolves to canonical 'agents' and dispatches the same.""" + cli = _make_cli() + cli._agent_running = True + cli._pending_input = MagicMock() + cli._handle_agents_command = MagicMock() + + cli.process_command("/tasks") + + cli._handle_agents_command.assert_called_once() + cli._pending_input.put.assert_not_called() + + +if __name__ == "__main__": # pragma: no cover + import pytest + + pytest.main([__file__, "-v"]) From b9d2f4a2d6268ac6855f9abb11c6d85a3b25cccc Mon Sep 17 00:00:00 2001 From: briandevans <252620095+briandevans@users.noreply.github.com> Date: Tue, 26 May 2026 04:09:29 -0700 Subject: [PATCH 2/2] fix(cli): tighten /agents inline detector + drop echo line MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address Copilot inline review on #32541: * Reject `/agents foo` / `/tasks something` on the busy path — `/agents` takes no args, so trailing tokens must fall through to the normal dispatch where `_handle_agents_command` can surface a usage error rather than silently dropping the argument. * Drop the `⚙️ {text}` echo from the busy-path block. Mirrors the existing `/steer` block which dispatches silently and lets `_handle_agents_command`'s own output land via `_cprint` / `run_in_terminal`. Avoids the screen-reader / terminal-font concern Copilot flagged. Adds a `test_ignores_agents_with_trailing_args` case covering the new detector constraint. --- cli.py | 9 +++++++-- tests/cli/test_cli_agents_busy_path.py | 9 +++++++++ 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/cli.py b/cli.py index a9b52f5ef447c..dced8e1233266 100644 --- a/cli.py +++ b/cli.py @@ -7835,9 +7835,15 @@ def _should_handle_agents_command_inline(self, text: str, has_images: bool = Fal return False if not getattr(self, "_agent_running", False): return False + # /agents takes no args (unlike /steer which carries a payload). + # Inputs like "/agents foo" should fall through to the normal path + # where _handle_agents_command can surface a usage error. + parts = text.split() + if len(parts) != 1: + return False try: from hermes_cli.commands import resolve_command - base = text.split(None, 1)[0].lower().lstrip('/') + base = parts[0].lower().lstrip('/') cmd = resolve_command(base) return bool(cmd and cmd.name == "agents") except Exception: @@ -12734,7 +12740,6 @@ def handle_enter(event): # makes it useless: the user sees "nothing happens" until the # whole delegation chain finishes. See #32477. if self._should_handle_agents_command_inline(text, has_images=has_images): - _cprint(f"\n⚙️ {text}") self.process_command(text) event.app.current_buffer.reset(append_to_history=True) return diff --git a/tests/cli/test_cli_agents_busy_path.py b/tests/cli/test_cli_agents_busy_path.py index 8d2fa29cec27f..2b2e9ad64e53d 100644 --- a/tests/cli/test_cli_agents_busy_path.py +++ b/tests/cli/test_cli_agents_busy_path.py @@ -108,6 +108,15 @@ def test_ignores_agents_with_attached_images(self): cli._agent_running = True assert cli._should_handle_agents_command_inline("/agents", has_images=True) is False + def test_ignores_agents_with_trailing_args(self): + """/agents takes no args, so '/agents foo' must fall through to the + normal path where _handle_agents_command can surface a usage error + rather than silently swallowing the argument on the busy path.""" + cli = _make_cli() + cli._agent_running = True + assert cli._should_handle_agents_command_inline("/agents foo") is False + assert cli._should_handle_agents_command_inline("/tasks something") is False + class TestAgentsBusyPathDispatch: """When the detector fires, /agents dispatch through process_command must