mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-25 00:51:20 +00:00
Classic-CLI /steer typed during an active agent run was queued through
self._pending_input alongside ordinary user input. process_loop, which
drains that queue, is blocked inside self.chat() for the entire run,
so the queued command was not pulled until AFTER _agent_running had
flipped back to False — at which point process_command() took the idle
fallback ("No agent running; queued as next turn") and delivered the
steer as an ordinary next-turn user message.
From Utku's bug report on PR #13205: mid-run /steer arrived minutes
later at the end of the turn as a /queue-style message, completely
defeating its purpose.
Fix: add _should_handle_steer_command_inline() gating — when
_agent_running is True and the user typed /steer, dispatch
process_command(text) directly from the prompt_toolkit Enter handler
on the UI thread instead of queueing. This mirrors the existing
_should_handle_model_command_inline() pattern for /model and is
safe because agent.steer() is thread-safe (uses _pending_steer_lock,
no prompt_toolkit state mutation, instant return).
No changes to the idle-path behavior: /steer typed with no active
agent still takes the normal queue-and-drain route so the fallback
"No agent running; queued as next turn" message is preserved.
Validation:
- 7 new unit tests in tests/cli/test_cli_steer_busy_path.py covering
the detector, dispatch path, and idle-path control behavior.
- All 21 existing tests in tests/run_agent/test_steer.py still pass.
- Live PTY end-to-end test with real agent + real openrouter model:
22:36:22 API call #1 (model requested execute_code)
22:36:26 ENTER FIRED: agent_running=True, text='/steer ...'
22:36:26 INLINE STEER DISPATCH fired
22:36:43 agent.log: 'Delivered /steer to agent after tool batch'
22:36:44 API call #2 included the steer; response contained marker
Same test on the tip of main without this fix shows the steer
landing as a new user turn ~20s after the run ended.
146 lines
5.8 KiB
Python
146 lines
5.8 KiB
Python
"""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)
|
|
# Make sure the idle-path fallback would be observable if taken
|
|
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")
|
|
|
|
# Idle path does NOT call agent.steer
|
|
cli.agent.steer.assert_not_called()
|
|
# It puts the payload in the queue as a normal next-turn message
|
|
cli._pending_input.put.assert_called_once_with("would-be-next-turn")
|
|
|
|
|
|
if __name__ == "__main__": # pragma: no cover
|
|
import pytest
|
|
|
|
pytest.main([__file__, "-v"])
|