| """Regression tests for classic-CLI mid-run /steer dispatch. |
| |
| Background |
| ---------- |
| /steer sent while the agent is running used to be queued through |
| ``self._pending_input`` alongside ordinary user input. ``process_loop`` |
| pulls from that queue and calls ``process_command()`` — but while the |
| agent is running, ``process_loop`` is blocked inside ``self.chat()``. |
| By the time the queued /steer was pulled, ``_agent_running`` had |
| already flipped back to False, so ``process_command()`` took the idle |
| fallback (``"No agent running; queued as next turn"``) and delivered |
| the steer as an ordinary next-turn message. |
| |
| The fix dispatches /steer inline on the UI thread when the agent is |
| running — matching the existing pattern for /model — so the steer |
| reaches ``agent.steer()`` (thread-safe) without touching the queue. |
| |
| These tests exercise the detector + inline dispatch without starting a |
| prompt_toolkit app. |
| """ |
|
|
| 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 TestSteerInlineDetector: |
| """_should_handle_steer_command_inline gates the busy-path fast dispatch.""" |
|
|
| def test_detects_steer_when_agent_running(self): |
| cli = _make_cli() |
| cli._agent_running = True |
| assert cli._should_handle_steer_command_inline("/steer focus on error handling") is True |
|
|
| def test_ignores_steer_when_agent_idle(self): |
| """Idle-path /steer should fall through to the normal process_loop |
| dispatch so the queue-style fallback message is emitted.""" |
| cli = _make_cli() |
| cli._agent_running = False |
| assert cli._should_handle_steer_command_inline("/steer do something") is False |
|
|
| def test_ignores_non_slash_input(self): |
| cli = _make_cli() |
| cli._agent_running = True |
| assert cli._should_handle_steer_command_inline("steer without slash") is False |
| assert cli._should_handle_steer_command_inline("") is False |
|
|
| def test_ignores_other_slash_commands(self): |
| cli = _make_cli() |
| cli._agent_running = True |
| assert cli._should_handle_steer_command_inline("/queue hello") is False |
| assert cli._should_handle_steer_command_inline("/stop") is False |
| assert cli._should_handle_steer_command_inline("/help") is False |
|
|
| def test_ignores_steer_with_attached_images(self): |
| """Image payloads take the normal path; steer doesn't accept images.""" |
| cli = _make_cli() |
| cli._agent_running = True |
| assert cli._should_handle_steer_command_inline("/steer text", has_images=True) is False |
|
|
|
|
| class TestSteerBusyPathDispatch: |
| """When the detector fires, process_command('/steer ...') must call |
| agent.steer() directly rather than the idle-path fallback.""" |
|
|
| def test_process_command_routes_to_agent_steer(self): |
| """With _agent_running=True and agent.steer present, /steer reaches |
| agent.steer(payload), NOT _pending_input.""" |
| cli = _make_cli() |
| cli._agent_running = True |
| cli.agent = MagicMock() |
| cli.agent.steer = MagicMock(return_value=True) |
| |
| cli._pending_input = MagicMock() |
|
|
| cli.process_command("/steer focus on errors") |
|
|
| cli.agent.steer.assert_called_once_with("focus on errors") |
| cli._pending_input.put.assert_not_called() |
|
|
| def test_idle_path_queues_as_next_turn(self): |
| """Control — when the agent is NOT running, /steer correctly falls |
| back to next-turn queue semantics. Demonstrates why the fix was |
| needed: the queue path only works when you can actually drain it.""" |
| cli = _make_cli() |
| cli._agent_running = False |
| cli.agent = MagicMock() |
| cli.agent.steer = MagicMock(return_value=True) |
| cli._pending_input = MagicMock() |
|
|
| cli.process_command("/steer would-be-next-turn") |
|
|
| |
| cli.agent.steer.assert_not_called() |
| |
| cli._pending_input.put.assert_called_once_with("would-be-next-turn") |
|
|
|
|
| if __name__ == "__main__": |
| import pytest |
|
|
| pytest.main([__file__, "-v"]) |
|
|