chore: import upstream snapshot with attribution
Deploy Site / deploy-vercel (push) Has been skipped
Deploy Site / deploy-docs (push) Has been skipped
Build Skills Index / build-index (push) Has been skipped
CI / Deny unrelated histories (push) Has been skipped
CI / Detect affected areas (push) Successful in 27m35s
CI / OSV scan (push) Failing after 4s
CI / Build&Test Docker image (push) Successful in 9s
CI / Supply-chain scan (push) Has been skipped
CI / Lint Docker scripts (push) Failing after 5m13s
CI / Check contributors (push) Failing after 12m8s
CI / Docs Site (push) Failing after 12m8s
CI / TypeScript (push) Failing after 12m8s
CI / Python lints (push) Failing after 12m9s
CI / Python tests (push) Failing after 12m9s
CI / Check uv.lock (push) Failing after 23m22s
CI / CI timing report (push) Has been cancelled
Build Skills Index / trigger-deploy (push) Has been cancelled
CI / All required checks pass (push) Has been cancelled
Deploy Site / deploy-vercel (push) Has been skipped
Deploy Site / deploy-docs (push) Has been skipped
Build Skills Index / build-index (push) Has been skipped
CI / Deny unrelated histories (push) Has been skipped
CI / Detect affected areas (push) Successful in 27m35s
CI / OSV scan (push) Failing after 4s
CI / Build&Test Docker image (push) Successful in 9s
CI / Supply-chain scan (push) Has been skipped
CI / Lint Docker scripts (push) Failing after 5m13s
CI / Check contributors (push) Failing after 12m8s
CI / Docs Site (push) Failing after 12m8s
CI / TypeScript (push) Failing after 12m8s
CI / Python lints (push) Failing after 12m9s
CI / Python tests (push) Failing after 12m9s
CI / Check uv.lock (push) Failing after 23m22s
CI / CI timing report (push) Has been cancelled
Build Skills Index / trigger-deploy (push) Has been cancelled
CI / All required checks pass (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,157 @@
|
||||
"""Tests for bracketed-paste timeout safety valve (#16263).
|
||||
|
||||
Verifies the production helper in cli.py monkey-patches prompt_toolkit's
|
||||
Vt100Parser.feed() so the parser auto-escapes from bracketed-paste mode when
|
||||
the ESC[201~ end mark is never received.
|
||||
"""
|
||||
import ast
|
||||
import importlib
|
||||
import logging
|
||||
import time
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from prompt_toolkit.keys import Keys
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
CLI_PATH = ROOT / "cli.py"
|
||||
|
||||
|
||||
def _load_production_patch_helper():
|
||||
"""Load cli._apply_bracketed_paste_timeout_patch without importing cli.
|
||||
|
||||
Importing cli.py pulls optional runtime deps that aren't required for this
|
||||
parser-level regression. AST-loading the exact helper keeps the test tied
|
||||
to production code while avoiding unrelated import side effects. If the
|
||||
production helper is removed, this test fails.
|
||||
"""
|
||||
source = CLI_PATH.read_text(encoding="utf-8")
|
||||
tree = ast.parse(source)
|
||||
helper_node = next(
|
||||
(
|
||||
node
|
||||
for node in tree.body
|
||||
if isinstance(node, ast.FunctionDef)
|
||||
and node.name == "_apply_bracketed_paste_timeout_patch"
|
||||
),
|
||||
None,
|
||||
)
|
||||
assert helper_node is not None, (
|
||||
"cli.py must define _apply_bracketed_paste_timeout_patch()"
|
||||
)
|
||||
helper_source = ast.get_source_segment(source, helper_node)
|
||||
namespace = {"time": time, "logger": logging.getLogger("test.cli")}
|
||||
exec(helper_source, namespace)
|
||||
return namespace["_apply_bracketed_paste_timeout_patch"]
|
||||
|
||||
|
||||
def _reset_and_apply_production_patch():
|
||||
"""Reload prompt_toolkit's parser and apply Hermes' production patch."""
|
||||
import prompt_toolkit.input.vt100_parser as vt100_mod
|
||||
|
||||
vt100_mod = importlib.reload(vt100_mod)
|
||||
# importlib.reload() preserves module dict entries that the reloaded source
|
||||
# does not redefine, so clear Hermes' sentinel before re-applying.
|
||||
if hasattr(vt100_mod, "_hermes_bp_timeout_patched"):
|
||||
delattr(vt100_mod, "_hermes_bp_timeout_patched")
|
||||
_load_production_patch_helper()()
|
||||
assert getattr(vt100_mod, "_hermes_bp_timeout_patched", False)
|
||||
return vt100_mod
|
||||
|
||||
|
||||
class TestBracketedPasteTimeout:
|
||||
"""Verify the Vt100Parser monkey-patch prevents frozen bracketed-paste."""
|
||||
|
||||
def _make_parser(self):
|
||||
"""Create a Vt100Parser after applying the production patch."""
|
||||
vt100_mod = _reset_and_apply_production_patch()
|
||||
callback = MagicMock()
|
||||
parser = vt100_mod.Vt100Parser(callback)
|
||||
return parser, callback
|
||||
|
||||
def test_normal_bracketed_paste_works(self):
|
||||
"""A complete bracketed-paste sequence should work normally."""
|
||||
parser, callback = self._make_parser()
|
||||
parser.feed("\x1b[200~hello world\x1b[201~")
|
||||
callback.assert_called_once()
|
||||
call_args = callback.call_args[0][0]
|
||||
assert call_args.data == "hello world"
|
||||
|
||||
def test_incomplete_paste_times_out(self):
|
||||
"""If ESC[201~ is never received, parser should recover after timeout."""
|
||||
parser, callback = self._make_parser()
|
||||
parser.feed("\x1b[200~some pasted text")
|
||||
assert parser._in_bracketed_paste
|
||||
|
||||
parser._hermes_bp_start = time.monotonic() - 3.0
|
||||
parser.feed("more data")
|
||||
|
||||
assert not parser._in_bracketed_paste
|
||||
assert callback.called
|
||||
|
||||
def test_timeout_preserves_buffered_content(self):
|
||||
"""Auto-escape should flush buffered content, not lose it."""
|
||||
parser, callback = self._make_parser()
|
||||
content = "line1\nline2\nline3"
|
||||
parser.feed(f"\x1b[200~{content}")
|
||||
parser._hermes_bp_start = time.monotonic() - 3.0
|
||||
parser.feed("")
|
||||
|
||||
paste_events = [
|
||||
c[0][0]
|
||||
for c in callback.call_args_list
|
||||
if hasattr(c[0][0], "key") and c[0][0].key == Keys.BracketedPaste
|
||||
]
|
||||
assert len(paste_events) >= 1
|
||||
assert content in paste_events[0].data
|
||||
|
||||
def test_normal_keys_after_timeout_recovery(self):
|
||||
"""After timeout recovery, normal key processing should resume."""
|
||||
parser, callback = self._make_parser()
|
||||
parser.feed("\x1b[200~stuck")
|
||||
parser._hermes_bp_start = time.monotonic() - 3.0
|
||||
parser.feed("")
|
||||
|
||||
assert not parser._in_bracketed_paste
|
||||
callback.reset_mock()
|
||||
parser.feed("a")
|
||||
assert not parser._in_bracketed_paste
|
||||
|
||||
def test_no_timeout_when_end_mark_arrives_quickly(self):
|
||||
"""No timeout should fire if end mark arrives within the window."""
|
||||
parser, callback = self._make_parser()
|
||||
parser.feed("\x1b[200~quick paste\x1b[201~")
|
||||
assert not parser._in_bracketed_paste
|
||||
callback.assert_called_once()
|
||||
|
||||
def test_subsequent_data_after_incomplete_paste(self):
|
||||
"""Data arriving after a stuck paste should be processable."""
|
||||
parser, callback = self._make_parser()
|
||||
parser.feed("\x1b[200~content")
|
||||
parser._hermes_bp_start = time.monotonic() - 5.0
|
||||
parser.feed("x")
|
||||
|
||||
assert not parser._in_bracketed_paste
|
||||
assert callback.call_count >= 1
|
||||
|
||||
def test_torn_end_mark_recovers(self):
|
||||
"""If end mark arrives split across feeds within timeout, it still works."""
|
||||
parser, callback = self._make_parser()
|
||||
parser.feed("\x1b[200~some content\x1b[20")
|
||||
assert parser._in_bracketed_paste
|
||||
|
||||
parser.feed("1~")
|
||||
assert not parser._in_bracketed_paste
|
||||
callback.assert_called_once()
|
||||
assert callback.call_args[0][0].data == "some content"
|
||||
|
||||
def test_no_timeout_under_threshold(self):
|
||||
"""Bracketed-paste mode should not timeout within the 2s window."""
|
||||
parser, callback = self._make_parser()
|
||||
parser.feed("\x1b[200~waiting")
|
||||
parser._hermes_bp_start = time.monotonic() - 0.5
|
||||
parser.feed("more waiting")
|
||||
|
||||
assert parser._in_bracketed_paste
|
||||
assert not callback.called
|
||||
@@ -0,0 +1,260 @@
|
||||
"""Tests for the /branch (/fork) command — session branching.
|
||||
|
||||
Verifies that:
|
||||
- Branching creates a new session with copied conversation history
|
||||
- The original session is preserved (ended with "branched" reason)
|
||||
- Auto-generated titles use lineage numbering
|
||||
- Custom branch names are used when provided
|
||||
- parent_session_id links are set correctly
|
||||
- Edge cases: empty conversation, missing session DB
|
||||
"""
|
||||
|
||||
import os
|
||||
from datetime import datetime
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def session_db(tmp_path):
|
||||
"""Create a real SessionDB for testing."""
|
||||
os.environ["HERMES_HOME"] = str(tmp_path / ".hermes")
|
||||
os.makedirs(tmp_path / ".hermes", exist_ok=True)
|
||||
from hermes_state import SessionDB
|
||||
db = SessionDB(db_path=tmp_path / ".hermes" / "test_sessions.db")
|
||||
yield db
|
||||
db.close()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def cli_instance(tmp_path, session_db):
|
||||
"""Create a minimal HermesCLI-like object for testing _handle_branch_command."""
|
||||
# We'll mock the CLI enough to test the branch logic without full init
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
cli = MagicMock()
|
||||
cli._session_db = session_db
|
||||
cli.session_id = "20260403_120000_abc123"
|
||||
cli.model = "anthropic/claude-sonnet-4.6"
|
||||
cli.max_turns = 90
|
||||
cli.reasoning_config = {"enabled": True, "effort": "medium"}
|
||||
cli.session_start = datetime.now()
|
||||
cli._pending_title = None
|
||||
cli._resumed = False
|
||||
cli.agent = None
|
||||
cli.conversation_history = [
|
||||
{"role": "user", "content": "Hello, can you help me?"},
|
||||
{"role": "assistant", "content": "Of course! How can I help?"},
|
||||
{"role": "user", "content": "Write a Python function to sort a list."},
|
||||
{"role": "assistant", "content": "def sort_list(lst): return sorted(lst)"},
|
||||
]
|
||||
|
||||
# Create the original session in the DB
|
||||
session_db.create_session(
|
||||
session_id=cli.session_id,
|
||||
source="cli",
|
||||
model=cli.model,
|
||||
)
|
||||
session_db.set_session_title(cli.session_id, "My Coding Session")
|
||||
|
||||
return cli
|
||||
|
||||
|
||||
class TestBranchCommandCLI:
|
||||
"""Test the /branch command logic for the CLI."""
|
||||
|
||||
def test_branch_creates_new_session(self, cli_instance, session_db):
|
||||
"""Branching should create a new session in the DB."""
|
||||
from cli import HermesCLI
|
||||
|
||||
# Call the real method on the mock, using the real implementation
|
||||
HermesCLI._handle_branch_command(cli_instance, "/branch")
|
||||
|
||||
# Verify a new session was created
|
||||
assert cli_instance.session_id != "20260403_120000_abc123"
|
||||
new_session = session_db.get_session(cli_instance.session_id)
|
||||
assert new_session is not None
|
||||
|
||||
def test_branch_copies_history(self, cli_instance, session_db):
|
||||
"""Branching should copy all messages to the new session."""
|
||||
from cli import HermesCLI
|
||||
|
||||
HermesCLI._handle_branch_command(cli_instance, "/branch")
|
||||
|
||||
messages = session_db.get_messages_as_conversation(cli_instance.session_id)
|
||||
assert len(messages) == 4 # All 4 messages copied
|
||||
|
||||
def test_branch_preserves_parent_link(self, cli_instance, session_db):
|
||||
"""The new session should reference the original as parent."""
|
||||
from cli import HermesCLI
|
||||
original_id = cli_instance.session_id
|
||||
|
||||
HermesCLI._handle_branch_command(cli_instance, "/branch")
|
||||
|
||||
new_session = session_db.get_session(cli_instance.session_id)
|
||||
assert new_session["parent_session_id"] == original_id
|
||||
|
||||
def test_branch_ends_original_session(self, cli_instance, session_db):
|
||||
"""The original session should be marked as ended with 'branched' reason."""
|
||||
from cli import HermesCLI
|
||||
original_id = cli_instance.session_id
|
||||
|
||||
HermesCLI._handle_branch_command(cli_instance, "/branch")
|
||||
|
||||
original = session_db.get_session(original_id)
|
||||
assert original["end_reason"] == "branched"
|
||||
|
||||
def test_branch_with_custom_name(self, cli_instance, session_db):
|
||||
"""Custom branch name should be used as the title."""
|
||||
from cli import HermesCLI
|
||||
|
||||
HermesCLI._handle_branch_command(cli_instance, "/branch refactor approach")
|
||||
|
||||
title = session_db.get_session_title(cli_instance.session_id)
|
||||
assert title == "refactor approach"
|
||||
|
||||
def test_branch_auto_title_lineage(self, cli_instance, session_db):
|
||||
"""Without a name, branch should auto-generate a title from the parent's title."""
|
||||
from cli import HermesCLI
|
||||
|
||||
HermesCLI._handle_branch_command(cli_instance, "/branch")
|
||||
|
||||
title = session_db.get_session_title(cli_instance.session_id)
|
||||
assert title == "My Coding Session #2"
|
||||
|
||||
def test_branch_empty_conversation(self, cli_instance, session_db):
|
||||
"""Branching with no history should show an error."""
|
||||
from cli import HermesCLI
|
||||
cli_instance.conversation_history = []
|
||||
|
||||
HermesCLI._handle_branch_command(cli_instance, "/branch")
|
||||
|
||||
# session_id should not have changed
|
||||
assert cli_instance.session_id == "20260403_120000_abc123"
|
||||
|
||||
def test_branch_no_session_db(self, cli_instance):
|
||||
"""Branching without a session DB should show an error."""
|
||||
from cli import HermesCLI
|
||||
cli_instance._session_db = None
|
||||
|
||||
HermesCLI._handle_branch_command(cli_instance, "/branch")
|
||||
|
||||
# session_id should not have changed
|
||||
assert cli_instance.session_id == "20260403_120000_abc123"
|
||||
|
||||
def test_branch_syncs_agent(self, cli_instance, session_db):
|
||||
"""If an agent is active, branch should sync it to the new session."""
|
||||
from cli import HermesCLI
|
||||
|
||||
agent = MagicMock()
|
||||
agent._last_flushed_db_idx = 0
|
||||
cli_instance.agent = agent
|
||||
|
||||
HermesCLI._handle_branch_command(cli_instance, "/branch")
|
||||
|
||||
# Agent should have been updated
|
||||
assert agent.session_id == cli_instance.session_id
|
||||
assert agent.reset_session_state.called
|
||||
assert agent._last_flushed_db_idx == 4 # len(conversation_history)
|
||||
|
||||
def test_branch_sets_resumed_flag(self, cli_instance, session_db):
|
||||
"""Branch should set _resumed=True to prevent auto-title generation."""
|
||||
from cli import HermesCLI
|
||||
|
||||
HermesCLI._handle_branch_command(cli_instance, "/branch")
|
||||
|
||||
assert cli_instance._resumed is True
|
||||
|
||||
def test_branch_rotates_hermes_session_id_env_and_context(self, cli_instance, session_db):
|
||||
"""Branching must update process-local session-id readers too."""
|
||||
from cli import HermesCLI
|
||||
from gateway.session_context import _UNSET, _VAR_MAP, get_session_env
|
||||
|
||||
old_session_id = cli_instance.session_id
|
||||
os.environ["HERMES_SESSION_ID"] = old_session_id
|
||||
_VAR_MAP["HERMES_SESSION_ID"].set(old_session_id)
|
||||
|
||||
try:
|
||||
HermesCLI._handle_branch_command(cli_instance, "/branch")
|
||||
|
||||
assert cli_instance.session_id != old_session_id
|
||||
assert os.environ["HERMES_SESSION_ID"] == cli_instance.session_id
|
||||
assert get_session_env("HERMES_SESSION_ID") == cli_instance.session_id
|
||||
finally:
|
||||
os.environ.pop("HERMES_SESSION_ID", None)
|
||||
_VAR_MAP["HERMES_SESSION_ID"].set(_UNSET)
|
||||
|
||||
def test_branch_fires_on_session_switch_hook(self, cli_instance, session_db):
|
||||
"""The /branch command must notify memory providers of the rotation.
|
||||
|
||||
Without this, providers that cache per-session state in
|
||||
initialize() keep writing under the old session_id. See #6672.
|
||||
"""
|
||||
from cli import HermesCLI
|
||||
|
||||
# Wire a real-ish agent object with a MagicMock memory_manager
|
||||
agent = MagicMock()
|
||||
mm = MagicMock()
|
||||
agent._memory_manager = mm
|
||||
cli_instance.agent = agent
|
||||
original_id = cli_instance.session_id
|
||||
|
||||
HermesCLI._handle_branch_command(cli_instance, "/branch")
|
||||
|
||||
# Hook must have been called exactly once with the new session_id,
|
||||
# parent pointing at the branched-from session, reset=False, and
|
||||
# reason="branch" for diagnostics.
|
||||
assert mm.on_session_switch.call_count == 1
|
||||
_, kwargs = mm.on_session_switch.call_args
|
||||
assert mm.on_session_switch.call_args.args[0] == cli_instance.session_id
|
||||
assert kwargs["parent_session_id"] == original_id
|
||||
assert kwargs["reset"] is False
|
||||
assert kwargs["reason"] == "branch"
|
||||
|
||||
def test_fork_alias(self):
|
||||
"""The /fork alias should resolve to 'branch'."""
|
||||
from hermes_cli.commands import resolve_command
|
||||
result = resolve_command("fork")
|
||||
assert result is not None
|
||||
assert result.name == "branch"
|
||||
|
||||
|
||||
class TestBranchCommandDef:
|
||||
"""Test the CommandDef registration for /branch."""
|
||||
|
||||
def test_branch_in_registry(self):
|
||||
"""The branch command should be in the command registry."""
|
||||
from hermes_cli.commands import COMMAND_REGISTRY
|
||||
names = [c.name for c in COMMAND_REGISTRY]
|
||||
assert "branch" in names
|
||||
|
||||
def test_branch_has_fork_alias(self):
|
||||
"""The branch command should have 'fork' as an alias."""
|
||||
from hermes_cli.commands import COMMAND_REGISTRY
|
||||
branch = next(c for c in COMMAND_REGISTRY if c.name == "branch")
|
||||
assert "fork" in branch.aliases
|
||||
|
||||
def test_branch_in_session_category(self):
|
||||
"""The branch command should be in the Session category."""
|
||||
from hermes_cli.commands import COMMAND_REGISTRY
|
||||
branch = next(c for c in COMMAND_REGISTRY if c.name == "branch")
|
||||
assert branch.category == "Session"
|
||||
|
||||
|
||||
class TestBranchFlushesBeforeEndSession:
|
||||
"""Regression for #47202: /branch must flush un-persisted messages to
|
||||
the session DB before ending the old session, just like /new and
|
||||
compress_context() already do."""
|
||||
|
||||
def test_branch_flushes_when_agent_present(self, cli_instance, session_db):
|
||||
from cli import HermesCLI
|
||||
|
||||
agent = MagicMock()
|
||||
cli_instance.agent = agent
|
||||
|
||||
HermesCLI._handle_branch_command(cli_instance, "/branch")
|
||||
|
||||
agent._flush_messages_to_session_db.assert_called_once_with(
|
||||
cli_instance.conversation_history
|
||||
)
|
||||
@@ -0,0 +1,123 @@
|
||||
"""Tests for the /busy CLI command and busy-input-mode config handling."""
|
||||
|
||||
import unittest
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
|
||||
def _import_cli():
|
||||
import hermes_cli.config as config_mod
|
||||
|
||||
if not hasattr(config_mod, "save_env_value_secure"):
|
||||
config_mod.save_env_value_secure = lambda key, value: {
|
||||
"success": True,
|
||||
"stored_as": key,
|
||||
"validated": False,
|
||||
}
|
||||
|
||||
import cli as cli_mod
|
||||
|
||||
return cli_mod
|
||||
|
||||
|
||||
class TestHandleBusyCommand(unittest.TestCase):
|
||||
def _make_cli(self, busy_input_mode="interrupt"):
|
||||
return SimpleNamespace(
|
||||
busy_input_mode=busy_input_mode,
|
||||
agent=None,
|
||||
)
|
||||
|
||||
def test_no_args_shows_status(self):
|
||||
cli_mod = _import_cli()
|
||||
stub = self._make_cli("queue")
|
||||
with (
|
||||
patch.object(cli_mod, "_cprint") as mock_cprint,
|
||||
patch.object(cli_mod, "save_config_value") as mock_save,
|
||||
):
|
||||
cli_mod.HermesCLI._handle_busy_command(stub, "/busy")
|
||||
|
||||
mock_save.assert_not_called()
|
||||
printed = " ".join(str(c) for c in mock_cprint.call_args_list)
|
||||
self.assertIn("queue", printed)
|
||||
self.assertIn("interrupt", printed)
|
||||
|
||||
def test_queue_argument_sets_queue_mode_and_saves(self):
|
||||
cli_mod = _import_cli()
|
||||
stub = self._make_cli("interrupt")
|
||||
with (
|
||||
patch.object(cli_mod, "_cprint"),
|
||||
patch.object(cli_mod, "save_config_value", return_value=True) as mock_save,
|
||||
):
|
||||
cli_mod.HermesCLI._handle_busy_command(stub, "/busy queue")
|
||||
|
||||
self.assertEqual(stub.busy_input_mode, "queue")
|
||||
mock_save.assert_called_once_with("display.busy_input_mode", "queue")
|
||||
|
||||
def test_interrupt_argument_sets_interrupt_mode_and_saves(self):
|
||||
cli_mod = _import_cli()
|
||||
stub = self._make_cli("queue")
|
||||
with (
|
||||
patch.object(cli_mod, "_cprint"),
|
||||
patch.object(cli_mod, "save_config_value", return_value=True) as mock_save,
|
||||
):
|
||||
cli_mod.HermesCLI._handle_busy_command(stub, "/busy interrupt")
|
||||
|
||||
self.assertEqual(stub.busy_input_mode, "interrupt")
|
||||
mock_save.assert_called_once_with("display.busy_input_mode", "interrupt")
|
||||
|
||||
def test_steer_argument_sets_steer_mode_and_saves(self):
|
||||
cli_mod = _import_cli()
|
||||
stub = self._make_cli("interrupt")
|
||||
with (
|
||||
patch.object(cli_mod, "_cprint") as mock_cprint,
|
||||
patch.object(cli_mod, "save_config_value", return_value=True) as mock_save,
|
||||
):
|
||||
cli_mod.HermesCLI._handle_busy_command(stub, "/busy steer")
|
||||
|
||||
self.assertEqual(stub.busy_input_mode, "steer")
|
||||
mock_save.assert_called_once_with("display.busy_input_mode", "steer")
|
||||
printed = " ".join(str(c) for c in mock_cprint.call_args_list)
|
||||
self.assertIn("steer", printed.lower())
|
||||
|
||||
def test_status_reports_steer_behavior(self):
|
||||
cli_mod = _import_cli()
|
||||
stub = self._make_cli("steer")
|
||||
with (
|
||||
patch.object(cli_mod, "_cprint") as mock_cprint,
|
||||
patch.object(cli_mod, "save_config_value") as mock_save,
|
||||
):
|
||||
cli_mod.HermesCLI._handle_busy_command(stub, "/busy status")
|
||||
|
||||
mock_save.assert_not_called()
|
||||
printed = " ".join(str(c) for c in mock_cprint.call_args_list)
|
||||
self.assertIn("steer", printed.lower())
|
||||
# The usage line should also advertise the steer option
|
||||
self.assertIn("steer", printed)
|
||||
|
||||
def test_invalid_argument_prints_usage(self):
|
||||
cli_mod = _import_cli()
|
||||
stub = self._make_cli()
|
||||
with (
|
||||
patch.object(cli_mod, "_cprint") as mock_cprint,
|
||||
patch.object(cli_mod, "save_config_value") as mock_save,
|
||||
):
|
||||
cli_mod.HermesCLI._handle_busy_command(stub, "/busy nonsense")
|
||||
|
||||
mock_save.assert_not_called()
|
||||
printed = " ".join(str(c) for c in mock_cprint.call_args_list)
|
||||
self.assertIn("Usage: /busy", printed)
|
||||
|
||||
|
||||
class TestBusyCommandRegistry(unittest.TestCase):
|
||||
def test_busy_in_registry(self):
|
||||
from hermes_cli.commands import COMMAND_REGISTRY
|
||||
|
||||
names = [c.name for c in COMMAND_REGISTRY]
|
||||
assert "busy" in names
|
||||
|
||||
def test_busy_subcommands_documented(self):
|
||||
from hermes_cli.commands import COMMAND_REGISTRY
|
||||
|
||||
busy = next(c for c in COMMAND_REGISTRY if c.name == "busy")
|
||||
assert busy.args_hint == "[queue|steer|interrupt|status]"
|
||||
assert busy.category == "Configuration"
|
||||
@@ -0,0 +1,149 @@
|
||||
"""Regression tests for #53009: chat -q final response erased by exit-summary clear."""
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
import cli as cli_mod
|
||||
|
||||
|
||||
# ── A3.1 Test-First: verify _clear_terminal_on_exit gating ──────────────────
|
||||
|
||||
def test_print_exit_summary_clears_screen_by_default(monkeypatch):
|
||||
"""Default behavior: _print_exit_summary() calls _clear_terminal_on_exit()."""
|
||||
calls = []
|
||||
|
||||
class FakeCLI:
|
||||
conversation_history = []
|
||||
session_start = None
|
||||
|
||||
def _clear_terminal_on_exit(self):
|
||||
calls.append("clear")
|
||||
|
||||
monkeypatch.setattr(cli_mod, "datetime", SimpleNamespace(
|
||||
now=lambda: SimpleNamespace(
|
||||
__sub__=lambda self, other: SimpleNamespace(
|
||||
total_seconds=lambda: 0
|
||||
)
|
||||
)
|
||||
))
|
||||
|
||||
fake = FakeCLI()
|
||||
cli_mod.HermesCLI._print_exit_summary(fake) # default clear_screen=True
|
||||
|
||||
assert "clear" in calls, "_clear_terminal_on_exit should be called by default"
|
||||
|
||||
|
||||
def test_print_exit_summary_skips_clear_when_clear_screen_false(monkeypatch):
|
||||
"""With clear_screen=False, _print_exit_summary() does NOT clear."""
|
||||
calls = []
|
||||
|
||||
class FakeCLI:
|
||||
conversation_history = []
|
||||
session_start = None
|
||||
|
||||
def _clear_terminal_on_exit(self):
|
||||
calls.append("clear")
|
||||
|
||||
monkeypatch.setattr(cli_mod, "datetime", SimpleNamespace(
|
||||
now=lambda: SimpleNamespace(
|
||||
__sub__=lambda self, other: SimpleNamespace(
|
||||
total_seconds=lambda: 0
|
||||
)
|
||||
)
|
||||
))
|
||||
|
||||
fake = FakeCLI()
|
||||
cli_mod.HermesCLI._print_exit_summary(fake, clear_screen=False)
|
||||
|
||||
assert "clear" not in calls, (
|
||||
"_clear_terminal_on_exit should NOT be called when clear_screen=False"
|
||||
)
|
||||
|
||||
|
||||
# ── Production-path test: single-query -q path skips the clear ──────────────
|
||||
|
||||
def test_single_query_main_skips_clear_on_exit_summary(monkeypatch):
|
||||
"""The single-query (-q) path calls _print_exit_summary without clearing."""
|
||||
calls = []
|
||||
clear_calls = []
|
||||
|
||||
class FakeCLI:
|
||||
def __init__(self, **_kwargs):
|
||||
self.console = SimpleNamespace(print=lambda *_a, **_kw: calls.append("query-label"))
|
||||
self.session_id = "sq-test"
|
||||
self.agent = SimpleNamespace(
|
||||
session_id="sq-test",
|
||||
platform="cli",
|
||||
)
|
||||
|
||||
def _claim_active_session(self, surface, *, stderr=False):
|
||||
calls.append(("claim", surface, stderr))
|
||||
return True
|
||||
|
||||
def _show_security_advisories(self):
|
||||
calls.append("advisories")
|
||||
|
||||
def chat(self, query, images=None):
|
||||
calls.append(("chat", query, images))
|
||||
return "done"
|
||||
|
||||
def _print_exit_summary(self, clear_screen=True):
|
||||
calls.append(("summary", clear_screen))
|
||||
if clear_screen:
|
||||
clear_calls.append("CLEARED") # should NOT happen
|
||||
|
||||
monkeypatch.setattr(cli_mod, "HermesCLI", FakeCLI)
|
||||
monkeypatch.setattr(cli_mod.atexit, "register", lambda *_a, **_kw: None)
|
||||
monkeypatch.setattr(
|
||||
cli_mod,
|
||||
"_finalize_single_query",
|
||||
lambda fake_cli: calls.append(("finalize", fake_cli.session_id)),
|
||||
)
|
||||
|
||||
cli_mod.main(query="hello", quiet=False, toolsets="terminal")
|
||||
|
||||
assert calls == [
|
||||
("claim", "cli", False),
|
||||
"query-label",
|
||||
"advisories",
|
||||
("chat", "hello", None),
|
||||
("summary", False), # <-- clear_screen=False for single-query
|
||||
("finalize", "sq-test"),
|
||||
]
|
||||
assert len(clear_calls) == 0, (
|
||||
"_clear_terminal_on_exit must NOT be called in single-query mode"
|
||||
)
|
||||
|
||||
|
||||
# ── Verify interactive mode still clears ────────────────────────────────────
|
||||
|
||||
def test_print_exit_summary_still_clears_in_interactive_path(monkeypatch):
|
||||
"""Interactive mode should still clear the screen (preserving #38928)."""
|
||||
from datetime import datetime as real_datetime
|
||||
|
||||
calls = []
|
||||
|
||||
class FakeCLI:
|
||||
conversation_history = [
|
||||
{"role": "user", "content": "hello"},
|
||||
{"role": "assistant", "content": "hi"},
|
||||
]
|
||||
session_start = real_datetime(2026, 1, 1, 12, 0, 0)
|
||||
session_id = "test-session"
|
||||
_session_db = None
|
||||
agent = None
|
||||
|
||||
def _clear_terminal_on_exit(self):
|
||||
calls.append("clear")
|
||||
|
||||
monkeypatch.setattr(cli_mod, "datetime", SimpleNamespace(
|
||||
now=lambda: real_datetime(2026, 1, 1, 12, 1, 0) # 1 min elapsed
|
||||
))
|
||||
|
||||
fake = FakeCLI()
|
||||
cli_mod.HermesCLI._print_exit_summary(fake) # default clear_screen=True
|
||||
|
||||
assert "clear" in calls, (
|
||||
"Interactive mode should still clear the screen (regression test for #38928)"
|
||||
)
|
||||
@@ -0,0 +1,70 @@
|
||||
"""Regression test for #49287 — the CLI memory-provider ``on_session_end``
|
||||
hook stopped firing on ``/exit`` after the god-file Phase 4 refactor
|
||||
(094aa85c37) moved agent construction into ``CLIAgentSetupMixin``.
|
||||
|
||||
``_run_cleanup`` (in ``cli.py``) gates the memory-shutdown call on the
|
||||
module global ``cli._active_agent_ref``. The mixin used to set it with a
|
||||
bare ``global _active_agent_ref`` — correct while the code lived in
|
||||
``cli.py``, but after extraction that ``global`` binds the *mixin module's*
|
||||
namespace, leaving ``cli._active_agent_ref`` ``None`` forever. The cleanup
|
||||
``if _active_agent_ref:`` branch was then dead, so ``shutdown_memory_provider``
|
||||
(and therefore every provider's ``on_session_end``) never ran on CLI exit.
|
||||
|
||||
The fix writes the reference onto the ``cli`` module explicitly. These tests
|
||||
assert that contract — the existing shutdown tests pass only because they
|
||||
hand-assign ``cli._active_agent_ref``, which is exactly what masked the bug.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import inspect
|
||||
|
||||
|
||||
def test_mixin_writes_active_agent_ref_to_cli_module():
|
||||
"""The mixin's agent-setup code must publish the agent reference where
|
||||
``_run_cleanup`` reads it — on the ``cli`` module, not the mixin module."""
|
||||
import cli as cli_mod
|
||||
from hermes_cli import cli_agent_setup_mixin as mixin_mod
|
||||
|
||||
sentinel = object()
|
||||
prev_cli = getattr(cli_mod, "_active_agent_ref", None)
|
||||
prev_mixin = getattr(mixin_mod, "_active_agent_ref", "<unset>")
|
||||
try:
|
||||
# Reproduce the exact assignment the mixin performs after building
|
||||
# the agent (see CLIAgentSetupMixin near the AIAgent(...) construction).
|
||||
import cli as _cli
|
||||
_cli._active_agent_ref = sentinel
|
||||
|
||||
# The cleanup path reads cli._active_agent_ref — it must see the value.
|
||||
assert cli_mod._active_agent_ref is sentinel
|
||||
finally:
|
||||
cli_mod._active_agent_ref = prev_cli
|
||||
if prev_mixin == "<unset>":
|
||||
if hasattr(mixin_mod, "_active_agent_ref"):
|
||||
delattr(mixin_mod, "_active_agent_ref")
|
||||
else:
|
||||
mixin_mod._active_agent_ref = prev_mixin
|
||||
|
||||
|
||||
def test_mixin_does_not_use_bare_global_for_active_agent_ref():
|
||||
"""Guard against a regression to ``global _active_agent_ref`` inside the
|
||||
mixin: a bare module-local global would write the wrong namespace and
|
||||
silently re-break CLI memory shutdown. The source must target ``cli``."""
|
||||
from hermes_cli import cli_agent_setup_mixin as mixin_mod
|
||||
|
||||
src = inspect.getsource(mixin_mod)
|
||||
assert "_active_agent_ref = self.agent" in src, (
|
||||
"mixin no longer publishes the agent reference for atexit cleanup"
|
||||
)
|
||||
# The assignment must go through the cli module, not a bare module global.
|
||||
# Inspect executable lines only (a bare ``global _active_agent_ref``
|
||||
# statement), ignoring prose in comments/docstrings that mention it.
|
||||
code_lines = [ln.split("#", 1)[0].strip() for ln in src.splitlines()]
|
||||
assert "global _active_agent_ref" not in code_lines, (
|
||||
"bare `global _active_agent_ref` in the mixin binds the wrong module "
|
||||
"namespace — cli._active_agent_ref stays None and memory shutdown dies "
|
||||
"(#49287). Write `cli._active_agent_ref = self.agent` instead."
|
||||
)
|
||||
assert "_cli._active_agent_ref = self.agent" in src, (
|
||||
"expected the agent reference to be published onto the cli module"
|
||||
)
|
||||
@@ -0,0 +1,739 @@
|
||||
import queue
|
||||
import threading
|
||||
import time
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import cli as cli_module
|
||||
from cli import HermesCLI
|
||||
|
||||
|
||||
class _FakeBuffer:
|
||||
def __init__(self, text="", cursor_position=None):
|
||||
self.text = text
|
||||
self.cursor_position = len(text) if cursor_position is None else cursor_position
|
||||
|
||||
def reset(self, append_to_history=False):
|
||||
self.text = ""
|
||||
self.cursor_position = 0
|
||||
|
||||
|
||||
def _make_cli_stub():
|
||||
cli = HermesCLI.__new__(HermesCLI)
|
||||
cli._approval_state = None
|
||||
cli._approval_deadline = 0
|
||||
cli._approval_lock = threading.Lock()
|
||||
cli._sudo_state = None
|
||||
cli._sudo_deadline = 0
|
||||
cli._modal_input_snapshot = None
|
||||
cli._invalidate = MagicMock()
|
||||
cli._app = SimpleNamespace(invalidate=MagicMock(), current_buffer=_FakeBuffer())
|
||||
return cli
|
||||
|
||||
|
||||
def _make_background_cli_stub():
|
||||
cli = _make_cli_stub()
|
||||
cli._background_task_counter = 0
|
||||
cli._background_tasks = {}
|
||||
cli._ensure_runtime_credentials = MagicMock(return_value=True)
|
||||
cli._resolve_turn_agent_config = MagicMock(return_value={
|
||||
"model": "test-model",
|
||||
"runtime": {
|
||||
"api_key": "test-key",
|
||||
"base_url": "https://example.test/v1",
|
||||
"provider": "test",
|
||||
"api_mode": "chat_completions",
|
||||
},
|
||||
"request_overrides": None,
|
||||
})
|
||||
cli.max_turns = 90
|
||||
cli.enabled_toolsets = []
|
||||
cli._session_db = None
|
||||
cli.reasoning_config = {}
|
||||
cli.service_tier = None
|
||||
cli._providers_only = None
|
||||
cli._providers_ignore = None
|
||||
cli._providers_order = None
|
||||
cli._provider_sort = None
|
||||
cli._provider_require_params = None
|
||||
cli._provider_data_collection = None
|
||||
cli._openrouter_min_coding_score = None
|
||||
cli._fallback_model = None
|
||||
cli._agent_running = False
|
||||
cli._spinner_text = ""
|
||||
cli.bell_on_complete = False
|
||||
cli.final_response_markdown = "strip"
|
||||
return cli
|
||||
|
||||
|
||||
class TestCliApprovalUi:
|
||||
def test_sudo_prompt_restores_existing_draft_after_response(self):
|
||||
cli = _make_cli_stub()
|
||||
cli._app.current_buffer = _FakeBuffer("draft command", cursor_position=5)
|
||||
result = {}
|
||||
|
||||
def _run_callback():
|
||||
result["value"] = cli._sudo_password_callback()
|
||||
|
||||
with patch.object(cli_module, "_cprint"):
|
||||
thread = threading.Thread(target=_run_callback, daemon=True)
|
||||
thread.start()
|
||||
|
||||
deadline = time.time() + 2
|
||||
while cli._sudo_state is None and time.time() < deadline:
|
||||
time.sleep(0.01)
|
||||
|
||||
assert cli._sudo_state is not None
|
||||
assert cli._app.current_buffer.text == ""
|
||||
|
||||
cli._app.current_buffer.text = "secret"
|
||||
cli._app.current_buffer.cursor_position = len("secret")
|
||||
cli._sudo_state["response_queue"].put("secret")
|
||||
|
||||
thread.join(timeout=2)
|
||||
|
||||
assert result["value"] == "secret"
|
||||
assert cli._app.current_buffer.text == "draft command"
|
||||
assert cli._app.current_buffer.cursor_position == 5
|
||||
|
||||
def test_approval_callback_includes_view_for_long_commands(self):
|
||||
cli = _make_cli_stub()
|
||||
command = "sudo dd if=/tmp/githubcli-keyring.gpg of=/usr/share/keyrings/githubcli-archive-keyring.gpg bs=4M status=progress"
|
||||
result = {}
|
||||
|
||||
def _run_callback():
|
||||
result["value"] = cli._approval_callback(command, "disk copy")
|
||||
|
||||
thread = threading.Thread(target=_run_callback, daemon=True)
|
||||
thread.start()
|
||||
|
||||
deadline = time.time() + 2
|
||||
while cli._approval_state is None and time.time() < deadline:
|
||||
time.sleep(0.01)
|
||||
|
||||
assert cli._approval_state is not None
|
||||
assert "view" in cli._approval_state["choices"]
|
||||
|
||||
cli._approval_state["response_queue"].put("deny")
|
||||
thread.join(timeout=2)
|
||||
assert result["value"] == "deny"
|
||||
|
||||
def test_handle_approval_selection_view_expands_in_place(self):
|
||||
cli = _make_cli_stub()
|
||||
cli._approval_state = {
|
||||
"command": "sudo dd if=/tmp/in of=/usr/share/keyrings/githubcli-archive-keyring.gpg bs=4M status=progress",
|
||||
"description": "disk copy",
|
||||
"choices": ["once", "session", "always", "deny", "view"],
|
||||
"selected": 4,
|
||||
"response_queue": queue.Queue(),
|
||||
}
|
||||
|
||||
cli._handle_approval_selection()
|
||||
|
||||
assert cli._approval_state is not None
|
||||
assert cli._approval_state["show_full"] is True
|
||||
assert "view" not in cli._approval_state["choices"]
|
||||
assert cli._approval_state["selected"] == 3
|
||||
assert cli._approval_state["response_queue"].empty()
|
||||
|
||||
def test_approval_display_places_title_inside_box_not_border(self):
|
||||
cli = _make_cli_stub()
|
||||
cli._approval_state = {
|
||||
"command": "sudo dd if=/tmp/in of=/usr/share/keyrings/githubcli-archive-keyring.gpg bs=4M status=progress",
|
||||
"description": "disk copy",
|
||||
"choices": ["once", "session", "always", "deny", "view"],
|
||||
"selected": 0,
|
||||
"response_queue": queue.Queue(),
|
||||
}
|
||||
|
||||
fragments = cli._get_approval_display_fragments()
|
||||
rendered = "".join(text for _style, text in fragments)
|
||||
lines = rendered.splitlines()
|
||||
|
||||
assert lines[0].startswith("╭")
|
||||
assert "Dangerous Command" not in lines[0]
|
||||
assert any("Dangerous Command" in line for line in lines[1:3])
|
||||
assert "Show full command" in rendered
|
||||
assert "githubcli-archive-" in rendered
|
||||
assert "keyring.gpg" in rendered
|
||||
assert "status=progress" in rendered
|
||||
|
||||
def test_approval_display_wraps_preview_hint_on_narrow_terminal(self):
|
||||
cli = _make_cli_stub()
|
||||
cli._approval_state = {
|
||||
"command": "sudo " + ("very-long-command-segment-" * 8),
|
||||
"description": "shell command via -c/-lc flag",
|
||||
"choices": ["once", "session", "always", "deny", "view"],
|
||||
"selected": 0,
|
||||
"response_queue": queue.Queue(),
|
||||
}
|
||||
|
||||
import shutil as _shutil
|
||||
|
||||
with patch("cli.shutil.get_terminal_size",
|
||||
return_value=_shutil.os.terminal_size((30, 24))):
|
||||
fragments = cli._get_approval_display_fragments()
|
||||
|
||||
rendered = "".join(text for _style, text in fragments)
|
||||
lines = rendered.splitlines()
|
||||
border_width = len(lines[0])
|
||||
|
||||
assert "Show full" in rendered
|
||||
assert "command)" in rendered
|
||||
assert all(len(line) == border_width for line in lines)
|
||||
|
||||
def test_approval_display_shows_full_command_after_view(self):
|
||||
cli = _make_cli_stub()
|
||||
full_command = "sudo dd if=/tmp/in of=/usr/share/keyrings/githubcli-archive-keyring.gpg bs=4M status=progress"
|
||||
cli._approval_state = {
|
||||
"command": full_command,
|
||||
"description": "disk copy",
|
||||
"choices": ["once", "session", "always", "deny"],
|
||||
"selected": 0,
|
||||
"show_full": True,
|
||||
"response_queue": queue.Queue(),
|
||||
}
|
||||
|
||||
fragments = cli._get_approval_display_fragments()
|
||||
rendered = "".join(text for _style, text in fragments)
|
||||
|
||||
assert "..." not in rendered
|
||||
assert "githubcli-" in rendered
|
||||
assert "archive-" in rendered
|
||||
assert "keyring.gpg" in rendered
|
||||
assert "status=progress" in rendered
|
||||
|
||||
def test_approval_display_preserves_command_and_choices_with_long_description(self):
|
||||
"""Regression: long tirith descriptions used to push approve/deny off-screen.
|
||||
|
||||
The panel must always render the command and every choice, even when
|
||||
the description would otherwise wrap into 10+ lines. The description
|
||||
gets truncated with a marker instead.
|
||||
"""
|
||||
cli = _make_cli_stub()
|
||||
long_desc = (
|
||||
"Security scan — [CRITICAL] Destructive shell command with wildcard expansion: "
|
||||
"The command performs a recursive deletion of log files which may contain "
|
||||
"audit information relevant to active incident investigations, running services "
|
||||
"that rely on log files for state, rotated archives, and other system artifacts. "
|
||||
"Review whether this is intended before approving. Consider whether a targeted "
|
||||
"deletion with more specific filters would better match the intent."
|
||||
)
|
||||
cli._approval_state = {
|
||||
"command": "rm -rf /var/log/apache2/*.log",
|
||||
"description": long_desc,
|
||||
"choices": ["once", "session", "always", "deny"],
|
||||
"selected": 0,
|
||||
"response_queue": queue.Queue(),
|
||||
}
|
||||
|
||||
# Simulate a compact terminal where the old unbounded panel would overflow.
|
||||
import shutil as _shutil
|
||||
|
||||
with patch("cli.shutil.get_terminal_size",
|
||||
return_value=_shutil.os.terminal_size((100, 20))):
|
||||
fragments = cli._get_approval_display_fragments()
|
||||
|
||||
rendered = "".join(text for _style, text in fragments)
|
||||
|
||||
# Command must be fully visible (rm -rf /var/log/apache2/*.log is short).
|
||||
assert "rm -rf /var/log/apache2/*.log" in rendered
|
||||
|
||||
# Every choice must render — this is the core bug: approve/deny were
|
||||
# getting clipped off the bottom of the panel.
|
||||
assert "Allow once" in rendered
|
||||
assert "Allow for this session" in rendered
|
||||
assert "Add to permanent allowlist" in rendered
|
||||
assert "Deny" in rendered
|
||||
|
||||
# The bottom border must render (i.e. the panel is self-contained).
|
||||
assert rendered.rstrip().endswith("╯")
|
||||
|
||||
# The description gets truncated — marker should appear.
|
||||
assert "(description truncated)" in rendered
|
||||
|
||||
def test_approval_display_skips_description_on_very_short_terminal(self):
|
||||
"""On a 12-row terminal, only the command and choices have room.
|
||||
|
||||
The description is dropped entirely rather than partially shown, so the
|
||||
choices never get clipped.
|
||||
"""
|
||||
cli = _make_cli_stub()
|
||||
cli._approval_state = {
|
||||
"command": "rm -rf /var/log/apache2/*.log",
|
||||
"description": "recursive delete",
|
||||
"choices": ["once", "session", "always", "deny"],
|
||||
"selected": 0,
|
||||
"response_queue": queue.Queue(),
|
||||
}
|
||||
|
||||
import shutil as _shutil
|
||||
|
||||
with patch("cli.shutil.get_terminal_size",
|
||||
return_value=_shutil.os.terminal_size((100, 12))):
|
||||
fragments = cli._get_approval_display_fragments()
|
||||
|
||||
rendered = "".join(text for _style, text in fragments)
|
||||
|
||||
# Command visible.
|
||||
assert "rm -rf /var/log/apache2/*.log" in rendered
|
||||
# All four choices visible.
|
||||
for label in ("Allow once", "Allow for this session",
|
||||
"Add to permanent allowlist", "Deny"):
|
||||
assert label in rendered, f"choice {label!r} missing"
|
||||
|
||||
def test_approval_display_truncates_giant_command_in_view_mode(self):
|
||||
"""If the user hits /view on a massive command, choices still render.
|
||||
|
||||
The command gets truncated with a marker; the description gets dropped
|
||||
if there's no remaining row budget.
|
||||
"""
|
||||
cli = _make_cli_stub()
|
||||
# 50 lines of command when wrapped at ~64 chars.
|
||||
giant_cmd = "bash -c 'echo " + ("x" * 3000) + "'"
|
||||
cli._approval_state = {
|
||||
"command": giant_cmd,
|
||||
"description": "shell command via -c/-lc flag",
|
||||
"choices": ["once", "session", "always", "deny"],
|
||||
"selected": 0,
|
||||
"show_full": True,
|
||||
"response_queue": queue.Queue(),
|
||||
}
|
||||
|
||||
import shutil as _shutil
|
||||
|
||||
with patch("cli.shutil.get_terminal_size",
|
||||
return_value=_shutil.os.terminal_size((100, 24))):
|
||||
fragments = cli._get_approval_display_fragments()
|
||||
|
||||
rendered = "".join(text for _style, text in fragments)
|
||||
|
||||
# All four choices visible even with a huge command.
|
||||
for label in ("Allow once", "Allow for this session",
|
||||
"Add to permanent allowlist", "Deny"):
|
||||
assert label in rendered, f"choice {label!r} missing"
|
||||
|
||||
# Command got truncated with a marker.
|
||||
assert "(command truncated" in rendered
|
||||
|
||||
def test_background_task_registers_thread_local_approval_callbacks(self):
|
||||
"""Background /btw tasks must use the prompt_toolkit approval UI.
|
||||
|
||||
The foreground chat path registers dangerous-command callbacks inside
|
||||
its worker thread because tools.terminal_tool stores them in
|
||||
threading.local(). /background used to skip that, so dangerous commands
|
||||
fell back to raw input() in a background thread and timed out under
|
||||
prompt_toolkit.
|
||||
"""
|
||||
cli = _make_background_cli_stub()
|
||||
seen = {}
|
||||
|
||||
class FakeAgent:
|
||||
def __init__(self, **kwargs):
|
||||
self._print_fn = None
|
||||
self.thinking_callback = None
|
||||
|
||||
def run_conversation(self, **kwargs):
|
||||
from tools.terminal_tool import (
|
||||
_get_approval_callback,
|
||||
_get_sudo_password_callback,
|
||||
)
|
||||
|
||||
seen["approval"] = _get_approval_callback()
|
||||
seen["sudo"] = _get_sudo_password_callback()
|
||||
return {
|
||||
"final_response": "done",
|
||||
"messages": [],
|
||||
"completed": True,
|
||||
"failed": False,
|
||||
}
|
||||
|
||||
with patch.object(cli_module, "AIAgent", FakeAgent), \
|
||||
patch.object(cli_module, "_cprint"), \
|
||||
patch.object(cli_module, "ChatConsole") as chat_console:
|
||||
chat_console.return_value.print = MagicMock()
|
||||
cli._handle_background_command("/btw check weather")
|
||||
|
||||
# Join the worker thread deterministically rather than polling a
|
||||
# wall-clock deadline — under load the thread's finally-block pop
|
||||
# of _background_tasks can lag a fixed timeout, which flaked CI.
|
||||
for _thread in list(cli._background_tasks.values()):
|
||||
_thread.join(timeout=10)
|
||||
|
||||
assert seen["approval"].__self__ is cli
|
||||
assert seen["approval"].__func__ is HermesCLI._approval_callback
|
||||
assert seen["sudo"].__self__ is cli
|
||||
assert seen["sudo"].__func__ is HermesCLI._sudo_password_callback
|
||||
assert not cli._background_tasks
|
||||
|
||||
|
||||
def _make_real_paint_cli_stub():
|
||||
"""A stub whose modal repaint path runs the REAL _paint_now / _invalidate.
|
||||
|
||||
Both gates are set adversarially: _resize_recovery_pending=True and a recent
|
||||
_last_invalidate inside the throttle window. A throttled _invalidate() would
|
||||
be dropped under these conditions — _paint_now must paint regardless.
|
||||
"""
|
||||
cli = HermesCLI.__new__(HermesCLI)
|
||||
cli._approval_state = None
|
||||
cli._approval_deadline = 0
|
||||
cli._approval_lock = threading.Lock()
|
||||
cli._sudo_state = None
|
||||
cli._sudo_deadline = 0
|
||||
cli._clarify_state = None
|
||||
cli._clarify_freetext = False
|
||||
cli._clarify_deadline = 0
|
||||
cli._modal_input_snapshot = None
|
||||
# Real methods, not mocks.
|
||||
cli._paint_now = HermesCLI._paint_now.__get__(cli, HermesCLI)
|
||||
cli._invalidate = HermesCLI._invalidate.__get__(cli, HermesCLI)
|
||||
cli._resize_recovery_pending = True # gate 1: resize in flight
|
||||
cli._last_invalidate = time.monotonic() # gate 2: inside throttle window
|
||||
cli._app = SimpleNamespace(invalidate=MagicMock(), current_buffer=_FakeBuffer())
|
||||
return cli
|
||||
|
||||
|
||||
class TestModalPaintNow:
|
||||
"""Regression for #41098 — modal prompts must paint immediately.
|
||||
|
||||
The dangerous-command approval, clarify, and sudo prompts run their wait
|
||||
loop on a background thread, set modal state a ConditionalContainer reads,
|
||||
then must repaint so the panel becomes visible. They used the throttled
|
||||
_invalidate(), whose paint is silently dropped on a 250ms window collision
|
||||
or while a resize is pending — so the prompt timed out unseen. They now use
|
||||
_paint_now(), which paints directly like the modal key-binding handlers.
|
||||
"""
|
||||
|
||||
def test_paint_now_bypasses_throttle_and_resize_guard(self):
|
||||
cli = _make_real_paint_cli_stub()
|
||||
# A bare _invalidate() is suppressed under both gates...
|
||||
cli._invalidate()
|
||||
assert not cli._app.invalidate.called
|
||||
# ...but _paint_now() always paints.
|
||||
cli._paint_now()
|
||||
assert cli._app.invalidate.called
|
||||
|
||||
def test_paint_now_no_app_is_safe(self):
|
||||
cli = HermesCLI.__new__(HermesCLI)
|
||||
cli._app = None
|
||||
cli._paint_now() # must not raise
|
||||
|
||||
def _drive(self, cli, target, state_attr):
|
||||
result = {}
|
||||
|
||||
def _run():
|
||||
result["value"] = target()
|
||||
|
||||
with patch.object(cli_module, "_cprint"):
|
||||
thread = threading.Thread(target=_run, daemon=True)
|
||||
thread.start()
|
||||
deadline = time.time() + 2
|
||||
while getattr(cli, state_attr) is None and time.time() < deadline:
|
||||
time.sleep(0.01)
|
||||
assert getattr(cli, state_attr) is not None
|
||||
assert cli._app.invalidate.called, (
|
||||
f"{state_attr} panel was not painted despite throttle + resize gates"
|
||||
)
|
||||
# Reset so we can prove the response-received teardown also repaints
|
||||
# (the panel must clear at once, not be held by the throttle).
|
||||
cli._app.invalidate.reset_mock()
|
||||
getattr(cli, state_attr)["response_queue"].put(
|
||||
"deny" if state_attr == "_approval_state" else
|
||||
("a" if state_attr == "_clarify_state" else "pw")
|
||||
)
|
||||
thread.join(timeout=2)
|
||||
# clarify returns immediately on a response (no teardown repaint);
|
||||
# approval and sudo repaint to tear the panel down.
|
||||
if state_attr != "_clarify_state":
|
||||
assert cli._app.invalidate.called, (
|
||||
f"{state_attr} panel was not repainted on teardown"
|
||||
)
|
||||
assert not thread.is_alive()
|
||||
return result["value"]
|
||||
|
||||
def test_approval_prompt_paints_under_both_gates(self):
|
||||
cli = _make_real_paint_cli_stub()
|
||||
value = self._drive(
|
||||
cli, lambda: cli._approval_callback("rm -rf /tmp/scratch", "danger"),
|
||||
"_approval_state",
|
||||
)
|
||||
assert value == "deny"
|
||||
|
||||
def test_clarify_prompt_paints_under_both_gates(self):
|
||||
cli = _make_real_paint_cli_stub()
|
||||
value = self._drive(
|
||||
cli, lambda: cli._clarify_callback("Pick one", ["a", "b"]),
|
||||
"_clarify_state",
|
||||
)
|
||||
assert value == "a"
|
||||
|
||||
def test_sudo_prompt_paints_under_both_gates(self):
|
||||
cli = _make_real_paint_cli_stub()
|
||||
value = self._drive(cli, cli._sudo_password_callback, "_sudo_state")
|
||||
assert value == "pw"
|
||||
|
||||
def test_secret_response_teardown_paints(self):
|
||||
"""_submit_secret_response tears the secret panel down via _paint_now,
|
||||
so the panel clears immediately rather than being held by the throttle."""
|
||||
cli = _make_real_paint_cli_stub()
|
||||
cli._secret_state = {"response_queue": queue.Queue()}
|
||||
cli._secret_deadline = 0
|
||||
cli._submit_secret_response("hunter2")
|
||||
assert cli._secret_state is None
|
||||
assert cli._app.invalidate.called
|
||||
assert cli._secret_state is None # cleared
|
||||
|
||||
|
||||
class TestApprovalCallbackThreadLocalWiring:
|
||||
"""Regression guard for the thread-local callback freeze (#13617 / #13618).
|
||||
|
||||
After 62348cff made _approval_callback / _sudo_password_callback thread-local
|
||||
(ACP GHSA-qg5c-hvr5-hjgr), the CLI agent thread could no longer see callbacks
|
||||
registered in the main thread — the dangerous-command prompt silently fell
|
||||
back to stdin input() and deadlocked against prompt_toolkit. The fix is to
|
||||
register the callbacks INSIDE the agent worker thread (matching the ACP
|
||||
pattern). These tests lock in that invariant.
|
||||
"""
|
||||
|
||||
def test_main_thread_registration_is_invisible_to_child_thread(self):
|
||||
"""Confirms the underlying threading.local semantics that drove the bug.
|
||||
|
||||
If this ever starts passing as "visible", the thread-local isolation
|
||||
is gone and the ACP race GHSA-qg5c-hvr5-hjgr may be back.
|
||||
"""
|
||||
from tools.terminal_tool import (
|
||||
set_approval_callback,
|
||||
_get_approval_callback,
|
||||
)
|
||||
|
||||
def main_cb(_cmd, _desc):
|
||||
return "once"
|
||||
|
||||
set_approval_callback(main_cb)
|
||||
try:
|
||||
seen = {}
|
||||
|
||||
def _child():
|
||||
seen["value"] = _get_approval_callback()
|
||||
|
||||
t = threading.Thread(target=_child, daemon=True)
|
||||
t.start()
|
||||
t.join(timeout=2)
|
||||
assert seen["value"] is None
|
||||
finally:
|
||||
set_approval_callback(None)
|
||||
|
||||
def test_child_thread_registration_is_visible_and_cleared_in_finally(self):
|
||||
"""The fix pattern: register INSIDE the worker thread, clear in finally.
|
||||
|
||||
This is exactly what cli.py's run_agent() closure does. If this test
|
||||
fails, the CLI approval prompt freeze (#13617) has regressed.
|
||||
"""
|
||||
from tools.terminal_tool import (
|
||||
set_approval_callback,
|
||||
set_sudo_password_callback,
|
||||
_get_approval_callback,
|
||||
_get_sudo_password_callback,
|
||||
)
|
||||
|
||||
def approval_cb(_cmd, _desc):
|
||||
return "once"
|
||||
|
||||
def sudo_cb():
|
||||
return "hunter2"
|
||||
|
||||
seen = {}
|
||||
|
||||
def _worker():
|
||||
# Mimic cli.py's run_agent() thread target.
|
||||
set_approval_callback(approval_cb)
|
||||
set_sudo_password_callback(sudo_cb)
|
||||
try:
|
||||
seen["approval"] = _get_approval_callback()
|
||||
seen["sudo"] = _get_sudo_password_callback()
|
||||
finally:
|
||||
set_approval_callback(None)
|
||||
set_sudo_password_callback(None)
|
||||
seen["approval_after"] = _get_approval_callback()
|
||||
seen["sudo_after"] = _get_sudo_password_callback()
|
||||
|
||||
t = threading.Thread(target=_worker, daemon=True)
|
||||
t.start()
|
||||
t.join(timeout=2)
|
||||
|
||||
assert seen["approval"] is approval_cb
|
||||
assert seen["sudo"] is sudo_cb
|
||||
# Finally block must clear both slots — otherwise a reused thread
|
||||
# would hold a stale reference to a disposed CLI instance.
|
||||
assert seen["approval_after"] is None
|
||||
assert seen["sudo_after"] is None
|
||||
|
||||
|
||||
class TestPersistPromptSummary:
|
||||
"""display.persist_prompts — one-line scrollback record of resolved modals."""
|
||||
|
||||
def _resolve_approval(self, cli, answer, command="rm -rf /tmp/scratch"):
|
||||
result = {}
|
||||
|
||||
def _run():
|
||||
result["value"] = cli._approval_callback(command, "danger")
|
||||
|
||||
t = threading.Thread(target=_run, daemon=True)
|
||||
t.start()
|
||||
deadline = time.time() + 2
|
||||
while cli._approval_state is None and time.time() < deadline:
|
||||
time.sleep(0.01)
|
||||
cli._approval_state["response_queue"].put(answer)
|
||||
t.join(timeout=2)
|
||||
return result["value"]
|
||||
|
||||
def test_approval_resolution_prints_summary_line(self):
|
||||
cli = _make_cli_stub()
|
||||
printed = []
|
||||
with patch.object(cli_module, "_cprint", printed.append):
|
||||
verdict = self._resolve_approval(cli, "session")
|
||||
assert verdict == "session"
|
||||
summary = "\n".join(printed)
|
||||
assert "Approval" in summary
|
||||
assert "rm -rf /tmp/scratch" in summary
|
||||
assert "allowed for session" in summary
|
||||
|
||||
def test_approval_summary_truncates_long_command(self):
|
||||
cli = _make_cli_stub()
|
||||
printed = []
|
||||
long_cmd = "sudo " + ("x" * 300)
|
||||
with patch.object(cli_module, "_cprint", printed.append):
|
||||
self._resolve_approval(cli, "deny", command=long_cmd)
|
||||
summary = "\n".join(printed)
|
||||
assert "denied" in summary
|
||||
assert "…" in summary
|
||||
# The raw 300-char tail must not be dumped wholesale.
|
||||
assert "x" * 200 not in summary
|
||||
|
||||
def test_persist_prompts_false_suppresses_summary(self):
|
||||
cli = _make_cli_stub()
|
||||
printed = []
|
||||
with patch.dict(cli_module.CLI_CONFIG.get("display", {}), {"persist_prompts": False}), \
|
||||
patch.object(cli_module, "_cprint", printed.append):
|
||||
verdict = self._resolve_approval(cli, "once")
|
||||
assert verdict == "once"
|
||||
assert not any("Approval" in p for p in printed)
|
||||
|
||||
def test_clarify_resolution_prints_summary_line(self):
|
||||
cli = _make_cli_stub()
|
||||
cli._clarify_state = None
|
||||
cli._clarify_freetext = False
|
||||
cli._clarify_deadline = 0
|
||||
printed = []
|
||||
result = {}
|
||||
|
||||
def _run():
|
||||
result["value"] = cli._clarify_callback("Pick a path?", ["A", "B"])
|
||||
|
||||
with patch.object(cli_module, "_cprint", printed.append):
|
||||
t = threading.Thread(target=_run, daemon=True)
|
||||
t.start()
|
||||
deadline = time.time() + 2
|
||||
while cli._clarify_state is None and time.time() < deadline:
|
||||
time.sleep(0.01)
|
||||
cli._clarify_state["response_queue"].put("B")
|
||||
t.join(timeout=2)
|
||||
|
||||
assert result["value"] == "B"
|
||||
summary = "\n".join(printed)
|
||||
assert "Clarify" in summary
|
||||
assert "Pick a path?" in summary
|
||||
assert "B" in summary
|
||||
|
||||
|
||||
class TestClearOverlaysForInterrupt:
|
||||
"""Regression tests for #14026 — interrupting a running agent must clear
|
||||
every input-blocking overlay (approval/clarify/sudo/secret) so the CLI
|
||||
isn't left frozen with no thread servicing the prompt."""
|
||||
|
||||
def _make_cli(self):
|
||||
cli = _make_cli_stub()
|
||||
# Attributes the helper touches that the base stub doesn't set.
|
||||
cli._clarify_state = None
|
||||
cli._clarify_freetext = False
|
||||
cli._secret_state = None
|
||||
cli._secret_deadline = 0
|
||||
cli._paint_now = MagicMock()
|
||||
return cli
|
||||
|
||||
def test_clears_all_four_overlays_and_unblocks_queues(self):
|
||||
cli = self._make_cli()
|
||||
approval_q = queue.Queue()
|
||||
clarify_q = queue.Queue()
|
||||
sudo_q = queue.Queue()
|
||||
secret_q = queue.Queue()
|
||||
cli._approval_state = {"response_queue": approval_q}
|
||||
cli._clarify_state = {"response_queue": clarify_q}
|
||||
cli._clarify_freetext = True
|
||||
cli._sudo_state = {"response_queue": sudo_q, "timeout": 60}
|
||||
cli._sudo_deadline = 99999.0
|
||||
cli._secret_state = {"response_queue": secret_q, "var_name": "X"}
|
||||
|
||||
cli._clear_active_overlays_for_interrupt()
|
||||
|
||||
# All states nilled out.
|
||||
assert cli._approval_state is None
|
||||
assert cli._clarify_state is None
|
||||
assert cli._clarify_freetext is False
|
||||
assert cli._sudo_state is None
|
||||
assert cli._sudo_deadline == 0
|
||||
assert cli._secret_state is None
|
||||
|
||||
# Each blocked thread would have received a terminal value.
|
||||
assert approval_q.get_nowait() == "deny"
|
||||
assert clarify_q.get_nowait() # cancellation sentinel string
|
||||
assert sudo_q.get_nowait() == ""
|
||||
assert secret_q.get_nowait() == ""
|
||||
|
||||
def test_noop_when_no_overlays_active(self):
|
||||
cli = self._make_cli()
|
||||
cli._clear_active_overlays_for_interrupt()
|
||||
assert cli._approval_state is None
|
||||
assert cli._clarify_state is None
|
||||
assert cli._sudo_state is None
|
||||
assert cli._secret_state is None
|
||||
|
||||
def test_dead_queue_does_not_block_clearing_others(self):
|
||||
"""A queue that raises on put() must not prevent the remaining
|
||||
overlays from being cleared."""
|
||||
cli = self._make_cli()
|
||||
|
||||
class _DeadQueue:
|
||||
def put(self, *_a, **_k):
|
||||
raise RuntimeError("queue gone")
|
||||
|
||||
clarify_q = queue.Queue()
|
||||
cli._approval_state = {"response_queue": _DeadQueue()}
|
||||
cli._clarify_state = {"response_queue": clarify_q}
|
||||
|
||||
cli._clear_active_overlays_for_interrupt()
|
||||
|
||||
assert cli._approval_state is None # cleared despite dead queue
|
||||
assert cli._clarify_state is None
|
||||
assert clarify_q.get_nowait()
|
||||
|
||||
def test_interrupt_unblocks_thread_blocked_on_approval(self):
|
||||
"""End-to-end: a worker blocked on the approval queue unblocks when the
|
||||
interrupt helper drains it."""
|
||||
cli = self._make_cli()
|
||||
approval_q = queue.Queue()
|
||||
cli._approval_state = {"response_queue": approval_q}
|
||||
result = {}
|
||||
|
||||
def _worker():
|
||||
result["value"] = approval_q.get(timeout=2)
|
||||
|
||||
t = threading.Thread(target=_worker, daemon=True)
|
||||
t.start()
|
||||
time.sleep(0.05)
|
||||
cli._clear_active_overlays_for_interrupt()
|
||||
t.join(timeout=2)
|
||||
|
||||
assert not t.is_alive(), "worker thread never unblocked"
|
||||
assert result["value"] == "deny"
|
||||
|
||||
@@ -0,0 +1,270 @@
|
||||
"""Tests for the /background indicator in the CLI status bar.
|
||||
|
||||
The classic prompt_toolkit status bar shows `▶ N` when N tasks launched via
|
||||
`/background` are still running. Source of truth is `self._background_tasks`
|
||||
(a Dict[str, threading.Thread]); entries are removed in the task thread's
|
||||
finally block, so len() reflects truly-running tasks.
|
||||
"""
|
||||
|
||||
import threading
|
||||
from datetime import datetime
|
||||
|
||||
from cli import HermesCLI
|
||||
|
||||
|
||||
def _stub_thread() -> threading.Thread:
|
||||
"""Return a Thread instance that's never started — pure dict-value stand-in."""
|
||||
return threading.Thread(target=lambda: None)
|
||||
|
||||
|
||||
def _make_cli():
|
||||
"""Bare-metal HermesCLI for snapshot/build tests (no __init__ side effects)."""
|
||||
cli_obj = HermesCLI.__new__(HermesCLI)
|
||||
cli_obj.model = "anthropic/claude-opus-4.6"
|
||||
cli_obj.agent = None
|
||||
cli_obj._background_tasks = {}
|
||||
# The snapshot reads session_start to compute duration; supply a stub.
|
||||
cli_obj.session_start = datetime.now()
|
||||
return cli_obj
|
||||
|
||||
|
||||
def test_snapshot_reports_zero_when_no_background_tasks():
|
||||
cli_obj = _make_cli()
|
||||
snap = cli_obj._get_status_bar_snapshot()
|
||||
assert snap["active_background_tasks"] == 0
|
||||
|
||||
|
||||
def test_snapshot_counts_live_background_tasks():
|
||||
cli_obj = _make_cli()
|
||||
cli_obj._background_tasks = {"bg_a": _stub_thread(), "bg_b": _stub_thread()}
|
||||
snap = cli_obj._get_status_bar_snapshot()
|
||||
assert snap["active_background_tasks"] == 2
|
||||
|
||||
|
||||
def test_snapshot_safe_when_background_tasks_attr_missing():
|
||||
"""Older HermesCLI instances (tests with __new__, etc.) may lack the attr."""
|
||||
cli_obj = HermesCLI.__new__(HermesCLI)
|
||||
cli_obj.model = "x"
|
||||
cli_obj.agent = None
|
||||
cli_obj.session_start = datetime.now()
|
||||
# No _background_tasks at all — must not raise.
|
||||
snap = cli_obj._get_status_bar_snapshot()
|
||||
assert snap["active_background_tasks"] == 0
|
||||
|
||||
|
||||
def test_plain_text_status_omits_indicator_when_idle():
|
||||
cli_obj = _make_cli()
|
||||
text = cli_obj._build_status_bar_text(width=80)
|
||||
assert "▶" not in text
|
||||
|
||||
|
||||
def test_plain_text_status_shows_indicator_when_active():
|
||||
cli_obj = _make_cli()
|
||||
cli_obj._background_tasks = {"bg_a": _stub_thread()}
|
||||
text = cli_obj._build_status_bar_text(width=80)
|
||||
assert "▶ 1" in text
|
||||
|
||||
|
||||
def test_plain_text_status_shows_higher_count():
|
||||
cli_obj = _make_cli()
|
||||
cli_obj._background_tasks = {
|
||||
"a": _stub_thread(),
|
||||
"b": _stub_thread(),
|
||||
"c": _stub_thread(),
|
||||
}
|
||||
text = cli_obj._build_status_bar_text(width=80)
|
||||
assert "▶ 3" in text
|
||||
|
||||
|
||||
def test_narrow_width_omits_bg_indicator():
|
||||
"""The narrow tier (<52) is already cramped — bg is secondary, drop it."""
|
||||
cli_obj = _make_cli()
|
||||
cli_obj._background_tasks = {"bg_a": _stub_thread()}
|
||||
text = cli_obj._build_status_bar_text(width=40)
|
||||
assert "▶" not in text
|
||||
|
||||
|
||||
def test_fragments_include_bg_segment_when_active():
|
||||
cli_obj = _make_cli()
|
||||
cli_obj._background_tasks = {"a": _stub_thread(), "b": _stub_thread()}
|
||||
cli_obj._status_bar_visible = True
|
||||
# _get_status_bar_fragments asks _get_tui_terminal_width(); stub it wide.
|
||||
cli_obj._get_tui_terminal_width = lambda: 120 # type: ignore[method-assign]
|
||||
frags = cli_obj._get_status_bar_fragments()
|
||||
rendered = "".join(text for _style, text in frags)
|
||||
assert "▶ 2" in rendered
|
||||
|
||||
|
||||
def test_fragments_omit_bg_segment_when_idle():
|
||||
cli_obj = _make_cli()
|
||||
cli_obj._status_bar_visible = True
|
||||
cli_obj._get_tui_terminal_width = lambda: 120 # type: ignore[method-assign]
|
||||
frags = cli_obj._get_status_bar_fragments()
|
||||
rendered = "".join(text for _style, text in frags)
|
||||
assert "▶" not in rendered
|
||||
|
||||
|
||||
# ── Background terminal-process indicator (⚙ N) ───────────────────────────
|
||||
# Source of truth is tools.process_registry.process_registry._running (a dict
|
||||
# of currently-running shell processes spawned by terminal(background=true)).
|
||||
# Distinct from /background tasks above: ▶ counts agent threads, ⚙ counts
|
||||
# shell processes. Both can be active simultaneously.
|
||||
|
||||
|
||||
class _FakeRunningRegistry:
|
||||
"""Minimal stand-in for process_registry; exposes count_running()."""
|
||||
|
||||
def __init__(self, count: int) -> None:
|
||||
self._count = count
|
||||
|
||||
def count_running(self) -> int:
|
||||
return self._count
|
||||
|
||||
|
||||
def _patch_process_registry(monkeypatch, count: int) -> None:
|
||||
import tools.process_registry as pr_mod
|
||||
monkeypatch.setattr(pr_mod, "process_registry", _FakeRunningRegistry(count))
|
||||
|
||||
|
||||
def test_snapshot_reports_zero_when_no_background_processes(monkeypatch):
|
||||
cli_obj = _make_cli()
|
||||
_patch_process_registry(monkeypatch, 0)
|
||||
snap = cli_obj._get_status_bar_snapshot()
|
||||
assert snap["active_background_processes"] == 0
|
||||
|
||||
|
||||
def test_snapshot_counts_live_background_processes(monkeypatch):
|
||||
cli_obj = _make_cli()
|
||||
_patch_process_registry(monkeypatch, 3)
|
||||
snap = cli_obj._get_status_bar_snapshot()
|
||||
assert snap["active_background_processes"] == 3
|
||||
|
||||
|
||||
def test_snapshot_safe_when_process_registry_raises(monkeypatch):
|
||||
"""If count_running() raises the snapshot stays at 0; no propagate."""
|
||||
cli_obj = _make_cli()
|
||||
import tools.process_registry as pr_mod
|
||||
|
||||
class _BoomRegistry:
|
||||
def count_running(self):
|
||||
raise RuntimeError("boom")
|
||||
|
||||
monkeypatch.setattr(pr_mod, "process_registry", _BoomRegistry())
|
||||
snap = cli_obj._get_status_bar_snapshot()
|
||||
assert snap["active_background_processes"] == 0
|
||||
|
||||
|
||||
def test_plain_text_status_shows_proc_indicator_when_active(monkeypatch):
|
||||
cli_obj = _make_cli()
|
||||
_patch_process_registry(monkeypatch, 2)
|
||||
text = cli_obj._build_status_bar_text(width=80)
|
||||
assert "⚙ 2" in text
|
||||
|
||||
|
||||
def test_plain_text_status_omits_proc_indicator_when_idle(monkeypatch):
|
||||
cli_obj = _make_cli()
|
||||
_patch_process_registry(monkeypatch, 0)
|
||||
text = cli_obj._build_status_bar_text(width=80)
|
||||
assert "⚙" not in text
|
||||
|
||||
|
||||
def test_fragments_include_proc_segment_when_active(monkeypatch):
|
||||
cli_obj = _make_cli()
|
||||
_patch_process_registry(monkeypatch, 1)
|
||||
cli_obj._status_bar_visible = True
|
||||
cli_obj._get_tui_terminal_width = lambda: 120 # type: ignore[method-assign]
|
||||
frags = cli_obj._get_status_bar_fragments()
|
||||
rendered = "".join(text for _style, text in frags)
|
||||
assert "⚙ 1" in rendered
|
||||
|
||||
|
||||
def test_indicators_independent_agents_and_processes(monkeypatch):
|
||||
"""▶ (agent tasks) and ⚙ (shell processes) render side-by-side."""
|
||||
cli_obj = _make_cli()
|
||||
cli_obj._background_tasks = {"bg_a": _stub_thread()}
|
||||
_patch_process_registry(monkeypatch, 2)
|
||||
cli_obj._status_bar_visible = True
|
||||
cli_obj._get_tui_terminal_width = lambda: 120 # type: ignore[method-assign]
|
||||
frags = cli_obj._get_status_bar_fragments()
|
||||
rendered = "".join(text for _style, text in frags)
|
||||
assert "▶ 1" in rendered
|
||||
assert "⚙ 2" in rendered
|
||||
|
||||
|
||||
# ── Background/async subagent indicator (⛓ N) ─────────────────────────────
|
||||
# Source of truth is tools.async_delegation.active_count() — the count of
|
||||
# delegate_task delegations (batch + background single) still in the
|
||||
# "running" state. Distinct from ▶ (/background agent threads) and ⚙ (shell
|
||||
# processes); all three can be active at once.
|
||||
|
||||
|
||||
def _patch_async_active(monkeypatch, count: int) -> None:
|
||||
import tools.async_delegation as ad_mod
|
||||
monkeypatch.setattr(ad_mod, "active_count", lambda: count)
|
||||
|
||||
|
||||
def test_snapshot_reports_zero_when_no_background_subagents(monkeypatch):
|
||||
cli_obj = _make_cli()
|
||||
_patch_async_active(monkeypatch, 0)
|
||||
snap = cli_obj._get_status_bar_snapshot()
|
||||
assert snap["active_background_subagents"] == 0
|
||||
|
||||
|
||||
def test_snapshot_counts_live_background_subagents(monkeypatch):
|
||||
cli_obj = _make_cli()
|
||||
_patch_async_active(monkeypatch, 4)
|
||||
snap = cli_obj._get_status_bar_snapshot()
|
||||
assert snap["active_background_subagents"] == 4
|
||||
|
||||
|
||||
def test_snapshot_safe_when_async_active_count_raises(monkeypatch):
|
||||
"""If active_count() raises the snapshot stays at 0; no propagate."""
|
||||
cli_obj = _make_cli()
|
||||
import tools.async_delegation as ad_mod
|
||||
|
||||
def _boom():
|
||||
raise RuntimeError("boom")
|
||||
|
||||
monkeypatch.setattr(ad_mod, "active_count", _boom)
|
||||
snap = cli_obj._get_status_bar_snapshot()
|
||||
assert snap["active_background_subagents"] == 0
|
||||
|
||||
|
||||
def test_plain_text_status_shows_subagent_indicator_when_active(monkeypatch):
|
||||
cli_obj = _make_cli()
|
||||
_patch_async_active(monkeypatch, 3)
|
||||
text = cli_obj._build_status_bar_text(width=80)
|
||||
assert "⛓ 3" in text
|
||||
|
||||
|
||||
def test_plain_text_status_omits_subagent_indicator_when_idle(monkeypatch):
|
||||
cli_obj = _make_cli()
|
||||
_patch_async_active(monkeypatch, 0)
|
||||
text = cli_obj._build_status_bar_text(width=80)
|
||||
assert "⛓" not in text
|
||||
|
||||
|
||||
def test_fragments_include_subagent_segment_when_active(monkeypatch):
|
||||
cli_obj = _make_cli()
|
||||
_patch_async_active(monkeypatch, 2)
|
||||
cli_obj._status_bar_visible = True
|
||||
cli_obj._get_tui_terminal_width = lambda: 120 # type: ignore[method-assign]
|
||||
frags = cli_obj._get_status_bar_fragments()
|
||||
rendered = "".join(text for _style, text in frags)
|
||||
assert "⛓ 2" in rendered
|
||||
|
||||
|
||||
def test_all_three_background_indicators_independent(monkeypatch):
|
||||
"""▶ (agent tasks), ⚙ (shell processes), ⛓ (subagents) all coexist."""
|
||||
cli_obj = _make_cli()
|
||||
cli_obj._background_tasks = {"bg_a": _stub_thread()}
|
||||
_patch_process_registry(monkeypatch, 2)
|
||||
_patch_async_active(monkeypatch, 5)
|
||||
cli_obj._status_bar_visible = True
|
||||
cli_obj._get_tui_terminal_width = lambda: 120 # type: ignore[method-assign]
|
||||
frags = cli_obj._get_status_bar_fragments()
|
||||
rendered = "".join(text for _style, text in frags)
|
||||
assert "▶ 1" in rendered
|
||||
assert "⚙ 2" in rendered
|
||||
assert "⛓ 5" in rendered
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
"""Tests for CLI background command TUI refresh behavior.
|
||||
|
||||
Ensures the TUI is properly refreshed before printing background task output
|
||||
to prevent spinner/status bar overlap (#2718).
|
||||
"""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
|
||||
from cli import HermesCLI
|
||||
|
||||
|
||||
def _make_cli():
|
||||
"""Create a minimal HermesCLI instance for testing."""
|
||||
cli_obj = HermesCLI.__new__(HermesCLI)
|
||||
cli_obj.model = "test-model"
|
||||
cli_obj._background_tasks = {}
|
||||
cli_obj._background_task_counter = 0
|
||||
cli_obj.conversation_history = []
|
||||
cli_obj.agent = None
|
||||
cli_obj._app = None
|
||||
return cli_obj
|
||||
|
||||
|
||||
class TestBackgroundCommandTuiRefresh:
|
||||
"""Tests for TUI refresh in background command output."""
|
||||
|
||||
def test_invalidate_called_before_success_output(self):
|
||||
"""App.invalidate() is called before printing background success output."""
|
||||
cli_obj = _make_cli()
|
||||
mock_app = MagicMock()
|
||||
cli_obj._app = mock_app
|
||||
|
||||
# Track call order
|
||||
call_order = []
|
||||
original_invalidate = mock_app.invalidate
|
||||
|
||||
def track_invalidate():
|
||||
call_order.append("invalidate")
|
||||
return original_invalidate()
|
||||
|
||||
mock_app.invalidate = track_invalidate
|
||||
|
||||
# Patch print to track when it's called
|
||||
with patch("builtins.print") as mock_print:
|
||||
mock_print.side_effect = lambda *args, **kwargs: call_order.append("print")
|
||||
|
||||
# Simulate the background task output code path
|
||||
if cli_obj._app:
|
||||
cli_obj._app.invalidate()
|
||||
import time
|
||||
time.sleep(0.01) # reduced for test
|
||||
print()
|
||||
|
||||
# Verify invalidate was called before print
|
||||
assert call_order[0] == "invalidate"
|
||||
assert "print" in call_order
|
||||
|
||||
def test_invalidate_called_before_error_output(self):
|
||||
"""App.invalidate() is called before printing background error output."""
|
||||
cli_obj = _make_cli()
|
||||
mock_app = MagicMock()
|
||||
cli_obj._app = mock_app
|
||||
|
||||
call_order = []
|
||||
mock_app.invalidate.side_effect = lambda: call_order.append("invalidate")
|
||||
|
||||
with patch("builtins.print") as mock_print:
|
||||
mock_print.side_effect = lambda *args, **kwargs: call_order.append("print")
|
||||
|
||||
# Simulate error path
|
||||
if cli_obj._app:
|
||||
cli_obj._app.invalidate()
|
||||
import time
|
||||
time.sleep(0.01)
|
||||
print()
|
||||
|
||||
assert call_order[0] == "invalidate"
|
||||
assert "print" in call_order
|
||||
|
||||
def test_no_crash_when_app_is_none(self):
|
||||
"""No crash when _app is None (non-TUI mode)."""
|
||||
cli_obj = _make_cli()
|
||||
cli_obj._app = None
|
||||
|
||||
# This should not raise
|
||||
if cli_obj._app:
|
||||
cli_obj._app.invalidate()
|
||||
# If we get here without exception, test passes
|
||||
|
||||
def test_background_task_thread_safety(self):
|
||||
"""Background task tracking is thread-safe."""
|
||||
cli_obj = _make_cli()
|
||||
|
||||
# Simulate adding and removing background tasks
|
||||
task_id = "test_task_1"
|
||||
cli_obj._background_tasks[task_id] = MagicMock()
|
||||
assert task_id in cli_obj._background_tasks
|
||||
|
||||
# Clean up
|
||||
cli_obj._background_tasks.pop(task_id, None)
|
||||
assert task_id not in cli_obj._background_tasks
|
||||
@@ -0,0 +1,49 @@
|
||||
"""Tests for defensive bracketed-paste wrapper stripping in the CLI."""
|
||||
|
||||
from cli import _strip_leaked_bracketed_paste_wrappers
|
||||
|
||||
|
||||
class TestStripLeakedBracketedPasteWrappers:
|
||||
def test_plain_text_unchanged(self):
|
||||
text = "hello world"
|
||||
assert _strip_leaked_bracketed_paste_wrappers(text) == text
|
||||
|
||||
def test_strips_canonical_escape_wrappers(self):
|
||||
text = "\x1b[200~hello\x1b[201~"
|
||||
assert _strip_leaked_bracketed_paste_wrappers(text) == "hello"
|
||||
|
||||
def test_strips_visible_caret_escape_wrappers(self):
|
||||
text = "^[[200~hello^[[201~"
|
||||
assert _strip_leaked_bracketed_paste_wrappers(text) == "hello"
|
||||
|
||||
def test_strips_degraded_bracket_only_wrappers(self):
|
||||
text = "[200~hello[201~"
|
||||
assert _strip_leaked_bracketed_paste_wrappers(text) == "hello"
|
||||
|
||||
def test_strips_degraded_bracket_only_wrappers_after_whitespace(self):
|
||||
text = "prefix [200~hello[201~ suffix"
|
||||
assert _strip_leaked_bracketed_paste_wrappers(text) == "prefix hello suffix"
|
||||
|
||||
def test_strips_wrapper_fragments_at_boundaries(self):
|
||||
text = "00~hello world01~"
|
||||
assert _strip_leaked_bracketed_paste_wrappers(text) == "hello world"
|
||||
|
||||
def test_strips_wrapper_fragments_after_whitespace(self):
|
||||
text = "prefix 00~hello world01~ suffix"
|
||||
assert _strip_leaked_bracketed_paste_wrappers(text) == "prefix hello world suffix"
|
||||
|
||||
def test_does_not_strip_non_wrapper_00_tilde_in_normal_text(self):
|
||||
text = "build00~tag should stay"
|
||||
assert _strip_leaked_bracketed_paste_wrappers(text) == text
|
||||
|
||||
def test_does_not_strip_non_wrapper_bracket_forms_in_normal_text(self):
|
||||
text = "literal[200~tag and literal[201~tag should stay"
|
||||
assert _strip_leaked_bracketed_paste_wrappers(text) == text
|
||||
|
||||
def test_preserves_multiline_content_while_stripping_wrappers(self):
|
||||
text = "^[[200~line 1\nline 2\nline 3^[[201~"
|
||||
assert _strip_leaked_bracketed_paste_wrappers(text) == "line 1\nline 2\nline 3"
|
||||
|
||||
def test_preserves_multiline_content_while_stripping_degraded_bracket_only_wrappers(self):
|
||||
text = "[200~line 1\nline 2\nline 3[201~"
|
||||
assert _strip_leaked_bracketed_paste_wrappers(text) == "line 1\nline 2\nline 3"
|
||||
@@ -0,0 +1,357 @@
|
||||
"""Tests for CLI browser CDP auto-launch helpers."""
|
||||
|
||||
from contextlib import redirect_stdout
|
||||
from io import StringIO
|
||||
import os
|
||||
from queue import Queue
|
||||
import subprocess
|
||||
from unittest.mock import patch
|
||||
|
||||
from cli import HermesCLI
|
||||
from hermes_cli.browser_connect import (
|
||||
_wait_for_browser_debug_ready_or_exit,
|
||||
get_chrome_debug_candidates,
|
||||
is_browser_debug_ready,
|
||||
launch_chrome_debug,
|
||||
manual_chrome_debug_command,
|
||||
)
|
||||
|
||||
|
||||
def _assert_chrome_debug_cmd(cmd, expected_chrome, expected_port):
|
||||
"""Verify the auto-launch command has all required flags."""
|
||||
assert cmd[0] == expected_chrome
|
||||
assert f"--remote-debugging-port={expected_port}" in cmd
|
||||
assert "--no-first-run" in cmd
|
||||
assert "--no-default-browser-check" in cmd
|
||||
user_data_args = [a for a in cmd if a.startswith("--user-data-dir=")]
|
||||
assert len(user_data_args) == 1, "Expected exactly one --user-data-dir flag"
|
||||
assert "chrome-debug" in user_data_args[0]
|
||||
|
||||
|
||||
class _FakeResponse:
|
||||
status = 200
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc, tb):
|
||||
return False
|
||||
|
||||
|
||||
class TestChromeDebugLaunch:
|
||||
def test_browser_debug_ready_requires_http_cdp_endpoint(self):
|
||||
requested = []
|
||||
|
||||
def fake_urlopen(url, timeout):
|
||||
requested.append(url)
|
||||
if url.endswith("/json/version"):
|
||||
return _FakeResponse()
|
||||
raise OSError("unexpected probe")
|
||||
|
||||
with patch("urllib.request.urlopen", side_effect=fake_urlopen):
|
||||
assert is_browser_debug_ready("http://127.0.0.1:9222", timeout=0.1) is True
|
||||
|
||||
assert requested == ["http://127.0.0.1:9222/json/version"]
|
||||
|
||||
def test_browser_debug_ready_rejects_non_cdp_listener(self):
|
||||
with patch("urllib.request.urlopen", side_effect=OSError("not cdp")):
|
||||
assert is_browser_debug_ready("http://127.0.0.1:9222", timeout=0.1) is False
|
||||
|
||||
def test_windows_launch_uses_browser_found_on_path(self):
|
||||
captured = {}
|
||||
|
||||
def fake_popen(cmd, **kwargs):
|
||||
captured["cmd"] = cmd
|
||||
captured["kwargs"] = kwargs
|
||||
return object()
|
||||
|
||||
with patch("hermes_cli.browser_connect.shutil.which", side_effect=lambda name: r"C:\Chrome\chrome.exe" if name == "chrome.exe" else None), \
|
||||
patch("hermes_cli.browser_connect.os.path.isfile", side_effect=lambda path: path == r"C:\Chrome\chrome.exe"), \
|
||||
patch("hermes_cli.browser_connect._wait_for_browser_debug_ready_or_exit", return_value="ready"), \
|
||||
patch("subprocess.Popen", side_effect=fake_popen):
|
||||
assert HermesCLI._try_launch_chrome_debug(9333, "Windows") is True
|
||||
|
||||
_assert_chrome_debug_cmd(captured["cmd"], r"C:\Chrome\chrome.exe", 9333)
|
||||
# Windows uses creationflags (POSIX-only start_new_session would raise).
|
||||
assert "start_new_session" not in captured["kwargs"]
|
||||
flags = captured["kwargs"].get("creationflags", 0)
|
||||
expected = getattr(subprocess, "DETACHED_PROCESS", 0) | getattr(
|
||||
subprocess, "CREATE_NEW_PROCESS_GROUP", 0
|
||||
)
|
||||
assert flags == expected
|
||||
|
||||
def test_windows_launch_falls_back_to_common_install_dirs(self, monkeypatch):
|
||||
captured = {}
|
||||
program_files = r"C:\Program Files"
|
||||
# Use os.path.join so path separators match cross-platform
|
||||
installed = os.path.join(program_files, "Google", "Chrome", "Application", "chrome.exe")
|
||||
|
||||
def fake_popen(cmd, **kwargs):
|
||||
captured["cmd"] = cmd
|
||||
captured["kwargs"] = kwargs
|
||||
return object()
|
||||
|
||||
monkeypatch.setenv("ProgramFiles", program_files)
|
||||
monkeypatch.delenv("ProgramFiles(x86)", raising=False)
|
||||
monkeypatch.delenv("LOCALAPPDATA", raising=False)
|
||||
|
||||
with patch("hermes_cli.browser_connect.shutil.which", return_value=None), \
|
||||
patch("hermes_cli.browser_connect.os.path.isfile", side_effect=lambda path: path == installed), \
|
||||
patch("hermes_cli.browser_connect._wait_for_browser_debug_ready_or_exit", return_value="ready"), \
|
||||
patch("subprocess.Popen", side_effect=fake_popen):
|
||||
assert HermesCLI._try_launch_chrome_debug(9222, "Windows") is True
|
||||
|
||||
_assert_chrome_debug_cmd(captured["cmd"], installed, 9222)
|
||||
|
||||
def test_manual_command_uses_detected_linux_browser(self):
|
||||
with patch("hermes_cli.browser_connect.shutil.which", side_effect=lambda name: "/usr/bin/chromium" if name == "chromium" else None), \
|
||||
patch("hermes_cli.browser_connect.os.path.isfile", side_effect=lambda path: path == "/usr/bin/chromium"):
|
||||
command = manual_chrome_debug_command(9222, "Linux")
|
||||
|
||||
assert command is not None
|
||||
assert command.startswith("/usr/bin/chromium --remote-debugging-port=9222")
|
||||
|
||||
def test_linux_candidates_prefer_chrome_before_brave_when_both_exist(self):
|
||||
chrome = "/usr/bin/google-chrome"
|
||||
brave = "/usr/bin/brave-browser"
|
||||
|
||||
def fake_which(name):
|
||||
return {"google-chrome": chrome, "brave-browser": brave}.get(name)
|
||||
|
||||
with patch("hermes_cli.browser_connect.shutil.which", side_effect=fake_which), \
|
||||
patch("hermes_cli.browser_connect.os.path.isfile", side_effect=lambda path: path in {chrome, brave}):
|
||||
candidates = get_chrome_debug_candidates("Linux")
|
||||
command = manual_chrome_debug_command(9222, "Linux")
|
||||
|
||||
assert candidates[:2] == [chrome, brave]
|
||||
assert command is not None
|
||||
assert command.startswith(f"{chrome} --remote-debugging-port=9222")
|
||||
|
||||
def test_linux_candidates_prefer_chrome_install_path_before_brave_on_path(self):
|
||||
chrome = "/opt/google/chrome/chrome"
|
||||
brave = "/usr/bin/brave-browser"
|
||||
|
||||
with patch("hermes_cli.browser_connect.shutil.which", side_effect=lambda name: brave if name == "brave-browser" else None), \
|
||||
patch("hermes_cli.browser_connect.os.path.isfile", side_effect=lambda path: path in {chrome, brave}):
|
||||
candidates = get_chrome_debug_candidates("Linux")
|
||||
|
||||
assert candidates[:2] == [chrome, brave]
|
||||
|
||||
def test_windows_candidates_prefer_chrome_install_path_before_brave_on_path(self, monkeypatch):
|
||||
program_files = r"C:\Program Files"
|
||||
chrome = os.path.join(program_files, "Google", "Chrome", "Application", "chrome.exe")
|
||||
brave = r"C:\Brave\brave.exe"
|
||||
|
||||
monkeypatch.setenv("ProgramFiles", program_files)
|
||||
monkeypatch.delenv("ProgramFiles(x86)", raising=False)
|
||||
monkeypatch.delenv("LOCALAPPDATA", raising=False)
|
||||
|
||||
with patch("hermes_cli.browser_connect.shutil.which", side_effect=lambda name: brave if name == "brave.exe" else None), \
|
||||
patch("hermes_cli.browser_connect.os.path.isfile", side_effect=lambda path: path in {chrome, brave}):
|
||||
candidates = get_chrome_debug_candidates("Windows")
|
||||
|
||||
assert candidates[:2] == [chrome, brave]
|
||||
|
||||
def test_linux_candidates_include_arch_brave_install_path(self):
|
||||
brave = "/opt/brave-bin/brave"
|
||||
|
||||
with patch("hermes_cli.browser_connect.shutil.which", return_value=None), \
|
||||
patch("hermes_cli.browser_connect.os.path.isfile", side_effect=lambda path: path == brave):
|
||||
candidates = get_chrome_debug_candidates("Linux")
|
||||
command = manual_chrome_debug_command(9222, "Linux")
|
||||
|
||||
assert candidates == [brave]
|
||||
assert command is not None
|
||||
assert command.startswith(f"{brave} --remote-debugging-port=9222")
|
||||
|
||||
def test_linux_candidates_include_brave_binary_name(self):
|
||||
brave = "/usr/bin/brave"
|
||||
|
||||
with patch("hermes_cli.browser_connect.shutil.which", side_effect=lambda name: brave if name == "brave" else None), \
|
||||
patch("hermes_cli.browser_connect.os.path.isfile", side_effect=lambda path: path == brave):
|
||||
candidates = get_chrome_debug_candidates("Linux")
|
||||
command = manual_chrome_debug_command(9222, "Linux")
|
||||
|
||||
assert candidates == [brave]
|
||||
assert command is not None
|
||||
assert command.startswith(f"{brave} --remote-debugging-port=9222")
|
||||
|
||||
def test_linux_candidates_include_official_brave_and_edge_stable_paths(self):
|
||||
brave = "/usr/bin/brave-browser-stable"
|
||||
edge = "/usr/bin/microsoft-edge-stable"
|
||||
|
||||
with patch("hermes_cli.browser_connect.shutil.which", return_value=None), \
|
||||
patch("hermes_cli.browser_connect.os.path.isfile", side_effect=lambda path: path in {brave, edge}):
|
||||
candidates = get_chrome_debug_candidates("Linux")
|
||||
|
||||
assert candidates == [brave, edge]
|
||||
|
||||
def test_launch_tries_next_browser_when_first_candidate_fails(self):
|
||||
brave = "/usr/bin/brave-browser"
|
||||
chrome = "/usr/bin/google-chrome"
|
||||
attempts = []
|
||||
|
||||
def fake_popen(cmd, **kwargs):
|
||||
attempts.append(cmd[0])
|
||||
if cmd[0] == brave:
|
||||
raise OSError("broken brave install")
|
||||
return object()
|
||||
|
||||
with patch("hermes_cli.browser_connect.get_chrome_debug_candidates", return_value=[brave, chrome]), \
|
||||
patch("hermes_cli.browser_connect._wait_for_browser_debug_ready_or_exit", return_value="ready"), \
|
||||
patch("subprocess.Popen", side_effect=fake_popen):
|
||||
assert HermesCLI._try_launch_chrome_debug(9222, "Linux") is True
|
||||
|
||||
assert attempts == [brave, chrome]
|
||||
|
||||
def test_wait_for_browser_debug_ready_or_exit_detects_early_exit(self, monkeypatch):
|
||||
class _Proc:
|
||||
def __init__(self):
|
||||
self.calls = 0
|
||||
|
||||
def poll(self):
|
||||
self.calls += 1
|
||||
return 1 if self.calls >= 2 else None
|
||||
|
||||
monkeypatch.setattr("hermes_cli.browser_connect.time.sleep", lambda _seconds: None)
|
||||
with patch("hermes_cli.browser_connect.is_browser_debug_ready", return_value=False):
|
||||
state = _wait_for_browser_debug_ready_or_exit(_Proc(), 9222, timeout=0.3, interval=0.01)
|
||||
|
||||
assert state == "exited"
|
||||
|
||||
def test_launch_tries_next_browser_when_first_candidate_exits_before_debug_ready(self):
|
||||
brave = "/usr/bin/brave-browser"
|
||||
chrome = "/usr/bin/google-chrome"
|
||||
attempts = []
|
||||
|
||||
class _Proc:
|
||||
pass
|
||||
|
||||
def fake_popen(cmd, **kwargs):
|
||||
attempts.append(cmd[0])
|
||||
return _Proc()
|
||||
|
||||
with patch("hermes_cli.browser_connect.get_chrome_debug_candidates", return_value=[brave, chrome]), \
|
||||
patch("hermes_cli.browser_connect._wait_for_browser_debug_ready_or_exit", side_effect=["exited", "ready"]), \
|
||||
patch("subprocess.Popen", side_effect=fake_popen):
|
||||
assert HermesCLI._try_launch_chrome_debug(9222, "Linux") is True
|
||||
|
||||
assert attempts == [brave, chrome]
|
||||
|
||||
def test_launch_result_hints_singleton_forward_on_clean_exit(self, tmp_path, monkeypatch):
|
||||
"""A candidate that exits code 0 without opening the port = an existing
|
||||
instance absorbed the launch (Chromium single-instance behavior)."""
|
||||
chrome = r"C:\Program Files\Google\Chrome\Application\chrome.exe"
|
||||
|
||||
class _Proc:
|
||||
pid = 1234
|
||||
returncode = 0
|
||||
|
||||
def poll(self):
|
||||
return 0
|
||||
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.browser_connect.chrome_debug_data_dir", lambda: str(tmp_path)
|
||||
)
|
||||
with patch("hermes_cli.browser_connect.get_chrome_debug_candidates", return_value=[chrome]), \
|
||||
patch("hermes_cli.browser_connect.is_browser_debug_ready", return_value=False), \
|
||||
patch("subprocess.Popen", return_value=_Proc()):
|
||||
result = launch_chrome_debug(9222, "Windows")
|
||||
|
||||
assert result.launched is False
|
||||
assert result.attempts[0].state == "exited"
|
||||
assert result.attempts[0].returncode == 0
|
||||
assert result.hint is not None
|
||||
assert "already-running" in result.hint
|
||||
assert "chrome.exe" in result.hint
|
||||
|
||||
def test_launch_result_surfaces_stderr_tail_on_crash(self, tmp_path, monkeypatch):
|
||||
chrome = "/usr/bin/google-chrome"
|
||||
|
||||
class _Proc:
|
||||
pid = 4321
|
||||
returncode = 127
|
||||
|
||||
def __init__(self, stderr_path):
|
||||
# Simulate the browser writing to the redirected stderr file.
|
||||
with open(stderr_path, "w", encoding="utf-8") as fh:
|
||||
fh.write("error while loading shared libraries: libnspr4.so\n")
|
||||
|
||||
def poll(self):
|
||||
return 127
|
||||
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.browser_connect.chrome_debug_data_dir", lambda: str(tmp_path)
|
||||
)
|
||||
stderr_path = tmp_path / "launch-stderr.log"
|
||||
with patch("hermes_cli.browser_connect.get_chrome_debug_candidates", return_value=[chrome]), \
|
||||
patch("hermes_cli.browser_connect.is_browser_debug_ready", return_value=False), \
|
||||
patch("subprocess.Popen", side_effect=lambda *a, **k: _Proc(stderr_path)):
|
||||
result = launch_chrome_debug(9222, "Linux")
|
||||
|
||||
assert result.launched is False
|
||||
assert result.attempts[0].returncode == 127
|
||||
assert "libnspr4.so" in result.attempts[0].stderr_tail
|
||||
assert result.hint is not None
|
||||
assert "libnspr4.so" in result.hint
|
||||
|
||||
def test_launch_result_no_hint_when_no_candidates(self):
|
||||
with patch("hermes_cli.browser_connect.get_chrome_debug_candidates", return_value=[]):
|
||||
result = launch_chrome_debug(9222, "Linux")
|
||||
|
||||
assert result.launched is False
|
||||
assert result.attempts == []
|
||||
assert result.hint is None
|
||||
|
||||
def test_manual_command_uses_wsl_windows_chrome_when_available(self):
|
||||
chrome = "/mnt/c/Program Files/Google/Chrome/Application/chrome.exe"
|
||||
|
||||
with patch("hermes_cli.browser_connect.shutil.which", return_value=None), \
|
||||
patch("hermes_cli.browser_connect.os.path.isfile", side_effect=lambda path: path == chrome):
|
||||
command = manual_chrome_debug_command(9222, "Linux")
|
||||
|
||||
assert command is not None
|
||||
# Linux/WSL uses POSIX shell quoting (single quotes around paths with spaces).
|
||||
assert command.startswith(f"'{chrome}' --remote-debugging-port=9222")
|
||||
|
||||
def test_manual_command_uses_windows_quoting_on_windows(self):
|
||||
chrome = r"C:\Program Files\Google\Chrome\Application\chrome.exe"
|
||||
|
||||
with patch("hermes_cli.browser_connect.shutil.which", side_effect=lambda name: chrome if name == "chrome.exe" else None), \
|
||||
patch("hermes_cli.browser_connect.os.path.isfile", side_effect=lambda path: path == chrome):
|
||||
command = manual_chrome_debug_command(9222, "Windows")
|
||||
|
||||
assert command is not None
|
||||
# Windows uses cmd.exe-compatible quoting via subprocess.list2cmdline.
|
||||
assert command.startswith(f'"{chrome}" --remote-debugging-port=9222')
|
||||
assert "'" not in command
|
||||
|
||||
def test_manual_command_returns_none_when_linux_browser_missing(self):
|
||||
with patch("hermes_cli.browser_connect.shutil.which", return_value=None), \
|
||||
patch("hermes_cli.browser_connect.os.path.isfile", return_value=False):
|
||||
assert manual_chrome_debug_command(9222, "Linux") is None
|
||||
|
||||
def test_connect_context_note_allows_expected_browser_use(self, monkeypatch):
|
||||
"""`/browser connect` is an instruction to use the CDP browser.
|
||||
|
||||
The queued context note must not tell the model to wait for a second
|
||||
permission step or imply that the attached browser is the user's main
|
||||
everyday Chrome profile.
|
||||
"""
|
||||
cli = HermesCLI.__new__(HermesCLI)
|
||||
cli._pending_input = Queue()
|
||||
monkeypatch.delenv("BROWSER_CDP_URL", raising=False)
|
||||
|
||||
with patch("hermes_cli.cli_commands_mixin.is_browser_debug_ready", return_value=True), \
|
||||
patch("tools.browser_tool.cleanup_all_browsers"), \
|
||||
patch("tools.browser_tool._ensure_cdp_supervisor"), \
|
||||
redirect_stdout(StringIO()):
|
||||
cli._handle_browser_command("/browser connect")
|
||||
|
||||
note = cli._pending_input.get_nowait()
|
||||
assert "Chromium-family" in note
|
||||
assert "dev/debug" in note
|
||||
assert "using browser tools for their current browser-related request is expected" in note
|
||||
assert "live Chrome browser" not in note
|
||||
assert "real browser" not in note
|
||||
assert "Please await their instruction" not in note
|
||||
@@ -0,0 +1,165 @@
|
||||
"""Tests for the low context length warning in the CLI banner."""
|
||||
|
||||
import os
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from agent.model_metadata import MINIMUM_CONTEXT_LENGTH
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def _isolate(tmp_path, monkeypatch):
|
||||
"""Isolate HERMES_HOME so tests don't touch real config."""
|
||||
home = tmp_path / ".hermes"
|
||||
home.mkdir()
|
||||
monkeypatch.setenv("HERMES_HOME", str(home))
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def cli_obj(_isolate):
|
||||
"""Create a minimal HermesCLI instance for banner testing."""
|
||||
with patch("cli.load_cli_config", return_value={
|
||||
"display": {"tool_progress": "new"},
|
||||
"terminal": {},
|
||||
}), patch("cli.get_tool_definitions", return_value=[]), \
|
||||
patch("cli.build_welcome_banner"):
|
||||
from cli import HermesCLI
|
||||
obj = HermesCLI.__new__(HermesCLI)
|
||||
obj.model = "test-model"
|
||||
obj.enabled_toolsets = ["hermes-core"]
|
||||
obj.compact = False
|
||||
obj.console = MagicMock()
|
||||
obj.session_id = None
|
||||
obj.api_key = "test"
|
||||
obj.base_url = ""
|
||||
obj.provider = "test"
|
||||
obj._provider_source = None
|
||||
# Mock agent with context compressor
|
||||
obj.agent = SimpleNamespace(
|
||||
context_compressor=SimpleNamespace(context_length=None)
|
||||
)
|
||||
return obj
|
||||
|
||||
|
||||
class TestLowContextWarning:
|
||||
"""Tests that the CLI warns about low context lengths."""
|
||||
|
||||
def test_warning_for_below_minimum_context(self, cli_obj):
|
||||
"""Warning shown when context is below Hermes' minimum."""
|
||||
cli_obj.agent.context_compressor.context_length = 32768
|
||||
with patch("cli.get_tool_definitions", return_value=[]), \
|
||||
patch("cli.build_welcome_banner"):
|
||||
cli_obj.show_banner()
|
||||
|
||||
calls = [str(c) for c in cli_obj.console.print.call_args_list]
|
||||
warning_calls = [c for c in calls if "too low" in c]
|
||||
assert len(warning_calls) == 1
|
||||
minimum_calls = [c for c in calls if f"{MINIMUM_CONTEXT_LENGTH:,}" in c]
|
||||
assert minimum_calls
|
||||
|
||||
def test_warning_for_low_context(self, cli_obj):
|
||||
"""Warning shown when context is 4096 (Ollama default)."""
|
||||
cli_obj.agent.context_compressor.context_length = 4096
|
||||
with patch("cli.get_tool_definitions", return_value=[]), \
|
||||
patch("cli.build_welcome_banner"):
|
||||
cli_obj.show_banner()
|
||||
|
||||
calls = [str(c) for c in cli_obj.console.print.call_args_list]
|
||||
warning_calls = [c for c in calls if "too low" in c]
|
||||
assert len(warning_calls) == 1
|
||||
assert "4,096" in warning_calls[0]
|
||||
|
||||
def test_warning_for_2048_context(self, cli_obj):
|
||||
"""Warning shown for 2048 tokens (common LM Studio default)."""
|
||||
cli_obj.agent.context_compressor.context_length = 2048
|
||||
with patch("cli.get_tool_definitions", return_value=[]), \
|
||||
patch("cli.build_welcome_banner"):
|
||||
cli_obj.show_banner()
|
||||
|
||||
calls = [str(c) for c in cli_obj.console.print.call_args_list]
|
||||
warning_calls = [c for c in calls if "too low" in c]
|
||||
assert len(warning_calls) == 1
|
||||
|
||||
def test_no_warning_at_boundary(self, cli_obj):
|
||||
"""No warning at exactly Hermes' minimum context length."""
|
||||
cli_obj.agent.context_compressor.context_length = MINIMUM_CONTEXT_LENGTH
|
||||
with patch("cli.get_tool_definitions", return_value=[]), \
|
||||
patch("cli.build_welcome_banner"):
|
||||
cli_obj.show_banner()
|
||||
|
||||
calls = [str(c) for c in cli_obj.console.print.call_args_list]
|
||||
warning_calls = [c for c in calls if "too low" in c]
|
||||
assert len(warning_calls) == 0
|
||||
|
||||
def test_no_warning_above_boundary(self, cli_obj):
|
||||
"""No warning above Hermes' minimum context length."""
|
||||
cli_obj.agent.context_compressor.context_length = MINIMUM_CONTEXT_LENGTH + 1
|
||||
with patch("cli.get_tool_definitions", return_value=[]), \
|
||||
patch("cli.build_welcome_banner"):
|
||||
cli_obj.show_banner()
|
||||
|
||||
calls = [str(c) for c in cli_obj.console.print.call_args_list]
|
||||
warning_calls = [c for c in calls if "too low" in c]
|
||||
assert len(warning_calls) == 0
|
||||
|
||||
def test_ollama_specific_hint(self, cli_obj):
|
||||
"""Ollama-specific fix shown when port 11434 detected."""
|
||||
cli_obj.agent.context_compressor.context_length = 4096
|
||||
cli_obj.base_url = "http://localhost:11434/v1"
|
||||
with patch("cli.get_tool_definitions", return_value=[]), \
|
||||
patch("cli.build_welcome_banner"):
|
||||
cli_obj.show_banner()
|
||||
|
||||
calls = [str(c) for c in cli_obj.console.print.call_args_list]
|
||||
ollama_hints = [c for c in calls if "OLLAMA_CONTEXT_LENGTH" in c]
|
||||
assert len(ollama_hints) == 1
|
||||
assert str(MINIMUM_CONTEXT_LENGTH) in ollama_hints[0]
|
||||
|
||||
def test_lm_studio_specific_hint(self, cli_obj):
|
||||
"""LM Studio-specific fix shown when port 1234 detected."""
|
||||
cli_obj.agent.context_compressor.context_length = 2048
|
||||
cli_obj.base_url = "http://localhost:1234/v1"
|
||||
with patch("cli.get_tool_definitions", return_value=[]), \
|
||||
patch("cli.build_welcome_banner"):
|
||||
cli_obj.show_banner()
|
||||
|
||||
calls = [str(c) for c in cli_obj.console.print.call_args_list]
|
||||
lms_hints = [c for c in calls if "LM Studio" in c]
|
||||
assert len(lms_hints) == 1
|
||||
|
||||
def test_generic_hint_for_other_servers(self, cli_obj):
|
||||
"""Generic fix shown for unknown servers."""
|
||||
cli_obj.agent.context_compressor.context_length = 4096
|
||||
cli_obj.base_url = "http://localhost:8080/v1"
|
||||
with patch("cli.get_tool_definitions", return_value=[]), \
|
||||
patch("cli.build_welcome_banner"):
|
||||
cli_obj.show_banner()
|
||||
|
||||
calls = [str(c) for c in cli_obj.console.print.call_args_list]
|
||||
generic_hints = [c for c in calls if "config.yaml" in c]
|
||||
assert len(generic_hints) == 1
|
||||
|
||||
def test_no_warning_when_no_context_length(self, cli_obj):
|
||||
"""No warning when context length is not yet known."""
|
||||
cli_obj.agent.context_compressor.context_length = None
|
||||
with patch("cli.get_tool_definitions", return_value=[]), \
|
||||
patch("cli.build_welcome_banner"):
|
||||
cli_obj.show_banner()
|
||||
|
||||
calls = [str(c) for c in cli_obj.console.print.call_args_list]
|
||||
warning_calls = [c for c in calls if "too low" in c]
|
||||
assert len(warning_calls) == 0
|
||||
|
||||
def test_compact_banner_does_not_crash_on_narrow_terminal(self, cli_obj):
|
||||
"""Compact mode should still have ctx_len defined for warning logic."""
|
||||
cli_obj.agent.context_compressor.context_length = 4096
|
||||
|
||||
with patch("shutil.get_terminal_size", return_value=os.terminal_size((70, 40))), \
|
||||
patch("cli._build_compact_banner", return_value="compact banner"):
|
||||
cli_obj.show_banner()
|
||||
|
||||
calls = [str(c) for c in cli_obj.console.print.call_args_list]
|
||||
warning_calls = [c for c in calls if "too low" in c]
|
||||
assert len(warning_calls) == 1
|
||||
@@ -0,0 +1,71 @@
|
||||
"""Tests for CLI /copy command."""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from cli import HermesCLI
|
||||
|
||||
|
||||
def _make_cli() -> HermesCLI:
|
||||
cli_obj = HermesCLI.__new__(HermesCLI)
|
||||
cli_obj.config = {}
|
||||
cli_obj.console = MagicMock()
|
||||
cli_obj.agent = None
|
||||
cli_obj.conversation_history = []
|
||||
cli_obj.session_id = "sess-copy-test"
|
||||
cli_obj._pending_input = MagicMock()
|
||||
cli_obj._app = None
|
||||
return cli_obj
|
||||
|
||||
|
||||
def test_copy_copies_latest_assistant_message():
|
||||
cli_obj = _make_cli()
|
||||
cli_obj.conversation_history = [
|
||||
{"role": "user", "content": "hi"},
|
||||
{"role": "assistant", "content": "first"},
|
||||
{"role": "assistant", "content": "latest"},
|
||||
]
|
||||
|
||||
with patch.object(cli_obj, "_write_osc52_clipboard") as mock_copy:
|
||||
result = cli_obj.process_command("/copy")
|
||||
|
||||
assert result is True
|
||||
mock_copy.assert_called_once_with("latest")
|
||||
|
||||
|
||||
def test_copy_with_index_uses_requested_assistant_message():
|
||||
cli_obj = _make_cli()
|
||||
cli_obj.conversation_history = [
|
||||
{"role": "assistant", "content": "one"},
|
||||
{"role": "assistant", "content": "two"},
|
||||
]
|
||||
|
||||
with patch.object(cli_obj, "_write_osc52_clipboard") as mock_copy:
|
||||
cli_obj.process_command("/copy 1")
|
||||
|
||||
mock_copy.assert_called_once_with("one")
|
||||
|
||||
|
||||
def test_copy_strips_reasoning_blocks_before_copy():
|
||||
cli_obj = _make_cli()
|
||||
cli_obj.conversation_history = [
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "<REASONING_SCRATCHPAD>internal</REASONING_SCRATCHPAD>\nVisible answer",
|
||||
}
|
||||
]
|
||||
|
||||
with patch.object(cli_obj, "_write_osc52_clipboard") as mock_copy:
|
||||
cli_obj.process_command("/copy")
|
||||
|
||||
mock_copy.assert_called_once_with("Visible answer")
|
||||
|
||||
|
||||
def test_copy_invalid_index_does_not_copy():
|
||||
cli_obj = _make_cli()
|
||||
cli_obj.conversation_history = [{"role": "assistant", "content": "only"}]
|
||||
|
||||
with patch.object(cli_obj, "_write_osc52_clipboard") as mock_copy, patch("cli._cprint") as mock_print:
|
||||
cli_obj.process_command("/copy 99")
|
||||
|
||||
mock_copy.assert_not_called()
|
||||
assert any("Invalid response number" in str(call) for call in mock_print.call_args_list)
|
||||
@@ -0,0 +1,69 @@
|
||||
"""The CLI spells out auto-resume when a delegate_task goes to the background.
|
||||
|
||||
A top-level ``delegate_task`` returns a handle immediately and runs the subagent
|
||||
in the background; the result re-enters the conversation as a fresh turn when it
|
||||
finishes. ``_on_tool_complete`` prints a one-line, no-spinner reassurance at
|
||||
dispatch so the idle prompt doesn't read as "nothing happened".
|
||||
"""
|
||||
|
||||
import json
|
||||
|
||||
import cli
|
||||
from cli import HermesCLI
|
||||
|
||||
|
||||
def _make_cli():
|
||||
cli_obj = HermesCLI.__new__(HermesCLI)
|
||||
cli_obj._pending_edit_snapshots = {}
|
||||
return cli_obj
|
||||
|
||||
|
||||
def _capture(monkeypatch):
|
||||
printed: list[str] = []
|
||||
monkeypatch.setattr(cli, "_cprint", lambda text: printed.append(text))
|
||||
return printed
|
||||
|
||||
|
||||
def test_background_dispatch_prints_resume_notice(monkeypatch):
|
||||
cli_obj = _make_cli()
|
||||
printed = _capture(monkeypatch)
|
||||
|
||||
result = json.dumps({"status": "dispatched", "mode": "background", "count": 1})
|
||||
cli_obj._on_tool_complete("tc1", "delegate_task", {"goal": "x"}, result)
|
||||
|
||||
joined = "\n".join(printed)
|
||||
assert "resume" in joined.lower()
|
||||
assert "it finishes" in joined
|
||||
|
||||
|
||||
def test_background_batch_dispatch_pluralizes(monkeypatch):
|
||||
cli_obj = _make_cli()
|
||||
printed = _capture(monkeypatch)
|
||||
|
||||
result = json.dumps({"status": "dispatched", "mode": "background", "count": 3})
|
||||
cli_obj._on_tool_complete("tc2", "delegate_task", {"tasks": []}, result)
|
||||
|
||||
joined = "\n".join(printed)
|
||||
assert "3 tasks" in joined
|
||||
assert "they finish" in joined
|
||||
|
||||
|
||||
def test_synchronous_delegate_result_prints_no_notice(monkeypatch):
|
||||
"""A non-background result (e.g. the stateless sync fallback) must not claim
|
||||
a background dispatch."""
|
||||
cli_obj = _make_cli()
|
||||
printed = _capture(monkeypatch)
|
||||
|
||||
result = json.dumps({"results": [{"status": "completed", "summary": "done"}]})
|
||||
cli_obj._on_tool_complete("tc3", "delegate_task", {"goal": "x"}, result)
|
||||
|
||||
assert not any("resume" in p.lower() for p in printed)
|
||||
|
||||
|
||||
def test_non_delegate_tool_prints_no_notice(monkeypatch):
|
||||
cli_obj = _make_cli()
|
||||
printed = _capture(monkeypatch)
|
||||
|
||||
cli_obj._on_tool_complete("tc4", "read_file", {"path": "a"}, '{"ok": true}')
|
||||
|
||||
assert not any("resume" in p.lower() for p in printed)
|
||||
@@ -0,0 +1,138 @@
|
||||
"""Tests for protected HermesCLI TUI extension hooks.
|
||||
|
||||
Verifies that wrapper CLIs can extend the TUI via:
|
||||
- _get_extra_tui_widgets()
|
||||
- _register_extra_tui_keybindings()
|
||||
- _build_tui_layout_children()
|
||||
without overriding run().
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import sys
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from prompt_toolkit.key_binding import KeyBindings
|
||||
|
||||
|
||||
def _make_cli(**kwargs):
|
||||
"""Create a HermesCLI with prompt_toolkit stubs (same pattern as test_cli_init)."""
|
||||
_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(**kwargs)
|
||||
|
||||
|
||||
class TestExtensionHookDefaults:
|
||||
def test_extra_tui_widgets_default_empty(self):
|
||||
cli = _make_cli()
|
||||
assert cli._get_extra_tui_widgets() == []
|
||||
|
||||
def test_register_extra_tui_keybindings_default_noop(self):
|
||||
cli = _make_cli()
|
||||
kb = KeyBindings()
|
||||
result = cli._register_extra_tui_keybindings(kb, input_area=None)
|
||||
assert result is None
|
||||
assert kb.bindings == []
|
||||
|
||||
def test_build_tui_layout_children_returns_all_widgets_in_order(self):
|
||||
cli = _make_cli()
|
||||
children = cli._build_tui_layout_children(
|
||||
sudo_widget="sudo",
|
||||
secret_widget="secret",
|
||||
approval_widget="approval",
|
||||
clarify_widget="clarify",
|
||||
spinner_widget="spinner",
|
||||
spacer="spacer",
|
||||
status_bar="status",
|
||||
input_rule_top="top-rule",
|
||||
image_bar="image-bar",
|
||||
input_area="input-area",
|
||||
input_rule_bot="bottom-rule",
|
||||
voice_status_bar="voice-status",
|
||||
completions_menu="completions-menu",
|
||||
)
|
||||
# First element is Window(height=0), rest are the named widgets
|
||||
assert children[1:] == [
|
||||
"sudo", "secret", "approval", "clarify", "spinner",
|
||||
"spacer", "status", "top-rule", "image-bar", "input-area",
|
||||
"bottom-rule", "voice-status", "completions-menu",
|
||||
]
|
||||
|
||||
|
||||
class TestExtensionHookSubclass:
|
||||
def test_extra_widgets_inserted_before_status_bar(self):
|
||||
cli = _make_cli()
|
||||
# Monkey-patch to simulate subclass override
|
||||
cli._get_extra_tui_widgets = lambda: ["radio-menu", "mini-player"]
|
||||
|
||||
children = cli._build_tui_layout_children(
|
||||
sudo_widget="sudo",
|
||||
secret_widget="secret",
|
||||
approval_widget="approval",
|
||||
clarify_widget="clarify",
|
||||
spinner_widget="spinner",
|
||||
spacer="spacer",
|
||||
status_bar="status",
|
||||
input_rule_top="top-rule",
|
||||
image_bar="image-bar",
|
||||
input_area="input-area",
|
||||
input_rule_bot="bottom-rule",
|
||||
voice_status_bar="voice-status",
|
||||
completions_menu="completions-menu",
|
||||
)
|
||||
# Extra widgets should appear between spacer and status bar
|
||||
spacer_idx = children.index("spacer")
|
||||
status_idx = children.index("status")
|
||||
assert children[spacer_idx + 1] == "radio-menu"
|
||||
assert children[spacer_idx + 2] == "mini-player"
|
||||
assert children[spacer_idx + 3] == "status"
|
||||
assert status_idx == spacer_idx + 3
|
||||
|
||||
def test_extra_keybindings_can_add_bindings(self):
|
||||
cli = _make_cli()
|
||||
kb = KeyBindings()
|
||||
|
||||
def _custom_hook(kb, *, input_area):
|
||||
@kb.add("f2")
|
||||
def _toggle(event):
|
||||
return None
|
||||
|
||||
cli._register_extra_tui_keybindings = _custom_hook
|
||||
cli._register_extra_tui_keybindings(kb, input_area=None)
|
||||
assert len(kb.bindings) == 1
|
||||
@@ -0,0 +1,105 @@
|
||||
"""Tests for CLI external-editor support."""
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
from cli import HermesCLI
|
||||
|
||||
|
||||
class _FakeBuffer:
|
||||
def __init__(self, text=""):
|
||||
self.calls = []
|
||||
self.text = text
|
||||
self.cursor_position = len(text)
|
||||
|
||||
def open_in_editor(self, validate_and_handle=False):
|
||||
self.calls.append(validate_and_handle)
|
||||
|
||||
|
||||
class _FakeApp:
|
||||
def __init__(self):
|
||||
self.current_buffer = _FakeBuffer()
|
||||
|
||||
|
||||
def _make_cli(with_app=True):
|
||||
cli_obj = HermesCLI.__new__(HermesCLI)
|
||||
cli_obj._app = _FakeApp() if with_app else None
|
||||
cli_obj._command_running = False
|
||||
cli_obj._command_status = ""
|
||||
cli_obj._command_display = ""
|
||||
cli_obj._sudo_state = None
|
||||
cli_obj._secret_state = None
|
||||
cli_obj._approval_state = None
|
||||
cli_obj._clarify_state = None
|
||||
cli_obj._skip_paste_collapse = False
|
||||
return cli_obj
|
||||
|
||||
def test_open_external_editor_uses_prompt_toolkit_buffer_editor():
|
||||
cli_obj = _make_cli()
|
||||
|
||||
assert cli_obj._open_external_editor() is True
|
||||
assert cli_obj._app.current_buffer.calls == [False]
|
||||
|
||||
|
||||
def test_open_external_editor_rejects_when_no_tui():
|
||||
cli_obj = _make_cli(with_app=False)
|
||||
|
||||
with patch("cli._cprint") as mock_cprint:
|
||||
assert cli_obj._open_external_editor() is False
|
||||
|
||||
assert mock_cprint.called
|
||||
assert "interactive cli" in str(mock_cprint.call_args).lower()
|
||||
|
||||
|
||||
def test_open_external_editor_rejects_modal_prompts():
|
||||
cli_obj = _make_cli()
|
||||
cli_obj._approval_state = {"selected": 0}
|
||||
|
||||
with patch("cli._cprint") as mock_cprint:
|
||||
assert cli_obj._open_external_editor() is False
|
||||
|
||||
assert mock_cprint.called
|
||||
assert "active prompt" in str(mock_cprint.call_args).lower()
|
||||
|
||||
def test_open_external_editor_uses_explicit_buffer_when_provided():
|
||||
cli_obj = _make_cli()
|
||||
external_buffer = _FakeBuffer()
|
||||
|
||||
assert cli_obj._open_external_editor(buffer=external_buffer) is True
|
||||
assert external_buffer.calls == [False]
|
||||
assert cli_obj._app.current_buffer.calls == []
|
||||
|
||||
|
||||
def test_expand_paste_references_replaces_placeholder_with_file_contents(tmp_path):
|
||||
cli_obj = _make_cli()
|
||||
paste_file = tmp_path / "paste.txt"
|
||||
paste_file.write_text("line one\nline two", encoding="utf-8")
|
||||
|
||||
text = f"before [Pasted text #1: 2 lines → {paste_file}] after"
|
||||
expanded = cli_obj._expand_paste_references(text)
|
||||
|
||||
assert expanded == "before line one\nline two after"
|
||||
|
||||
|
||||
def test_open_external_editor_expands_paste_placeholders_before_open(tmp_path):
|
||||
cli_obj = _make_cli()
|
||||
paste_file = tmp_path / "paste.txt"
|
||||
paste_file.write_text("alpha\nbeta", encoding="utf-8")
|
||||
buffer = _FakeBuffer(text=f"[Pasted text #1: 2 lines → {paste_file}]")
|
||||
|
||||
assert cli_obj._open_external_editor(buffer=buffer) is True
|
||||
assert buffer.text == "alpha\nbeta"
|
||||
assert buffer.cursor_position == len("alpha\nbeta")
|
||||
assert buffer.calls == [False]
|
||||
|
||||
|
||||
def test_open_external_editor_sets_skip_collapse_flag_during_expansion(tmp_path):
|
||||
cli_obj = _make_cli()
|
||||
paste_file = tmp_path / "paste.txt"
|
||||
paste_file.write_text("a\nb\nc\nd\ne\nf", encoding="utf-8")
|
||||
buffer = _FakeBuffer(text=f"[Pasted text #1: 6 lines \u2192 {paste_file}]")
|
||||
|
||||
# After expansion the flag should have been set (to prevent re-collapse)
|
||||
assert cli_obj._open_external_editor(buffer=buffer) is True
|
||||
# Flag is consumed by _on_text_changed, but since no handler is attached
|
||||
# in tests it stays True until the handler resets it.
|
||||
assert cli_obj._skip_paste_collapse is True
|
||||
@@ -0,0 +1,249 @@
|
||||
"""Tests for _detect_file_drop — file path detection that prevents
|
||||
dragged/pasted absolute paths from being mistaken for slash commands."""
|
||||
|
||||
|
||||
import pytest
|
||||
|
||||
from cli import _detect_file_drop
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.fixture()
|
||||
def tmp_image(tmp_path):
|
||||
"""Create a temporary .png file and return its path."""
|
||||
img = tmp_path / "screenshot.png"
|
||||
img.write_bytes(b"\x89PNG\r\n\x1a\n") # minimal PNG header
|
||||
return img
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def tmp_text(tmp_path):
|
||||
"""Create a temporary .py file and return its path."""
|
||||
f = tmp_path / "main.py"
|
||||
f.write_text("print('hello')\n")
|
||||
return f
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def tmp_image_with_spaces(tmp_path):
|
||||
"""Create a file whose name contains spaces (like macOS screenshots)."""
|
||||
img = tmp_path / "Screenshot 2026-04-01 at 7.25.32 PM.png"
|
||||
img.write_bytes(b"\x89PNG\r\n\x1a\n")
|
||||
return img
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests: returns None for non-file inputs
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestNonFileInputs:
|
||||
def test_regular_slash_command(self):
|
||||
assert _detect_file_drop("/help") is None
|
||||
|
||||
def test_unknown_slash_command(self):
|
||||
assert _detect_file_drop("/xyz") is None
|
||||
|
||||
def test_slash_command_with_args(self):
|
||||
assert _detect_file_drop("/config set key value") is None
|
||||
|
||||
def test_empty_string(self):
|
||||
assert _detect_file_drop("") is None
|
||||
|
||||
def test_non_slash_input(self):
|
||||
assert _detect_file_drop("hello world") is None
|
||||
|
||||
def test_non_string_input(self):
|
||||
assert _detect_file_drop(42) is None
|
||||
|
||||
def test_nonexistent_path(self):
|
||||
assert _detect_file_drop("/nonexistent/path/to/file.png") is None
|
||||
|
||||
def test_directory_not_file(self, tmp_path):
|
||||
"""A directory path should not be treated as a file drop."""
|
||||
assert _detect_file_drop(str(tmp_path)) is None
|
||||
|
||||
def test_long_slash_command_does_not_raise(self):
|
||||
"""Regression: long pasted slash commands like `/goal <long prose>`
|
||||
used to raise OSError(ENAMETOOLONG, errno 63 macOS / 36 Linux)
|
||||
from `Path.exists()` inside `_resolve_attachment_path`, which
|
||||
propagated up to `process_loop`'s catch-all and silently lost
|
||||
the user's input. The fix wraps the stat call in a try/except
|
||||
OSError and returns None, letting the slash-command dispatch
|
||||
path handle the input downstream.
|
||||
|
||||
Reproducer: paste a `/goal` followed by ~430 chars of prose.
|
||||
Without the fix this triggers ENAMETOOLONG; with the fix it
|
||||
cleanly returns None (file-drop = no), so `_looks_like_slash_command`
|
||||
gets a chance to dispatch it.
|
||||
"""
|
||||
# 430-char `/goal` payload — well above NAME_MAX (255 bytes) on
|
||||
# all common filesystems.
|
||||
long_goal = (
|
||||
"/goal " + ("Drive the board: triage triage-status items, "
|
||||
"unblock spillover tasks where work is shipped, "
|
||||
"advance P1 items by decomposing where needed. ") * 4
|
||||
)
|
||||
assert len(long_goal) > 255 # confirms it would have triggered ENAMETOOLONG
|
||||
assert _detect_file_drop(long_goal) is None
|
||||
|
||||
def test_path_longer_than_namemax_does_not_raise(self):
|
||||
"""Defensive: a single token longer than NAME_MAX should return
|
||||
None, not raise. Could happen with absurdly long synthetic inputs
|
||||
from prompt-injection attempts or fuzzers."""
|
||||
very_long_path = "/" + ("a" * 300)
|
||||
assert _detect_file_drop(very_long_path) is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests: image file detection
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestImageFileDrop:
|
||||
def test_simple_image_path(self, tmp_image):
|
||||
result = _detect_file_drop(str(tmp_image))
|
||||
assert result is not None
|
||||
assert result["path"] == tmp_image
|
||||
assert result["is_image"] is True
|
||||
assert result["remainder"] == ""
|
||||
|
||||
def test_image_with_trailing_text(self, tmp_image):
|
||||
user_input = f"{tmp_image} analyze this please"
|
||||
result = _detect_file_drop(user_input)
|
||||
assert result is not None
|
||||
assert result["path"] == tmp_image
|
||||
assert result["is_image"] is True
|
||||
assert result["remainder"] == "analyze this please"
|
||||
|
||||
@pytest.mark.parametrize("ext", [".png", ".jpg", ".jpeg", ".gif", ".webp",
|
||||
".bmp", ".tiff", ".tif", ".svg", ".ico"])
|
||||
def test_all_image_extensions(self, tmp_path, ext):
|
||||
img = tmp_path / f"test{ext}"
|
||||
img.write_bytes(b"fake")
|
||||
result = _detect_file_drop(str(img))
|
||||
assert result is not None
|
||||
assert result["is_image"] is True
|
||||
|
||||
def test_uppercase_extension(self, tmp_path):
|
||||
img = tmp_path / "photo.JPG"
|
||||
img.write_bytes(b"fake")
|
||||
result = _detect_file_drop(str(img))
|
||||
assert result is not None
|
||||
assert result["is_image"] is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests: non-image file detection
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestNonImageFileDrop:
|
||||
def test_python_file(self, tmp_text):
|
||||
result = _detect_file_drop(str(tmp_text))
|
||||
assert result is not None
|
||||
assert result["path"] == tmp_text
|
||||
assert result["is_image"] is False
|
||||
assert result["remainder"] == ""
|
||||
|
||||
def test_non_image_with_trailing_text(self, tmp_text):
|
||||
user_input = f"{tmp_text} review this code"
|
||||
result = _detect_file_drop(user_input)
|
||||
assert result is not None
|
||||
assert result["is_image"] is False
|
||||
assert result["remainder"] == "review this code"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests: backslash-escaped spaces (macOS drag-and-drop)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestEscapedSpaces:
|
||||
def test_escaped_spaces_in_path(self, tmp_image_with_spaces):
|
||||
r"""macOS drags produce paths like /path/to/my\ file.png"""
|
||||
escaped = str(tmp_image_with_spaces).replace(' ', '\\ ')
|
||||
result = _detect_file_drop(escaped)
|
||||
assert result is not None
|
||||
assert result["path"] == tmp_image_with_spaces
|
||||
assert result["is_image"] is True
|
||||
|
||||
def test_escaped_spaces_with_trailing_text(self, tmp_image_with_spaces):
|
||||
escaped = str(tmp_image_with_spaces).replace(' ', '\\ ')
|
||||
user_input = f"{escaped} what is this?"
|
||||
result = _detect_file_drop(user_input)
|
||||
assert result is not None
|
||||
assert result["path"] == tmp_image_with_spaces
|
||||
assert result["remainder"] == "what is this?"
|
||||
|
||||
def test_unquoted_spaces_in_path(self, tmp_image_with_spaces):
|
||||
result = _detect_file_drop(str(tmp_image_with_spaces))
|
||||
assert result is not None
|
||||
assert result["path"] == tmp_image_with_spaces
|
||||
assert result["is_image"] is True
|
||||
assert result["remainder"] == ""
|
||||
|
||||
def test_unquoted_spaces_with_trailing_text(self, tmp_image_with_spaces):
|
||||
user_input = f"{tmp_image_with_spaces} what is this?"
|
||||
result = _detect_file_drop(user_input)
|
||||
assert result is not None
|
||||
assert result["path"] == tmp_image_with_spaces
|
||||
assert result["remainder"] == "what is this?"
|
||||
|
||||
def test_mixed_escaped_and_literal_spaces_in_path(self, tmp_path):
|
||||
img = tmp_path / "Screenshot 2026-04-21 at 1.04.43 PM.png"
|
||||
img.write_bytes(b"\x89PNG\r\n\x1a\n")
|
||||
mixed = str(img).replace("Screenshot ", "Screenshot\\ ").replace("2026-04-21 ", "2026-04-21\\ ").replace("at ", "at\\ ")
|
||||
result = _detect_file_drop(mixed)
|
||||
assert result is not None
|
||||
assert result["path"] == img
|
||||
assert result["is_image"] is True
|
||||
assert result["remainder"] == ""
|
||||
|
||||
def test_file_uri_image_path(self, tmp_image_with_spaces):
|
||||
uri = tmp_image_with_spaces.as_uri()
|
||||
result = _detect_file_drop(uri)
|
||||
assert result is not None
|
||||
assert result["path"] == tmp_image_with_spaces
|
||||
assert result["is_image"] is True
|
||||
|
||||
def test_tilde_prefixed_path(self, tmp_path, monkeypatch):
|
||||
home = tmp_path / "home"
|
||||
img = home / "storage" / "shared" / "Pictures" / "cat.png"
|
||||
img.parent.mkdir(parents=True, exist_ok=True)
|
||||
img.write_bytes(b"\x89PNG\r\n\x1a\n")
|
||||
monkeypatch.setenv("HOME", str(home))
|
||||
|
||||
result = _detect_file_drop("~/storage/shared/Pictures/cat.png what is this?")
|
||||
|
||||
assert result is not None
|
||||
assert result["path"] == img
|
||||
assert result["is_image"] is True
|
||||
assert result["remainder"] == "what is this?"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests: edge cases
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestEdgeCases:
|
||||
def test_path_with_no_extension(self, tmp_path):
|
||||
f = tmp_path / "Makefile"
|
||||
f.write_text("all:\n\techo hi\n")
|
||||
result = _detect_file_drop(str(f))
|
||||
assert result is not None
|
||||
assert result["is_image"] is False
|
||||
|
||||
def test_path_that_looks_like_command_but_is_file(self, tmp_path):
|
||||
"""A file literally named 'help' inside a directory starting with /."""
|
||||
f = tmp_path / "help"
|
||||
f.write_text("not a command\n")
|
||||
result = _detect_file_drop(str(f))
|
||||
assert result is not None
|
||||
assert result["is_image"] is False
|
||||
|
||||
def test_symlink_to_file(self, tmp_image, tmp_path):
|
||||
link = tmp_path / "link.png"
|
||||
link.symlink_to(tmp_image)
|
||||
result = _detect_file_drop(str(link))
|
||||
assert result is not None
|
||||
assert result["is_image"] is True
|
||||
@@ -0,0 +1,225 @@
|
||||
"""Tests for CLI redraw helpers used to recover from terminal buffer drift.
|
||||
|
||||
Covers:
|
||||
- _force_full_redraw (#8688 cmux tab switch, /redraw, Ctrl+L)
|
||||
- the resize handler we install over prompt_toolkit's _on_resize (#5474)
|
||||
|
||||
Both behaviors are exercised against fake prompt_toolkit renderer/output
|
||||
objects — we're asserting the escape sequences the CLI sends, not that
|
||||
the terminal physically repainted.
|
||||
"""
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
import cli as cli_mod
|
||||
from cli import HermesCLI
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def bare_cli():
|
||||
"""A HermesCLI with no __init__ — we only exercise the redraw helper."""
|
||||
cli = object.__new__(HermesCLI)
|
||||
return cli
|
||||
|
||||
|
||||
class TestForceFullRedraw:
|
||||
def test_no_app_is_safe(self, bare_cli):
|
||||
# _force_full_redraw must be a no-op when the TUI isn't running.
|
||||
bare_cli._app = None
|
||||
bare_cli._force_full_redraw() # must not raise
|
||||
|
||||
def test_missing_app_attr_is_safe(self, bare_cli):
|
||||
# Simulate HermesCLI before the TUI has ever been constructed.
|
||||
bare_cli._force_full_redraw() # must not raise
|
||||
|
||||
def test_sends_full_clear_replays_then_invalidates(self, bare_cli, monkeypatch):
|
||||
app = MagicMock()
|
||||
out = app.renderer.output
|
||||
bare_cli._app = app
|
||||
events = []
|
||||
out.reset_attributes.side_effect = lambda: events.append("reset_attrs")
|
||||
out.erase_screen.side_effect = lambda: events.append("erase")
|
||||
out.cursor_goto.side_effect = lambda *_: events.append("home")
|
||||
out.flush.side_effect = lambda: events.append("flush")
|
||||
app.renderer.reset.side_effect = lambda **_: events.append("renderer_reset")
|
||||
monkeypatch.setattr(cli_mod, "_replay_output_history", lambda: events.append("replay"))
|
||||
app.invalidate.side_effect = lambda: events.append("invalidate")
|
||||
|
||||
bare_cli._force_full_redraw()
|
||||
|
||||
# Must erase screen, home cursor, and flush — in that order.
|
||||
out.reset_attributes.assert_called_once()
|
||||
out.erase_screen.assert_called_once()
|
||||
out.cursor_goto.assert_called_once_with(0, 0)
|
||||
out.flush.assert_called_once()
|
||||
|
||||
# Must reset prompt_toolkit's tracked screen/cursor state so the
|
||||
# next incremental redraw starts from a clean (0, 0) origin.
|
||||
app.renderer.reset.assert_called_once_with(leave_alternate_screen=False)
|
||||
|
||||
# Must schedule a repaint.
|
||||
app.invalidate.assert_called_once()
|
||||
assert events == [
|
||||
"reset_attrs",
|
||||
"erase",
|
||||
"home",
|
||||
"flush",
|
||||
"renderer_reset",
|
||||
"replay",
|
||||
"invalidate",
|
||||
]
|
||||
|
||||
def test_resize_recovery_skips_clear_when_width_unchanged(self, bare_cli, monkeypatch):
|
||||
"""A rows-only resize (same width) must NOT clear the screen.
|
||||
|
||||
prompt_toolkit's built-in Application._on_resize() starts with
|
||||
renderer.erase(leave_alternate_screen=False), which uses the renderer's
|
||||
cached cursor position to move back to the live prompt origin before
|
||||
erase_down(). With no column reflow there is no ghost chrome to wipe,
|
||||
so we delegate straight to prompt_toolkit and avoid an extra repaint.
|
||||
"""
|
||||
app = MagicMock()
|
||||
events = []
|
||||
app.renderer.reset.side_effect = lambda **_: events.append("renderer_reset")
|
||||
app.invalidate.side_effect = lambda: events.append("invalidate")
|
||||
original_on_resize = lambda: events.append("original_resize")
|
||||
|
||||
# bare_cli skips __init__, so seed attributes the way __init__ would.
|
||||
bare_cli._status_bar_suppressed_after_resize = False
|
||||
bare_cli._last_resize_width = 120
|
||||
# Same width on this resize → rows-only change.
|
||||
monkeypatch.setattr(bare_cli, "_get_tui_terminal_width", lambda: 120)
|
||||
monkeypatch.setattr(bare_cli, "_schedule_status_bar_unsuppress", lambda *_: None)
|
||||
|
||||
bare_cli._recover_after_resize(app, original_on_resize)
|
||||
|
||||
assert events == ["original_resize"]
|
||||
app.renderer.reset.assert_not_called()
|
||||
app.invalidate.assert_not_called()
|
||||
# Must NOT clear the screen or scrollback — those destroy the banner.
|
||||
app.renderer.output.erase_screen.assert_not_called()
|
||||
app.renderer.output.write_raw.assert_not_called()
|
||||
app.renderer.output.cursor_goto.assert_not_called()
|
||||
# Status bar / input rules must be suppressed until the next prompt.
|
||||
assert bare_cli._status_bar_suppressed_after_resize is True
|
||||
|
||||
def test_resize_recovery_clears_viewport_on_width_change(self, bare_cli, monkeypatch):
|
||||
"""A WIDTH change must wipe the visible viewport (CSI 2J) and replay.
|
||||
|
||||
On column shrink the terminal reflows the old full-width chrome into
|
||||
extra rows that prompt_toolkit's stale-cursor erase cannot reach,
|
||||
leaving a duplicated status bar (#19280/#5474 class). We route through
|
||||
the same recovery as Ctrl+L: erase_screen (2J) + replay transcript.
|
||||
It must be banner-safe — CSI 3J (write_raw) must NOT fire.
|
||||
"""
|
||||
app = MagicMock()
|
||||
events = []
|
||||
app.renderer.output.erase_screen.side_effect = lambda: events.append("erase")
|
||||
app.renderer.output.write_raw.side_effect = lambda *_: events.append("scrollback_wipe")
|
||||
original_on_resize = lambda: events.append("original_resize")
|
||||
|
||||
bare_cli._status_bar_suppressed_after_resize = False
|
||||
bare_cli._last_resize_width = 200
|
||||
monkeypatch.setattr(bare_cli, "_get_tui_terminal_width", lambda: 90)
|
||||
monkeypatch.setattr(bare_cli, "_schedule_status_bar_unsuppress", lambda *_: None)
|
||||
monkeypatch.setattr(cli_mod, "_replay_output_history", lambda: events.append("replay"))
|
||||
|
||||
bare_cli._recover_after_resize(app, original_on_resize)
|
||||
|
||||
# Viewport cleared and transcript replayed BEFORE prompt_toolkit's resize.
|
||||
assert "erase" in events
|
||||
assert "replay" in events
|
||||
assert events.index("erase") < events.index("original_resize")
|
||||
# Banner-safe: scrollback (CSI 3J) must never be wiped on a resize.
|
||||
assert "scrollback_wipe" not in events
|
||||
# New width recorded for the next comparison.
|
||||
assert bare_cli._last_resize_width == 90
|
||||
assert bare_cli._status_bar_suppressed_after_resize is True
|
||||
|
||||
def test_force_redraw_uses_full_screen_clear_without_scrollback_clear(self, bare_cli):
|
||||
app = MagicMock()
|
||||
bare_cli._app = app
|
||||
|
||||
bare_cli._force_full_redraw()
|
||||
|
||||
app.renderer.output.erase_screen.assert_called_once()
|
||||
app.renderer.output.cursor_goto.assert_called_once_with(0, 0)
|
||||
app.renderer.output.write_raw.assert_not_called()
|
||||
|
||||
def test_resize_recovery_is_debounced(self, bare_cli, monkeypatch):
|
||||
timers = []
|
||||
calls = []
|
||||
|
||||
class FakeTimer:
|
||||
def __init__(self, delay, callback):
|
||||
self.delay = delay
|
||||
self.callback = callback
|
||||
self.cancelled = False
|
||||
self.daemon = False
|
||||
timers.append(self)
|
||||
|
||||
def start(self):
|
||||
calls.append(("start", self.delay))
|
||||
|
||||
def cancel(self):
|
||||
self.cancelled = True
|
||||
calls.append(("cancel", self.delay))
|
||||
|
||||
def fire(self):
|
||||
self.callback()
|
||||
|
||||
app = MagicMock()
|
||||
app.loop.call_soon_threadsafe.side_effect = lambda cb: cb()
|
||||
monkeypatch.setattr(cli_mod.threading, "Timer", FakeTimer)
|
||||
monkeypatch.setattr(
|
||||
bare_cli,
|
||||
"_recover_after_resize",
|
||||
lambda _app, _orig: calls.append(("recover", _orig())),
|
||||
)
|
||||
|
||||
original_one = lambda: "first"
|
||||
original_two = lambda: "second"
|
||||
|
||||
bare_cli._schedule_resize_recovery(app, original_one, delay=0.25)
|
||||
assert bare_cli._resize_recovery_pending is True
|
||||
bare_cli._schedule_resize_recovery(app, original_two, delay=0.25)
|
||||
|
||||
assert len(timers) == 2
|
||||
assert timers[0].cancelled is True
|
||||
timers[0].fire()
|
||||
assert ("recover", "first") not in calls
|
||||
|
||||
timers[1].fire()
|
||||
assert ("recover", "second") in calls
|
||||
assert bare_cli._resize_recovery_pending is False
|
||||
|
||||
def test_invalidate_is_suppressed_while_resize_recovery_is_pending(self, bare_cli):
|
||||
app = MagicMock()
|
||||
bare_cli._app = app
|
||||
bare_cli._last_invalidate = 0.0
|
||||
bare_cli._resize_recovery_pending = True
|
||||
|
||||
bare_cli._invalidate(min_interval=0)
|
||||
|
||||
app.invalidate.assert_not_called()
|
||||
|
||||
def test_swallows_renderer_exceptions(self, bare_cli):
|
||||
# If the renderer blows up for any reason, the helper must not
|
||||
# propagate — otherwise a stray Ctrl+L would crash the CLI.
|
||||
app = MagicMock()
|
||||
app.renderer.output.erase_screen.side_effect = RuntimeError("boom")
|
||||
bare_cli._app = app
|
||||
|
||||
bare_cli._force_full_redraw() # must not raise
|
||||
|
||||
# invalidate() is still attempted after a renderer failure.
|
||||
app.invalidate.assert_called_once()
|
||||
|
||||
def test_swallows_invalidate_exceptions(self, bare_cli):
|
||||
app = MagicMock()
|
||||
app.invalidate.side_effect = RuntimeError("boom")
|
||||
bare_cli._app = app
|
||||
|
||||
bare_cli._force_full_redraw() # must not raise
|
||||
@@ -0,0 +1,220 @@
|
||||
"""Tests for CLI goal-continuation interrupt handling.
|
||||
|
||||
Covers:
|
||||
- Ctrl+C during a /goal turn auto-pauses the goal (no more continuations).
|
||||
- Empty/whitespace-only responses skip the judge (no phantom continuations).
|
||||
- Clean response without interrupt still drives the judge + enqueues.
|
||||
|
||||
These tests exercise ``_maybe_continue_goal_after_turn`` directly on a
|
||||
minimal ``HermesCLI`` stub (pattern used elsewhere in tests/cli).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import queue
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
# Fixtures
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def hermes_home(tmp_path, monkeypatch):
|
||||
"""Isolated HERMES_HOME so SessionDB.state_meta writes stay hermetic."""
|
||||
home = tmp_path / ".hermes"
|
||||
home.mkdir()
|
||||
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
||||
monkeypatch.setenv("HERMES_HOME", str(home))
|
||||
|
||||
# Bust the goal module's DB cache so it re-resolves HERMES_HOME each test.
|
||||
from hermes_cli import goals
|
||||
goals._DB_CACHE.clear()
|
||||
yield home
|
||||
goals._DB_CACHE.clear()
|
||||
|
||||
|
||||
def _make_cli_with_goal(session_id: str, goal_text: str = "build a thing"):
|
||||
"""Build a minimal HermesCLI stub with an active goal wired in."""
|
||||
from cli import HermesCLI
|
||||
from hermes_cli.goals import GoalManager
|
||||
|
||||
cli = HermesCLI.__new__(HermesCLI)
|
||||
# State the hook + helpers touch directly.
|
||||
cli._pending_input = queue.Queue()
|
||||
cli._last_turn_interrupted = False
|
||||
cli.conversation_history = []
|
||||
# `_get_goal_manager()` reads `self.session_id` directly, not
|
||||
# `self.agent.session_id`. Match the production lookup.
|
||||
cli.session_id = session_id
|
||||
cli.agent = MagicMock()
|
||||
cli.agent.session_id = session_id
|
||||
|
||||
mgr = GoalManager(session_id=session_id, default_max_turns=5)
|
||||
mgr.set(goal_text)
|
||||
cli._goal_manager = mgr
|
||||
return cli, mgr
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
# Tests
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestInterruptAutoPause:
|
||||
def test_interrupted_turn_pauses_goal_and_skips_continuation(self, hermes_home):
|
||||
"""Ctrl+C mid-turn must auto-pause the goal, not queue another round."""
|
||||
sid = f"sid-interrupt-{uuid.uuid4().hex}"
|
||||
cli, mgr = _make_cli_with_goal(sid)
|
||||
# Simulate an interrupted turn with a partial assistant reply.
|
||||
cli._last_turn_interrupted = True
|
||||
cli.conversation_history = [
|
||||
{"role": "user", "content": "kickoff"},
|
||||
{"role": "assistant", "content": "starting work..."},
|
||||
]
|
||||
|
||||
# Judge MUST NOT run on an interrupted turn. If it does, we've
|
||||
# regressed — fail loudly instead of silently querying a mock.
|
||||
with patch("hermes_cli.goals.judge_goal") as judge_mock:
|
||||
judge_mock.side_effect = AssertionError(
|
||||
"judge_goal called on an interrupted turn"
|
||||
)
|
||||
cli._maybe_continue_goal_after_turn()
|
||||
|
||||
# Pending input must NOT contain a continuation prompt.
|
||||
assert cli._pending_input.empty(), (
|
||||
"Interrupted turn should not enqueue a continuation prompt"
|
||||
)
|
||||
|
||||
# Goal should be paused, not active.
|
||||
state = mgr.state
|
||||
assert state is not None
|
||||
assert state.status == "paused"
|
||||
assert "interrupt" in (state.paused_reason or "").lower()
|
||||
|
||||
def test_interrupted_turn_is_resumable(self, hermes_home):
|
||||
"""After auto-pause from Ctrl+C, /goal resume puts it back to active."""
|
||||
sid = f"sid-resume-{uuid.uuid4().hex}"
|
||||
cli, mgr = _make_cli_with_goal(sid)
|
||||
cli._last_turn_interrupted = True
|
||||
cli.conversation_history = [
|
||||
{"role": "assistant", "content": "partial"},
|
||||
]
|
||||
with patch("hermes_cli.goals.judge_goal"):
|
||||
cli._maybe_continue_goal_after_turn()
|
||||
assert mgr.state.status == "paused"
|
||||
|
||||
mgr.resume()
|
||||
assert mgr.state.status == "active"
|
||||
|
||||
|
||||
class TestEmptyResponseSkip:
|
||||
def test_empty_response_does_not_invoke_judge(self, hermes_home):
|
||||
"""Whitespace-only replies skip judging (transient failure guard)."""
|
||||
sid = f"sid-empty-{uuid.uuid4().hex}"
|
||||
cli, mgr = _make_cli_with_goal(sid)
|
||||
cli._last_turn_interrupted = False
|
||||
cli.conversation_history = [
|
||||
{"role": "user", "content": "go"},
|
||||
{"role": "assistant", "content": " \n\n "},
|
||||
]
|
||||
|
||||
with patch("hermes_cli.goals.judge_goal") as judge_mock:
|
||||
judge_mock.side_effect = AssertionError(
|
||||
"judge_goal called on an empty response"
|
||||
)
|
||||
cli._maybe_continue_goal_after_turn()
|
||||
|
||||
# No continuation queued; goal still active (neither paused nor done).
|
||||
assert cli._pending_input.empty()
|
||||
assert mgr.state.status == "active"
|
||||
|
||||
def test_no_assistant_message_skipped(self, hermes_home):
|
||||
"""Conversation with zero assistant replies must not trip the judge."""
|
||||
sid = f"sid-noassistant-{uuid.uuid4().hex}"
|
||||
cli, mgr = _make_cli_with_goal(sid)
|
||||
cli._last_turn_interrupted = False
|
||||
cli.conversation_history = [
|
||||
{"role": "user", "content": "go"},
|
||||
]
|
||||
|
||||
with patch("hermes_cli.goals.judge_goal") as judge_mock:
|
||||
judge_mock.side_effect = AssertionError(
|
||||
"judge_goal called without an assistant response"
|
||||
)
|
||||
cli._maybe_continue_goal_after_turn()
|
||||
|
||||
assert cli._pending_input.empty()
|
||||
assert mgr.state.status == "active"
|
||||
|
||||
|
||||
class TestHealthyTurnStillRuns:
|
||||
def test_clean_response_enqueues_continuation_when_judge_says_continue(
|
||||
self, hermes_home,
|
||||
):
|
||||
"""Sanity check: the hook still works in the happy path."""
|
||||
sid = f"sid-healthy-{uuid.uuid4().hex}"
|
||||
cli, mgr = _make_cli_with_goal(sid)
|
||||
cli._last_turn_interrupted = False
|
||||
cli.conversation_history = [
|
||||
{"role": "user", "content": "go"},
|
||||
{"role": "assistant", "content": "did some work, more to do"},
|
||||
]
|
||||
|
||||
# Force the judge to say "continue" without touching the network.
|
||||
with patch(
|
||||
"hermes_cli.goals.judge_goal",
|
||||
return_value=("continue", "needs more steps", False, None),
|
||||
):
|
||||
cli._maybe_continue_goal_after_turn()
|
||||
|
||||
# Continuation prompt must be queued.
|
||||
assert not cli._pending_input.empty()
|
||||
queued = cli._pending_input.get_nowait()
|
||||
assert "Continuing toward your standing goal" in queued
|
||||
assert mgr.state.status == "active"
|
||||
|
||||
def test_clean_response_marks_done_when_judge_says_done(self, hermes_home):
|
||||
sid = f"sid-done-{uuid.uuid4().hex}"
|
||||
cli, mgr = _make_cli_with_goal(sid)
|
||||
cli._last_turn_interrupted = False
|
||||
cli.conversation_history = [
|
||||
{"role": "assistant", "content": "all finished, here's the result"},
|
||||
]
|
||||
|
||||
with patch(
|
||||
"hermes_cli.goals.judge_goal",
|
||||
return_value=("done", "goal satisfied", False, None),
|
||||
):
|
||||
cli._maybe_continue_goal_after_turn()
|
||||
|
||||
assert cli._pending_input.empty()
|
||||
assert mgr.state.status == "done"
|
||||
|
||||
|
||||
class TestInterruptFlagLifecycle:
|
||||
def test_chat_resets_flag_at_entry(self, hermes_home):
|
||||
"""chat() must reset _last_turn_interrupted at the top of each turn.
|
||||
|
||||
This guards against stale flag state: if turn N was interrupted and
|
||||
turn N+1 runs clean, the hook must not see True from N.
|
||||
"""
|
||||
# We can't run chat() end-to-end here, but we can assert the reset
|
||||
# is the first thing after the secret-capture registration by
|
||||
# inspecting the source shape.
|
||||
from cli import HermesCLI
|
||||
import inspect
|
||||
|
||||
src = inspect.getsource(HermesCLI.chat)
|
||||
# Look for an explicit reset near the top of chat().
|
||||
head = src.split("if not self._ensure_runtime_credentials", 1)[0]
|
||||
assert "self._last_turn_interrupted = False" in head, (
|
||||
"chat() must reset _last_turn_interrupted before run_conversation "
|
||||
"runs — otherwise a prior turn's interrupt state leaks into the "
|
||||
"next turn's goal hook decision."
|
||||
)
|
||||
@@ -0,0 +1,109 @@
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from cli import (
|
||||
HermesCLI,
|
||||
_collect_query_images,
|
||||
_format_image_attachment_badges,
|
||||
_termux_example_image_path,
|
||||
)
|
||||
|
||||
|
||||
def _make_cli():
|
||||
cli_obj = HermesCLI.__new__(HermesCLI)
|
||||
cli_obj._attached_images = []
|
||||
return cli_obj
|
||||
|
||||
|
||||
def _make_image(path: Path) -> Path:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_bytes(b"\x89PNG\r\n\x1a\n")
|
||||
return path
|
||||
|
||||
|
||||
class TestImageCommand:
|
||||
def test_handle_image_command_attaches_local_image(self, tmp_path):
|
||||
img = _make_image(tmp_path / "photo.png")
|
||||
cli_obj = _make_cli()
|
||||
|
||||
with patch("cli._cprint"):
|
||||
cli_obj._handle_image_command(f"/image {img}")
|
||||
|
||||
assert cli_obj._attached_images == [img]
|
||||
|
||||
def test_handle_image_command_supports_quoted_path_with_spaces(self, tmp_path):
|
||||
img = _make_image(tmp_path / "my photo.png")
|
||||
cli_obj = _make_cli()
|
||||
|
||||
with patch("cli._cprint"):
|
||||
cli_obj._handle_image_command(f'/image "{img}"')
|
||||
|
||||
assert cli_obj._attached_images == [img]
|
||||
|
||||
def test_handle_image_command_rejects_non_image_file(self, tmp_path):
|
||||
file_path = tmp_path / "notes.txt"
|
||||
file_path.write_text("hello\n", encoding="utf-8")
|
||||
cli_obj = _make_cli()
|
||||
|
||||
with patch("cli._cprint") as mock_print:
|
||||
cli_obj._handle_image_command(f"/image {file_path}")
|
||||
|
||||
assert cli_obj._attached_images == []
|
||||
rendered = " ".join(str(arg) for call in mock_print.call_args_list for arg in call.args)
|
||||
assert "Not a supported image file" in rendered
|
||||
|
||||
|
||||
class TestCollectQueryImages:
|
||||
def test_collect_query_images_accepts_explicit_image_arg(self, tmp_path):
|
||||
img = _make_image(tmp_path / "diagram.png")
|
||||
|
||||
message, images = _collect_query_images("describe this", str(img))
|
||||
|
||||
assert message == "describe this"
|
||||
assert images == [img]
|
||||
|
||||
def test_collect_query_images_extracts_leading_path(self, tmp_path):
|
||||
img = _make_image(tmp_path / "camera.png")
|
||||
|
||||
message, images = _collect_query_images(f"{img} what do you see?")
|
||||
|
||||
assert message == "what do you see?"
|
||||
assert images == [img]
|
||||
|
||||
def test_collect_query_images_supports_tilde_paths(self, tmp_path, monkeypatch):
|
||||
home = tmp_path / "home"
|
||||
img = _make_image(home / "storage" / "shared" / "Pictures" / "cat.png")
|
||||
monkeypatch.setenv("HOME", str(home))
|
||||
|
||||
message, images = _collect_query_images("describe this", "~/storage/shared/Pictures/cat.png")
|
||||
|
||||
assert message == "describe this"
|
||||
assert images == [img]
|
||||
|
||||
|
||||
class TestTermuxImageHints:
|
||||
def test_termux_example_image_path_prefers_real_shared_storage_root(self, monkeypatch):
|
||||
existing = {"/sdcard", "/storage/emulated/0"}
|
||||
monkeypatch.setattr("cli.os.path.isdir", lambda path: path in existing)
|
||||
|
||||
hint = _termux_example_image_path()
|
||||
|
||||
assert hint == "/sdcard/Pictures/cat.png"
|
||||
|
||||
|
||||
class TestImageBadgeFormatting:
|
||||
def test_compact_badges_use_filename_on_narrow_terminals(self, tmp_path):
|
||||
img = _make_image(tmp_path / "Screenshot 2026-04-09 at 11.22.33 AM.png")
|
||||
|
||||
badges = _format_image_attachment_badges([img], image_counter=1, width=40)
|
||||
|
||||
assert badges.startswith("[📎 ")
|
||||
assert "Image #1" not in badges
|
||||
|
||||
def test_compact_badges_summarize_multiple_images(self, tmp_path):
|
||||
img1 = _make_image(tmp_path / "one.png")
|
||||
img2 = _make_image(tmp_path / "two.png")
|
||||
|
||||
badges = _format_image_attachment_badges([img1, img2], image_counter=2, width=45)
|
||||
|
||||
assert badges == "[📎 2 images attached]"
|
||||
@@ -0,0 +1,826 @@
|
||||
"""Tests for HermesCLI initialization -- catches configuration bugs
|
||||
that only manifest at runtime (not in mocked unit tests)."""
|
||||
|
||||
import os
|
||||
import sys
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||
|
||||
|
||||
def _make_cli(env_overrides=None, config_overrides=None, **kwargs):
|
||||
"""Create a HermesCLI instance with minimal mocking."""
|
||||
import importlib
|
||||
|
||||
_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"},
|
||||
}
|
||||
if config_overrides:
|
||||
_clean_config.update(config_overrides)
|
||||
clean_env = {"LLM_MODEL": "", "HERMES_MAX_ITERATIONS": ""}
|
||||
if env_overrides:
|
||||
clean_env.update(env_overrides)
|
||||
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(**kwargs)
|
||||
|
||||
|
||||
class TestMaxTurnsResolution:
|
||||
"""max_turns must always resolve to a positive integer, never None."""
|
||||
|
||||
def test_default_max_turns_is_integer(self):
|
||||
cli = _make_cli()
|
||||
assert isinstance(cli.max_turns, int)
|
||||
assert cli.max_turns == 90
|
||||
|
||||
def test_explicit_max_turns_honored(self):
|
||||
cli = _make_cli(max_turns=25)
|
||||
assert cli.max_turns == 25
|
||||
|
||||
def test_none_max_turns_gets_default(self):
|
||||
cli = _make_cli(max_turns=None)
|
||||
assert isinstance(cli.max_turns, int)
|
||||
assert cli.max_turns == 90
|
||||
|
||||
def test_env_var_max_turns(self):
|
||||
"""Env var is used when config file doesn't set max_turns."""
|
||||
cli_obj = _make_cli(env_overrides={"HERMES_MAX_ITERATIONS": "42"})
|
||||
assert cli_obj.max_turns == 42
|
||||
|
||||
def test_invalid_env_var_max_turns_falls_back_to_default(self):
|
||||
"""Invalid env values should not crash CLI init."""
|
||||
cli_obj = _make_cli(env_overrides={"HERMES_MAX_ITERATIONS": "not-a-number"})
|
||||
assert cli_obj.max_turns == 90
|
||||
|
||||
def test_legacy_root_max_turns_is_used_when_agent_key_exists_without_value(self):
|
||||
cli_obj = _make_cli(config_overrides={"agent": {}, "max_turns": 77})
|
||||
assert cli_obj.max_turns == 77
|
||||
|
||||
def test_max_turns_never_none_for_agent(self):
|
||||
"""The value passed to AIAgent must never be None (causes TypeError in run_conversation)."""
|
||||
cli = _make_cli()
|
||||
assert isinstance(cli.max_turns, int) and cli.max_turns == 90
|
||||
|
||||
|
||||
class TestVerboseAndToolProgress:
|
||||
def test_default_verbose_is_bool(self):
|
||||
cli = _make_cli()
|
||||
assert isinstance(cli.verbose, bool)
|
||||
|
||||
def test_tool_progress_mode_is_string(self):
|
||||
cli = _make_cli()
|
||||
assert isinstance(cli.tool_progress_mode, str)
|
||||
assert cli.tool_progress_mode in {"off", "new", "all", "verbose"}
|
||||
|
||||
|
||||
class TestFallbackChainInit:
|
||||
def test_merges_new_and_legacy_fallback_config(self):
|
||||
cli = _make_cli(config_overrides={
|
||||
"fallback_providers": [
|
||||
{"provider": "openrouter", "model": "anthropic/claude-sonnet-4.6"},
|
||||
],
|
||||
"fallback_model": {"provider": "nous", "model": "Hermes-4"},
|
||||
})
|
||||
assert cli._fallback_model == [
|
||||
{"provider": "openrouter", "model": "anthropic/claude-sonnet-4.6"},
|
||||
{"provider": "nous", "model": "Hermes-4"},
|
||||
]
|
||||
|
||||
|
||||
class TestBusyInputMode:
|
||||
def test_default_busy_input_mode_is_interrupt(self):
|
||||
cli = _make_cli()
|
||||
assert cli.busy_input_mode == "interrupt"
|
||||
|
||||
def test_busy_input_mode_queue_is_honored(self):
|
||||
cli = _make_cli(config_overrides={"display": {"busy_input_mode": "queue"}})
|
||||
assert cli.busy_input_mode == "queue"
|
||||
|
||||
def test_unknown_busy_input_mode_falls_back_to_interrupt(self):
|
||||
cli = _make_cli(config_overrides={"display": {"busy_input_mode": "bogus"}})
|
||||
assert cli.busy_input_mode == "interrupt"
|
||||
|
||||
def test_queue_command_works_while_busy(self):
|
||||
"""When agent is running, /queue should still put the prompt in _pending_input."""
|
||||
cli = _make_cli()
|
||||
cli._agent_running = True
|
||||
cli.process_command("/queue follow up")
|
||||
assert cli._pending_input.get_nowait() == "follow up"
|
||||
|
||||
def test_queue_command_works_while_idle(self):
|
||||
"""When agent is idle, /queue should still queue (not reject)."""
|
||||
cli = _make_cli()
|
||||
cli._agent_running = False
|
||||
cli.process_command("/queue follow up")
|
||||
assert cli._pending_input.get_nowait() == "follow up"
|
||||
|
||||
def test_q_alias_queues_prompt(self):
|
||||
"""The /q alias should resolve to /queue, not /quit."""
|
||||
cli = _make_cli()
|
||||
cli._agent_running = False
|
||||
assert cli.process_command("/q follow up") is True
|
||||
assert cli._pending_input.get_nowait() == "follow up"
|
||||
|
||||
def test_queue_mode_routes_busy_enter_to_pending(self):
|
||||
"""In queue mode, Enter while busy should go to _pending_input, not _interrupt_queue."""
|
||||
cli = _make_cli(config_overrides={"display": {"busy_input_mode": "queue"}})
|
||||
cli._agent_running = True
|
||||
# Simulate what handle_enter does for non-command input while busy
|
||||
text = "follow up"
|
||||
if cli.busy_input_mode == "queue":
|
||||
cli._pending_input.put(text)
|
||||
else:
|
||||
cli._interrupt_queue.put(text)
|
||||
assert cli._pending_input.get_nowait() == "follow up"
|
||||
assert cli._interrupt_queue.empty()
|
||||
|
||||
def test_interrupt_mode_routes_busy_enter_to_interrupt(self):
|
||||
"""In interrupt mode (default), Enter while busy goes to _interrupt_queue."""
|
||||
cli = _make_cli()
|
||||
cli._agent_running = True
|
||||
text = "redirect"
|
||||
if cli.busy_input_mode == "queue":
|
||||
cli._pending_input.put(text)
|
||||
else:
|
||||
cli._interrupt_queue.put(text)
|
||||
assert cli._interrupt_queue.get_nowait() == "redirect"
|
||||
assert cli._pending_input.empty()
|
||||
|
||||
|
||||
class TestPromptToolkitTerminalCompatibility:
|
||||
def test_lf_enter_binds_to_submit_handler_posix(self):
|
||||
"""Some thin PTYs deliver Enter as LF/c-j instead of CR/enter.
|
||||
|
||||
On a bare local POSIX TTY (no SSH/WSL/WT/Ghostty) we keep c-j → submit so
|
||||
Enter works on thin PTYs (docker exec, certain ssh configurations).
|
||||
On Windows, WSL, SSH sessions, Windows Terminal, and Ghostty we leave c-j
|
||||
unbound here so it can be used as the Ctrl+Enter newline keystroke
|
||||
without conflicting with submit. See issue #22379.
|
||||
"""
|
||||
import sys as _sys
|
||||
import os as _os
|
||||
from unittest.mock import patch as _patch
|
||||
from prompt_toolkit.key_binding import KeyBindings
|
||||
|
||||
from cli import _bind_prompt_submit_keys
|
||||
|
||||
def submit_handler(event):
|
||||
return None
|
||||
|
||||
# Bare local POSIX (no SSH/WSL markers): both enter and c-j submit.
|
||||
with _patch.object(_sys, "platform", "linux"), \
|
||||
_patch.dict(_os.environ, {}, clear=True), \
|
||||
_patch("builtins.open", side_effect=OSError("no /proc")):
|
||||
kb = KeyBindings()
|
||||
_bind_prompt_submit_keys(kb, submit_handler)
|
||||
bindings = {tuple(key.value for key in binding.keys): binding.handler for binding in kb.bindings}
|
||||
assert bindings[("c-m",)] is submit_handler
|
||||
assert bindings[("c-j",)] is submit_handler
|
||||
|
||||
# POSIX over SSH: c-j stays free so Ctrl+Enter (sent as LF by
|
||||
# Windows Terminal / Kitty / mintty over SSH) inserts a newline.
|
||||
with _patch.object(_sys, "platform", "linux"), \
|
||||
_patch.dict(_os.environ, {"SSH_CONNECTION": "1.2.3.4 5 6.7.8.9 22"}, clear=True), \
|
||||
_patch("builtins.open", side_effect=OSError("no /proc")):
|
||||
kb = KeyBindings()
|
||||
_bind_prompt_submit_keys(kb, submit_handler)
|
||||
bindings = {tuple(key.value for key in binding.keys): binding.handler for binding in kb.bindings}
|
||||
assert bindings[("c-m",)] is submit_handler
|
||||
assert ("c-j",) not in bindings
|
||||
|
||||
# Ghostty through tmux: TERM_PROGRAM is tmux, but Ghostty exports a
|
||||
# stable env marker. Keep c-j free so Ctrl+J inserts a newline.
|
||||
with _patch.object(_sys, "platform", "linux"), \
|
||||
_patch.dict(_os.environ, {"TERM": "tmux-256color", "TERM_PROGRAM": "tmux", "GHOSTTY_RESOURCES_DIR": "/usr/share/ghostty"}, clear=True), \
|
||||
_patch("builtins.open", side_effect=OSError("no /proc")):
|
||||
kb = KeyBindings()
|
||||
_bind_prompt_submit_keys(kb, submit_handler)
|
||||
bindings = {tuple(key.value for key in binding.keys): binding.handler for binding in kb.bindings}
|
||||
assert bindings[("c-m",)] is submit_handler
|
||||
assert ("c-j",) not in bindings
|
||||
|
||||
# Windows: only enter submits; c-j is free for the newline binding
|
||||
# added separately in the prompt setup.
|
||||
with _patch.object(_sys, "platform", "win32"):
|
||||
kb = KeyBindings()
|
||||
_bind_prompt_submit_keys(kb, submit_handler)
|
||||
bindings = {tuple(key.value for key in binding.keys): binding.handler for binding in kb.bindings}
|
||||
assert bindings[("c-m",)] is submit_handler
|
||||
assert ("c-j",) not in bindings
|
||||
|
||||
def test_cpr_warning_callback_is_disabled(self):
|
||||
from cli import _disable_prompt_toolkit_cpr_warning
|
||||
|
||||
renderer = SimpleNamespace(cpr_not_supported_callback=lambda: None)
|
||||
app = SimpleNamespace(renderer=renderer)
|
||||
|
||||
_disable_prompt_toolkit_cpr_warning(app)
|
||||
|
||||
assert renderer.cpr_not_supported_callback is None
|
||||
|
||||
def test_cpr_disabled_output_marks_renderer_not_supported(self):
|
||||
"""CPR-disabled output must make prompt_toolkit skip ESC[6n entirely.
|
||||
|
||||
The root cause of #13870 is that prompt_toolkit sends ESC[6n cursor
|
||||
queries whose CPR replies leak into the display over tunnels/slow PTYs.
|
||||
Building the output with enable_cpr=False is what stops the queries:
|
||||
the renderer marks CPR NOT_SUPPORTED and never calls ask_for_cpr().
|
||||
"""
|
||||
import sys as _sys
|
||||
from cli import _build_cpr_disabled_output
|
||||
from prompt_toolkit.application import Application
|
||||
from prompt_toolkit.layout import Layout, Window, FormattedTextControl
|
||||
from prompt_toolkit.renderer import CPR_Support
|
||||
|
||||
out = _build_cpr_disabled_output(_sys.stdout)
|
||||
assert out is not None
|
||||
# The contract: this output does not respond to CPR.
|
||||
assert out.enable_cpr is False
|
||||
assert out.responds_to_cpr is False
|
||||
|
||||
# And wired into an Application, the renderer treats CPR as unsupported,
|
||||
# so request_absolute_cursor_position() never sends ESC[6n.
|
||||
app = Application(
|
||||
layout=Layout(Window(FormattedTextControl("x"))),
|
||||
output=out,
|
||||
full_screen=False,
|
||||
)
|
||||
assert app.renderer.cpr_support == CPR_Support.NOT_SUPPORTED
|
||||
|
||||
def test_cpr_disabled_output_returns_none_on_failure(self):
|
||||
"""A non-fileno stdout must degrade to None (default output fallback)."""
|
||||
from cli import _build_cpr_disabled_output
|
||||
|
||||
class _NoFileno:
|
||||
def fileno(self):
|
||||
raise OSError("not a real fd")
|
||||
|
||||
# Build must not raise; worst case it returns a usable output or None.
|
||||
# The hard guarantee is no exception escapes (startup must never break).
|
||||
result = _build_cpr_disabled_output(_NoFileno())
|
||||
assert result is None or result.enable_cpr is False
|
||||
|
||||
def test_cpr_gating_local_vs_tunnel(self, monkeypatch):
|
||||
"""CPR is only suppressed on tunneled links / explicit opt-out.
|
||||
|
||||
CPR works fine on local terminals and is only a layout hint, so the fix
|
||||
for #13870 must not change default behavior locally — it gates on
|
||||
_terminal_may_leak_cpr(). Local (no SSH env) -> CPR left enabled;
|
||||
SSH session or PROMPT_TOOLKIT_NO_CPR=1 -> CPR suppressed.
|
||||
"""
|
||||
from cli import _terminal_may_leak_cpr
|
||||
|
||||
for var in ("SSH_CONNECTION", "SSH_CLIENT", "SSH_TTY", "PROMPT_TOOLKIT_NO_CPR"):
|
||||
monkeypatch.delenv(var, raising=False)
|
||||
|
||||
# Local terminal: leave prompt_toolkit's default (CPR on) untouched.
|
||||
assert _terminal_may_leak_cpr() is False
|
||||
|
||||
# SSH session: the tunnel where the leak reproduces.
|
||||
monkeypatch.setenv("SSH_CONNECTION", "10.0.0.1 22 10.0.0.2 51234")
|
||||
assert _terminal_may_leak_cpr() is True
|
||||
monkeypatch.delenv("SSH_CONNECTION", raising=False)
|
||||
|
||||
# prompt_toolkit's own explicit opt-out is honored.
|
||||
monkeypatch.setenv("PROMPT_TOOLKIT_NO_CPR", "1")
|
||||
assert _terminal_may_leak_cpr() is True
|
||||
|
||||
|
||||
class TestSingleQueryState:
|
||||
def test_voice_and_interrupt_state_initialized_before_run(self):
|
||||
"""Single-query mode calls chat() without going through run()."""
|
||||
cli = _make_cli()
|
||||
assert cli._voice_tts is False
|
||||
assert cli._voice_mode is False
|
||||
assert cli._voice_tts_done.is_set()
|
||||
assert hasattr(cli, "_interrupt_queue")
|
||||
assert hasattr(cli, "_pending_input")
|
||||
|
||||
|
||||
class TestHistoryDisplay:
|
||||
def test_history_numbers_only_visible_messages_and_summarizes_tools(self, capsys):
|
||||
cli = _make_cli()
|
||||
cli.conversation_history = [
|
||||
{"role": "system", "content": "system prompt"},
|
||||
{"role": "user", "content": "Hello"},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": None,
|
||||
"tool_calls": [{"id": "call_1"}, {"id": "call_2"}],
|
||||
},
|
||||
{"role": "tool", "content": "tool output 1"},
|
||||
{"role": "tool", "content": "tool output 2"},
|
||||
{"role": "assistant", "content": "All set."},
|
||||
{"role": "user", "content": "A" * 250},
|
||||
]
|
||||
|
||||
cli.show_history()
|
||||
output = capsys.readouterr().out
|
||||
|
||||
assert "[You #1]" in output
|
||||
assert "[Hermes #2]" in output
|
||||
assert "(requested 2 tool calls)" in output
|
||||
assert "[Tools]" in output
|
||||
assert "(2 tool messages hidden)" in output
|
||||
assert "[Hermes #3]" in output
|
||||
assert "[You #4]" in output
|
||||
assert "[You #5]" not in output
|
||||
assert "A" * 250 in output
|
||||
assert "A" * 250 + "..." not in output
|
||||
|
||||
def test_history_shows_recent_sessions_when_current_chat_is_empty(self, capsys):
|
||||
cli = _make_cli()
|
||||
cli.session_id = "current"
|
||||
cli._session_db = MagicMock()
|
||||
cli._session_db.list_sessions_rich.return_value = [
|
||||
{
|
||||
"id": "current",
|
||||
"title": "Current",
|
||||
"preview": "Current preview",
|
||||
"last_active": 0,
|
||||
},
|
||||
{
|
||||
"id": "20260401_201329_d85961",
|
||||
"title": "Checking Running Hermes Agent",
|
||||
"preview": "check running gateways for hermes agent",
|
||||
"last_active": 0,
|
||||
},
|
||||
]
|
||||
|
||||
cli.show_history()
|
||||
output = capsys.readouterr().out
|
||||
|
||||
assert "No messages in the current chat yet" in output
|
||||
assert "Checking Running Hermes Agent" in output
|
||||
assert "20260401_201329_d85961" in output
|
||||
assert "/resume" in output
|
||||
assert "Current preview" not in output
|
||||
|
||||
def test_resume_without_target_lists_recent_sessions(self, capsys):
|
||||
cli = _make_cli()
|
||||
cli.session_id = "current"
|
||||
cli._session_db = MagicMock()
|
||||
cli._session_db.list_sessions_rich.return_value = [
|
||||
{
|
||||
"id": "current",
|
||||
"title": "Current",
|
||||
"preview": "Current preview",
|
||||
"last_active": 0,
|
||||
},
|
||||
{
|
||||
"id": "20260401_201329_d85961",
|
||||
"title": "Checking Running Hermes Agent",
|
||||
"preview": "check running gateways for hermes agent",
|
||||
"last_active": 0,
|
||||
},
|
||||
]
|
||||
|
||||
cli._handle_resume_command("/resume")
|
||||
output = capsys.readouterr().out
|
||||
|
||||
assert "Recent sessions" in output
|
||||
assert "Checking Running Hermes Agent" in output
|
||||
assert "Use /resume" in output
|
||||
assert "session title" in output
|
||||
|
||||
def test_resume_updates_hermes_session_id_env_and_context(self, tmp_path):
|
||||
from gateway.session_context import _UNSET, _VAR_MAP, get_session_env
|
||||
from hermes_state import SessionDB
|
||||
|
||||
cli = _make_cli()
|
||||
cli.session_id = "current_session"
|
||||
cli.conversation_history = []
|
||||
cli.agent = None
|
||||
cli._session_db = SessionDB(db_path=tmp_path / "state.db")
|
||||
cli._session_db.create_session("current_session", "cli")
|
||||
cli._session_db.create_session("target_session", "cli")
|
||||
cli._session_db.append_message("target_session", "user", "hello from resumed session")
|
||||
|
||||
os.environ["HERMES_SESSION_ID"] = "current_session"
|
||||
_VAR_MAP["HERMES_SESSION_ID"].set("current_session")
|
||||
|
||||
try:
|
||||
cli._handle_resume_command("/resume target_session")
|
||||
|
||||
assert cli.session_id == "target_session"
|
||||
assert os.environ["HERMES_SESSION_ID"] == "target_session"
|
||||
assert get_session_env("HERMES_SESSION_ID") == "target_session"
|
||||
finally:
|
||||
cli._session_db.close()
|
||||
os.environ.pop("HERMES_SESSION_ID", None)
|
||||
_VAR_MAP["HERMES_SESSION_ID"].set(_UNSET)
|
||||
|
||||
def test_resume_list_shows_full_long_titles(self, capsys):
|
||||
"""Long session titles render in full in the /resume table — not
|
||||
truncated to 30 chars (fixes #14082)."""
|
||||
cli = _make_cli()
|
||||
cli.session_id = "current"
|
||||
cli._session_db = MagicMock()
|
||||
long_title = "Salvage BytePlus Volcengine PR With Fixes"
|
||||
cli._session_db.list_sessions_rich.return_value = [
|
||||
{
|
||||
"id": "current",
|
||||
"title": "Current",
|
||||
"preview": "Current preview",
|
||||
"last_active": 0,
|
||||
},
|
||||
{
|
||||
"id": "20260401_201329_d85961",
|
||||
"title": long_title,
|
||||
"preview": "fix byteplus pr and resume",
|
||||
"last_active": 0,
|
||||
},
|
||||
]
|
||||
|
||||
cli._handle_resume_command("/resume")
|
||||
output = capsys.readouterr().out
|
||||
|
||||
assert long_title in output
|
||||
assert "20260401_201329_d85961" in output
|
||||
|
||||
def test_sessions_command_no_args_lists_recent_sessions(self, capsys):
|
||||
"""/sessions with no args prints the recent-sessions table (TUI parity).
|
||||
|
||||
Regression test: `sessions` was registered in the central command
|
||||
registry and surfaced by /help and tab-completion, but the classic
|
||||
CLI dispatcher had no elif branch for it, so the canonical name fell
|
||||
through and printed `Unknown command: sessions`.
|
||||
"""
|
||||
cli = _make_cli()
|
||||
cli.session_id = "current"
|
||||
cli._session_db = MagicMock()
|
||||
cli._session_db.list_sessions_rich.return_value = [
|
||||
{
|
||||
"id": "20260401_201329_d85961",
|
||||
"title": "Checking Running Hermes Agent",
|
||||
"preview": "check running gateways for hermes agent",
|
||||
"last_active": 0,
|
||||
},
|
||||
]
|
||||
|
||||
# Drive it through the public dispatcher to also lock in the
|
||||
# process_command wiring, not just the handler in isolation.
|
||||
cli.process_command("/sessions")
|
||||
output = capsys.readouterr().out
|
||||
|
||||
assert "Unknown command" not in output
|
||||
assert "Recent sessions" in output
|
||||
assert "Checking Running Hermes Agent" in output
|
||||
assert "20260401_201329_d85961" in output
|
||||
|
||||
def test_sessions_list_subcommand_lists_recent_sessions(self, capsys):
|
||||
"""/sessions list is an explicit alias for the no-arg list view."""
|
||||
cli = _make_cli()
|
||||
cli.session_id = "current"
|
||||
cli._session_db = MagicMock()
|
||||
cli._session_db.list_sessions_rich.return_value = [
|
||||
{
|
||||
"id": "20260401_201329_d85961",
|
||||
"title": "Checking Running Hermes Agent",
|
||||
"preview": "check running gateways for hermes agent",
|
||||
"last_active": 0,
|
||||
},
|
||||
]
|
||||
|
||||
cli.process_command("/sessions list")
|
||||
output = capsys.readouterr().out
|
||||
|
||||
assert "Unknown command" not in output
|
||||
assert "Recent sessions" in output
|
||||
assert "Checking Running Hermes Agent" in output
|
||||
|
||||
def test_sessions_with_target_delegates_to_resume(self):
|
||||
"""/sessions <id_or_title> behaves identically to /resume <id_or_title>.
|
||||
|
||||
We intercept `_handle_resume_command` rather than the full resume
|
||||
machinery (which would otherwise require simulating an entire session
|
||||
switch). The contract under test is the dispatch wiring.
|
||||
"""
|
||||
cli = _make_cli()
|
||||
with patch.object(cli, "_handle_resume_command") as mock_resume:
|
||||
cli.process_command("/sessions Checking Running Hermes Agent")
|
||||
|
||||
mock_resume.assert_called_once_with(
|
||||
"/resume Checking Running Hermes Agent"
|
||||
)
|
||||
|
||||
def test_sessions_command_is_dispatched(self):
|
||||
"""/sessions must hit _handle_sessions_command, not fall through.
|
||||
|
||||
Direct test that the process_command elif chain routes the canonical
|
||||
name to the handler. Without this wiring, /sessions printed
|
||||
`Unknown command: sessions` even though it was a registered command.
|
||||
"""
|
||||
cli = _make_cli()
|
||||
cli._session_db = None # exercise the no-db path too
|
||||
|
||||
with patch.object(cli, "_handle_sessions_command") as mock_handler:
|
||||
cli.process_command("/sessions")
|
||||
|
||||
mock_handler.assert_called_once()
|
||||
called_with = mock_handler.call_args.args[0]
|
||||
assert called_with.lower().startswith("/sessions")
|
||||
|
||||
|
||||
class TestRootLevelProviderOverride:
|
||||
"""Root-level provider/base_url in config.yaml must NOT override model.provider."""
|
||||
|
||||
def test_model_provider_wins_over_root_provider(self, tmp_path, monkeypatch):
|
||||
"""model.provider takes priority — root-level provider is only a fallback."""
|
||||
import yaml
|
||||
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
hermes_home.mkdir()
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
|
||||
config_path = hermes_home / "config.yaml"
|
||||
config_path.write_text(yaml.safe_dump({
|
||||
"provider": "opencode-go", # stale root-level key
|
||||
"model": {
|
||||
"default": "google/gemini-3-flash-preview",
|
||||
"provider": "openrouter", # correct canonical key
|
||||
},
|
||||
}))
|
||||
|
||||
import cli
|
||||
monkeypatch.setattr(cli, "_hermes_home", hermes_home)
|
||||
cfg = cli.load_cli_config()
|
||||
|
||||
assert cfg["model"]["provider"] == "openrouter"
|
||||
|
||||
def test_root_provider_used_as_fallback_when_model_provider_missing(self, tmp_path, monkeypatch):
|
||||
"""Legacy root-level provider still populates model.provider in the CLI loader."""
|
||||
import yaml
|
||||
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
hermes_home.mkdir()
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
|
||||
config_path = hermes_home / "config.yaml"
|
||||
config_path.write_text(yaml.safe_dump({
|
||||
"provider": "opencode-go", # stale root key
|
||||
"model": {
|
||||
"default": "google/gemini-3-flash-preview",
|
||||
# no explicit model.provider — defaults provide "auto"
|
||||
},
|
||||
}))
|
||||
|
||||
import cli
|
||||
monkeypatch.setattr(cli, "_hermes_home", hermes_home)
|
||||
cfg = cli.load_cli_config()
|
||||
|
||||
assert cfg["model"]["provider"] == "opencode-go"
|
||||
|
||||
def test_root_base_url_used_as_fallback_when_model_base_url_missing(self, tmp_path, monkeypatch):
|
||||
"""Legacy root-level base_url still populates model.base_url in the CLI loader."""
|
||||
import yaml
|
||||
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
hermes_home.mkdir()
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
|
||||
config_path = hermes_home / "config.yaml"
|
||||
config_path.write_text(yaml.safe_dump({
|
||||
"base_url": "https://example.com/v1",
|
||||
"model": {
|
||||
"default": "google/gemini-3-flash-preview",
|
||||
},
|
||||
}))
|
||||
|
||||
import cli
|
||||
monkeypatch.setattr(cli, "_hermes_home", hermes_home)
|
||||
cfg = cli.load_cli_config()
|
||||
|
||||
assert cfg["model"]["base_url"] == "https://example.com/v1"
|
||||
|
||||
def test_normalize_root_model_keys_moves_to_model(self):
|
||||
"""_normalize_root_model_keys migrates root keys into model section."""
|
||||
from hermes_cli.config import _normalize_root_model_keys
|
||||
|
||||
config = {
|
||||
"provider": "opencode-go",
|
||||
"base_url": "https://example.com/v1",
|
||||
"model": {
|
||||
"default": "some-model",
|
||||
},
|
||||
}
|
||||
result = _normalize_root_model_keys(config)
|
||||
# Root keys removed
|
||||
assert "provider" not in result
|
||||
assert "base_url" not in result
|
||||
# Migrated into model section
|
||||
assert result["model"]["provider"] == "opencode-go"
|
||||
assert result["model"]["base_url"] == "https://example.com/v1"
|
||||
|
||||
def test_normalize_root_model_keys_does_not_override_existing(self):
|
||||
"""Existing model.provider is never overridden by root-level key."""
|
||||
from hermes_cli.config import _normalize_root_model_keys
|
||||
|
||||
config = {
|
||||
"provider": "stale-provider",
|
||||
"model": {
|
||||
"default": "some-model",
|
||||
"provider": "correct-provider",
|
||||
},
|
||||
}
|
||||
result = _normalize_root_model_keys(config)
|
||||
assert result["model"]["provider"] == "correct-provider"
|
||||
assert "provider" not in result # root key still cleaned up
|
||||
|
||||
def test_normalize_model_api_base_aliases_to_base_url(self):
|
||||
"""model.api_base is migrated to model.base_url (issue #8919)."""
|
||||
from hermes_cli.config import _normalize_root_model_keys
|
||||
|
||||
config = {
|
||||
"model": {
|
||||
"provider": "custom",
|
||||
"api_base": "http://localhost:4000",
|
||||
"api_key": "my-key",
|
||||
"default": "default",
|
||||
},
|
||||
}
|
||||
result = _normalize_root_model_keys(config)
|
||||
assert result["model"]["base_url"] == "http://localhost:4000"
|
||||
assert "api_base" not in result["model"] # alias cleaned up
|
||||
|
||||
def test_normalize_api_base_does_not_override_base_url(self):
|
||||
"""An explicit model.base_url is never overridden by api_base."""
|
||||
from hermes_cli.config import _normalize_root_model_keys
|
||||
|
||||
config = {
|
||||
"model": {
|
||||
"provider": "custom",
|
||||
"api_base": "http://wrong:9999",
|
||||
"base_url": "http://localhost:4000",
|
||||
"default": "default",
|
||||
},
|
||||
}
|
||||
result = _normalize_root_model_keys(config)
|
||||
assert result["model"]["base_url"] == "http://localhost:4000"
|
||||
assert "api_base" not in result["model"]
|
||||
|
||||
def test_normalize_root_context_length_migrates_to_model(self):
|
||||
"""Root-level context_length is migrated into the model section."""
|
||||
from hermes_cli.config import _normalize_root_model_keys
|
||||
|
||||
config = {
|
||||
"context_length": 128000,
|
||||
"model": {
|
||||
"default": "my-model",
|
||||
},
|
||||
}
|
||||
result = _normalize_root_model_keys(config)
|
||||
assert result["model"]["context_length"] == 128000
|
||||
assert "context_length" not in result # root key cleaned up
|
||||
|
||||
def test_normalize_root_context_length_does_not_override_existing(self):
|
||||
"""Existing model.context_length is not overridden by root-level key."""
|
||||
from hermes_cli.config import _normalize_root_model_keys
|
||||
|
||||
config = {
|
||||
"context_length": 256000,
|
||||
"model": {
|
||||
"default": "my-model",
|
||||
"context_length": 128000,
|
||||
},
|
||||
}
|
||||
result = _normalize_root_model_keys(config)
|
||||
assert result["model"]["context_length"] == 128000 # preserved
|
||||
assert "context_length" not in result # root key still cleaned up
|
||||
|
||||
def test_normalize_root_context_length_with_string_model(self):
|
||||
"""Root-level context_length is migrated even when model is a string."""
|
||||
from hermes_cli.config import _normalize_root_model_keys
|
||||
|
||||
config = {
|
||||
"context_length": 128000,
|
||||
"model": "my-model",
|
||||
}
|
||||
result = _normalize_root_model_keys(config)
|
||||
assert isinstance(result["model"], dict)
|
||||
assert result["model"]["default"] == "my-model"
|
||||
assert result["model"]["context_length"] == 128000
|
||||
assert "context_length" not in result
|
||||
|
||||
# --- model-id alias canonicalization (issue #34500) -------------------
|
||||
# ``model.name`` / ``model.model`` must canonicalize to ``model.default``
|
||||
# so the runtime resolver (and ~14 other readers) never sends an empty
|
||||
# ``model=`` to the backend. Precedence: default > model > name.
|
||||
|
||||
def test_normalize_model_name_aliases_to_default(self):
|
||||
"""model.name (custom-provider repro) becomes model.default (#34500)."""
|
||||
from hermes_cli.config import _normalize_root_model_keys
|
||||
|
||||
config = {
|
||||
"model": {"name": "claude-sonnet-4-20250514", "provider": "my-litellm"},
|
||||
}
|
||||
result = _normalize_root_model_keys(config)
|
||||
assert result["model"]["default"] == "claude-sonnet-4-20250514"
|
||||
assert "name" not in result["model"] # stale alias dropped
|
||||
|
||||
def test_normalize_model_alias_to_default(self):
|
||||
"""model.model becomes model.default."""
|
||||
from hermes_cli.config import _normalize_root_model_keys
|
||||
|
||||
result = _normalize_root_model_keys({"model": {"model": "via-model-key"}})
|
||||
assert result["model"]["default"] == "via-model-key"
|
||||
assert "model" not in result["model"]
|
||||
|
||||
def test_normalize_explicit_default_wins_over_name(self):
|
||||
"""An explicit model.default is never overridden, and a stale alias is dropped."""
|
||||
from hermes_cli.config import _normalize_root_model_keys
|
||||
|
||||
result = _normalize_root_model_keys(
|
||||
{"model": {"default": "real-model", "name": "ignored"}}
|
||||
)
|
||||
assert result["model"]["default"] == "real-model"
|
||||
assert "name" not in result["model"]
|
||||
|
||||
def test_normalize_explicit_default_wins_over_model(self):
|
||||
from hermes_cli.config import _normalize_root_model_keys
|
||||
|
||||
result = _normalize_root_model_keys(
|
||||
{"model": {"default": "real-model", "model": "ignored"}}
|
||||
)
|
||||
assert result["model"]["default"] == "real-model"
|
||||
assert "model" not in result["model"]
|
||||
|
||||
def test_normalize_model_wins_over_name(self):
|
||||
"""Precedence: model > name when both are aliases and default is empty."""
|
||||
from hermes_cli.config import _normalize_root_model_keys
|
||||
|
||||
result = _normalize_root_model_keys({"model": {"model": "m-key", "name": "n-key"}})
|
||||
assert result["model"]["default"] == "m-key"
|
||||
assert "model" not in result["model"] and "name" not in result["model"]
|
||||
|
||||
def test_normalize_empty_model_dict_stays_empty(self):
|
||||
"""No id key anywhere → default stays empty (no fabricated value)."""
|
||||
from hermes_cli.config import _normalize_root_model_keys
|
||||
|
||||
result = _normalize_root_model_keys({"model": {"provider": "my-litellm"}})
|
||||
assert (result["model"].get("default") or "") == ""
|
||||
|
||||
def test_normalize_model_name_save_roundtrip_migrates_key(self, tmp_path, monkeypatch):
|
||||
"""A model.name config is permanently migrated to model.default on save."""
|
||||
import hermes_cli.config as cfgmod
|
||||
|
||||
home = tmp_path / ".hermes"
|
||||
home.mkdir()
|
||||
monkeypatch.setenv("HERMES_HOME", str(home))
|
||||
cfg_path = home / "config.yaml"
|
||||
cfg_path.write_text("model:\n name: claude-sonnet-4\n provider: my-litellm\n")
|
||||
# bust the mtime cache
|
||||
cfgmod._RAW_CONFIG_CACHE.clear()
|
||||
|
||||
loaded = cfgmod.load_config()
|
||||
assert loaded["model"]["default"] == "claude-sonnet-4"
|
||||
cfgmod.save_config(loaded)
|
||||
|
||||
raw = cfg_path.read_text()
|
||||
assert "name:" not in raw # stale alias gone from the file
|
||||
assert "default: claude-sonnet-4" in raw
|
||||
|
||||
|
||||
class TestProviderResolution:
|
||||
def test_api_key_is_string_or_none(self):
|
||||
cli = _make_cli()
|
||||
assert cli.api_key is None or isinstance(cli.api_key, str)
|
||||
|
||||
def test_base_url_is_string(self):
|
||||
cli = _make_cli()
|
||||
assert isinstance(cli.base_url, str)
|
||||
assert cli.base_url.startswith("http")
|
||||
|
||||
def test_model_is_string(self):
|
||||
cli = _make_cli()
|
||||
assert isinstance(cli.model, str)
|
||||
assert isinstance(cli.model, str) and '/' in cli.model
|
||||
@@ -0,0 +1,43 @@
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from cli import HermesCLI
|
||||
|
||||
|
||||
class _InsightsEngineStub:
|
||||
calls = []
|
||||
|
||||
def __init__(self, db):
|
||||
self.db = db
|
||||
|
||||
def generate(self, *, days=30, source=None):
|
||||
self.calls.append({"days": days, "source": source})
|
||||
return {"days": days, "source": source}
|
||||
|
||||
def format_terminal(self, report):
|
||||
return f"days={report['days']} source={report['source']}"
|
||||
|
||||
|
||||
def _run_show_insights(command: str):
|
||||
cli_obj = HermesCLI.__new__(HermesCLI)
|
||||
db = MagicMock()
|
||||
_InsightsEngineStub.calls = []
|
||||
with patch("hermes_state.SessionDB", return_value=db), \
|
||||
patch("agent.insights.InsightsEngine", _InsightsEngineStub):
|
||||
cli_obj._show_insights(command)
|
||||
return _InsightsEngineStub.calls, db
|
||||
|
||||
|
||||
def test_cli_insights_accepts_positional_days(capsys):
|
||||
calls, db = _run_show_insights("/insights 7")
|
||||
|
||||
assert calls == [{"days": 7, "source": None}]
|
||||
db.close.assert_called_once()
|
||||
assert "days=7 source=None" in capsys.readouterr().out
|
||||
|
||||
|
||||
def test_cli_insights_keeps_days_flag_and_source(capsys):
|
||||
calls, db = _run_show_insights("/insights --days 14 --source discord")
|
||||
|
||||
assert calls == [{"days": 14, "source": "discord"}]
|
||||
db.close.assert_called_once()
|
||||
assert "days=14 source=discord" in capsys.readouterr().out
|
||||
@@ -0,0 +1,194 @@
|
||||
"""Regression tests for the CLI interrupt-acknowledgement race.
|
||||
|
||||
Symptom (user report, July 2026): interrupting an active turn is
|
||||
unreliable — the interrupt message is sometimes "vacuumed into the void".
|
||||
|
||||
Root cause: ``HermesCLI.chat()`` fires ``agent.interrupt(msg)`` from its
|
||||
monitor loop, but only re-queued the message when the turn RESULT carried
|
||||
``interrupted=True``. Two races defeat that:
|
||||
|
||||
1. The agent thread passes its last ``_interrupt_requested`` check (or
|
||||
finishes entirely) just before the interrupt lands — the turn
|
||||
completes "normally", ``finalize_turn()`` never acknowledges the
|
||||
interrupt, and the user's message was silently dropped.
|
||||
2. Worse, when the interrupt lands *after* ``finalize_turn()``'s
|
||||
``clear_interrupt()``, the stale ``_interrupt_requested`` flag
|
||||
survives on the agent and instantly aborts the NEXT turn at its
|
||||
first loop check.
|
||||
|
||||
The fix: when ``chat()`` consumed an ``interrupt_msg`` but the result
|
||||
doesn't acknowledge the interrupt, re-queue the message as the next turn
|
||||
and clear the stale agent flag (only when the agent thread has exited).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import queue
|
||||
import sys
|
||||
import time
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
|
||||
def _make_cli():
|
||||
"""Build a HermesCLI with prompt_toolkit stubbed (same pattern as
|
||||
test_cli_interrupt_drain_regression.py)."""
|
||||
_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 _StubAgent:
|
||||
"""Agent whose turn completes WITHOUT acknowledging the interrupt."""
|
||||
|
||||
def __init__(self, session_id, turn_seconds=0.5):
|
||||
self.session_id = session_id
|
||||
self.turn_seconds = turn_seconds
|
||||
self._interrupt_requested = False
|
||||
self._interrupt_message = None
|
||||
self._active_children = []
|
||||
self.interrupt_calls = []
|
||||
self.clear_calls = 0
|
||||
self.max_iterations = 90
|
||||
self.model = "test/model"
|
||||
self.platform = "cli"
|
||||
|
||||
def run_conversation(self, **kwargs):
|
||||
# Simulate a turn that finishes normally — it never observed the
|
||||
# interrupt flag (raced past its last check).
|
||||
time.sleep(self.turn_seconds)
|
||||
return {
|
||||
"final_response": "turn finished normally",
|
||||
"messages": [
|
||||
{"role": "user", "content": "original"},
|
||||
{"role": "assistant", "content": "turn finished normally"},
|
||||
],
|
||||
"api_calls": 1,
|
||||
"completed": True,
|
||||
# NOTE: no "interrupted" key — the race means finalize_turn
|
||||
# never saw the flag (or cleared it before it was re-set).
|
||||
"partial": True, # skip auto-title thread in the test
|
||||
# Skip the Rich Panel rendering path (crashes under the
|
||||
# prompt_toolkit/skin mocks; irrelevant to this regression).
|
||||
"response_previewed": True,
|
||||
}
|
||||
|
||||
def interrupt(self, message=None):
|
||||
self.interrupt_calls.append(message)
|
||||
self._interrupt_requested = True
|
||||
self._interrupt_message = message
|
||||
|
||||
def clear_interrupt(self):
|
||||
self.clear_calls += 1
|
||||
self._interrupt_requested = False
|
||||
self._interrupt_message = None
|
||||
|
||||
|
||||
def test_unacknowledged_interrupt_message_is_requeued_not_dropped():
|
||||
cli = _make_cli()
|
||||
agent = _StubAgent(cli.session_id)
|
||||
cli.agent = agent
|
||||
|
||||
cli._interrupt_queue = queue.Queue()
|
||||
cli._pending_input = queue.Queue()
|
||||
cli._interrupt_queue.put("urgent new message")
|
||||
|
||||
with patch.object(cli, "_ensure_runtime_credentials", return_value=True), \
|
||||
patch.object(cli, "_resolve_turn_agent_config", return_value={
|
||||
"signature": cli._active_agent_route_signature,
|
||||
"model": None, "runtime": None, "request_overrides": None,
|
||||
}), \
|
||||
patch.object(cli, "_init_agent", return_value=True):
|
||||
cli.chat("original")
|
||||
|
||||
# The interrupt fired against the agent...
|
||||
assert agent.interrupt_calls == ["urgent new message"]
|
||||
# ...the turn result never acknowledged it, so the message must be
|
||||
# re-queued as the next turn instead of dropped.
|
||||
queued = []
|
||||
while not cli._pending_input.empty():
|
||||
queued.append(cli._pending_input.get_nowait())
|
||||
assert any("urgent new message" in str(q) for q in queued), (
|
||||
f"interrupt message was dropped; pending_input={queued!r}"
|
||||
)
|
||||
# ...and the stale flag must be cleared so the NEXT turn doesn't
|
||||
# instantly self-abort at its first _interrupt_requested check.
|
||||
assert agent._interrupt_requested is False
|
||||
assert agent.clear_calls >= 1
|
||||
|
||||
|
||||
def test_acknowledged_interrupt_still_requeues_message():
|
||||
"""The pre-existing path (result carries interrupted=True) still works."""
|
||||
cli = _make_cli()
|
||||
|
||||
class _AckAgent(_StubAgent):
|
||||
def run_conversation(self, **kwargs):
|
||||
# Wait until the monitor loop delivers the interrupt.
|
||||
for _ in range(100):
|
||||
if self._interrupt_requested:
|
||||
break
|
||||
time.sleep(0.05)
|
||||
return {
|
||||
"final_response": "partial work",
|
||||
"messages": [{"role": "assistant", "content": "partial work"}],
|
||||
"api_calls": 1,
|
||||
"completed": False,
|
||||
"interrupted": True,
|
||||
"interrupt_message": self._interrupt_message,
|
||||
"partial": True,
|
||||
}
|
||||
|
||||
agent = _AckAgent(cli.session_id)
|
||||
cli.agent = agent
|
||||
cli._interrupt_queue = queue.Queue()
|
||||
cli._pending_input = queue.Queue()
|
||||
cli._interrupt_queue.put("redirect please")
|
||||
|
||||
with patch.object(cli, "_ensure_runtime_credentials", return_value=True), \
|
||||
patch.object(cli, "_resolve_turn_agent_config", return_value={
|
||||
"signature": cli._active_agent_route_signature,
|
||||
"model": None, "runtime": None, "request_overrides": None,
|
||||
}), \
|
||||
patch.object(cli, "_init_agent", return_value=True):
|
||||
cli.chat("original")
|
||||
|
||||
queued = []
|
||||
while not cli._pending_input.empty():
|
||||
queued.append(cli._pending_input.get_nowait())
|
||||
assert any("redirect please" in str(q) for q in queued)
|
||||
assert cli._last_turn_interrupted is True
|
||||
@@ -0,0 +1,138 @@
|
||||
"""Regression test for #20271: classic-CLI hangs when messages typed during
|
||||
an agent turn never leave ``_interrupt_queue``.
|
||||
|
||||
Background
|
||||
----------
|
||||
The CLI routes user input typed while ``_agent_running`` is True into
|
||||
``_interrupt_queue`` (separate from ``_pending_input``) so that the explicit
|
||||
interrupt path can opt to deliver them as a single combined "interrupt"
|
||||
message. The explicit drain at the top of ``process_loop`` only fires when
|
||||
``busy_input_mode == "interrupt"`` AND a ``pending_message`` was
|
||||
acknowledged.
|
||||
|
||||
The original PR #17939 paired the paste-file TOCTOU fix with a separate
|
||||
drain inside ``process_loop``'s ``finally`` block: any message left in
|
||||
``_interrupt_queue`` after the agent's turn ends gets re-queued onto
|
||||
``_pending_input``. The drain was split off in #17666 / #18760 as "worth
|
||||
its own review" and never re-landed. v0.12.0 users hit a hang when typing
|
||||
during a turn that completes naturally — the message sits in
|
||||
``_interrupt_queue``, the next ``Enter`` re-routes input to the same
|
||||
blocked queue, and the CLI looks frozen.
|
||||
|
||||
This test exercises the restored ``_drain_interrupt_queue_to_pending_input``
|
||||
helper that ``process_loop`` now calls every turn. The integration into
|
||||
``process_loop`` itself is not threaded here (it requires a real
|
||||
prompt_toolkit app); the helper is unit-testable on its own and is the
|
||||
load-bearing piece.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import queue
|
||||
import sys
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
|
||||
def _make_cli():
|
||||
"""Build a HermesCLI instance with prompt_toolkit stubbed out.
|
||||
|
||||
Mirrors the helper in ``test_cli_steer_busy_path.py``.
|
||||
"""
|
||||
_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 TestInterruptQueueDrain:
|
||||
"""``_drain_interrupt_queue_to_pending_input`` re-queues stray messages."""
|
||||
|
||||
def test_drains_single_pending_message_into_pending_input(self):
|
||||
cli = _make_cli()
|
||||
cli._interrupt_queue.put("typed during agent turn")
|
||||
|
||||
cli._drain_interrupt_queue_to_pending_input()
|
||||
|
||||
assert cli._interrupt_queue.empty()
|
||||
assert cli._pending_input.qsize() == 1
|
||||
assert cli._pending_input.get_nowait() == "typed during agent turn"
|
||||
|
||||
def test_preserves_order_when_draining_multiple_messages(self):
|
||||
cli = _make_cli()
|
||||
for msg in ("first", "second", "third"):
|
||||
cli._interrupt_queue.put(msg)
|
||||
|
||||
cli._drain_interrupt_queue_to_pending_input()
|
||||
|
||||
assert cli._interrupt_queue.empty()
|
||||
drained = []
|
||||
while not cli._pending_input.empty():
|
||||
drained.append(cli._pending_input.get_nowait())
|
||||
assert drained == ["first", "second", "third"]
|
||||
|
||||
def test_noop_when_interrupt_queue_is_empty(self):
|
||||
cli = _make_cli()
|
||||
|
||||
cli._drain_interrupt_queue_to_pending_input()
|
||||
|
||||
assert cli._interrupt_queue.empty()
|
||||
assert cli._pending_input.empty()
|
||||
|
||||
def test_skips_falsy_messages(self):
|
||||
cli = _make_cli()
|
||||
cli._interrupt_queue.put("")
|
||||
cli._interrupt_queue.put(None)
|
||||
cli._interrupt_queue.put("real")
|
||||
|
||||
cli._drain_interrupt_queue_to_pending_input()
|
||||
|
||||
assert cli._interrupt_queue.empty()
|
||||
assert cli._pending_input.qsize() == 1
|
||||
assert cli._pending_input.get_nowait() == "real"
|
||||
|
||||
def test_swallows_exceptions_so_main_loop_never_breaks(self):
|
||||
cli = _make_cli()
|
||||
# Replace _pending_input with an object whose .put raises — simulating
|
||||
# an unexpected internal error. The drain must NOT propagate.
|
||||
broken = MagicMock(spec=queue.Queue)
|
||||
broken.put.side_effect = RuntimeError("simulated put failure")
|
||||
cli._pending_input = broken
|
||||
cli._interrupt_queue.put("anything")
|
||||
|
||||
# Should not raise.
|
||||
cli._drain_interrupt_queue_to_pending_input()
|
||||
@@ -0,0 +1,170 @@
|
||||
"""End-to-end test simulating CLI interrupt during subagent execution.
|
||||
|
||||
Reproduces the exact scenario:
|
||||
1. Parent agent calls delegate_task
|
||||
2. Child agent is running (simulated with a slow tool)
|
||||
3. User "types a message" (simulated by calling parent.interrupt from another thread)
|
||||
4. Child should detect the interrupt and stop
|
||||
|
||||
This tests the COMPLETE path including _run_single_child, _active_children
|
||||
registration, interrupt propagation, and child detection.
|
||||
"""
|
||||
|
||||
import threading
|
||||
import time
|
||||
import unittest
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from tools.interrupt import set_interrupt
|
||||
|
||||
|
||||
class TestCLISubagentInterrupt(unittest.TestCase):
|
||||
"""Simulate exact CLI scenario."""
|
||||
|
||||
def setUp(self):
|
||||
set_interrupt(False)
|
||||
|
||||
def tearDown(self):
|
||||
set_interrupt(False)
|
||||
|
||||
def test_full_delegate_interrupt_flow(self):
|
||||
"""Full integration: parent runs delegate_task, main thread interrupts."""
|
||||
from run_agent import AIAgent
|
||||
|
||||
interrupt_detected = threading.Event()
|
||||
child_started = threading.Event()
|
||||
child_api_call_count = 0
|
||||
|
||||
# Create a real-enough parent agent
|
||||
parent = AIAgent.__new__(AIAgent)
|
||||
parent._interrupt_requested = False
|
||||
parent._interrupt_message = None
|
||||
parent._active_children = []
|
||||
parent._active_children_lock = threading.Lock()
|
||||
parent.quiet_mode = True
|
||||
parent.model = "test/model"
|
||||
parent.base_url = "http://localhost:1"
|
||||
parent.api_key = "test"
|
||||
parent.provider = "test"
|
||||
parent.api_mode = "chat_completions"
|
||||
parent.platform = "cli"
|
||||
parent.enabled_toolsets = ["terminal", "file"]
|
||||
parent.providers_allowed = None
|
||||
parent.providers_ignored = None
|
||||
parent.providers_order = None
|
||||
parent.provider_sort = None
|
||||
parent.max_tokens = None
|
||||
parent.reasoning_config = None
|
||||
parent.prefill_messages = None
|
||||
parent._session_db = None
|
||||
parent._delegate_depth = 0
|
||||
parent._delegate_spinner = None
|
||||
parent.tool_progress_callback = None
|
||||
parent._execution_thread_id = None
|
||||
|
||||
# We'll track what happens with _active_children
|
||||
original_children = parent._active_children
|
||||
|
||||
# Mock the child's run_conversation to simulate a slow operation
|
||||
# that checks _interrupt_requested like the real one does
|
||||
def mock_child_run_conversation(user_message, **kwargs):
|
||||
child_started.set()
|
||||
# Find the child in parent._active_children
|
||||
child = parent._active_children[-1] if parent._active_children else None
|
||||
|
||||
# Simulate the agent loop: poll _interrupt_requested like run_conversation does
|
||||
for i in range(100): # Up to 10 seconds (100 * 0.1s)
|
||||
if child and child._interrupt_requested:
|
||||
interrupt_detected.set()
|
||||
return {
|
||||
"final_response": "Interrupted!",
|
||||
"messages": [],
|
||||
"api_calls": 1,
|
||||
"completed": False,
|
||||
"interrupted": True,
|
||||
"interrupt_message": child._interrupt_message,
|
||||
}
|
||||
time.sleep(0.1)
|
||||
|
||||
return {
|
||||
"final_response": "Finished without interrupt",
|
||||
"messages": [],
|
||||
"api_calls": 5,
|
||||
"completed": True,
|
||||
"interrupted": False,
|
||||
}
|
||||
|
||||
# Patch AIAgent to use our mock
|
||||
from tools.delegate_tool import _run_single_child
|
||||
from run_agent import IterationBudget
|
||||
|
||||
parent.iteration_budget = IterationBudget(max_total=100)
|
||||
|
||||
# Run delegate in a thread (simulates agent_thread)
|
||||
delegate_result = [None]
|
||||
delegate_error = [None]
|
||||
|
||||
def run_delegate():
|
||||
try:
|
||||
with patch('run_agent.AIAgent') as MockAgent:
|
||||
mock_instance = MagicMock()
|
||||
mock_instance._interrupt_requested = False
|
||||
mock_instance._interrupt_message = None
|
||||
mock_instance._active_children = []
|
||||
mock_instance._active_children_lock = threading.Lock()
|
||||
mock_instance.quiet_mode = True
|
||||
mock_instance.run_conversation = mock_child_run_conversation
|
||||
mock_instance.interrupt = lambda msg=None: setattr(mock_instance, '_interrupt_requested', True) or setattr(mock_instance, '_interrupt_message', msg)
|
||||
mock_instance.tools = []
|
||||
MockAgent.return_value = mock_instance
|
||||
|
||||
# Register child manually (normally done by _build_child_agent)
|
||||
parent._active_children.append(mock_instance)
|
||||
|
||||
result = _run_single_child(
|
||||
task_index=0,
|
||||
goal="Do something slow",
|
||||
child=mock_instance,
|
||||
parent_agent=parent,
|
||||
)
|
||||
delegate_result[0] = result
|
||||
except Exception as e:
|
||||
delegate_error[0] = e
|
||||
|
||||
agent_thread = threading.Thread(target=run_delegate, daemon=True)
|
||||
agent_thread.start()
|
||||
|
||||
# Wait for child to start
|
||||
assert child_started.wait(timeout=5), "Child never started!"
|
||||
|
||||
# Now simulate user interrupt (from main/process thread)
|
||||
time.sleep(0.2) # Give child a moment to be in its loop
|
||||
|
||||
print(f"Parent has {len(parent._active_children)} active children")
|
||||
assert len(parent._active_children) >= 1, f"Expected child in _active_children, got {len(parent._active_children)}"
|
||||
|
||||
# This is what the CLI does:
|
||||
parent.interrupt("Hey stop that")
|
||||
|
||||
print(f"Parent._interrupt_requested: {parent._interrupt_requested}")
|
||||
for i, child in enumerate(parent._active_children):
|
||||
print(f"Child {i}._interrupt_requested: {child._interrupt_requested}")
|
||||
|
||||
# Wait for child to detect interrupt
|
||||
detected = interrupt_detected.wait(timeout=3.0)
|
||||
|
||||
# Wait for delegate to finish
|
||||
agent_thread.join(timeout=5)
|
||||
|
||||
if delegate_error[0]:
|
||||
raise delegate_error[0]
|
||||
|
||||
assert detected, "Child never detected the interrupt!"
|
||||
result = delegate_result[0]
|
||||
assert result is not None, "Delegate returned no result"
|
||||
assert result["status"] == "interrupted", f"Expected 'interrupted', got '{result['status']}'"
|
||||
print(f"✓ Interrupt detected! Result: {result}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,176 @@
|
||||
"""Tests for the light-mode terminal detection + color remap in cli.py.
|
||||
|
||||
Covers the env-override path and the SkinConfig.get_color() wrapper that
|
||||
the resize / light-mode salvage installs at module import time. We don't
|
||||
try to fake an OSC 11 reply — the env-override branch short-circuits
|
||||
before the terminal query, which is the path most users hit.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def cli_mod(monkeypatch):
|
||||
"""Import cli with the light-mode cache cleared each test."""
|
||||
import cli as _cli
|
||||
|
||||
# The module-level _install_skin_light_mode_hook() and import-time
|
||||
# _detect_light_mode() prime ran once at first import. We just reset
|
||||
# the detection cache so the per-test env override takes effect.
|
||||
monkeypatch.setattr(_cli, "_LIGHT_MODE_CACHE", None)
|
||||
return _cli
|
||||
|
||||
|
||||
class TestLightModeDetection:
|
||||
def test_hermes_light_env_true_forces_light(self, cli_mod, monkeypatch):
|
||||
monkeypatch.setenv("HERMES_LIGHT", "1")
|
||||
assert cli_mod._detect_light_mode() is True
|
||||
|
||||
def test_hermes_light_env_false_forces_dark(self, cli_mod, monkeypatch):
|
||||
monkeypatch.setenv("HERMES_LIGHT", "0")
|
||||
# Also blank out other signals so nothing else flips it light.
|
||||
monkeypatch.delenv("HERMES_TUI_LIGHT", raising=False)
|
||||
monkeypatch.delenv("HERMES_TUI_THEME", raising=False)
|
||||
monkeypatch.delenv("HERMES_TUI_BACKGROUND", raising=False)
|
||||
monkeypatch.delenv("COLORFGBG", raising=False)
|
||||
assert cli_mod._detect_light_mode() is False
|
||||
|
||||
def test_theme_hint_light(self, cli_mod, monkeypatch):
|
||||
monkeypatch.delenv("HERMES_LIGHT", raising=False)
|
||||
monkeypatch.delenv("HERMES_TUI_LIGHT", raising=False)
|
||||
monkeypatch.setenv("HERMES_TUI_THEME", "light")
|
||||
assert cli_mod._detect_light_mode() is True
|
||||
|
||||
def test_background_hex_hint_light(self, cli_mod, monkeypatch):
|
||||
monkeypatch.delenv("HERMES_LIGHT", raising=False)
|
||||
monkeypatch.delenv("HERMES_TUI_LIGHT", raising=False)
|
||||
monkeypatch.delenv("HERMES_TUI_THEME", raising=False)
|
||||
monkeypatch.setenv("HERMES_TUI_BACKGROUND", "#FFFFFF")
|
||||
assert cli_mod._detect_light_mode() is True
|
||||
|
||||
def test_background_hex_hint_dark(self, cli_mod, monkeypatch):
|
||||
monkeypatch.delenv("HERMES_LIGHT", raising=False)
|
||||
monkeypatch.delenv("HERMES_TUI_LIGHT", raising=False)
|
||||
monkeypatch.delenv("HERMES_TUI_THEME", raising=False)
|
||||
monkeypatch.setenv("HERMES_TUI_BACKGROUND", "#1a1a2e")
|
||||
monkeypatch.delenv("COLORFGBG", raising=False)
|
||||
assert cli_mod._detect_light_mode() is False
|
||||
|
||||
def test_colorfgbg_light_bg_slot(self, cli_mod, monkeypatch):
|
||||
monkeypatch.delenv("HERMES_LIGHT", raising=False)
|
||||
monkeypatch.delenv("HERMES_TUI_LIGHT", raising=False)
|
||||
monkeypatch.delenv("HERMES_TUI_THEME", raising=False)
|
||||
monkeypatch.delenv("HERMES_TUI_BACKGROUND", raising=False)
|
||||
monkeypatch.setenv("COLORFGBG", "0;15") # bg slot 15 = light
|
||||
assert cli_mod._detect_light_mode() is True
|
||||
|
||||
def test_cache_is_sticky(self, cli_mod, monkeypatch):
|
||||
monkeypatch.setenv("HERMES_LIGHT", "1")
|
||||
assert cli_mod._detect_light_mode() is True
|
||||
# Even if the env flips, the cached result wins until reset.
|
||||
monkeypatch.setenv("HERMES_LIGHT", "0")
|
||||
assert cli_mod._detect_light_mode() is True
|
||||
|
||||
|
||||
class TestOsc11Probe:
|
||||
"""The OSC 11 background probe must never run where its reply can leak
|
||||
into prompt_toolkit's input (a late BEL-terminated reply reads as Ctrl+G
|
||||
= open-editor, trapping the user in a stray editor). Guard the cases we
|
||||
refuse to probe in.
|
||||
"""
|
||||
|
||||
@pytest.mark.parametrize("var", ("SSH_CONNECTION", "SSH_CLIENT", "SSH_TTY"))
|
||||
def test_skips_over_ssh(self, cli_mod, monkeypatch, var):
|
||||
monkeypatch.setattr(cli_mod.sys.stdin, "isatty", lambda: True, raising=False)
|
||||
monkeypatch.setattr(cli_mod.sys.stdout, "isatty", lambda: True, raising=False)
|
||||
for v in ("SSH_CONNECTION", "SSH_CLIENT", "SSH_TTY"):
|
||||
monkeypatch.delenv(v, raising=False)
|
||||
monkeypatch.setenv(var, "1.2.3.4 5555 22")
|
||||
assert cli_mod._query_osc11_background() is None
|
||||
|
||||
def test_skips_when_not_a_tty(self, cli_mod, monkeypatch):
|
||||
monkeypatch.setattr(cli_mod.sys.stdin, "isatty", lambda: False, raising=False)
|
||||
assert cli_mod._query_osc11_background() is None
|
||||
|
||||
|
||||
class TestLightModeRemap:
|
||||
def test_remap_no_op_in_dark_mode(self, cli_mod, monkeypatch):
|
||||
monkeypatch.setenv("HERMES_LIGHT", "0")
|
||||
# Cache is None from the fixture; first call sticks at False.
|
||||
assert cli_mod._maybe_remap_for_light_mode("#FFF8DC") == "#FFF8DC"
|
||||
|
||||
def test_remap_known_dark_color(self, cli_mod, monkeypatch):
|
||||
monkeypatch.setenv("HERMES_LIGHT", "1")
|
||||
# Force the detect cache to True for this test.
|
||||
cli_mod._LIGHT_MODE_CACHE = True
|
||||
assert cli_mod._maybe_remap_for_light_mode("#FFF8DC") == "#1A1A1A"
|
||||
assert cli_mod._maybe_remap_for_light_mode("#FFD700") == "#9A6B00"
|
||||
|
||||
def test_remap_case_insensitive(self, cli_mod, monkeypatch):
|
||||
cli_mod._LIGHT_MODE_CACHE = True
|
||||
# Lowercase input should still remap.
|
||||
assert cli_mod._maybe_remap_for_light_mode("#fff8dc") == "#1A1A1A"
|
||||
|
||||
def test_remap_unknown_color_passthrough(self, cli_mod, monkeypatch):
|
||||
cli_mod._LIGHT_MODE_CACHE = True
|
||||
# A color not in the remap table is returned unchanged.
|
||||
assert cli_mod._maybe_remap_for_light_mode("#ABCDEF") == "#ABCDEF"
|
||||
|
||||
def test_remap_skips_statusbar_paired_colors(self, cli_mod, monkeypatch):
|
||||
"""Colors that live on a dark bg (status bar fg) MUST NOT be
|
||||
remapped — otherwise they go dark-on-dark and disappear.
|
||||
|
||||
Regression guard for the patch-11 fix (intentional table omission).
|
||||
"""
|
||||
cli_mod._LIGHT_MODE_CACHE = True
|
||||
for fg in ("#C0C0C0", "#888888", "#555555", "#8B8682"):
|
||||
assert cli_mod._maybe_remap_for_light_mode(fg) == fg, (
|
||||
f"{fg} is a status-bar fg paired with dark bg; remapping it "
|
||||
"would produce dark-on-dark"
|
||||
)
|
||||
|
||||
|
||||
class TestSkinConfigHook:
|
||||
"""The salvage wraps SkinConfig.get_color at module import time so
|
||||
every skin color read goes through the light-mode remap. Verify
|
||||
the hook installed and functions correctly.
|
||||
"""
|
||||
|
||||
def test_hook_installed(self, cli_mod):
|
||||
from hermes_cli.skin_engine import SkinConfig
|
||||
|
||||
assert getattr(SkinConfig, "_hermes_light_mode_hook_installed", False) is True
|
||||
|
||||
def test_hook_is_idempotent(self, cli_mod):
|
||||
# Calling the installer twice must not double-wrap (the marker
|
||||
# attribute is the guard).
|
||||
from hermes_cli.skin_engine import SkinConfig
|
||||
|
||||
before = SkinConfig.get_color
|
||||
cli_mod._install_skin_light_mode_hook()
|
||||
after = SkinConfig.get_color
|
||||
assert before is after
|
||||
|
||||
def test_skin_color_remaps_through_wrapper_in_light_mode(
|
||||
self, cli_mod, monkeypatch
|
||||
):
|
||||
from hermes_cli.skin_engine import SkinConfig
|
||||
|
||||
cli_mod._LIGHT_MODE_CACHE = True
|
||||
skin = SkinConfig(
|
||||
name="test",
|
||||
colors={"banner_text": "#FFF8DC", "response_border": "#FFD700"},
|
||||
)
|
||||
# The wrapper kicks in at get_color, not at construction time.
|
||||
assert skin.get_color("banner_text") == "#1A1A1A"
|
||||
assert skin.get_color("response_border") == "#9A6B00"
|
||||
|
||||
def test_skin_color_passthrough_in_dark_mode(self, cli_mod, monkeypatch):
|
||||
from hermes_cli.skin_engine import SkinConfig
|
||||
|
||||
cli_mod._LIGHT_MODE_CACHE = False
|
||||
skin = SkinConfig(name="test", colors={"banner_text": "#FFF8DC"})
|
||||
assert skin.get_color("banner_text") == "#FFF8DC"
|
||||
@@ -0,0 +1,72 @@
|
||||
"""Regression tests for loading feedback on slow slash commands."""
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
from cli import HermesCLI
|
||||
|
||||
|
||||
class TestCLILoadingIndicator:
|
||||
def _make_cli(self):
|
||||
cli_obj = HermesCLI.__new__(HermesCLI)
|
||||
cli_obj._app = None
|
||||
cli_obj._last_invalidate = 0.0
|
||||
cli_obj._command_running = False
|
||||
cli_obj._command_status = ""
|
||||
return cli_obj
|
||||
|
||||
def test_skills_command_sets_busy_state_and_prints_status(self, capsys):
|
||||
cli_obj = self._make_cli()
|
||||
seen = {}
|
||||
|
||||
def fake_handle(cmd: str):
|
||||
seen["cmd"] = cmd
|
||||
seen["running"] = cli_obj._command_running
|
||||
seen["status"] = cli_obj._command_status
|
||||
print("skills done")
|
||||
|
||||
with patch.object(cli_obj, "_handle_skills_command", side_effect=fake_handle), \
|
||||
patch.object(cli_obj, "_invalidate") as invalidate_mock:
|
||||
assert cli_obj.process_command("/skills search kubernetes")
|
||||
|
||||
output = capsys.readouterr().out
|
||||
assert "⏳ Searching skills..." in output
|
||||
assert "skills done" in output
|
||||
assert seen == {
|
||||
"cmd": "/skills search kubernetes",
|
||||
"running": True,
|
||||
"status": "Searching skills...",
|
||||
}
|
||||
assert cli_obj._command_running is False
|
||||
assert cli_obj._command_status == ""
|
||||
assert invalidate_mock.call_count == 2
|
||||
|
||||
def test_reload_mcp_sets_busy_state_and_prints_status(self, capsys):
|
||||
cli_obj = self._make_cli()
|
||||
seen = {}
|
||||
|
||||
def fake_reload():
|
||||
seen["running"] = cli_obj._command_running
|
||||
seen["status"] = cli_obj._command_status
|
||||
print("reload done")
|
||||
|
||||
# /reload-mcp now wraps the actual reload in a prompt-cache-invalidation
|
||||
# confirmation prompt (commit 4d7fc0f37). This test exercises the
|
||||
# loading-indicator path, not the confirmation UX, so pre-approve the
|
||||
# reload via config so the handler goes straight into _reload_mcp().
|
||||
fake_cfg = {"approvals": {"mcp_reload_confirm": False}}
|
||||
|
||||
with patch.object(cli_obj, "_reload_mcp", side_effect=fake_reload), \
|
||||
patch.object(cli_obj, "_invalidate") as invalidate_mock, \
|
||||
patch("cli.load_cli_config", return_value=fake_cfg):
|
||||
assert cli_obj.process_command("/reload-mcp")
|
||||
|
||||
output = capsys.readouterr().out
|
||||
assert "⏳ Reloading MCP servers..." in output
|
||||
assert "reload done" in output
|
||||
assert seen == {
|
||||
"running": True,
|
||||
"status": "Reloading MCP servers...",
|
||||
}
|
||||
assert cli_obj._command_running is False
|
||||
assert cli_obj._command_status == ""
|
||||
assert invalidate_mock.call_count == 2
|
||||
@@ -0,0 +1,193 @@
|
||||
from io import StringIO
|
||||
|
||||
from rich.console import Console
|
||||
from rich.markdown import Markdown
|
||||
|
||||
from cli import _render_final_assistant_content
|
||||
|
||||
|
||||
def _render_to_text(renderable) -> str:
|
||||
buf = StringIO()
|
||||
Console(file=buf, width=80, force_terminal=False, color_system=None).print(renderable)
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def test_final_assistant_content_uses_markdown_renderable():
|
||||
renderable = _render_final_assistant_content("# Title\n\n- one\n- two")
|
||||
|
||||
assert isinstance(renderable, Markdown)
|
||||
output = _render_to_text(renderable)
|
||||
assert "Title" in output
|
||||
assert "one" in output
|
||||
assert "two" in output
|
||||
|
||||
|
||||
def test_final_assistant_content_preserves_windows_hidden_dir_paths():
|
||||
renderable = _render_final_assistant_content(
|
||||
r"D:\Projects\SourceCode\hermes-agent\.ai\skills" + "\\"
|
||||
)
|
||||
|
||||
output = _render_to_text(renderable)
|
||||
assert r"D:\Projects\SourceCode\hermes-agent\.ai\skills" + "\\" in output
|
||||
|
||||
|
||||
def test_final_assistant_content_keeps_non_path_markdown_escapes():
|
||||
renderable = _render_final_assistant_content(r"1\. Not an ordered list")
|
||||
|
||||
output = _render_to_text(renderable)
|
||||
assert "1. Not an ordered list" in output
|
||||
assert r"1\." not in output
|
||||
|
||||
|
||||
def test_final_assistant_content_strips_ansi_before_markdown_rendering():
|
||||
renderable = _render_final_assistant_content("\x1b[31m# Title\x1b[0m")
|
||||
|
||||
output = _render_to_text(renderable)
|
||||
assert "Title" in output
|
||||
assert "\x1b" not in output
|
||||
|
||||
|
||||
def test_final_assistant_content_can_strip_markdown_syntax():
|
||||
renderable = _render_final_assistant_content(
|
||||
"***Bold italic***\n~~Strike~~\n- item\n# Title\n`code`",
|
||||
mode="strip",
|
||||
)
|
||||
|
||||
output = _render_to_text(renderable)
|
||||
assert "Bold italic" in output
|
||||
assert "Strike" in output
|
||||
assert "item" in output
|
||||
assert "Title" in output
|
||||
assert "code" in output
|
||||
assert "***" not in output
|
||||
assert "~~" not in output
|
||||
assert "`" not in output
|
||||
|
||||
|
||||
def test_strip_mode_preserves_lists():
|
||||
renderable = _render_final_assistant_content(
|
||||
"**Formatting**\n- Ran prettier\n- Files changed\n- Verified clean",
|
||||
mode="strip",
|
||||
)
|
||||
|
||||
output = _render_to_text(renderable)
|
||||
assert "- Ran prettier" in output
|
||||
assert "- Files changed" in output
|
||||
assert "- Verified clean" in output
|
||||
assert "**" not in output
|
||||
|
||||
|
||||
def test_strip_mode_preserves_ordered_lists():
|
||||
renderable = _render_final_assistant_content(
|
||||
"1. First item\n2. Second item\n3. Third item",
|
||||
mode="strip",
|
||||
)
|
||||
|
||||
output = _render_to_text(renderable)
|
||||
assert "1. First" in output
|
||||
assert "2. Second" in output
|
||||
assert "3. Third" in output
|
||||
|
||||
|
||||
def test_strip_mode_preserves_blockquotes():
|
||||
renderable = _render_final_assistant_content(
|
||||
"> This is quoted text\n> Another quoted line",
|
||||
mode="strip",
|
||||
)
|
||||
|
||||
output = _render_to_text(renderable)
|
||||
assert "> This is quoted" in output
|
||||
assert "> Another quoted" in output
|
||||
|
||||
|
||||
def test_strip_mode_preserves_checkboxes():
|
||||
renderable = _render_final_assistant_content(
|
||||
"- [ ] Todo item\n- [x] Done item",
|
||||
mode="strip",
|
||||
)
|
||||
|
||||
output = _render_to_text(renderable)
|
||||
assert "- [ ] Todo" in output
|
||||
assert "- [x] Done" in output
|
||||
|
||||
|
||||
def test_strip_mode_preserves_table_structure_while_cleaning_cell_markdown():
|
||||
renderable = _render_final_assistant_content(
|
||||
"| Syntax | Example |\n|---|---|\n| Bold | `**bold**` |\n| Strike | `~~strike~~` |",
|
||||
mode="strip",
|
||||
)
|
||||
|
||||
output = _render_to_text(renderable)
|
||||
|
||||
# Inline cell markdown is stripped (the contract this test enforces).
|
||||
assert "**" not in output
|
||||
assert "~~" not in output
|
||||
assert "`" not in output
|
||||
|
||||
# Cell *content* survives, even if the surrounding whitespace was
|
||||
# rewritten by the wcwidth-aware re-aligner. Asserting on bare
|
||||
# cell text keeps this test focused on the strip behaviour rather
|
||||
# than snapshotting incidental column padding (which is what the
|
||||
# CJK-alignment fix changes).
|
||||
assert "Syntax" in output
|
||||
assert "Example" in output
|
||||
assert "Bold" in output and "bold" in output
|
||||
assert "Strike" in output and "strike" in output
|
||||
|
||||
# Structural sanity: the table still renders as pipe-bordered rows
|
||||
# (header + divider + 2 body rows).
|
||||
body_rows = [ln for ln in output.splitlines() if ln.strip().startswith("|")]
|
||||
assert len(body_rows) == 4
|
||||
|
||||
# Every rendered table row shares the same pipe column offsets — the
|
||||
# alignment guarantee from realign_markdown_tables.
|
||||
pipe_cols = [
|
||||
[i for i, ch in enumerate(row) if ch == "|"] for row in body_rows
|
||||
]
|
||||
assert all(p == pipe_cols[0] for p in pipe_cols), (
|
||||
"table rows misaligned after strip-mode rendering:\n"
|
||||
+ "\n".join(body_rows)
|
||||
)
|
||||
|
||||
|
||||
def test_strip_mode_preserves_cron_asterisks_in_plain_text():
|
||||
renderable = _render_final_assistant_content("* * * * *", mode="strip")
|
||||
|
||||
output = _render_to_text(renderable)
|
||||
assert "* * * * *" in output
|
||||
|
||||
# Still treat the canonical 3-asterisk Markdown horizontal rule as decoration.
|
||||
renderable = _render_final_assistant_content("* * *", mode="strip")
|
||||
output = _render_to_text(renderable)
|
||||
assert "* * *" not in output
|
||||
|
||||
|
||||
def test_final_assistant_content_can_leave_markdown_raw():
|
||||
renderable = _render_final_assistant_content("***Bold italic***", mode="raw")
|
||||
|
||||
output = _render_to_text(renderable)
|
||||
assert "***Bold italic***" in output
|
||||
|
||||
|
||||
def test_strip_mode_preserves_intraword_underscores_in_snake_case_identifiers():
|
||||
renderable = _render_final_assistant_content(
|
||||
"Let me look at test_case_with_underscores and SOME_CONST "
|
||||
"then /tmp/snake_case_dir/file_with_name.py",
|
||||
mode="strip",
|
||||
)
|
||||
|
||||
output = _render_to_text(renderable)
|
||||
assert "test_case_with_underscores" in output
|
||||
assert "SOME_CONST" in output
|
||||
assert "snake_case_dir" in output
|
||||
assert "file_with_name" in output
|
||||
|
||||
|
||||
def test_strip_mode_still_strips_boundary_underscore_emphasis():
|
||||
renderable = _render_final_assistant_content(
|
||||
"say _hi_ and __bold__ now",
|
||||
mode="strip",
|
||||
)
|
||||
|
||||
output = _render_to_text(renderable)
|
||||
assert "say hi and bold now" in output
|
||||
@@ -0,0 +1,103 @@
|
||||
"""Tests for automatic MCP reload when config.yaml mcp_servers section changes."""
|
||||
import time
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
|
||||
def _make_cli(tmp_path, mcp_servers=None):
|
||||
"""Create a minimal HermesCLI instance with mocked config."""
|
||||
import cli as cli_mod
|
||||
obj = object.__new__(cli_mod.HermesCLI)
|
||||
obj.config = {"mcp_servers": mcp_servers or {}}
|
||||
obj._agent_running = False
|
||||
obj._last_config_check = 0.0
|
||||
obj._config_mcp_servers = mcp_servers or {}
|
||||
|
||||
cfg_file = tmp_path / "config.yaml"
|
||||
cfg_file.write_text("mcp_servers: {}\n")
|
||||
obj._config_mtime = cfg_file.stat().st_mtime
|
||||
|
||||
obj._reload_mcp = MagicMock()
|
||||
obj._busy_command = MagicMock()
|
||||
obj._busy_command.return_value.__enter__ = MagicMock(return_value=None)
|
||||
obj._busy_command.return_value.__exit__ = MagicMock(return_value=False)
|
||||
obj._slow_command_status = MagicMock(return_value="reloading...")
|
||||
|
||||
return obj, cfg_file
|
||||
|
||||
|
||||
class TestMCPConfigWatch:
|
||||
|
||||
def test_no_change_does_not_reload(self, tmp_path):
|
||||
"""If mtime and mcp_servers unchanged, _reload_mcp is NOT called."""
|
||||
obj, cfg_file = _make_cli(tmp_path)
|
||||
|
||||
with patch("hermes_cli.config.get_config_path", return_value=cfg_file):
|
||||
obj._check_config_mcp_changes()
|
||||
|
||||
obj._reload_mcp.assert_not_called()
|
||||
|
||||
def test_mtime_change_with_same_mcp_servers_does_not_reload(self, tmp_path):
|
||||
"""If file mtime changes but mcp_servers is identical, no reload."""
|
||||
import yaml
|
||||
obj, cfg_file = _make_cli(tmp_path, mcp_servers={"fs": {"command": "npx"}})
|
||||
|
||||
# Write same mcp_servers but touch the file
|
||||
cfg_file.write_text(yaml.dump({"mcp_servers": {"fs": {"command": "npx"}}}))
|
||||
# Force mtime to appear changed
|
||||
obj._config_mtime = 0.0
|
||||
|
||||
with patch("hermes_cli.config.get_config_path", return_value=cfg_file):
|
||||
obj._check_config_mcp_changes()
|
||||
|
||||
obj._reload_mcp.assert_not_called()
|
||||
|
||||
def test_new_mcp_server_triggers_reload(self, tmp_path):
|
||||
"""Adding a new MCP server to config triggers auto-reload."""
|
||||
import yaml
|
||||
obj, cfg_file = _make_cli(tmp_path, mcp_servers={})
|
||||
|
||||
# Simulate user adding a new MCP server to config.yaml
|
||||
cfg_file.write_text(yaml.dump({"mcp_servers": {"github": {"url": "https://mcp.github.com"}}}))
|
||||
obj._config_mtime = 0.0 # force stale mtime
|
||||
|
||||
with patch("hermes_cli.config.get_config_path", return_value=cfg_file):
|
||||
obj._check_config_mcp_changes()
|
||||
|
||||
obj._reload_mcp.assert_called_once()
|
||||
|
||||
def test_removed_mcp_server_triggers_reload(self, tmp_path):
|
||||
"""Removing an MCP server from config triggers auto-reload."""
|
||||
import yaml
|
||||
obj, cfg_file = _make_cli(tmp_path, mcp_servers={"github": {"url": "https://mcp.github.com"}})
|
||||
|
||||
# Simulate user removing the server
|
||||
cfg_file.write_text(yaml.dump({"mcp_servers": {}}))
|
||||
obj._config_mtime = 0.0
|
||||
|
||||
with patch("hermes_cli.config.get_config_path", return_value=cfg_file):
|
||||
obj._check_config_mcp_changes()
|
||||
|
||||
obj._reload_mcp.assert_called_once()
|
||||
|
||||
def test_interval_throttle_skips_check(self, tmp_path):
|
||||
"""If called within CONFIG_WATCH_INTERVAL, stat() is skipped."""
|
||||
obj, cfg_file = _make_cli(tmp_path)
|
||||
obj._last_config_check = time.monotonic() # just checked
|
||||
|
||||
with patch("hermes_cli.config.get_config_path", return_value=cfg_file), \
|
||||
patch.object(Path, "stat") as mock_stat:
|
||||
obj._check_config_mcp_changes()
|
||||
mock_stat.assert_not_called()
|
||||
|
||||
obj._reload_mcp.assert_not_called()
|
||||
|
||||
def test_missing_config_file_does_not_crash(self, tmp_path):
|
||||
"""If config.yaml doesn't exist, _check_config_mcp_changes is a no-op."""
|
||||
obj, cfg_file = _make_cli(tmp_path)
|
||||
missing = tmp_path / "nonexistent.yaml"
|
||||
|
||||
with patch("hermes_cli.config.get_config_path", return_value=missing):
|
||||
obj._check_config_mcp_changes() # should not raise
|
||||
|
||||
obj._reload_mcp.assert_not_called()
|
||||
@@ -0,0 +1,389 @@
|
||||
"""Regression tests for CLI fresh-session commands."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime, timedelta
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from hermes_state import SessionDB
|
||||
from tools.todo_tool import TodoStore
|
||||
|
||||
|
||||
class _FakeCompressor:
|
||||
"""Minimal stand-in for ContextCompressor."""
|
||||
|
||||
def __init__(self):
|
||||
self.last_prompt_tokens = 500
|
||||
self.last_completion_tokens = 200
|
||||
self.last_total_tokens = 700
|
||||
self.compression_count = 3
|
||||
self._context_probed = True
|
||||
|
||||
|
||||
class _FakeAgent:
|
||||
def __init__(self, session_id: str, session_start):
|
||||
self.session_id = session_id
|
||||
self.session_start = session_start
|
||||
self.model = "anthropic/claude-opus-4.6"
|
||||
self._last_flushed_db_idx = 7
|
||||
self._todo_store = TodoStore()
|
||||
self._todo_store.write(
|
||||
[{"id": "t1", "content": "unfinished task", "status": "in_progress"}]
|
||||
)
|
||||
self.commit_memory_session = MagicMock()
|
||||
self._invalidate_system_prompt = MagicMock()
|
||||
|
||||
# Token counters (non-zero to verify reset)
|
||||
self.session_total_tokens = 1000
|
||||
self.session_input_tokens = 600
|
||||
self.session_output_tokens = 400
|
||||
self.session_prompt_tokens = 550
|
||||
self.session_completion_tokens = 350
|
||||
self.session_cache_read_tokens = 100
|
||||
self.session_cache_write_tokens = 50
|
||||
self.session_reasoning_tokens = 80
|
||||
self.session_api_calls = 5
|
||||
self.session_estimated_cost_usd = 0.42
|
||||
self.session_cost_status = "estimated"
|
||||
self.session_cost_source = "openrouter"
|
||||
self.context_compressor = _FakeCompressor()
|
||||
|
||||
def reset_session_state(self):
|
||||
"""Mirror the real AIAgent.reset_session_state()."""
|
||||
self.session_total_tokens = 0
|
||||
self.session_input_tokens = 0
|
||||
self.session_output_tokens = 0
|
||||
self.session_prompt_tokens = 0
|
||||
self.session_completion_tokens = 0
|
||||
self.session_cache_read_tokens = 0
|
||||
self.session_cache_write_tokens = 0
|
||||
self.session_reasoning_tokens = 0
|
||||
self.session_api_calls = 0
|
||||
self.session_estimated_cost_usd = 0.0
|
||||
self.session_cost_status = "unknown"
|
||||
self.session_cost_source = "none"
|
||||
if hasattr(self, "context_compressor") and self.context_compressor:
|
||||
self.context_compressor.last_prompt_tokens = 0
|
||||
self.context_compressor.last_completion_tokens = 0
|
||||
self.context_compressor.last_total_tokens = 0
|
||||
self.context_compressor.compression_count = 0
|
||||
self.context_compressor._context_probed = False
|
||||
|
||||
|
||||
def _make_cli(env_overrides=None, config_overrides=None, **kwargs):
|
||||
"""Create a HermesCLI instance with minimal mocking."""
|
||||
_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"},
|
||||
}
|
||||
if config_overrides:
|
||||
_clean_config.update(config_overrides)
|
||||
clean_env = {"LLM_MODEL": "", "HERMES_MAX_ITERATIONS": ""}
|
||||
if env_overrides:
|
||||
clean_env.update(env_overrides)
|
||||
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(**kwargs)
|
||||
|
||||
|
||||
def _prepare_cli_with_active_session(tmp_path):
|
||||
cli = _make_cli()
|
||||
cli._session_db = SessionDB(db_path=tmp_path / "state.db")
|
||||
cli._session_db.create_session(session_id=cli.session_id, source="cli", model=cli.model)
|
||||
|
||||
cli.agent = _FakeAgent(cli.session_id, cli.session_start)
|
||||
cli.conversation_history = [{"role": "user", "content": "hello"}]
|
||||
|
||||
old_session_start = cli.session_start - timedelta(seconds=1)
|
||||
cli.session_start = old_session_start
|
||||
cli.agent.session_start = old_session_start
|
||||
|
||||
# Bypass the destructive-slash confirmation gate — these tests focus on
|
||||
# the new-session mechanics, not the confirm prompt itself (covered in
|
||||
# tests/cli/test_destructive_slash_confirm.py).
|
||||
cli._confirm_destructive_slash = lambda *_a, **_kw: "once"
|
||||
return cli
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_session_id_context():
|
||||
from gateway.session_context import _UNSET, _VAR_MAP
|
||||
|
||||
yield
|
||||
os.environ.pop("HERMES_SESSION_ID", None)
|
||||
_VAR_MAP["HERMES_SESSION_ID"].set(_UNSET)
|
||||
|
||||
|
||||
def test_new_command_creates_real_fresh_session_and_resets_agent_state(tmp_path):
|
||||
cli = _prepare_cli_with_active_session(tmp_path)
|
||||
old_session_id = cli.session_id
|
||||
old_session_start = cli.session_start
|
||||
|
||||
cli.process_command("/new")
|
||||
|
||||
assert cli.session_id != old_session_id
|
||||
|
||||
old_session = cli._session_db.get_session(old_session_id)
|
||||
assert old_session is not None
|
||||
assert old_session["end_reason"] == "new_session"
|
||||
|
||||
new_session = cli._session_db.get_session(cli.session_id)
|
||||
assert new_session is not None
|
||||
|
||||
cli._session_db.append_message(cli.session_id, role="user", content="next turn")
|
||||
|
||||
assert cli.agent.session_id == cli.session_id
|
||||
assert cli.agent._last_flushed_db_idx == 0
|
||||
assert cli.agent._todo_store.read() == []
|
||||
assert cli.session_start > old_session_start
|
||||
assert cli.agent.session_start == cli.session_start
|
||||
cli.agent._invalidate_system_prompt.assert_called_once()
|
||||
|
||||
|
||||
def test_new_session_queues_boundary_commit_with_snapshot(tmp_path):
|
||||
"""/new hands the OLD session's history + ids to the memory manager's
|
||||
serialized boundary task instead of blocking on extraction inline."""
|
||||
cli = _prepare_cli_with_active_session(tmp_path)
|
||||
old_session_id = cli.session_id
|
||||
|
||||
mm = MagicMock()
|
||||
cli.agent._memory_manager = mm
|
||||
|
||||
cli.process_command("/new")
|
||||
|
||||
mm.commit_session_boundary_async.assert_called_once()
|
||||
args, kwargs = mm.commit_session_boundary_async.call_args
|
||||
assert args[0] == [{"role": "user", "content": "hello"}]
|
||||
assert kwargs["new_session_id"] == cli.session_id
|
||||
assert kwargs["parent_session_id"] == old_session_id
|
||||
assert kwargs["reason"] == "new_session"
|
||||
# The queued path replaces the inline switch — not both.
|
||||
mm.on_session_switch.assert_not_called()
|
||||
|
||||
|
||||
def test_new_session_without_history_switches_inline(tmp_path):
|
||||
"""No old-session history → nothing to extract → plain inline switch."""
|
||||
cli = _prepare_cli_with_active_session(tmp_path)
|
||||
cli.conversation_history = []
|
||||
|
||||
mm = MagicMock()
|
||||
cli.agent._memory_manager = mm
|
||||
|
||||
cli.process_command("/new")
|
||||
|
||||
mm.commit_session_boundary_async.assert_not_called()
|
||||
mm.on_session_switch.assert_called_once()
|
||||
_, kwargs = mm.on_session_switch.call_args
|
||||
assert kwargs["reset"] is True
|
||||
|
||||
|
||||
def test_new_session_delivers_context_engine_boundary_synchronously(tmp_path):
|
||||
"""The context-engine on_session_end must fire during /new itself.
|
||||
|
||||
It is cheap local state work and ordering-sensitive: it must land before
|
||||
reset_session_state() rebinds the engine to the new session. The LLM-bound
|
||||
provider extraction is what gets deferred, not this."""
|
||||
cli = _prepare_cli_with_active_session(tmp_path)
|
||||
old_session_id = cli.session_id
|
||||
|
||||
engine_calls = []
|
||||
cli.agent.context_compressor.on_session_end = (
|
||||
lambda sid, msgs: engine_calls.append((sid, list(msgs)))
|
||||
)
|
||||
|
||||
cli.process_command("/new")
|
||||
|
||||
assert engine_calls == [(old_session_id, [{"role": "user", "content": "hello"}])]
|
||||
|
||||
|
||||
def test_run_cleanup_flushes_pending_memory_manager_work(tmp_path):
|
||||
"""A '/new then quit' must not drop the queued old-session extraction.
|
||||
|
||||
_run_cleanup gives the manager's serialized worker a bounded drain via
|
||||
flush_pending() before shutdown_all()'s short-fuse drain runs."""
|
||||
import cli as _cli_mod
|
||||
|
||||
agent = MagicMock()
|
||||
mm = MagicMock()
|
||||
mm.flush_pending.return_value = True
|
||||
agent._memory_manager = mm
|
||||
agent._session_messages = []
|
||||
|
||||
old_ref = _cli_mod._active_agent_ref
|
||||
_cli_mod._active_agent_ref = agent
|
||||
_cli_mod._cleanup_done = False
|
||||
try:
|
||||
_cli_mod._run_cleanup(notify_session_finalize=False)
|
||||
finally:
|
||||
_cli_mod._cleanup_done = True
|
||||
_cli_mod._active_agent_ref = old_ref
|
||||
|
||||
mm.flush_pending.assert_called_once_with(timeout=10)
|
||||
|
||||
|
||||
def test_new_command_rotates_hermes_session_id_env_and_context(tmp_path):
|
||||
from gateway.session_context import _VAR_MAP, get_session_env
|
||||
|
||||
cli = _prepare_cli_with_active_session(tmp_path)
|
||||
old_session_id = cli.session_id
|
||||
os.environ["HERMES_SESSION_ID"] = old_session_id
|
||||
_VAR_MAP["HERMES_SESSION_ID"].set(old_session_id)
|
||||
|
||||
cli.process_command("/new")
|
||||
|
||||
assert cli.session_id != old_session_id
|
||||
assert os.environ["HERMES_SESSION_ID"] == cli.session_id
|
||||
assert get_session_env("HERMES_SESSION_ID") == cli.session_id
|
||||
|
||||
|
||||
def test_reset_command_is_alias_for_new_session(tmp_path):
|
||||
cli = _prepare_cli_with_active_session(tmp_path)
|
||||
old_session_id = cli.session_id
|
||||
|
||||
cli.process_command("/reset")
|
||||
|
||||
assert cli.session_id != old_session_id
|
||||
assert cli._session_db.get_session(old_session_id)["end_reason"] == "new_session"
|
||||
assert cli._session_db.get_session(cli.session_id) is not None
|
||||
|
||||
|
||||
def test_clear_command_starts_new_session_before_redrawing(tmp_path):
|
||||
cli = _prepare_cli_with_active_session(tmp_path)
|
||||
cli.console = MagicMock()
|
||||
cli.show_banner = MagicMock()
|
||||
|
||||
old_session_id = cli.session_id
|
||||
cli.process_command("/clear")
|
||||
|
||||
assert cli.session_id != old_session_id
|
||||
assert cli._session_db.get_session(old_session_id)["end_reason"] == "new_session"
|
||||
assert cli._session_db.get_session(cli.session_id) is not None
|
||||
cli.console.clear.assert_called_once()
|
||||
cli.show_banner.assert_called_once()
|
||||
assert cli.conversation_history == []
|
||||
|
||||
|
||||
def test_new_session_resets_token_counters(tmp_path):
|
||||
"""Regression test for #2099: /new must zero all token counters."""
|
||||
cli = _prepare_cli_with_active_session(tmp_path)
|
||||
|
||||
# Verify counters are non-zero before reset
|
||||
agent = cli.agent
|
||||
assert agent.session_total_tokens > 0
|
||||
assert agent.session_api_calls > 0
|
||||
assert agent.context_compressor.compression_count > 0
|
||||
|
||||
cli.process_command("/new")
|
||||
|
||||
# All agent token counters must be zero
|
||||
assert agent.session_total_tokens == 0
|
||||
assert agent.session_input_tokens == 0
|
||||
assert agent.session_output_tokens == 0
|
||||
assert agent.session_prompt_tokens == 0
|
||||
assert agent.session_completion_tokens == 0
|
||||
assert agent.session_cache_read_tokens == 0
|
||||
assert agent.session_cache_write_tokens == 0
|
||||
assert agent.session_reasoning_tokens == 0
|
||||
assert agent.session_api_calls == 0
|
||||
assert agent.session_estimated_cost_usd == 0.0
|
||||
assert agent.session_cost_status == "unknown"
|
||||
assert agent.session_cost_source == "none"
|
||||
|
||||
# Context compressor counters must also be zero
|
||||
comp = agent.context_compressor
|
||||
assert comp.last_prompt_tokens == 0
|
||||
assert comp.last_completion_tokens == 0
|
||||
assert comp.last_total_tokens == 0
|
||||
assert comp.compression_count == 0
|
||||
assert comp._context_probed is False
|
||||
|
||||
|
||||
def test_new_session_with_title(capsys):
|
||||
"""new_session(title=...) creates a session and sets the title."""
|
||||
cli = _make_cli()
|
||||
cli._session_db = MagicMock()
|
||||
cli.agent = _FakeAgent("old_session_id", datetime.now())
|
||||
cli.conversation_history = []
|
||||
|
||||
cli.new_session(title="My Test Session")
|
||||
|
||||
# Assert set_session_title was called with the new session ID and sanitized title
|
||||
cli._session_db.set_session_title.assert_called_once()
|
||||
call_args = cli._session_db.set_session_title.call_args
|
||||
assert call_args[0][0] == cli.session_id
|
||||
assert call_args[0][1] == "My Test Session"
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "My Test Session" in captured.out
|
||||
|
||||
|
||||
def test_new_session_with_duplicate_title_surfaces_error(capsys):
|
||||
"""new_session(title=...) handles ValueError from a duplicate-title conflict.
|
||||
|
||||
The session is still created; the title assignment fails; the success banner
|
||||
must not claim the rejected title as the session name.
|
||||
"""
|
||||
cli = _make_cli()
|
||||
cli._session_db = MagicMock()
|
||||
cli._session_db.set_session_title.side_effect = ValueError(
|
||||
"Title 'Dup' is already in use by session abc-123"
|
||||
)
|
||||
cli.agent = _FakeAgent("old_session_id", datetime.now())
|
||||
cli.conversation_history = []
|
||||
|
||||
# Capture warnings printed via cli._cprint. After importlib.reload(),
|
||||
# the method's __globals__ dict is the one from the live module — patch
|
||||
# the exact dict the method will read.
|
||||
warnings: list[str] = []
|
||||
method_globals = cli.new_session.__globals__
|
||||
original = method_globals["_cprint"]
|
||||
method_globals["_cprint"] = lambda msg: warnings.append(msg)
|
||||
try:
|
||||
cli.new_session(title="Dup")
|
||||
finally:
|
||||
method_globals["_cprint"] = original
|
||||
|
||||
cli._session_db.set_session_title.assert_called_once()
|
||||
joined = "\n".join(warnings)
|
||||
assert "already in use" in joined
|
||||
assert "session started untitled" in joined
|
||||
|
||||
# The success banner must NOT claim the rejected title as the session name.
|
||||
captured = capsys.readouterr()
|
||||
assert "New session started: Dup" not in captured.out
|
||||
assert "New session started!" in captured.out
|
||||
@@ -0,0 +1,136 @@
|
||||
"""The base-CLI petdex pane: reactive half-block sprite above the prompt.
|
||||
|
||||
Mirrors the TUI's PetPane. The methods are tested in isolation via __new__ so
|
||||
we don't pay the full HermesCLI.__init__ cost; a synthetic spritesheet exercises
|
||||
the real engine decode + half-block fragment building.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
|
||||
import pytest
|
||||
|
||||
from agent.pet import store
|
||||
from agent.pet.constants import FRAME_H, FRAME_W
|
||||
from agent.pet.render import PetRenderer
|
||||
from cli import HermesCLI
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def boba_like(tmp_path, monkeypatch):
|
||||
"""Install a synthetic pet into a temp HERMES_HOME and return its slug."""
|
||||
from PIL import Image
|
||||
|
||||
home = tmp_path / ".hermes"
|
||||
home.mkdir()
|
||||
monkeypatch.setenv("HERMES_HOME", str(home))
|
||||
|
||||
cols, rows = 8, 9
|
||||
sheet = Image.new("RGBA", (FRAME_W * cols, FRAME_H * rows), (0, 0, 0, 0))
|
||||
for r in range(rows):
|
||||
color = (20 + r * 25, 60, 120, 255)
|
||||
for c in range(cols):
|
||||
block = Image.new("RGBA", (FRAME_W, FRAME_H), color)
|
||||
sheet.paste(block, (c * FRAME_W, r * FRAME_H))
|
||||
|
||||
pet_dir = store.pets_dir() / "boba"
|
||||
pet_dir.mkdir(parents=True, exist_ok=True)
|
||||
sheet.save(pet_dir / "spritesheet.webp")
|
||||
(pet_dir / "pet.json").write_text(
|
||||
'{"id":"boba","displayName":"Boba","description":"d","spritesheetPath":"spritesheet.webp"}'
|
||||
)
|
||||
return "boba"
|
||||
|
||||
|
||||
def _make_cli():
|
||||
cli_obj = HermesCLI.__new__(HermesCLI)
|
||||
cli_obj._app = None
|
||||
cli_obj._pet_lock = threading.Lock()
|
||||
cli_obj._pet_enabled = False
|
||||
cli_obj._pet_renderer = None
|
||||
cli_obj._pet_slug = ""
|
||||
cli_obj._pet_cols = 18
|
||||
cli_obj._pet_scale = 0.7
|
||||
cli_obj._pet_frames_cache = {}
|
||||
cli_obj._pet_frame_idx = 0
|
||||
cli_obj._agent_running = False
|
||||
# Transient-beat + reasoning state (set by HermesCLI.__init__ in production).
|
||||
cli_obj._pet_event = ""
|
||||
cli_obj._pet_event_until = 0.0
|
||||
cli_obj._pet_reasoning = False
|
||||
# Blocking-modal state — a live one maps the pet to `waiting`.
|
||||
cli_obj._approval_state = None
|
||||
cli_obj._clarify_state = None
|
||||
cli_obj._sudo_state = None
|
||||
cli_obj._secret_state = None
|
||||
cli_obj._slash_confirm_state = None
|
||||
return cli_obj
|
||||
|
||||
|
||||
def test_pet_state_tracks_agent_running():
|
||||
cli_obj = _make_cli()
|
||||
assert cli_obj._derive_pet_state() == "idle"
|
||||
cli_obj._agent_running = True
|
||||
assert cli_obj._derive_pet_state() == "run"
|
||||
|
||||
|
||||
def test_pet_state_waits_on_a_blocking_modal():
|
||||
# A live clarify/approval pauses the agent on the user → `waiting`, even
|
||||
# while the turn is technically still running.
|
||||
cli_obj = _make_cli()
|
||||
cli_obj._agent_running = True
|
||||
cli_obj._clarify_state = {"question": "?"}
|
||||
assert cli_obj._derive_pet_state() == "waiting"
|
||||
|
||||
|
||||
def test_pet_pane_collapsed_when_disabled():
|
||||
# No renderer resolved → the window reports zero height and no fragments,
|
||||
# so it's invisible for users without a pet.
|
||||
cli_obj = _make_cli()
|
||||
assert cli_obj._pet_widget_height() == 0
|
||||
assert cli_obj._pet_fragments() == []
|
||||
|
||||
|
||||
def test_pet_fragments_render_half_blocks(boba_like):
|
||||
cli_obj = _make_cli()
|
||||
cli_obj._pet_renderer = PetRenderer(
|
||||
str(store.load_pet("boba").spritesheet), mode="unicode", scale=0.4, unicode_cols=14
|
||||
)
|
||||
cli_obj._pet_cols = 14
|
||||
cli_obj._pet_enabled = True
|
||||
|
||||
height = cli_obj._pet_widget_height()
|
||||
assert height > 0
|
||||
|
||||
frags = cli_obj._pet_fragments()
|
||||
assert frags, "expected fragments for an enabled pet"
|
||||
# Each fragment is a (style, text) pair; glyphs are half-blocks or blanks.
|
||||
glyphs = {text for _, text in frags}
|
||||
assert glyphs <= {"▀", "▄", " ", "\n"}
|
||||
# Opaque cells carry a truecolor foreground style.
|
||||
assert any(text == "▀" and "fg:#" in style for style, text in frags)
|
||||
# Row count in the fragment stream matches the reported window height.
|
||||
assert sum(1 for _, text in frags if text == "\n") == height - 1
|
||||
|
||||
|
||||
def test_pet_resolve_config_enables_and_disables(boba_like):
|
||||
from hermes_cli.config import load_config, save_config
|
||||
|
||||
cli_obj = _make_cli()
|
||||
|
||||
cfg = load_config()
|
||||
cfg.setdefault("display", {}).setdefault("pet", {})
|
||||
cfg["display"]["pet"].update({"enabled": True, "slug": "boba"})
|
||||
save_config(cfg)
|
||||
|
||||
cli_obj._pet_resolve_config()
|
||||
assert cli_obj._pet_enabled is True
|
||||
assert cli_obj._pet_renderer is not None
|
||||
assert cli_obj._pet_slug == "boba"
|
||||
|
||||
cfg["display"]["pet"]["enabled"] = False
|
||||
save_config(cfg)
|
||||
cli_obj._pet_resolve_config()
|
||||
assert cli_obj._pet_enabled is False
|
||||
assert cli_obj._pet_renderer is None
|
||||
@@ -0,0 +1,160 @@
|
||||
"""Tests for slash command prefix matching in HermesCLI.process_command."""
|
||||
from unittest.mock import MagicMock, patch
|
||||
from cli import HermesCLI
|
||||
|
||||
|
||||
def _make_cli():
|
||||
cli_obj = HermesCLI.__new__(HermesCLI)
|
||||
cli_obj.config = {}
|
||||
cli_obj.console = MagicMock()
|
||||
cli_obj.agent = None
|
||||
cli_obj.conversation_history = []
|
||||
cli_obj.session_id = None
|
||||
cli_obj._pending_input = MagicMock()
|
||||
return cli_obj
|
||||
|
||||
|
||||
class TestSlashCommandPrefixMatching:
|
||||
def test_unique_prefix_dispatches_command(self):
|
||||
"""/con should dispatch to /config when it uniquely matches."""
|
||||
cli_obj = _make_cli()
|
||||
with patch.object(cli_obj, 'show_config') as mock_config:
|
||||
cli_obj.process_command("/con")
|
||||
mock_config.assert_called_once()
|
||||
|
||||
def test_unique_prefix_with_args_does_not_recurse(self):
|
||||
"""/con set key value should expand to /config set key value without infinite recursion."""
|
||||
cli_obj = _make_cli()
|
||||
dispatched = []
|
||||
|
||||
original = cli_obj.process_command.__func__
|
||||
|
||||
def counting_process_command(self_inner, cmd):
|
||||
dispatched.append(cmd)
|
||||
if len(dispatched) > 5:
|
||||
raise RecursionError("process_command called too many times")
|
||||
return original(self_inner, cmd)
|
||||
|
||||
# Mock show_config since the test is about recursion, not config display
|
||||
with patch.object(type(cli_obj), 'process_command', counting_process_command), \
|
||||
patch.object(cli_obj, 'show_config'):
|
||||
try:
|
||||
cli_obj.process_command("/con set key value")
|
||||
except RecursionError:
|
||||
assert False, "process_command recursed infinitely"
|
||||
|
||||
# Should have been called at most twice: once for /con set..., once for /config set...
|
||||
assert len(dispatched) <= 2
|
||||
|
||||
def test_exact_command_with_args_does_not_recurse(self):
|
||||
"""/config set key value hits exact branch and does not loop back to prefix."""
|
||||
cli_obj = _make_cli()
|
||||
call_count = [0]
|
||||
|
||||
original_pc = HermesCLI.process_command
|
||||
|
||||
def guarded(self_inner, cmd):
|
||||
call_count[0] += 1
|
||||
if call_count[0] > 10:
|
||||
raise RecursionError("Infinite recursion detected")
|
||||
return original_pc(self_inner, cmd)
|
||||
|
||||
# Mock show_config since the test is about recursion, not config display
|
||||
with patch.object(HermesCLI, 'process_command', guarded), \
|
||||
patch.object(cli_obj, 'show_config'):
|
||||
try:
|
||||
cli_obj.process_command("/config set key value")
|
||||
except RecursionError:
|
||||
assert False, "Recursed infinitely on /config set key value"
|
||||
|
||||
assert call_count[0] <= 3
|
||||
|
||||
def test_ambiguous_prefix_shows_suggestions(self):
|
||||
"""/re matches multiple commands — should show ambiguous message."""
|
||||
cli_obj = _make_cli()
|
||||
with patch("cli._cprint") as mock_cprint:
|
||||
cli_obj.process_command("/re")
|
||||
printed = " ".join(str(c) for c in mock_cprint.call_args_list)
|
||||
assert "Ambiguous" in printed or "Did you mean" in printed
|
||||
|
||||
def test_unknown_command_shows_error(self):
|
||||
"""/xyz should show unknown command error."""
|
||||
cli_obj = _make_cli()
|
||||
with patch("cli._cprint") as mock_cprint:
|
||||
cli_obj.process_command("/xyz")
|
||||
printed = " ".join(str(c) for c in mock_cprint.call_args_list)
|
||||
assert "Unknown command" in printed
|
||||
|
||||
def test_exact_command_still_works(self):
|
||||
"""/help should still work as exact match."""
|
||||
cli_obj = _make_cli()
|
||||
with patch.object(cli_obj, 'show_help') as mock_help:
|
||||
cli_obj.process_command("/help")
|
||||
mock_help.assert_called_once()
|
||||
|
||||
def test_skill_command_prefix_matches(self):
|
||||
"""A prefix that uniquely matches a skill command should dispatch it."""
|
||||
cli_obj = _make_cli()
|
||||
fake_skill = {"/test-skill-xyz": {"name": "Test Skill", "description": "test"}}
|
||||
printed = []
|
||||
cli_obj.console.print = lambda *a, **kw: printed.append(str(a))
|
||||
|
||||
import cli as cli_mod
|
||||
with patch.object(cli_mod, '_skill_commands', fake_skill):
|
||||
cli_obj.process_command("/test-skill-xy")
|
||||
|
||||
# Should NOT show "Unknown command" — should have dispatched or attempted skill
|
||||
unknown = any("Unknown command" in p for p in printed)
|
||||
assert not unknown, f"Expected skill prefix to match, got: {printed}"
|
||||
|
||||
def test_ambiguous_between_builtin_and_skill(self):
|
||||
"""Ambiguous prefix spanning builtin + skill commands shows suggestions."""
|
||||
cli_obj = _make_cli()
|
||||
# /help-extra is a fake skill that shares /hel prefix with /help
|
||||
fake_skill = {"/help-extra": {"name": "Help Extra", "description": "test"}}
|
||||
|
||||
import cli as cli_mod
|
||||
with patch.object(cli_mod, '_skill_commands', fake_skill), patch.object(cli_obj, 'show_help') as mock_help:
|
||||
cli_obj.process_command("/help")
|
||||
|
||||
# /help is an exact match so should work normally, not show ambiguous
|
||||
mock_help.assert_called_once()
|
||||
printed = " ".join(str(c) for c in cli_obj.console.print.call_args_list)
|
||||
assert "Ambiguous" not in printed
|
||||
|
||||
def test_shortest_match_preferred_over_longer_skill(self):
|
||||
"""/qui should dispatch to /quit (5 chars) not report ambiguous with /quint-pipeline (15 chars)."""
|
||||
cli_obj = _make_cli()
|
||||
fake_skill = {"/quint-pipeline": {"name": "Quint Pipeline", "description": "test"}}
|
||||
|
||||
import cli as cli_mod
|
||||
with patch.object(cli_mod, '_skill_commands', fake_skill):
|
||||
# /quit is caught by the exact "/quit" branch → process_command returns False
|
||||
result = cli_obj.process_command("/qui")
|
||||
|
||||
# Returns False because /quit was dispatched (exits chat loop)
|
||||
assert result is False
|
||||
printed = " ".join(str(c) for c in cli_obj.console.print.call_args_list)
|
||||
assert "Ambiguous" not in printed
|
||||
|
||||
def test_tied_shortest_matches_still_ambiguous(self):
|
||||
"""/re matches /reset and /retry (both 6 chars) — no unique shortest, stays ambiguous."""
|
||||
cli_obj = _make_cli()
|
||||
printed = []
|
||||
import cli as cli_mod
|
||||
with patch.object(cli_mod, '_cprint', side_effect=lambda t: printed.append(t)):
|
||||
cli_obj.process_command("/re")
|
||||
combined = " ".join(printed)
|
||||
assert "Ambiguous" in combined or "Did you mean" in combined
|
||||
|
||||
def test_exact_typed_name_dispatches_over_longer_match(self):
|
||||
"""/help typed with /help-extra skill installed → exact match wins."""
|
||||
cli_obj = _make_cli()
|
||||
fake_skill = {"/help-extra": {"name": "Help Extra", "description": ""}}
|
||||
import cli as cli_mod
|
||||
with patch.object(cli_mod, '_skill_commands', fake_skill), \
|
||||
patch.object(cli_obj, 'show_help') as mock_help:
|
||||
cli_obj.process_command("/help")
|
||||
mock_help.assert_called_once()
|
||||
printed = " ".join(str(c) for c in cli_obj.console.print.call_args_list)
|
||||
assert "Ambiguous" not in printed
|
||||
@@ -0,0 +1,127 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import os
|
||||
import sys
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def _make_real_cli(**kwargs):
|
||||
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(),
|
||||
}
|
||||
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(**kwargs)
|
||||
|
||||
|
||||
class _DummyCLI:
|
||||
def __init__(self, **kwargs):
|
||||
self.kwargs = kwargs
|
||||
self.session_id = "session-123"
|
||||
self.system_prompt = "base prompt"
|
||||
self.preloaded_skills = []
|
||||
|
||||
def show_banner(self):
|
||||
return None
|
||||
|
||||
def show_tools(self):
|
||||
return None
|
||||
|
||||
def show_toolsets(self):
|
||||
return None
|
||||
|
||||
def run(self):
|
||||
return None
|
||||
|
||||
|
||||
def test_main_applies_preloaded_skills_to_system_prompt(monkeypatch):
|
||||
import cli as cli_mod
|
||||
|
||||
created = {}
|
||||
|
||||
def fake_cli(**kwargs):
|
||||
created["cli"] = _DummyCLI(**kwargs)
|
||||
return created["cli"]
|
||||
|
||||
monkeypatch.setattr(cli_mod, "HermesCLI", fake_cli)
|
||||
monkeypatch.setattr(
|
||||
cli_mod,
|
||||
"build_preloaded_skills_prompt",
|
||||
lambda skills, task_id=None: ("skill prompt", ["hermes-agent-dev", "github-auth"], []),
|
||||
)
|
||||
|
||||
with pytest.raises(SystemExit):
|
||||
cli_mod.main(skills="hermes-agent-dev,github-auth", list_tools=True)
|
||||
|
||||
cli_obj = created["cli"]
|
||||
assert cli_obj.system_prompt == "base prompt\n\nskill prompt"
|
||||
assert cli_obj.preloaded_skills == ["hermes-agent-dev", "github-auth"]
|
||||
|
||||
|
||||
def test_main_raises_for_unknown_preloaded_skill(monkeypatch):
|
||||
import cli as cli_mod
|
||||
|
||||
monkeypatch.setattr(cli_mod, "HermesCLI", lambda **kwargs: _DummyCLI(**kwargs))
|
||||
monkeypatch.setattr(
|
||||
cli_mod,
|
||||
"build_preloaded_skills_prompt",
|
||||
lambda skills, task_id=None: ("", [], ["missing-skill"]),
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match=r"Unknown skill\(s\): missing-skill"):
|
||||
cli_mod.main(skills="missing-skill", list_tools=True)
|
||||
|
||||
|
||||
def test_show_banner_does_not_print_skills():
|
||||
"""show_banner() no longer prints the activated skills line — it moved to run()."""
|
||||
cli_obj = _make_real_cli(compact=False)
|
||||
cli_obj.preloaded_skills = ["hermes-agent-dev", "github-auth"]
|
||||
cli_obj.console = MagicMock()
|
||||
|
||||
with patch("cli.build_welcome_banner") as mock_banner, patch(
|
||||
"shutil.get_terminal_size", return_value=os.terminal_size((120, 40))
|
||||
):
|
||||
cli_obj.show_banner()
|
||||
|
||||
print_calls = [
|
||||
call.args[0]
|
||||
for call in cli_obj.console.print.call_args_list
|
||||
if call.args and isinstance(call.args[0], str)
|
||||
]
|
||||
startup_lines = [line for line in print_calls if "Activated skills:" in line]
|
||||
assert len(startup_lines) == 0
|
||||
assert mock_banner.call_count == 1
|
||||
@@ -0,0 +1,884 @@
|
||||
import importlib
|
||||
import sys
|
||||
import types
|
||||
from contextlib import nullcontext
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from hermes_cli.auth import AuthError
|
||||
from hermes_cli import main as hermes_main
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Module isolation: _import_cli() wipes tools.* / cli / run_agent from
|
||||
# sys.modules so it can re-import cli fresh. Without cleanup the wiped
|
||||
# modules leak into subsequent tests, breaking
|
||||
# mock patches that target "tools.file_tools._get_file_ops" etc.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _reset_modules(prefixes: tuple[str, ...]):
|
||||
for name in list(sys.modules):
|
||||
if any(name == p or name.startswith(p + ".") for p in prefixes):
|
||||
sys.modules.pop(name, None)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _restore_cli_and_tool_modules():
|
||||
"""Save and restore tools/cli/run_agent modules around every test."""
|
||||
prefixes = ("tools", "cli", "run_agent")
|
||||
original_modules = {
|
||||
name: module
|
||||
for name, module in sys.modules.items()
|
||||
if any(name == p or name.startswith(p + ".") for p in prefixes)
|
||||
}
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
_reset_modules(prefixes)
|
||||
sys.modules.update(original_modules)
|
||||
|
||||
|
||||
def _install_prompt_toolkit_stubs():
|
||||
class _Dummy:
|
||||
def __init__(self, *args, **kwargs):
|
||||
pass
|
||||
|
||||
class _Condition:
|
||||
def __init__(self, func):
|
||||
self.func = func
|
||||
|
||||
def __bool__(self):
|
||||
return bool(self.func())
|
||||
|
||||
class _ANSI(str):
|
||||
pass
|
||||
|
||||
root = types.ModuleType("prompt_toolkit")
|
||||
history = types.ModuleType("prompt_toolkit.history")
|
||||
styles = types.ModuleType("prompt_toolkit.styles")
|
||||
patch_stdout = types.ModuleType("prompt_toolkit.patch_stdout")
|
||||
application = types.ModuleType("prompt_toolkit.application")
|
||||
layout = types.ModuleType("prompt_toolkit.layout")
|
||||
processors = types.ModuleType("prompt_toolkit.layout.processors")
|
||||
filters = types.ModuleType("prompt_toolkit.filters")
|
||||
dimension = types.ModuleType("prompt_toolkit.layout.dimension")
|
||||
menus = types.ModuleType("prompt_toolkit.layout.menus")
|
||||
widgets = types.ModuleType("prompt_toolkit.widgets")
|
||||
key_binding = types.ModuleType("prompt_toolkit.key_binding")
|
||||
completion = types.ModuleType("prompt_toolkit.completion")
|
||||
formatted_text = types.ModuleType("prompt_toolkit.formatted_text")
|
||||
|
||||
history.FileHistory = _Dummy
|
||||
styles.Style = _Dummy
|
||||
patch_stdout.patch_stdout = lambda *args, **kwargs: nullcontext()
|
||||
application.Application = _Dummy
|
||||
layout.Layout = _Dummy
|
||||
layout.HSplit = _Dummy
|
||||
layout.Window = _Dummy
|
||||
layout.FormattedTextControl = _Dummy
|
||||
layout.ConditionalContainer = _Dummy
|
||||
processors.Processor = _Dummy
|
||||
processors.Transformation = _Dummy
|
||||
processors.PasswordProcessor = _Dummy
|
||||
processors.ConditionalProcessor = _Dummy
|
||||
filters.Condition = _Condition
|
||||
dimension.Dimension = _Dummy
|
||||
menus.CompletionsMenu = _Dummy
|
||||
widgets.TextArea = _Dummy
|
||||
key_binding.KeyBindings = _Dummy
|
||||
completion.Completer = _Dummy
|
||||
completion.Completion = _Dummy
|
||||
formatted_text.ANSI = _ANSI
|
||||
root.print_formatted_text = lambda *args, **kwargs: None
|
||||
|
||||
sys.modules.setdefault("prompt_toolkit", root)
|
||||
sys.modules.setdefault("prompt_toolkit.history", history)
|
||||
sys.modules.setdefault("prompt_toolkit.styles", styles)
|
||||
sys.modules.setdefault("prompt_toolkit.patch_stdout", patch_stdout)
|
||||
sys.modules.setdefault("prompt_toolkit.application", application)
|
||||
sys.modules.setdefault("prompt_toolkit.layout", layout)
|
||||
sys.modules.setdefault("prompt_toolkit.layout.processors", processors)
|
||||
sys.modules.setdefault("prompt_toolkit.filters", filters)
|
||||
sys.modules.setdefault("prompt_toolkit.layout.dimension", dimension)
|
||||
sys.modules.setdefault("prompt_toolkit.layout.menus", menus)
|
||||
sys.modules.setdefault("prompt_toolkit.widgets", widgets)
|
||||
sys.modules.setdefault("prompt_toolkit.key_binding", key_binding)
|
||||
sys.modules.setdefault("prompt_toolkit.completion", completion)
|
||||
sys.modules.setdefault("prompt_toolkit.formatted_text", formatted_text)
|
||||
|
||||
|
||||
def _import_cli():
|
||||
for name in list(sys.modules):
|
||||
if name == "cli" or name == "run_agent" or name == "tools" or name.startswith("tools."):
|
||||
sys.modules.pop(name, None)
|
||||
|
||||
if "firecrawl" not in sys.modules:
|
||||
sys.modules["firecrawl"] = types.SimpleNamespace(Firecrawl=object)
|
||||
|
||||
try:
|
||||
importlib.import_module("prompt_toolkit")
|
||||
except ModuleNotFoundError:
|
||||
_install_prompt_toolkit_stubs()
|
||||
return importlib.import_module("cli")
|
||||
|
||||
|
||||
def test_hermes_cli_init_does_not_eagerly_resolve_runtime_provider(monkeypatch):
|
||||
cli = _import_cli()
|
||||
calls = {"count": 0}
|
||||
|
||||
def _unexpected_runtime_resolve(**kwargs):
|
||||
calls["count"] += 1
|
||||
raise AssertionError("resolve_runtime_provider should not be called in HermesCLI.__init__")
|
||||
|
||||
monkeypatch.setattr("hermes_cli.runtime_provider.resolve_runtime_provider", _unexpected_runtime_resolve)
|
||||
monkeypatch.setattr("hermes_cli.runtime_provider.format_runtime_provider_error", lambda exc: str(exc))
|
||||
|
||||
shell = cli.HermesCLI(model="gpt-5", compact=True, max_turns=1)
|
||||
|
||||
assert shell is not None
|
||||
assert calls["count"] == 0
|
||||
|
||||
|
||||
def test_runtime_resolution_failure_is_not_sticky(monkeypatch):
|
||||
cli = _import_cli()
|
||||
calls = {"count": 0}
|
||||
|
||||
def _runtime_resolve(**kwargs):
|
||||
calls["count"] += 1
|
||||
if calls["count"] == 1:
|
||||
raise RuntimeError("temporary auth failure")
|
||||
return {
|
||||
"provider": "openrouter",
|
||||
"api_mode": "chat_completions",
|
||||
"base_url": "https://openrouter.ai/api/v1",
|
||||
"api_key": "test-key",
|
||||
"source": "env/config",
|
||||
}
|
||||
|
||||
class _DummyAgent:
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.kwargs = kwargs
|
||||
|
||||
monkeypatch.setattr("hermes_cli.runtime_provider.resolve_runtime_provider", _runtime_resolve)
|
||||
monkeypatch.setattr("hermes_cli.runtime_provider.format_runtime_provider_error", lambda exc: str(exc))
|
||||
monkeypatch.setattr(cli, "AIAgent", _DummyAgent)
|
||||
|
||||
shell = cli.HermesCLI(model="gpt-5", compact=True, max_turns=1)
|
||||
|
||||
assert shell._init_agent() is False
|
||||
assert shell._init_agent() is True
|
||||
assert calls["count"] == 2
|
||||
assert shell.agent is not None
|
||||
|
||||
|
||||
def test_runtime_resolution_rebuilds_agent_on_routing_change(monkeypatch):
|
||||
cli = _import_cli()
|
||||
|
||||
def _runtime_resolve(**kwargs):
|
||||
return {
|
||||
"provider": "openai-codex",
|
||||
"api_mode": "codex_responses",
|
||||
"base_url": "https://same-endpoint.example/v1",
|
||||
"api_key": "same-key",
|
||||
"source": "env/config",
|
||||
}
|
||||
|
||||
monkeypatch.setattr("hermes_cli.runtime_provider.resolve_runtime_provider", _runtime_resolve)
|
||||
monkeypatch.setattr("hermes_cli.runtime_provider.format_runtime_provider_error", lambda exc: str(exc))
|
||||
|
||||
shell = cli.HermesCLI(model="gpt-5", compact=True, max_turns=1)
|
||||
shell.provider = "openrouter"
|
||||
shell.api_mode = "chat_completions"
|
||||
shell.base_url = "https://same-endpoint.example/v1"
|
||||
shell.api_key = "same-key"
|
||||
shell.agent = object()
|
||||
|
||||
assert shell._ensure_runtime_credentials() is True
|
||||
assert shell.agent is None
|
||||
assert shell.provider == "openai-codex"
|
||||
assert shell.api_mode == "codex_responses"
|
||||
|
||||
|
||||
def test_cli_turn_routing_uses_primary_when_disabled(monkeypatch):
|
||||
cli = _import_cli()
|
||||
shell = cli.HermesCLI(model="gpt-5", compact=True, max_turns=1)
|
||||
shell.provider = "openrouter"
|
||||
shell.api_mode = "chat_completions"
|
||||
shell.base_url = "https://openrouter.ai/api/v1"
|
||||
shell.api_key = "sk-primary"
|
||||
|
||||
result = shell._resolve_turn_agent_config("what time is it in tokyo?")
|
||||
|
||||
assert result["model"] == "gpt-5"
|
||||
assert result["runtime"]["provider"] == "openrouter"
|
||||
|
||||
|
||||
def test_cli_prefers_config_provider_over_stale_env_override(monkeypatch):
|
||||
cli = _import_cli()
|
||||
|
||||
monkeypatch.setenv("HERMES_INFERENCE_PROVIDER", "openrouter")
|
||||
config_copy = dict(cli.CLI_CONFIG)
|
||||
model_copy = dict(config_copy.get("model", {}))
|
||||
model_copy["provider"] = "custom"
|
||||
model_copy["base_url"] = "https://api.fireworks.ai/inference/v1"
|
||||
config_copy["model"] = model_copy
|
||||
monkeypatch.setattr(cli, "CLI_CONFIG", config_copy)
|
||||
|
||||
shell = cli.HermesCLI(model="fireworks/minimax-m2p5", compact=True, max_turns=1)
|
||||
|
||||
assert shell.requested_provider == "custom"
|
||||
|
||||
|
||||
def test_codex_provider_replaces_incompatible_default_model(monkeypatch):
|
||||
"""When provider resolves to openai-codex and no model was explicitly
|
||||
chosen, the global config default (e.g. anthropic/claude-opus-4.6) must
|
||||
be replaced with a Codex-compatible model. Fixes #651."""
|
||||
cli = _import_cli()
|
||||
|
||||
monkeypatch.delenv("LLM_MODEL", raising=False)
|
||||
monkeypatch.delenv("OPENAI_MODEL", raising=False)
|
||||
# Ensure local user config does not leak a model into the test
|
||||
monkeypatch.setitem(cli.CLI_CONFIG, "model", {
|
||||
"default": "",
|
||||
"base_url": "https://openrouter.ai/api/v1",
|
||||
})
|
||||
|
||||
def _runtime_resolve(**kwargs):
|
||||
return {
|
||||
"provider": "openai-codex",
|
||||
"api_mode": "codex_responses",
|
||||
"base_url": "https://chatgpt.com/backend-api/codex",
|
||||
"api_key": "test-key",
|
||||
"source": "env/config",
|
||||
}
|
||||
|
||||
monkeypatch.setattr("hermes_cli.runtime_provider.resolve_runtime_provider", _runtime_resolve)
|
||||
monkeypatch.setattr("hermes_cli.runtime_provider.format_runtime_provider_error", lambda exc: str(exc))
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.codex_models.get_codex_model_ids",
|
||||
lambda access_token=None: ["gpt-5.2-codex", "gpt-5.1-codex-mini"],
|
||||
)
|
||||
|
||||
shell = cli.HermesCLI(compact=True, max_turns=1)
|
||||
|
||||
assert shell._model_is_default is True
|
||||
assert shell._ensure_runtime_credentials() is True
|
||||
assert shell.provider == "openai-codex"
|
||||
assert "anthropic" not in shell.model
|
||||
assert "claude" not in shell.model
|
||||
assert shell.model == "gpt-5.2-codex"
|
||||
|
||||
|
||||
def test_model_flow_nous_prints_subscription_guidance_without_mutating_explicit_tts(monkeypatch, capsys):
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.nous_subscription.managed_nous_tools_enabled",
|
||||
lambda *args, **kwargs: True,
|
||||
)
|
||||
config = {
|
||||
"model": {"provider": "nous", "default": "claude-opus-4-6"},
|
||||
"tts": {"provider": "elevenlabs"},
|
||||
"browser": {"cloud_provider": "browser-use"},
|
||||
}
|
||||
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.auth.get_provider_auth_state",
|
||||
lambda provider: {"access_token": "nous-token"},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.auth.resolve_nous_runtime_credentials",
|
||||
lambda *args, **kwargs: {
|
||||
"base_url": "https://inference.example.com/v1",
|
||||
"api_key": "nous-key",
|
||||
},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.auth.fetch_nous_models",
|
||||
lambda *args, **kwargs: ["claude-opus-4-6"],
|
||||
)
|
||||
monkeypatch.setattr("hermes_cli.auth._prompt_model_selection", lambda model_ids, current_model="", pricing=None, **kw: "claude-opus-4-6")
|
||||
monkeypatch.setattr("hermes_cli.auth._save_model_choice", lambda model: None)
|
||||
monkeypatch.setattr("hermes_cli.auth._update_config_for_provider", lambda provider, url: None)
|
||||
|
||||
hermes_main._model_flow_nous(config, current_model="claude-opus-4-6")
|
||||
|
||||
out = capsys.readouterr().out
|
||||
assert "Default model set to:" in out
|
||||
assert config["tts"]["provider"] == "elevenlabs"
|
||||
assert config["browser"]["cloud_provider"] == "browser-use"
|
||||
|
||||
|
||||
def test_model_flow_nous_does_not_restore_stale_custom_api_key(tmp_path, monkeypatch):
|
||||
import yaml
|
||||
|
||||
config_home = tmp_path / "hermes"
|
||||
config_home.mkdir()
|
||||
monkeypatch.setenv("HERMES_HOME", str(config_home))
|
||||
|
||||
config_path = config_home / "config.yaml"
|
||||
config_path.write_text(
|
||||
yaml.safe_dump(
|
||||
{
|
||||
"model": {
|
||||
"provider": "custom",
|
||||
"default": "glm-5.2",
|
||||
"base_url": "https://api.neuralwatt.com/v1",
|
||||
"api_key": "${NEURALWATT_API_KEY}",
|
||||
"api_mode": "chat_completions",
|
||||
}
|
||||
},
|
||||
sort_keys=False,
|
||||
)
|
||||
)
|
||||
|
||||
stale_config = yaml.safe_load(config_path.read_text()) or {}
|
||||
selected_model = "deepseek/deepseek-v4-flash"
|
||||
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.auth.get_provider_auth_state",
|
||||
lambda provider: {
|
||||
"access_token": "nous-token",
|
||||
"portal_base_url": "https://portal.example.com",
|
||||
},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.auth.resolve_nous_runtime_credentials",
|
||||
lambda *args, **kwargs: {
|
||||
"base_url": "https://inference-api.nousresearch.com/v1",
|
||||
"api_key": "nous-key",
|
||||
},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.models.get_curated_nous_model_ids",
|
||||
lambda: [selected_model],
|
||||
)
|
||||
monkeypatch.setattr("hermes_cli.models.get_pricing_for_provider", lambda provider: {})
|
||||
monkeypatch.setattr("hermes_cli.models.check_nous_free_tier", lambda **kwargs: False)
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.models.union_with_portal_paid_recommendations",
|
||||
lambda model_ids, pricing, portal_url: (model_ids, pricing),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.auth._prompt_model_selection",
|
||||
lambda *args, **kwargs: selected_model,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.nous_subscription.prompt_enable_tool_gateway",
|
||||
lambda config: None,
|
||||
)
|
||||
|
||||
hermes_main._model_flow_nous(stale_config, current_model="glm-5.2")
|
||||
|
||||
config = yaml.safe_load(config_path.read_text()) or {}
|
||||
model = config.get("model")
|
||||
assert model["provider"] == "nous"
|
||||
assert model["default"] == selected_model
|
||||
assert model["base_url"] == "https://inference-api.nousresearch.com/v1"
|
||||
assert "api_key" not in model
|
||||
assert "api_mode" not in model
|
||||
|
||||
|
||||
def _seed_stale_custom_model(tmp_path, monkeypatch):
|
||||
import yaml
|
||||
|
||||
config_home = tmp_path / "hermes"
|
||||
config_home.mkdir()
|
||||
monkeypatch.setenv("HERMES_HOME", str(config_home))
|
||||
config_path = config_home / "config.yaml"
|
||||
config_path.write_text(
|
||||
yaml.safe_dump(
|
||||
{
|
||||
"model": {
|
||||
"provider": "custom",
|
||||
"default": "glm-5.2",
|
||||
"base_url": "https://api.neuralwatt.com/v1",
|
||||
"api_key": "${NEURALWATT_API_KEY}",
|
||||
"api": "legacy-stale-key",
|
||||
"api_mode": "anthropic_messages",
|
||||
}
|
||||
},
|
||||
sort_keys=False,
|
||||
)
|
||||
)
|
||||
(config_home / ".env").write_text("")
|
||||
return config_path
|
||||
|
||||
|
||||
def test_model_flow_openrouter_clears_stale_custom_key(tmp_path, monkeypatch):
|
||||
import yaml
|
||||
|
||||
config_path = _seed_stale_custom_model(tmp_path, monkeypatch)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.main._prompt_api_key",
|
||||
lambda *args, **kwargs: ("sk-openrouter", False),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.models.model_ids",
|
||||
lambda **kwargs: ["anthropic/claude-sonnet-4.6"],
|
||||
)
|
||||
monkeypatch.setattr("hermes_cli.models.get_pricing_for_provider", lambda *a, **k: {})
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.auth._prompt_model_selection",
|
||||
lambda *args, **kwargs: "anthropic/claude-sonnet-4.6",
|
||||
)
|
||||
monkeypatch.setattr("hermes_cli.auth.deactivate_provider", lambda: None)
|
||||
|
||||
hermes_main._model_flow_openrouter({}, current_model="glm-5.2")
|
||||
|
||||
config = yaml.safe_load(config_path.read_text()) or {}
|
||||
model = config["model"]
|
||||
assert model["provider"] == "openrouter"
|
||||
assert model["default"] == "anthropic/claude-sonnet-4.6"
|
||||
assert model["api_mode"] == "chat_completions"
|
||||
assert "api_key" not in model
|
||||
assert "api" not in model
|
||||
|
||||
|
||||
def test_model_flow_anthropic_clears_stale_custom_key_and_mode(tmp_path, monkeypatch):
|
||||
import yaml
|
||||
|
||||
config_path = _seed_stale_custom_model(tmp_path, monkeypatch)
|
||||
|
||||
monkeypatch.setattr("hermes_cli.auth.get_anthropic_key", lambda: "sk-ant-api03-test")
|
||||
monkeypatch.setattr(
|
||||
"agent.anthropic_adapter.read_claude_code_credentials",
|
||||
lambda: None,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"agent.anthropic_adapter.is_claude_code_token_valid",
|
||||
lambda creds: False,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.model_setup_flows._prompt_auth_credentials_choice",
|
||||
lambda title: "use",
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.auth._prompt_model_selection",
|
||||
lambda *args, **kwargs: "claude-sonnet-4-6",
|
||||
)
|
||||
monkeypatch.setattr("hermes_cli.auth.deactivate_provider", lambda: None)
|
||||
|
||||
hermes_main._model_flow_anthropic({}, current_model="glm-5.2")
|
||||
|
||||
config = yaml.safe_load(config_path.read_text()) or {}
|
||||
model = config["model"]
|
||||
assert model["provider"] == "anthropic"
|
||||
assert model["default"] == "claude-sonnet-4-6"
|
||||
assert "base_url" not in model
|
||||
assert "api_key" not in model
|
||||
assert "api" not in model
|
||||
assert "api_mode" not in model
|
||||
|
||||
|
||||
def test_model_flow_nous_offers_tool_gateway_prompt_when_unconfigured(monkeypatch, capsys):
|
||||
from hermes_cli.nous_account import NousPortalAccountInfo
|
||||
|
||||
# Entitled account (paid → all tools eligible) drives the offer; the prompt
|
||||
# is a per-tool checklist now, so capture the call rather than scrape stdout.
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.nous_subscription.get_nous_portal_account_info",
|
||||
lambda **kwargs: NousPortalAccountInfo(
|
||||
logged_in=True,
|
||||
source="account_api",
|
||||
fresh=True,
|
||||
paid_service_access=True,
|
||||
),
|
||||
)
|
||||
captured = {}
|
||||
|
||||
def _fake_checklist(title, items, pre_selected=None):
|
||||
captured["title"] = title
|
||||
captured["items"] = list(items)
|
||||
return [] # decline; we only assert the prompt was offered
|
||||
|
||||
monkeypatch.setattr("hermes_cli.setup.prompt_checklist", _fake_checklist, raising=False)
|
||||
|
||||
config = {
|
||||
"model": {"provider": "nous", "default": "claude-opus-4-6"},
|
||||
"tts": {"provider": "edge"},
|
||||
}
|
||||
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.auth.get_provider_auth_state",
|
||||
lambda provider: {"access_token": "***"},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.auth.resolve_nous_runtime_credentials",
|
||||
lambda *args, **kwargs: {
|
||||
"base_url": "https://inference.example.com/v1",
|
||||
"api_key": "***",
|
||||
},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.auth.fetch_nous_models",
|
||||
lambda *args, **kwargs: ["claude-opus-4-6"],
|
||||
)
|
||||
monkeypatch.setattr("hermes_cli.auth._prompt_model_selection", lambda model_ids, current_model="", pricing=None, **kw: "claude-opus-4-6")
|
||||
monkeypatch.setattr("hermes_cli.auth._save_model_choice", lambda model: None)
|
||||
monkeypatch.setattr("hermes_cli.auth._update_config_for_provider", lambda provider, url: None)
|
||||
hermes_main._model_flow_nous(config, current_model="claude-opus-4-6")
|
||||
|
||||
# The per-tool Tool Gateway checklist was offered.
|
||||
assert "title" in captured
|
||||
assert "Tool Gateway" in captured["title"] or "tool pool" in captured["title"].lower()
|
||||
|
||||
|
||||
def test_codex_provider_uses_config_model(monkeypatch):
|
||||
"""Model comes from config.yaml, not LLM_MODEL env var.
|
||||
Config.yaml is the single source of truth to avoid multi-agent conflicts."""
|
||||
cli = _import_cli()
|
||||
|
||||
# LLM_MODEL env var should be IGNORED (even if set)
|
||||
monkeypatch.setenv("LLM_MODEL", "should-be-ignored")
|
||||
monkeypatch.delenv("OPENAI_MODEL", raising=False)
|
||||
|
||||
# Set model via config
|
||||
monkeypatch.setitem(cli.CLI_CONFIG, "model", {
|
||||
"default": "gpt-5.2-codex",
|
||||
"provider": "openai-codex",
|
||||
"base_url": "https://chatgpt.com/backend-api/codex",
|
||||
})
|
||||
|
||||
def _runtime_resolve(**kwargs):
|
||||
return {
|
||||
"provider": "openai-codex",
|
||||
"api_mode": "codex_responses",
|
||||
"base_url": "https://chatgpt.com/backend-api/codex",
|
||||
"api_key": "fake-codex-token",
|
||||
"source": "env/config",
|
||||
}
|
||||
|
||||
monkeypatch.setattr("hermes_cli.runtime_provider.resolve_runtime_provider", _runtime_resolve)
|
||||
monkeypatch.setattr("hermes_cli.runtime_provider.format_runtime_provider_error", lambda exc: str(exc))
|
||||
# Prevent live API call from overriding the config model
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.codex_models.get_codex_model_ids",
|
||||
lambda access_token=None: ["gpt-5.2-codex"],
|
||||
)
|
||||
|
||||
shell = cli.HermesCLI(compact=True, max_turns=1)
|
||||
|
||||
assert shell._ensure_runtime_credentials() is True
|
||||
assert shell.provider == "openai-codex"
|
||||
# Model from config (may be normalized by codex provider logic)
|
||||
assert "codex" in shell.model.lower()
|
||||
# LLM_MODEL env var is NOT used
|
||||
assert shell.model != "should-be-ignored"
|
||||
|
||||
|
||||
def test_codex_config_model_not_replaced_by_normalization(monkeypatch):
|
||||
"""When the user sets model.default in config.yaml to a specific codex
|
||||
model, _normalize_model_for_provider must NOT replace it with the latest
|
||||
available model from the API. Regression test for #1887."""
|
||||
cli = _import_cli()
|
||||
|
||||
monkeypatch.delenv("LLM_MODEL", raising=False)
|
||||
monkeypatch.delenv("OPENAI_MODEL", raising=False)
|
||||
|
||||
# User explicitly configured gpt-5.3-codex in config.yaml
|
||||
monkeypatch.setitem(cli.CLI_CONFIG, "model", {
|
||||
"default": "gpt-5.3-codex",
|
||||
"provider": "openai-codex",
|
||||
"base_url": "https://chatgpt.com/backend-api/codex",
|
||||
})
|
||||
|
||||
def _runtime_resolve(**kwargs):
|
||||
return {
|
||||
"provider": "openai-codex",
|
||||
"api_mode": "codex_responses",
|
||||
"base_url": "https://chatgpt.com/backend-api/codex",
|
||||
"api_key": "fake-key",
|
||||
"source": "env/config",
|
||||
}
|
||||
|
||||
monkeypatch.setattr("hermes_cli.runtime_provider.resolve_runtime_provider", _runtime_resolve)
|
||||
monkeypatch.setattr("hermes_cli.runtime_provider.format_runtime_provider_error", lambda exc: str(exc))
|
||||
# API returns a DIFFERENT model than what the user configured
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.codex_models.get_codex_model_ids",
|
||||
lambda access_token=None: ["gpt-5.4", "gpt-5.3-codex"],
|
||||
)
|
||||
|
||||
shell = cli.HermesCLI(compact=True, max_turns=1)
|
||||
|
||||
# Config model is NOT the global default — user made a deliberate choice
|
||||
assert shell._model_is_default is False
|
||||
assert shell._ensure_runtime_credentials() is True
|
||||
assert shell.provider == "openai-codex"
|
||||
# Model must stay as user configured, not replaced by gpt-5.4
|
||||
assert shell.model == "gpt-5.3-codex"
|
||||
|
||||
|
||||
def test_codex_provider_preserves_explicit_codex_model(monkeypatch):
|
||||
"""If the user explicitly passes a Codex-compatible model, it must be
|
||||
preserved even when the provider resolves to openai-codex."""
|
||||
cli = _import_cli()
|
||||
|
||||
monkeypatch.delenv("LLM_MODEL", raising=False)
|
||||
monkeypatch.delenv("OPENAI_MODEL", raising=False)
|
||||
|
||||
def _runtime_resolve(**kwargs):
|
||||
return {
|
||||
"provider": "openai-codex",
|
||||
"api_mode": "codex_responses",
|
||||
"base_url": "https://chatgpt.com/backend-api/codex",
|
||||
"api_key": "test-key",
|
||||
"source": "env/config",
|
||||
}
|
||||
|
||||
monkeypatch.setattr("hermes_cli.runtime_provider.resolve_runtime_provider", _runtime_resolve)
|
||||
monkeypatch.setattr("hermes_cli.runtime_provider.format_runtime_provider_error", lambda exc: str(exc))
|
||||
|
||||
shell = cli.HermesCLI(model="gpt-5.1-codex-mini", compact=True, max_turns=1)
|
||||
|
||||
assert shell._model_is_default is False
|
||||
assert shell._ensure_runtime_credentials() is True
|
||||
assert shell.model == "gpt-5.1-codex-mini"
|
||||
|
||||
|
||||
def test_codex_provider_strips_provider_prefix_from_model(monkeypatch):
|
||||
"""openai/gpt-5.3-codex should become gpt-5.3-codex — the Codex
|
||||
Responses API does not accept provider-prefixed model slugs."""
|
||||
cli = _import_cli()
|
||||
|
||||
monkeypatch.delenv("LLM_MODEL", raising=False)
|
||||
monkeypatch.delenv("OPENAI_MODEL", raising=False)
|
||||
|
||||
def _runtime_resolve(**kwargs):
|
||||
return {
|
||||
"provider": "openai-codex",
|
||||
"api_mode": "codex_responses",
|
||||
"base_url": "https://chatgpt.com/backend-api/codex",
|
||||
"api_key": "test-key",
|
||||
"source": "env/config",
|
||||
}
|
||||
|
||||
monkeypatch.setattr("hermes_cli.runtime_provider.resolve_runtime_provider", _runtime_resolve)
|
||||
monkeypatch.setattr("hermes_cli.runtime_provider.format_runtime_provider_error", lambda exc: str(exc))
|
||||
|
||||
shell = cli.HermesCLI(model="openai/gpt-5.3-codex", compact=True, max_turns=1)
|
||||
|
||||
assert shell._ensure_runtime_credentials() is True
|
||||
assert shell.model == "gpt-5.3-codex"
|
||||
|
||||
|
||||
def test_cmd_model_falls_back_to_auto_on_invalid_provider(monkeypatch, capsys):
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.config.load_config",
|
||||
lambda: {"model": {"default": "gpt-5", "provider": "invalid-provider"}},
|
||||
)
|
||||
monkeypatch.setattr("hermes_cli.config.save_config", lambda cfg: None)
|
||||
monkeypatch.setattr("hermes_cli.config.get_env_value", lambda key: "")
|
||||
monkeypatch.setattr("hermes_cli.config.save_env_value", lambda key, value: None)
|
||||
|
||||
def _resolve_provider(requested, **kwargs):
|
||||
if requested == "invalid-provider":
|
||||
raise AuthError("Unknown provider 'invalid-provider'.", code="invalid_provider")
|
||||
return "openrouter"
|
||||
|
||||
monkeypatch.setattr("hermes_cli.auth.resolve_provider", _resolve_provider)
|
||||
monkeypatch.setattr(hermes_main, "_prompt_provider_choice", lambda choices, **kwargs: len(choices) - 1)
|
||||
monkeypatch.setattr("sys.stdin", type("FakeTTY", (), {"isatty": lambda self: True})())
|
||||
|
||||
hermes_main.cmd_model(SimpleNamespace())
|
||||
output = capsys.readouterr().out
|
||||
|
||||
assert "Warning:" in output
|
||||
assert "falling back to auto provider detection" in output.lower()
|
||||
assert "No change." in output
|
||||
|
||||
|
||||
def test_model_flow_custom_saves_verified_v1_base_url(monkeypatch, capsys):
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.config.get_env_value",
|
||||
lambda key: "" if key in {"OPENAI_BASE_URL", "OPENAI_API_KEY"} else "",
|
||||
)
|
||||
saved_env = {}
|
||||
monkeypatch.setattr("hermes_cli.config.save_env_value", lambda key, value: saved_env.__setitem__(key, value))
|
||||
monkeypatch.setattr("hermes_cli.auth._save_model_choice", lambda model: saved_env.__setitem__("MODEL", model))
|
||||
monkeypatch.setattr("hermes_cli.auth.deactivate_provider", lambda: None)
|
||||
monkeypatch.setattr("hermes_cli.main._save_custom_provider", lambda *args, **kwargs: None)
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.models.probe_api_models",
|
||||
lambda api_key, base_url: {
|
||||
"models": ["llm"],
|
||||
"probed_url": "http://localhost:8000/v1/models",
|
||||
"resolved_base_url": "http://localhost:8000/v1",
|
||||
"suggested_base_url": "http://localhost:8000/v1",
|
||||
"used_fallback": True,
|
||||
},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.config.load_config",
|
||||
lambda: {"model": {"default": "", "provider": "custom", "base_url": ""}},
|
||||
)
|
||||
monkeypatch.setattr("hermes_cli.config.save_config", lambda cfg: None)
|
||||
|
||||
# After the probe detects a single model ("llm"), the flow asks
|
||||
# "Use this model? [Y/n]:" — confirm with Enter, then context length,
|
||||
# then display name. The api_mode prompt also runs before model selection.
|
||||
answers = iter(["http://localhost:8000", "local-key", "", "", "", "", ""])
|
||||
monkeypatch.setattr("builtins.input", lambda _prompt="": next(answers))
|
||||
monkeypatch.setattr("hermes_cli.secret_prompt.masked_secret_prompt", lambda _prompt="": next(answers))
|
||||
|
||||
hermes_main._model_flow_custom({})
|
||||
output = capsys.readouterr().out
|
||||
|
||||
assert "Saving the working base URL instead" in output
|
||||
assert "Detected model: llm" in output
|
||||
# OPENAI_BASE_URL is no longer saved to .env — config.yaml is authoritative
|
||||
assert "OPENAI_BASE_URL" not in saved_env
|
||||
assert saved_env["MODEL"] == "llm"
|
||||
|
||||
|
||||
def test_model_flow_custom_persists_selected_api_mode(monkeypatch):
|
||||
saved_cfg = {"model": {"default": "", "provider": "custom", "base_url": ""}}
|
||||
captured_provider = {}
|
||||
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.config.get_env_value",
|
||||
lambda key: "" if key in {"OPENAI_BASE_URL", "OPENAI_API_KEY"} else "",
|
||||
)
|
||||
monkeypatch.setattr("hermes_cli.auth._save_model_choice", lambda model: None)
|
||||
monkeypatch.setattr("hermes_cli.auth.deactivate_provider", lambda: None)
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.models.probe_api_models",
|
||||
lambda api_key, base_url: {
|
||||
"models": [],
|
||||
"probed_url": f"{base_url.rstrip('/')}/models",
|
||||
"resolved_base_url": None,
|
||||
"suggested_base_url": None,
|
||||
"used_fallback": False,
|
||||
},
|
||||
)
|
||||
monkeypatch.setattr("hermes_cli.config.load_config", lambda: saved_cfg)
|
||||
monkeypatch.setattr("hermes_cli.config.save_config", lambda cfg: saved_cfg.update(cfg))
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.main._save_custom_provider",
|
||||
lambda base_url, api_key="", model="", context_length=None, name=None, api_mode=None: captured_provider.update(
|
||||
{
|
||||
"base_url": base_url,
|
||||
"api_key": api_key,
|
||||
"model": model,
|
||||
"context_length": context_length,
|
||||
"name": name,
|
||||
"api_mode": api_mode,
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
answers = iter(
|
||||
[
|
||||
"https://codex.example.com/v1",
|
||||
"3",
|
||||
"chosen-model",
|
||||
"",
|
||||
"",
|
||||
]
|
||||
)
|
||||
monkeypatch.setattr("builtins.input", lambda _prompt="": next(answers))
|
||||
monkeypatch.setattr("hermes_cli.secret_prompt.masked_secret_prompt", lambda _prompt="": "test-key")
|
||||
|
||||
hermes_main._model_flow_custom({"model": {"provider": "custom"}})
|
||||
|
||||
assert saved_cfg["model"]["provider"] == "custom"
|
||||
assert saved_cfg["model"]["base_url"] == "https://codex.example.com/v1"
|
||||
assert saved_cfg["model"]["api_key"] == "test-key"
|
||||
assert saved_cfg["model"]["api_mode"] == "codex_responses"
|
||||
assert captured_provider["api_mode"] == "codex_responses"
|
||||
|
||||
|
||||
def test_cmd_model_forwards_nous_login_tls_options(monkeypatch):
|
||||
monkeypatch.setattr(hermes_main, "_require_tty", lambda *a: None)
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.config.load_config",
|
||||
lambda: {"model": {"default": "gpt-5", "provider": "nous"}},
|
||||
)
|
||||
monkeypatch.setattr("hermes_cli.config.save_config", lambda cfg: None)
|
||||
monkeypatch.setattr("hermes_cli.config.get_env_value", lambda key: "")
|
||||
monkeypatch.setattr("hermes_cli.config.save_env_value", lambda key, value: None)
|
||||
monkeypatch.setattr("hermes_cli.auth.resolve_provider", lambda requested, **kwargs: "nous")
|
||||
monkeypatch.setattr("hermes_cli.auth.get_provider_auth_state", lambda provider_id: None)
|
||||
monkeypatch.setattr(hermes_main, "_prompt_provider_choice", lambda choices, **kwargs: 0)
|
||||
|
||||
captured = {}
|
||||
|
||||
def _fake_login(login_args, provider_config):
|
||||
captured["portal_url"] = login_args.portal_url
|
||||
captured["inference_url"] = login_args.inference_url
|
||||
captured["client_id"] = login_args.client_id
|
||||
captured["scope"] = login_args.scope
|
||||
captured["no_browser"] = login_args.no_browser
|
||||
captured["timeout"] = login_args.timeout
|
||||
captured["ca_bundle"] = login_args.ca_bundle
|
||||
captured["insecure"] = login_args.insecure
|
||||
|
||||
monkeypatch.setattr("hermes_cli.auth._login_nous", _fake_login)
|
||||
|
||||
hermes_main.cmd_model(
|
||||
SimpleNamespace(
|
||||
portal_url="https://portal.nousresearch.com",
|
||||
inference_url="https://inference.nousresearch.com/v1",
|
||||
client_id="hermes-local",
|
||||
scope="openid profile",
|
||||
no_browser=True,
|
||||
timeout=7.5,
|
||||
ca_bundle="/tmp/local-ca.pem",
|
||||
insecure=True,
|
||||
)
|
||||
)
|
||||
|
||||
assert captured == {
|
||||
"portal_url": "https://portal.nousresearch.com",
|
||||
"inference_url": "https://inference.nousresearch.com/v1",
|
||||
"client_id": "hermes-local",
|
||||
"scope": "openid profile",
|
||||
"no_browser": True,
|
||||
"timeout": 7.5,
|
||||
"ca_bundle": "/tmp/local-ca.pem",
|
||||
"insecure": True,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _auto_provider_name — unit tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_auto_provider_name_localhost():
|
||||
from hermes_cli.main import _auto_provider_name
|
||||
assert _auto_provider_name("http://localhost:11434/v1") == "Local (localhost:11434)"
|
||||
assert _auto_provider_name("http://127.0.0.1:1234/v1") == "Local (127.0.0.1:1234)"
|
||||
|
||||
|
||||
def test_auto_provider_name_runpod():
|
||||
from hermes_cli.main import _auto_provider_name
|
||||
assert "RunPod" in _auto_provider_name("https://xyz.runpod.io/v1")
|
||||
|
||||
|
||||
def test_auto_provider_name_remote():
|
||||
from hermes_cli.main import _auto_provider_name
|
||||
result = _auto_provider_name("https://api.together.xyz/v1")
|
||||
assert result == "Api.together.xyz"
|
||||
|
||||
|
||||
def test_save_custom_provider_uses_provided_name(monkeypatch, tmp_path):
|
||||
"""When a display name is passed, it should appear in the saved entry."""
|
||||
import yaml
|
||||
from hermes_cli.main import _save_custom_provider
|
||||
|
||||
cfg_path = tmp_path / "config.yaml"
|
||||
cfg_path.write_text(yaml.dump({}))
|
||||
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.config.load_config", lambda: yaml.safe_load(cfg_path.read_text()) or {},
|
||||
)
|
||||
saved = {}
|
||||
def _save(cfg):
|
||||
saved.update(cfg)
|
||||
monkeypatch.setattr("hermes_cli.config.save_config", _save)
|
||||
|
||||
_save_custom_provider("http://localhost:11434/v1", name="Ollama")
|
||||
entries = saved.get("custom_providers", [])
|
||||
assert len(entries) == 1
|
||||
assert entries[0]["name"] == "Ollama"
|
||||
@@ -0,0 +1,99 @@
|
||||
"""Tests for the ``/reload-skills`` CLI slash command (``HermesCLI._reload_skills``).
|
||||
|
||||
The CLI handler prints the diff (name + description) for the user and —
|
||||
when any skills were added or removed — queues a one-shot note on
|
||||
``self._pending_skills_reload_note``. The note is prepended to the NEXT
|
||||
user message (see cli.py ~L8770, same pattern as
|
||||
``_pending_model_switch_note``) and cleared after use, so no phantom user
|
||||
turn is persisted to ``conversation_history``.
|
||||
"""
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
|
||||
def _make_cli():
|
||||
"""Build a minimal HermesCLI shell exposing ``_reload_skills``."""
|
||||
import cli as cli_mod
|
||||
|
||||
obj = object.__new__(cli_mod.HermesCLI)
|
||||
obj._command_running = False
|
||||
obj.conversation_history = []
|
||||
obj.agent = None
|
||||
return obj
|
||||
|
||||
|
||||
class TestReloadSkillsCLI:
|
||||
def test_reports_added_and_removed_and_queues_note(self, capsys):
|
||||
cli = _make_cli()
|
||||
with patch(
|
||||
"agent.skill_commands.reload_skills",
|
||||
return_value={
|
||||
"added": [
|
||||
{"name": "alpha", "description": "Run alpha to do xyz"},
|
||||
{"name": "beta", "description": "Run beta to do abc"},
|
||||
],
|
||||
"removed": [
|
||||
{"name": "gamma", "description": "Old removed skill"},
|
||||
],
|
||||
"unchanged": ["delta"],
|
||||
"total": 3,
|
||||
"commands": 3,
|
||||
},
|
||||
):
|
||||
cli._reload_skills()
|
||||
|
||||
out = capsys.readouterr().out
|
||||
assert "Added Skills:" in out
|
||||
assert "- alpha: Run alpha to do xyz" in out
|
||||
assert "- beta: Run beta to do abc" in out
|
||||
assert "Removed Skills:" in out
|
||||
assert "- gamma: Old removed skill" in out
|
||||
assert "3 skill(s) available" in out
|
||||
|
||||
# Must NOT pollute conversation_history — alternation-safe.
|
||||
assert cli.conversation_history == []
|
||||
|
||||
# One-shot note queued with system-prompt-style formatting.
|
||||
note = getattr(cli, "_pending_skills_reload_note", None)
|
||||
assert note is not None
|
||||
assert note.startswith("[USER INITIATED SKILLS RELOAD:")
|
||||
assert note.endswith("Use skills_list to see the updated catalog.]")
|
||||
assert "Added Skills:" in note
|
||||
assert " - alpha: Run alpha to do xyz" in note
|
||||
assert " - beta: Run beta to do abc" in note
|
||||
assert "Removed Skills:" in note
|
||||
assert " - gamma: Old removed skill" in note
|
||||
|
||||
def test_reports_no_changes_and_queues_nothing(self, capsys):
|
||||
cli = _make_cli()
|
||||
with patch(
|
||||
"agent.skill_commands.reload_skills",
|
||||
return_value={
|
||||
"added": [],
|
||||
"removed": [],
|
||||
"unchanged": ["alpha"],
|
||||
"total": 1,
|
||||
"commands": 1,
|
||||
},
|
||||
):
|
||||
cli._reload_skills()
|
||||
|
||||
out = capsys.readouterr().out
|
||||
assert "No new skills detected" in out
|
||||
assert "1 skill(s) available" in out
|
||||
assert cli.conversation_history == []
|
||||
assert getattr(cli, "_pending_skills_reload_note", None) is None
|
||||
|
||||
def test_handles_reload_failure_gracefully(self, capsys):
|
||||
cli = _make_cli()
|
||||
with patch(
|
||||
"agent.skill_commands.reload_skills",
|
||||
side_effect=RuntimeError("boom"),
|
||||
):
|
||||
cli._reload_skills()
|
||||
|
||||
out = capsys.readouterr().out
|
||||
assert "Skills reload failed" in out
|
||||
assert "boom" in out
|
||||
assert cli.conversation_history == []
|
||||
assert getattr(cli, "_pending_skills_reload_note", None) is None
|
||||
@@ -0,0 +1,353 @@
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from cli import HermesCLI
|
||||
|
||||
|
||||
def _make_cli():
|
||||
cli_obj = HermesCLI.__new__(HermesCLI)
|
||||
cli_obj.session_id = "current_session"
|
||||
cli_obj._resumed = False
|
||||
cli_obj._pending_title = None
|
||||
cli_obj.conversation_history = []
|
||||
cli_obj.agent = None
|
||||
cli_obj._session_db = MagicMock()
|
||||
cli_obj._pending_resume_sessions = None
|
||||
# _handle_resume_command now triggers _display_resumed_history (#31695),
|
||||
# which reads self.resume_display. "minimal" short-circuits the recap so
|
||||
# the test only exercises session-switch behavior.
|
||||
cli_obj.resume_display = "minimal"
|
||||
return cli_obj
|
||||
|
||||
|
||||
class TestCliResumeCommand:
|
||||
def test_show_recent_sessions_includes_indexes_and_resume_hint(self, capsys):
|
||||
cli_obj = _make_cli()
|
||||
cli_obj._list_recent_sessions = MagicMock(return_value=[
|
||||
{"id": "sess_002", "title": "Coding", "preview": "build feature", "last_active": None},
|
||||
{"id": "sess_001", "title": "Research", "preview": "read docs", "last_active": None},
|
||||
])
|
||||
|
||||
shown = cli_obj._show_recent_sessions(reason="resume")
|
||||
output = capsys.readouterr().out
|
||||
|
||||
assert shown is True
|
||||
assert "1" in output
|
||||
assert "2" in output
|
||||
assert "Coding" in output
|
||||
assert "Research" in output
|
||||
assert "/resume 2" in output
|
||||
assert "/resume <session title>" in output
|
||||
|
||||
def test_show_recent_sessions_uses_prompt_toolkit_safe_print(self):
|
||||
cli_obj = _make_cli()
|
||||
cli_obj._list_recent_sessions = MagicMock(return_value=[
|
||||
{"id": "sess_002", "title": "Coding", "preview": "build feature", "last_active": None},
|
||||
])
|
||||
|
||||
running_app = SimpleNamespace(_is_running=True)
|
||||
with (
|
||||
patch("prompt_toolkit.application.get_app_or_none", return_value=running_app),
|
||||
patch("cli._cprint") as mock_cprint,
|
||||
):
|
||||
shown = cli_obj._show_recent_sessions(reason="sessions")
|
||||
|
||||
assert shown is True
|
||||
printed = "\n".join(call.args[0] for call in mock_cprint.call_args_list)
|
||||
assert "Recent sessions" in printed
|
||||
assert "Coding" in printed
|
||||
|
||||
def test_show_history_uses_prompt_toolkit_safe_print(self):
|
||||
cli_obj = _make_cli()
|
||||
cli_obj.conversation_history = [{"role": "user", "content": "Hello"}]
|
||||
|
||||
running_app = SimpleNamespace(_is_running=True)
|
||||
with (
|
||||
patch("prompt_toolkit.application.get_app_or_none", return_value=running_app),
|
||||
patch("cli._cprint") as mock_cprint,
|
||||
):
|
||||
cli_obj.show_history()
|
||||
|
||||
printed = "\n".join(call.args[0] for call in mock_cprint.call_args_list)
|
||||
assert "Conversation History" in printed
|
||||
assert "Hello" in printed
|
||||
|
||||
def test_handle_resume_by_index_switches_to_numbered_session(self):
|
||||
cli_obj = _make_cli()
|
||||
cli_obj._list_recent_sessions = MagicMock(return_value=[
|
||||
{"id": "sess_002", "title": "Coding"},
|
||||
{"id": "sess_001", "title": "Research"},
|
||||
])
|
||||
cli_obj._session_db.get_session.return_value = {"id": "sess_001", "title": "Research"}
|
||||
cli_obj._session_db.get_messages_as_conversation.return_value = [
|
||||
{"role": "user", "content": "hello"},
|
||||
{"role": "assistant", "content": "hi"},
|
||||
]
|
||||
# resolve_resume_session_id passes the id through when no compression chain.
|
||||
cli_obj._session_db.resolve_resume_session_id.return_value = "sess_001"
|
||||
|
||||
with (
|
||||
patch("hermes_cli.main._resolve_session_by_name_or_id", return_value=None),
|
||||
patch("cli._cprint") as mock_cprint,
|
||||
):
|
||||
cli_obj._handle_resume_command("/resume 2")
|
||||
|
||||
printed = " ".join(str(call) for call in mock_cprint.call_args_list)
|
||||
assert cli_obj.session_id == "sess_001"
|
||||
assert "Resumed session sess_001" in printed
|
||||
assert "Research" in printed
|
||||
|
||||
def test_handle_resume_by_index_out_of_range(self):
|
||||
cli_obj = _make_cli()
|
||||
cli_obj._list_recent_sessions = MagicMock(return_value=[
|
||||
{"id": "sess_002", "title": "Coding"},
|
||||
])
|
||||
|
||||
with patch("cli._cprint") as mock_cprint:
|
||||
cli_obj._handle_resume_command("/resume 9")
|
||||
|
||||
printed = " ".join(str(call) for call in mock_cprint.call_args_list)
|
||||
assert "out of range" in printed.lower()
|
||||
assert "/resume" in printed
|
||||
assert cli_obj.session_id == "current_session"
|
||||
|
||||
def test_handle_resume_strips_outer_brackets(self):
|
||||
"""Users copy `<session_id>` from the usage hint literally.
|
||||
|
||||
Strip outer ``<>``, ``[]``, ``""``, and ``''`` before lookup so
|
||||
``/resume <abc123>`` works the same as ``/resume abc123``.
|
||||
"""
|
||||
cli_obj = _make_cli()
|
||||
cli_obj._session_db.get_session.return_value = {"id": "sess_alpha", "title": "Alpha"}
|
||||
cli_obj._session_db.get_messages_as_conversation.return_value = []
|
||||
cli_obj._session_db.resolve_resume_session_id.return_value = "sess_alpha"
|
||||
|
||||
for raw in ("<sess_alpha>", "[sess_alpha]", '"sess_alpha"', "'sess_alpha'"):
|
||||
cli_obj.session_id = "current_session"
|
||||
with (
|
||||
patch("hermes_cli.main._resolve_session_by_name_or_id", return_value="sess_alpha"),
|
||||
patch("cli._cprint"),
|
||||
):
|
||||
cli_obj._handle_resume_command(f"/resume {raw}")
|
||||
assert cli_obj.session_id == "sess_alpha", (
|
||||
f"bracket-stripping failed for {raw!r}: session_id stayed {cli_obj.session_id}"
|
||||
)
|
||||
|
||||
def test_handle_resume_does_not_strip_partial_brackets(self):
|
||||
"""Mismatched or single brackets must pass through unmodified.
|
||||
|
||||
``"<half`` (just an open angle) is not a wrapping pair, so the
|
||||
lookup should treat it verbatim — preserving the existing
|
||||
not-found error path instead of mangling the input.
|
||||
"""
|
||||
cli_obj = _make_cli()
|
||||
cli_obj._session_db.get_session.return_value = None
|
||||
|
||||
with (
|
||||
patch("hermes_cli.main._resolve_session_by_name_or_id", return_value=None),
|
||||
patch("cli._cprint") as mock_cprint,
|
||||
):
|
||||
cli_obj._handle_resume_command("/resume <half")
|
||||
|
||||
printed = " ".join(str(call) for call in mock_cprint.call_args_list)
|
||||
assert "<half" in printed
|
||||
|
||||
|
||||
class TestPendingResumeNumberedSelection:
|
||||
"""Bare `/resume` arms a one-shot prompt so the next bare number resumes.
|
||||
|
||||
Regression coverage for #34584: previously, running `/resume` (no args)
|
||||
printed the recent-sessions list but left no selection state armed, so
|
||||
typing just `3` on the next line was sent to the agent as chat instead of
|
||||
resuming session #3.
|
||||
"""
|
||||
|
||||
def test_bare_resume_arms_pending_selection(self):
|
||||
cli_obj = _make_cli()
|
||||
sessions = [
|
||||
{"id": "sess_002", "title": "Coding"},
|
||||
{"id": "sess_001", "title": "Research"},
|
||||
]
|
||||
cli_obj._list_recent_sessions = MagicMock(return_value=sessions)
|
||||
cli_obj._show_recent_sessions = MagicMock(return_value=True)
|
||||
|
||||
with patch("cli._cprint"):
|
||||
cli_obj._handle_resume_command("/resume")
|
||||
|
||||
assert cli_obj._pending_resume_sessions == sessions
|
||||
|
||||
def test_bare_resume_no_sessions_does_not_arm(self):
|
||||
cli_obj = _make_cli()
|
||||
cli_obj._show_recent_sessions = MagicMock(return_value=False)
|
||||
cli_obj._list_recent_sessions = MagicMock(return_value=[])
|
||||
|
||||
with patch("cli._cprint"):
|
||||
cli_obj._handle_resume_command("/resume")
|
||||
|
||||
assert cli_obj._pending_resume_sessions is None
|
||||
|
||||
def test_pending_number_resumes_selected_session(self):
|
||||
cli_obj = _make_cli()
|
||||
sessions = [
|
||||
{"id": "sess_002", "title": "Coding"},
|
||||
{"id": "sess_001", "title": "Research"},
|
||||
]
|
||||
cli_obj._pending_resume_sessions = sessions
|
||||
# _handle_resume_command("/resume 2") re-resolves the index via
|
||||
# _list_recent_sessions, so it must return the same list.
|
||||
cli_obj._list_recent_sessions = MagicMock(return_value=sessions)
|
||||
cli_obj._session_db.get_session.return_value = {"id": "sess_001", "title": "Research"}
|
||||
cli_obj._session_db.get_messages_as_conversation.return_value = [
|
||||
{"role": "user", "content": "hello"},
|
||||
]
|
||||
cli_obj._session_db.resolve_resume_session_id.return_value = "sess_001"
|
||||
|
||||
with (
|
||||
patch("hermes_cli.main._resolve_session_by_name_or_id", return_value=None),
|
||||
patch("cli._cprint"),
|
||||
):
|
||||
consumed = cli_obj._consume_pending_resume_selection("2")
|
||||
|
||||
assert consumed is True
|
||||
assert cli_obj.session_id == "sess_001"
|
||||
# One-shot: prompt is disarmed after consuming.
|
||||
assert cli_obj._pending_resume_sessions is None
|
||||
|
||||
def test_pending_out_of_range_consumed_with_message(self):
|
||||
cli_obj = _make_cli()
|
||||
cli_obj._pending_resume_sessions = [{"id": "sess_002", "title": "Coding"}]
|
||||
|
||||
with patch("cli._cprint") as mock_cprint:
|
||||
consumed = cli_obj._consume_pending_resume_selection("9")
|
||||
|
||||
printed = " ".join(str(call) for call in mock_cprint.call_args_list)
|
||||
# An out-of-range number is still consumed (not sent to the agent),
|
||||
# and the prompt is disarmed.
|
||||
assert consumed is True
|
||||
assert "out of range" in printed.lower()
|
||||
assert cli_obj.session_id == "current_session"
|
||||
assert cli_obj._pending_resume_sessions is None
|
||||
|
||||
def test_pending_non_numeric_falls_through_and_disarms(self):
|
||||
cli_obj = _make_cli()
|
||||
cli_obj._pending_resume_sessions = [{"id": "sess_002", "title": "Coding"}]
|
||||
|
||||
with patch("cli._cprint"):
|
||||
consumed = cli_obj._consume_pending_resume_selection("hello there")
|
||||
|
||||
# Free text is NOT consumed (caller treats it as chat), but the
|
||||
# one-shot prompt is disarmed so a later number isn't hijacked.
|
||||
assert consumed is False
|
||||
assert cli_obj._pending_resume_sessions is None
|
||||
|
||||
def test_no_pending_returns_false(self):
|
||||
cli_obj = _make_cli()
|
||||
assert cli_obj._pending_resume_sessions is None
|
||||
assert cli_obj._consume_pending_resume_selection("3") is False
|
||||
|
||||
def test_pending_disarmed_by_other_command(self):
|
||||
cli_obj = _make_cli()
|
||||
cli_obj._pending_resume_sessions = [{"id": "sess_002", "title": "Coding"}]
|
||||
# Stub out the help handler so process_command("/help") is cheap.
|
||||
cli_obj.show_help = MagicMock()
|
||||
|
||||
cli_obj.process_command("/help")
|
||||
|
||||
# A non-resume command disarms the one-shot prompt (#34584).
|
||||
assert cli_obj._pending_resume_sessions is None
|
||||
|
||||
|
||||
class TestRestoreSessionCwdMarkup:
|
||||
"""Regression: _restore_session_cwd must not crash with Rich MarkupError.
|
||||
|
||||
Lines that used ``[{_DIM}]`` inside Rich markup triggered
|
||||
``rich.errors.MarkupError: closing tag [/] at position N has nothing to
|
||||
close`` because ``_DIM`` is an ANSI escape (``\\x1b[2;3m``), not a valid
|
||||
Rich tag. The fix replaces ``[{_DIM}]`` with Rich's native ``[dim]`` tag.
|
||||
See: https://github.com/NousResearch/hermes-agent/issues/39469
|
||||
"""
|
||||
|
||||
def test_missing_dir_does_not_raise_markup_error(self):
|
||||
"""Session cwd gone → dim warning, no MarkupError."""
|
||||
cli_obj = _make_cli()
|
||||
console = MagicMock()
|
||||
cli_obj._output_console = MagicMock(return_value=console)
|
||||
|
||||
# Use a path that definitely does not exist.
|
||||
cli_obj._restore_session_cwd({"cwd": "/nonexistent/path/to/nowhere"})
|
||||
|
||||
# Should have printed a warning via console.print, not crashed.
|
||||
assert console.print.called
|
||||
printed = str(console.print.call_args)
|
||||
assert "Working directory is gone" in printed or "gone" in printed.lower()
|
||||
|
||||
def test_chdir_failure_does_not_raise_markup_error(self, tmp_path):
|
||||
"""os.chdir fails → dim warning, no MarkupError."""
|
||||
import os
|
||||
cli_obj = _make_cli()
|
||||
console = MagicMock()
|
||||
cli_obj._output_console = MagicMock(return_value=console)
|
||||
|
||||
# Create a directory, then make it unreadable (simulate chdir failure).
|
||||
target = tmp_path / "locked"
|
||||
target.mkdir()
|
||||
|
||||
# Patch os.chdir to raise OSError for our target path.
|
||||
original_chdir = os.chdir
|
||||
def fake_chdir(path):
|
||||
if str(path) == str(target):
|
||||
raise OSError("Permission denied")
|
||||
return original_chdir(path)
|
||||
|
||||
with patch("os.chdir", side_effect=fake_chdir):
|
||||
cli_obj._restore_session_cwd({"cwd": str(target)})
|
||||
|
||||
assert console.print.called
|
||||
printed = str(console.print.call_args)
|
||||
assert "Could not enter" in printed or "permission" in printed.lower()
|
||||
|
||||
def test_success_path_does_not_raise_markup_error(self, tmp_path):
|
||||
"""Successful cwd switch → dim info, no MarkupError."""
|
||||
import os
|
||||
cli_obj = _make_cli()
|
||||
console = MagicMock()
|
||||
cli_obj._output_console = MagicMock(return_value=console)
|
||||
|
||||
original_cwd = os.getcwd()
|
||||
try:
|
||||
cli_obj._restore_session_cwd({"cwd": str(tmp_path)})
|
||||
assert console.print.called
|
||||
printed = str(console.print.call_args)
|
||||
assert "Working directory" in printed or "working" in printed.lower()
|
||||
finally:
|
||||
os.chdir(original_cwd)
|
||||
|
||||
|
||||
class TestResumeFlushesBeforeEndSession:
|
||||
"""Regression for #47202: /resume must flush un-persisted messages to
|
||||
the session DB before ending the old session, just like /new and
|
||||
compress_context() already do."""
|
||||
|
||||
def test_resume_flushes_when_agent_present(self):
|
||||
cli_obj = _make_cli()
|
||||
cli_obj.conversation_history = [
|
||||
{"role": "user", "content": "hello"},
|
||||
{"role": "assistant", "content": "hi"},
|
||||
]
|
||||
agent = MagicMock()
|
||||
cli_obj.agent = agent
|
||||
|
||||
cli_obj._session_db.get_session.return_value = {"id": "target", "title": "T"}
|
||||
cli_obj._session_db.get_messages_as_conversation.return_value = []
|
||||
cli_obj._session_db.resolve_resume_session_id.return_value = "target"
|
||||
|
||||
with (
|
||||
patch("hermes_cli.main._resolve_session_by_name_or_id", return_value="target"),
|
||||
patch("cli._cprint"),
|
||||
):
|
||||
cli_obj._handle_resume_command("/resume target")
|
||||
|
||||
agent._flush_messages_to_session_db.assert_called_once_with(
|
||||
[{"role": "user", "content": "hello"}, {"role": "assistant", "content": "hi"}]
|
||||
)
|
||||
cli_obj._session_db.end_session.assert_called_once()
|
||||
@@ -0,0 +1,49 @@
|
||||
"""Regression tests for CLI /retry history replacement semantics."""
|
||||
|
||||
from tests.cli.test_cli_init import _make_cli
|
||||
|
||||
|
||||
def test_retry_last_truncates_history_before_requeueing_message():
|
||||
cli = _make_cli()
|
||||
cli.conversation_history = [
|
||||
{"role": "user", "content": "first"},
|
||||
{"role": "assistant", "content": "one"},
|
||||
{"role": "user", "content": "retry me"},
|
||||
{"role": "assistant", "content": "old answer"},
|
||||
]
|
||||
|
||||
retry_msg = cli.retry_last()
|
||||
|
||||
assert retry_msg == "retry me"
|
||||
assert cli.conversation_history == [
|
||||
{"role": "user", "content": "first"},
|
||||
{"role": "assistant", "content": "one"},
|
||||
]
|
||||
|
||||
cli.conversation_history.append({"role": "user", "content": retry_msg})
|
||||
cli.conversation_history.append({"role": "assistant", "content": "new answer"})
|
||||
|
||||
assert [m["content"] for m in cli.conversation_history if m["role"] == "user"] == [
|
||||
"first",
|
||||
"retry me",
|
||||
]
|
||||
|
||||
|
||||
def test_process_command_retry_requeues_original_message_not_retry_command():
|
||||
cli = _make_cli()
|
||||
queued = []
|
||||
|
||||
class _Queue:
|
||||
def put(self, value):
|
||||
queued.append(value)
|
||||
|
||||
cli._pending_input = _Queue()
|
||||
cli.conversation_history = [
|
||||
{"role": "user", "content": "retry me"},
|
||||
{"role": "assistant", "content": "old answer"},
|
||||
]
|
||||
|
||||
cli.process_command("/retry")
|
||||
|
||||
assert queued == ["retry me"]
|
||||
assert cli.conversation_history == []
|
||||
@@ -0,0 +1,134 @@
|
||||
"""Tests for save_config_value() in cli.py — atomic write behavior."""
|
||||
|
||||
import yaml
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
class TestSaveConfigValueAtomic:
|
||||
"""save_config_value() must use atomic round-trip YAML updates."""
|
||||
|
||||
@pytest.fixture
|
||||
def config_env(self, tmp_path, monkeypatch):
|
||||
"""Isolated config environment with a writable config.yaml."""
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
hermes_home.mkdir()
|
||||
config_path = hermes_home / "config.yaml"
|
||||
config_path.write_text(yaml.dump({
|
||||
"model": {"default": "test-model", "provider": "openrouter"},
|
||||
"display": {"skin": "default"},
|
||||
}))
|
||||
monkeypatch.setattr("cli._hermes_home", hermes_home)
|
||||
return config_path
|
||||
|
||||
def test_calls_roundtrip_yaml_update(self, config_env, monkeypatch):
|
||||
"""save_config_value must preserve user-edited YAML structure."""
|
||||
mock_update = MagicMock()
|
||||
monkeypatch.setattr("utils.atomic_roundtrip_yaml_update", mock_update)
|
||||
|
||||
from cli import save_config_value
|
||||
save_config_value("display.skin", "mono")
|
||||
|
||||
mock_update.assert_called_once_with(config_env, "display.skin", "mono")
|
||||
|
||||
def test_preserves_existing_keys(self, config_env):
|
||||
"""Writing a new key must not clobber existing config entries."""
|
||||
from cli import save_config_value
|
||||
save_config_value("agent.max_turns", 50)
|
||||
|
||||
result = yaml.safe_load(config_env.read_text())
|
||||
assert result["model"]["default"] == "test-model"
|
||||
assert result["model"]["provider"] == "openrouter"
|
||||
assert result["display"]["skin"] == "default"
|
||||
assert result["agent"]["max_turns"] == 50
|
||||
|
||||
def test_creates_nested_keys(self, config_env):
|
||||
"""Dot-separated paths create intermediate dicts as needed."""
|
||||
from cli import save_config_value
|
||||
save_config_value("auxiliary.compression.model", "google/gemini-3-flash-preview")
|
||||
|
||||
result = yaml.safe_load(config_env.read_text())
|
||||
assert result["auxiliary"]["compression"]["model"] == "google/gemini-3-flash-preview"
|
||||
|
||||
def test_overwrites_existing_value(self, config_env):
|
||||
"""Updating an existing key replaces the value."""
|
||||
from cli import save_config_value
|
||||
save_config_value("display.skin", "ares")
|
||||
|
||||
result = yaml.safe_load(config_env.read_text())
|
||||
assert result["display"]["skin"] == "ares"
|
||||
|
||||
def test_preserves_env_ref_templates_in_unrelated_fields(self, config_env):
|
||||
"""The /model --global persistence path must not inline env-backed secrets."""
|
||||
config_env.write_text(yaml.dump({
|
||||
"custom_providers": [{
|
||||
"name": "tuzi",
|
||||
"api_key": "${TU_ZI_API_KEY}",
|
||||
"model": "claude-opus-4-6",
|
||||
}],
|
||||
"model": {"default": "test-model", "provider": "openrouter"},
|
||||
}))
|
||||
|
||||
from cli import save_config_value
|
||||
save_config_value("model.default", "doubao-pro")
|
||||
|
||||
result = yaml.safe_load(config_env.read_text())
|
||||
assert result["model"]["default"] == "doubao-pro"
|
||||
assert result["custom_providers"][0]["api_key"] == "${TU_ZI_API_KEY}"
|
||||
|
||||
def test_preserves_comments_after_config_mutation(self, config_env):
|
||||
"""CLI config writes should not strip existing user comments."""
|
||||
config_env.write_text(
|
||||
"# user selected model\n"
|
||||
"model:\n"
|
||||
" # keep this provider note\n"
|
||||
" provider: openrouter\n"
|
||||
"display:\n"
|
||||
" skin: default # inline skin note\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
from cli import save_config_value
|
||||
save_config_value("display.skin", "mono")
|
||||
|
||||
text = config_env.read_text(encoding="utf-8")
|
||||
result = yaml.safe_load(text)
|
||||
assert result["display"]["skin"] == "mono"
|
||||
assert "# user selected model" in text
|
||||
assert "# keep this provider note" in text
|
||||
assert "# inline skin note" in text
|
||||
|
||||
def test_preserves_readable_unicode_after_config_mutation(self, config_env):
|
||||
"""Non-ASCII prompts should remain readable instead of \\u-escaped."""
|
||||
config_env.write_text(
|
||||
"agent:\n"
|
||||
" system_prompt: 你好,保持中文输出\n"
|
||||
"display:\n"
|
||||
" skin: default\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
from cli import save_config_value
|
||||
save_config_value("display.skin", "mono")
|
||||
|
||||
text = config_env.read_text(encoding="utf-8")
|
||||
result = yaml.safe_load(text)
|
||||
assert result["agent"]["system_prompt"] == "你好,保持中文输出"
|
||||
assert "你好,保持中文输出" in text
|
||||
assert "\\u4f60" not in text
|
||||
|
||||
def test_file_not_truncated_on_error(self, config_env, monkeypatch):
|
||||
"""If atomic_yaml_write raises, the original file is untouched."""
|
||||
original_content = config_env.read_text()
|
||||
|
||||
def exploding_write(*args, **kwargs):
|
||||
raise OSError("disk full")
|
||||
|
||||
monkeypatch.setattr("utils.atomic_roundtrip_yaml_update", exploding_write)
|
||||
|
||||
from cli import save_config_value
|
||||
result = save_config_value("display.skin", "broken")
|
||||
|
||||
assert result is False
|
||||
assert config_env.read_text() == original_content
|
||||
@@ -0,0 +1,147 @@
|
||||
import queue
|
||||
import threading
|
||||
import time
|
||||
from unittest.mock import patch
|
||||
|
||||
import cli as cli_module
|
||||
import tools.skills_tool as skills_tool_module
|
||||
from cli import HermesCLI
|
||||
from hermes_cli.callbacks import prompt_for_secret
|
||||
from tools.skills_tool import set_secret_capture_callback
|
||||
|
||||
|
||||
class _FakeBuffer:
|
||||
def __init__(self):
|
||||
self.reset_called = False
|
||||
|
||||
def reset(self):
|
||||
self.reset_called = True
|
||||
|
||||
|
||||
class _FakeApp:
|
||||
def __init__(self):
|
||||
self.invalidated = False
|
||||
self.current_buffer = _FakeBuffer()
|
||||
|
||||
def invalidate(self):
|
||||
self.invalidated = True
|
||||
|
||||
|
||||
def _make_cli_stub(with_app=False):
|
||||
cli = HermesCLI.__new__(HermesCLI)
|
||||
cli._app = _FakeApp() if with_app else None
|
||||
cli._last_invalidate = 0.0
|
||||
cli._secret_state = None
|
||||
cli._secret_deadline = 0
|
||||
return cli
|
||||
|
||||
|
||||
def test_secret_capture_callback_can_be_completed_from_cli_state_machine():
|
||||
cli = _make_cli_stub(with_app=True)
|
||||
results = []
|
||||
|
||||
with patch("hermes_cli.callbacks.save_env_value_secure") as save_secret:
|
||||
save_secret.return_value = {
|
||||
"success": True,
|
||||
"stored_as": "TENOR_API_KEY",
|
||||
"validated": False,
|
||||
}
|
||||
|
||||
thread = threading.Thread(
|
||||
target=lambda: results.append(
|
||||
cli._secret_capture_callback("TENOR_API_KEY", "Tenor API key")
|
||||
)
|
||||
)
|
||||
thread.start()
|
||||
|
||||
deadline = time.time() + 2
|
||||
while cli._secret_state is None and time.time() < deadline:
|
||||
time.sleep(0.01)
|
||||
|
||||
assert cli._secret_state is not None
|
||||
cli._submit_secret_response("super-secret-value")
|
||||
thread.join(timeout=2)
|
||||
|
||||
assert results[0]["success"] is True
|
||||
assert results[0]["stored_as"] == "TENOR_API_KEY"
|
||||
assert results[0]["skipped"] is False
|
||||
|
||||
|
||||
def test_cancel_secret_capture_marks_setup_skipped():
|
||||
cli = _make_cli_stub()
|
||||
cli._secret_state = {
|
||||
"response_queue": queue.Queue(),
|
||||
"var_name": "TENOR_API_KEY",
|
||||
"prompt": "Tenor API key",
|
||||
"metadata": {},
|
||||
}
|
||||
cli._secret_deadline = 123
|
||||
|
||||
cli._cancel_secret_capture()
|
||||
|
||||
assert cli._secret_state is None
|
||||
assert cli._secret_deadline == 0
|
||||
|
||||
|
||||
def test_secret_capture_uses_masked_prompt_without_tui():
|
||||
cli = _make_cli_stub()
|
||||
|
||||
with patch("hermes_cli.callbacks.masked_secret_prompt", return_value="secret-value"), patch(
|
||||
"hermes_cli.callbacks.save_env_value_secure"
|
||||
) as save_secret:
|
||||
save_secret.return_value = {
|
||||
"success": True,
|
||||
"stored_as": "TENOR_API_KEY",
|
||||
"validated": False,
|
||||
}
|
||||
result = prompt_for_secret(cli, "TENOR_API_KEY", "Tenor API key")
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["stored_as"] == "TENOR_API_KEY"
|
||||
assert result["skipped"] is False
|
||||
|
||||
|
||||
def test_secret_capture_timeout_clears_hidden_input_buffer():
|
||||
cli = _make_cli_stub(with_app=True)
|
||||
cleared = {"value": False}
|
||||
|
||||
def clear_buffer():
|
||||
cleared["value"] = True
|
||||
|
||||
cli._clear_secret_input_buffer = clear_buffer
|
||||
|
||||
with patch("hermes_cli.callbacks.queue.Queue.get", side_effect=queue.Empty), patch(
|
||||
"hermes_cli.callbacks._time.monotonic",
|
||||
side_effect=[0, 121],
|
||||
):
|
||||
result = prompt_for_secret(cli, "TENOR_API_KEY", "Tenor API key")
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["skipped"] is True
|
||||
assert result["reason"] == "timeout"
|
||||
assert cleared["value"] is True
|
||||
|
||||
|
||||
def test_cli_chat_registers_secret_capture_callback():
|
||||
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"},
|
||||
}
|
||||
|
||||
with patch("cli.get_tool_definitions", return_value=[]), patch.dict(
|
||||
"os.environ", {"LLM_MODEL": "", "HERMES_MAX_ITERATIONS": ""}, clear=False
|
||||
), patch.dict(cli_module.__dict__, {"CLI_CONFIG": clean_config}):
|
||||
cli_obj = HermesCLI()
|
||||
with patch.object(cli_obj, "_ensure_runtime_credentials", return_value=False):
|
||||
cli_obj.chat("hello")
|
||||
|
||||
try:
|
||||
assert skills_tool_module._secret_capture_callback == cli_obj._secret_capture_callback
|
||||
finally:
|
||||
set_secret_capture_callback(None)
|
||||
@@ -0,0 +1,88 @@
|
||||
"""Verify Shift+Enter byte sequences parse to the same key tuple Alt+Enter
|
||||
produces, so the existing Alt+Enter newline handler in `cli.py` fires for
|
||||
terminals that emit a distinct Shift+Enter under the Kitty keyboard protocol
|
||||
or xterm modifyOtherKeys mode.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from prompt_toolkit.input.ansi_escape_sequences import ANSI_SEQUENCES
|
||||
from prompt_toolkit.input.vt100_parser import Vt100Parser
|
||||
from prompt_toolkit.keys import Keys
|
||||
|
||||
from hermes_cli.pt_input_extras import install_shift_enter_alias
|
||||
|
||||
|
||||
SHIFT_ENTER_SEQUENCES = (
|
||||
"\x1b[13;2u", # Kitty / CSI-u, modifier=2 (Shift)
|
||||
"\x1b[27;2;13~", # xterm modifyOtherKeys=2
|
||||
"\x1b[27;2;13u",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _ensure_alias_installed():
|
||||
"""Make every test idempotent — install the alias once per test run."""
|
||||
install_shift_enter_alias()
|
||||
|
||||
|
||||
def _parse(byte_seq: str):
|
||||
out = []
|
||||
parser = Vt100Parser(out.append)
|
||||
for ch in byte_seq:
|
||||
parser.feed(ch)
|
||||
parser.flush()
|
||||
return [kp.key for kp in out]
|
||||
|
||||
|
||||
def test_install_registers_all_three_sequences():
|
||||
for seq in SHIFT_ENTER_SEQUENCES:
|
||||
assert seq in ANSI_SEQUENCES, f"missing mapping for {seq!r}"
|
||||
assert ANSI_SEQUENCES[seq] == (Keys.Escape, Keys.ControlM)
|
||||
|
||||
|
||||
def test_install_overwrites_stock_modifyotherkeys_shift_enter():
|
||||
"""Stock prompt_toolkit maps `\\x1b[27;2;13~` to plain Keys.ControlM —
|
||||
i.e. it drops the Shift modifier and treats Shift+Enter like Enter,
|
||||
which is the bug this helper exists to fix. The install must overwrite
|
||||
that entry."""
|
||||
seq = "\x1b[27;2;13~"
|
||||
ANSI_SEQUENCES[seq] = Keys.ControlM
|
||||
install_shift_enter_alias()
|
||||
assert ANSI_SEQUENCES[seq] == (Keys.Escape, Keys.ControlM)
|
||||
|
||||
|
||||
def test_install_returns_zero_when_already_correct():
|
||||
"""Idempotency — running install twice should not report a second change."""
|
||||
install_shift_enter_alias()
|
||||
assert install_shift_enter_alias() == 0
|
||||
|
||||
|
||||
def test_csi_u_shift_enter_parses_as_alt_enter():
|
||||
"""Kitty keyboard protocol Shift+Enter must parse to the same key tuple
|
||||
Alt+Enter produces, so the existing handler is reused."""
|
||||
alt_enter = _parse("\x1b\r")
|
||||
shift_enter = _parse("\x1b[13;2u")
|
||||
assert shift_enter == alt_enter, (
|
||||
f"Shift+Enter via CSI-u should parse identically to Alt+Enter; "
|
||||
f"got {shift_enter!r} vs {alt_enter!r}"
|
||||
)
|
||||
|
||||
|
||||
def test_modify_other_keys_shift_enter_parses_as_alt_enter():
|
||||
"""xterm modifyOtherKeys=2 Shift+Enter must parse identically to Alt+Enter."""
|
||||
alt_enter = _parse("\x1b\r")
|
||||
shift_enter = _parse("\x1b[27;2;13~")
|
||||
assert shift_enter == alt_enter
|
||||
|
||||
|
||||
def test_plain_enter_remains_distinct_from_alt_enter():
|
||||
"""Plain Enter must keep emitting a single key (submit), not a two-key
|
||||
Alt+Enter tuple — otherwise we would have broken submit."""
|
||||
enter = _parse("\r")
|
||||
alt_enter = _parse("\x1b\r")
|
||||
assert enter != alt_enter
|
||||
assert len(enter) == 1
|
||||
assert len(alt_enter) == 2
|
||||
@@ -0,0 +1,169 @@
|
||||
"""Regression tests for #15165 (CLI sibling site) — CLI exit cleanup must
|
||||
forward the agent's conversation transcript to ``shutdown_memory_provider``
|
||||
so memory providers' ``on_session_end`` hooks see the real messages.
|
||||
|
||||
Before the fix, ``_run_cleanup`` called
|
||||
``shutdown_memory_provider(getattr(agent, 'conversation_history', None) or [])``.
|
||||
``AIAgent`` has no ``conversation_history`` attribute — so the ``or []``
|
||||
branch always fired and providers got an empty list on CLI exit. This
|
||||
mirrors the gateway bug fixed in the same commit (gateway/run.py uses
|
||||
``_session_messages``, which IS set on ``AIAgent``).
|
||||
|
||||
The fix reads ``_session_messages`` (same attribute the gateway path uses)
|
||||
with an ``isinstance(..., list)`` guard so MagicMock-based agents in
|
||||
other tests keep their existing no-arg behaviour.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
|
||||
@patch("hermes_cli.plugins.invoke_hook")
|
||||
def test_cleanup_forwards_session_messages(mock_invoke_hook):
|
||||
"""_run_cleanup forwards a populated ``_session_messages`` list."""
|
||||
import cli as cli_mod
|
||||
|
||||
transcript = [
|
||||
{"role": "user", "content": "remember my dog is named Biscuit"},
|
||||
{"role": "assistant", "content": "Got it — Biscuit."},
|
||||
]
|
||||
|
||||
agent = MagicMock()
|
||||
agent.session_id = "cli-session-id"
|
||||
agent._session_messages = transcript
|
||||
|
||||
cli_mod._active_agent_ref = agent
|
||||
cli_mod._cleanup_done = False
|
||||
try:
|
||||
cli_mod._run_cleanup()
|
||||
finally:
|
||||
cli_mod._active_agent_ref = None
|
||||
cli_mod._cleanup_done = False
|
||||
|
||||
agent.shutdown_memory_provider.assert_called_once_with(transcript)
|
||||
|
||||
|
||||
@patch("hermes_cli.plugins.invoke_hook")
|
||||
def test_cleanup_empty_list_still_forwarded(mock_invoke_hook):
|
||||
"""An agent that initialised but ran no turns has an empty list.
|
||||
Forwarding it (rather than falling through) matches the gateway-side
|
||||
behaviour and is explicit to providers."""
|
||||
import cli as cli_mod
|
||||
|
||||
agent = MagicMock()
|
||||
agent.session_id = "cli-session-id"
|
||||
agent._session_messages = []
|
||||
|
||||
cli_mod._active_agent_ref = agent
|
||||
cli_mod._cleanup_done = False
|
||||
try:
|
||||
cli_mod._run_cleanup()
|
||||
finally:
|
||||
cli_mod._active_agent_ref = None
|
||||
cli_mod._cleanup_done = False
|
||||
|
||||
agent.shutdown_memory_provider.assert_called_once_with([])
|
||||
|
||||
|
||||
@patch("hermes_cli.plugins.invoke_hook")
|
||||
def test_cleanup_non_list_attribute_falls_back_to_no_arg(mock_invoke_hook):
|
||||
"""A MagicMock agent auto-synthesises ``_session_messages`` as a
|
||||
nested MagicMock. ``isinstance(mock, list)`` is False, so we fall
|
||||
back to the no-arg path rather than passing a garbage value to
|
||||
providers expecting ``List[Dict]``. This keeps existing CLI test
|
||||
suites that use bare ``MagicMock()`` agents green."""
|
||||
import cli as cli_mod
|
||||
|
||||
agent = MagicMock()
|
||||
agent.session_id = "cli-session-id"
|
||||
# No explicit _session_messages — MagicMock synthesises one on access.
|
||||
|
||||
cli_mod._active_agent_ref = agent
|
||||
cli_mod._cleanup_done = False
|
||||
try:
|
||||
cli_mod._run_cleanup()
|
||||
finally:
|
||||
cli_mod._active_agent_ref = None
|
||||
cli_mod._cleanup_done = False
|
||||
|
||||
agent.shutdown_memory_provider.assert_called_once_with()
|
||||
|
||||
|
||||
@patch("hermes_cli.plugins.invoke_hook")
|
||||
def test_cleanup_provider_exception_is_swallowed(mock_invoke_hook):
|
||||
"""A raising ``shutdown_memory_provider`` must not crash CLI exit."""
|
||||
import cli as cli_mod
|
||||
|
||||
agent = MagicMock()
|
||||
agent.session_id = "cli-session-id"
|
||||
agent._session_messages = [{"role": "user", "content": "x"}]
|
||||
agent.shutdown_memory_provider.side_effect = RuntimeError("boom")
|
||||
|
||||
cli_mod._active_agent_ref = agent
|
||||
cli_mod._cleanup_done = False
|
||||
try:
|
||||
cli_mod._run_cleanup() # must not raise
|
||||
finally:
|
||||
cli_mod._active_agent_ref = None
|
||||
cli_mod._cleanup_done = False
|
||||
|
||||
agent.shutdown_memory_provider.assert_called_once()
|
||||
|
||||
|
||||
def test_cli_close_persists_agent_session_messages_before_end_session():
|
||||
"""CLI shutdown flushes live agent messages before closing the session."""
|
||||
import cli as cli_mod
|
||||
|
||||
transcript = [
|
||||
{"role": "user", "content": "long task"},
|
||||
{"role": "assistant", "content": "partial answer"},
|
||||
]
|
||||
conversation_history = [{"role": "user", "content": "long task"}]
|
||||
|
||||
cli = object.__new__(cli_mod.HermesCLI)
|
||||
cli.conversation_history = conversation_history
|
||||
cli.session_id = "old-session"
|
||||
agent = MagicMock()
|
||||
agent.session_id = "live-session"
|
||||
agent._session_messages = transcript
|
||||
cli.agent = agent
|
||||
|
||||
cli._persist_active_session_before_close()
|
||||
|
||||
agent._persist_session.assert_called_once_with(transcript, conversation_history)
|
||||
assert cli.session_id == "live-session"
|
||||
|
||||
|
||||
def test_cli_close_persist_falls_back_to_conversation_history():
|
||||
"""Bare MagicMock agents do not provide a real _session_messages list."""
|
||||
import cli as cli_mod
|
||||
|
||||
conversation_history = [{"role": "user", "content": "saved from cli"}]
|
||||
cli = object.__new__(cli_mod.HermesCLI)
|
||||
cli.conversation_history = conversation_history
|
||||
cli.session_id = "session-id"
|
||||
agent = MagicMock()
|
||||
agent.session_id = "session-id"
|
||||
cli.agent = agent
|
||||
|
||||
cli._persist_active_session_before_close()
|
||||
|
||||
agent._persist_session.assert_called_once_with(conversation_history, conversation_history)
|
||||
|
||||
|
||||
def test_cli_close_persist_skips_empty_transcripts():
|
||||
"""Do not create empty session writes for idle CLI startup/shutdown."""
|
||||
import cli as cli_mod
|
||||
|
||||
cli = object.__new__(cli_mod.HermesCLI)
|
||||
cli.conversation_history = []
|
||||
cli.session_id = "session-id"
|
||||
agent = MagicMock()
|
||||
agent.session_id = "session-id"
|
||||
agent._session_messages = []
|
||||
cli.agent = agent
|
||||
|
||||
cli._persist_active_session_before_close()
|
||||
|
||||
agent._persist_session.assert_not_called()
|
||||
@@ -0,0 +1,117 @@
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from cli import HermesCLI, _rich_text_from_ansi
|
||||
from hermes_cli.skin_engine import get_active_skin, set_active_skin
|
||||
|
||||
|
||||
def _make_cli_stub():
|
||||
cli = HermesCLI.__new__(HermesCLI)
|
||||
cli._sudo_state = None
|
||||
cli._secret_state = None
|
||||
cli._approval_state = None
|
||||
cli._clarify_state = None
|
||||
cli._clarify_freetext = False
|
||||
cli._command_running = False
|
||||
cli._agent_running = False
|
||||
cli._voice_recording = False
|
||||
cli._voice_processing = False
|
||||
cli._voice_mode = False
|
||||
cli._command_spinner_frame = lambda: "⟳"
|
||||
cli._tui_style_base = {
|
||||
"prompt": "#fff",
|
||||
"input-area": "#fff",
|
||||
"input-rule": "#aaa",
|
||||
"prompt-working": "#888 italic",
|
||||
}
|
||||
cli._app = SimpleNamespace(style=None)
|
||||
cli._invalidate = MagicMock()
|
||||
return cli
|
||||
|
||||
|
||||
class TestCliSkinPromptIntegration:
|
||||
def test_default_prompt_fragments_use_default_symbol(self):
|
||||
cli = _make_cli_stub()
|
||||
|
||||
set_active_skin("default")
|
||||
assert cli._get_tui_prompt_fragments() == [("class:prompt", "❯ ")]
|
||||
|
||||
def test_ares_prompt_fragments_use_skin_symbol(self):
|
||||
cli = _make_cli_stub()
|
||||
|
||||
set_active_skin("ares")
|
||||
assert cli._get_tui_prompt_fragments() == [("class:prompt", "⚔ ")]
|
||||
|
||||
def test_secret_prompt_fragments_preserve_secret_state(self):
|
||||
cli = _make_cli_stub()
|
||||
cli._secret_state = {"response_queue": object()}
|
||||
|
||||
set_active_skin("ares")
|
||||
assert cli._get_tui_prompt_fragments() == [("class:sudo-prompt", "🔑 ⚔ ")]
|
||||
|
||||
def test_narrow_terminals_compact_voice_prompt_fragments(self):
|
||||
cli = _make_cli_stub()
|
||||
cli._voice_mode = True
|
||||
|
||||
with patch.object(HermesCLI, "_get_tui_terminal_width", return_value=50):
|
||||
assert cli._get_tui_prompt_fragments() == [("class:voice-prompt", "🎤 ")]
|
||||
|
||||
def test_narrow_terminals_compact_voice_recording_prompt_fragments(self):
|
||||
cli = _make_cli_stub()
|
||||
cli._voice_recording = True
|
||||
cli._voice_recorder = SimpleNamespace(current_rms=3000)
|
||||
|
||||
with patch.object(HermesCLI, "_get_tui_terminal_width", return_value=50):
|
||||
frags = cli._get_tui_prompt_fragments()
|
||||
|
||||
assert frags[0][0] == "class:voice-recording"
|
||||
assert frags[0][1].startswith("●")
|
||||
assert "❯" not in frags[0][1]
|
||||
|
||||
def test_icon_only_skin_symbol_still_visible_in_special_states(self):
|
||||
cli = _make_cli_stub()
|
||||
cli._secret_state = {"response_queue": object()}
|
||||
|
||||
with patch("hermes_cli.skin_engine.get_active_prompt_symbol", return_value="⚔ "):
|
||||
assert cli._get_tui_prompt_fragments() == [("class:sudo-prompt", "🔑 ⚔ ")]
|
||||
|
||||
def test_build_tui_style_dict_uses_skin_overrides(self):
|
||||
cli = _make_cli_stub()
|
||||
|
||||
set_active_skin("ares")
|
||||
skin = get_active_skin()
|
||||
style_dict = cli._build_tui_style_dict()
|
||||
|
||||
assert style_dict["prompt"] == skin.get_color("prompt")
|
||||
assert style_dict["input-rule"] == skin.get_color("input_rule")
|
||||
assert style_dict["prompt-working"] == f"{skin.get_color('banner_dim')} italic"
|
||||
assert style_dict["approval-title"] == f"{skin.get_color('ui_warn')} bold"
|
||||
|
||||
def test_apply_tui_skin_style_updates_running_app(self):
|
||||
cli = _make_cli_stub()
|
||||
|
||||
set_active_skin("ares")
|
||||
assert cli._apply_tui_skin_style() is True
|
||||
assert cli._app.style is not None
|
||||
cli._invalidate.assert_called_once_with(min_interval=0.0)
|
||||
|
||||
def test_handle_skin_command_refreshes_live_tui(self, capsys):
|
||||
cli = _make_cli_stub()
|
||||
|
||||
with patch("cli.save_config_value", return_value=True):
|
||||
cli._handle_skin_command("/skin ares")
|
||||
|
||||
output = capsys.readouterr().out
|
||||
assert "Skin set to: ares (saved)" in output
|
||||
assert "Prompt + TUI colors updated." in output
|
||||
assert cli._app.style is not None
|
||||
|
||||
|
||||
class TestAnsiRichTextHelper:
|
||||
def test_preserves_literal_brackets(self):
|
||||
text = _rich_text_from_ansi("[notatag] literal")
|
||||
assert text.plain == "[notatag] literal"
|
||||
|
||||
def test_strips_ansi_but_keeps_plain_text(self):
|
||||
text = _rich_text_from_ansi("\x1b[31mred\x1b[0m")
|
||||
assert text.plain == "red"
|
||||
@@ -0,0 +1,680 @@
|
||||
import time
|
||||
from datetime import datetime, timedelta
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import cli as cli_mod
|
||||
from cli import HermesCLI
|
||||
|
||||
|
||||
def _make_cli(model: str = "anthropic/claude-sonnet-4-20250514"):
|
||||
cli_obj = HermesCLI.__new__(HermesCLI)
|
||||
cli_obj.model = model
|
||||
cli_obj.session_start = datetime.now() - timedelta(minutes=14, seconds=32)
|
||||
cli_obj.conversation_history = [{"role": "user", "content": "hi"}]
|
||||
cli_obj.agent = None
|
||||
return cli_obj
|
||||
|
||||
|
||||
def _attach_agent(
|
||||
cli_obj,
|
||||
*,
|
||||
input_tokens: int | None = None,
|
||||
output_tokens: int | None = None,
|
||||
cache_read_tokens: int = 0,
|
||||
cache_write_tokens: int = 0,
|
||||
prompt_tokens: int,
|
||||
completion_tokens: int,
|
||||
total_tokens: int,
|
||||
api_calls: int,
|
||||
context_tokens: int,
|
||||
context_length: int,
|
||||
compressions: int = 0,
|
||||
):
|
||||
cli_obj.agent = SimpleNamespace(
|
||||
model=cli_obj.model,
|
||||
provider="anthropic" if cli_obj.model.startswith("anthropic/") else None,
|
||||
base_url="",
|
||||
session_input_tokens=input_tokens if input_tokens is not None else prompt_tokens,
|
||||
session_output_tokens=output_tokens if output_tokens is not None else completion_tokens,
|
||||
session_cache_read_tokens=cache_read_tokens,
|
||||
session_cache_write_tokens=cache_write_tokens,
|
||||
session_prompt_tokens=prompt_tokens,
|
||||
session_completion_tokens=completion_tokens,
|
||||
session_total_tokens=total_tokens,
|
||||
session_api_calls=api_calls,
|
||||
get_rate_limit_state=lambda: None,
|
||||
context_compressor=SimpleNamespace(
|
||||
last_prompt_tokens=context_tokens,
|
||||
context_length=context_length,
|
||||
compression_count=compressions,
|
||||
),
|
||||
)
|
||||
return cli_obj
|
||||
|
||||
|
||||
class TestCLIStatusBar:
|
||||
def test_context_style_thresholds(self):
|
||||
cli_obj = _make_cli()
|
||||
|
||||
assert cli_obj._status_bar_context_style(None) == "class:status-bar-dim"
|
||||
assert cli_obj._status_bar_context_style(10) == "class:status-bar-good"
|
||||
assert cli_obj._status_bar_context_style(50) == "class:status-bar-warn"
|
||||
assert cli_obj._status_bar_context_style(81) == "class:status-bar-bad"
|
||||
assert cli_obj._status_bar_context_style(95) == "class:status-bar-critical"
|
||||
|
||||
def test_build_status_bar_text_for_wide_terminal(self):
|
||||
cli_obj = _attach_agent(
|
||||
_make_cli(),
|
||||
prompt_tokens=10_230,
|
||||
completion_tokens=2_220,
|
||||
total_tokens=12_450,
|
||||
api_calls=7,
|
||||
context_tokens=12_450,
|
||||
context_length=200_000,
|
||||
)
|
||||
|
||||
text = cli_obj._build_status_bar_text(width=120)
|
||||
|
||||
assert "claude-sonnet-4-20250514" in text
|
||||
assert "12.4K/200K" in text
|
||||
assert "6%" in text
|
||||
assert "$0.06" not in text # cost hidden by default
|
||||
assert "15m" in text
|
||||
|
||||
def test_post_compression_sentinel_does_not_render_negative(self):
|
||||
"""Right after a compression, last_prompt_tokens is parked at the -1
|
||||
sentinel until the next API call reports real usage. The status bar
|
||||
must clamp it to 0 instead of rendering "-1/200K" / "-1%".
|
||||
"""
|
||||
cli_obj = _attach_agent(
|
||||
_make_cli(),
|
||||
prompt_tokens=10_230,
|
||||
completion_tokens=2_220,
|
||||
total_tokens=12_450,
|
||||
api_calls=7,
|
||||
context_tokens=-1,
|
||||
context_length=200_000,
|
||||
)
|
||||
|
||||
snapshot = cli_obj._get_status_bar_snapshot()
|
||||
assert snapshot["context_tokens"] == 0
|
||||
assert snapshot["context_percent"] == 0
|
||||
|
||||
text = cli_obj._build_status_bar_text(width=120)
|
||||
assert "-1" not in text
|
||||
assert "0/200K" in text
|
||||
|
||||
def test_input_height_counts_prompt_only_on_first_wrapped_row(self):
|
||||
# Regression for prompt_toolkit classic CLI resize glitches: the prompt
|
||||
# is inserted by BeforeInput only on logical line 0. At three terminal
|
||||
# cells, "⚔ " leaves one cell for the first input character, but
|
||||
# wrapped continuation rows use the full three cells. Estimating every
|
||||
# wrapped row as one-cell wide over-allocates the TextArea and can leave
|
||||
# stale prompt/input cells visible after resize.
|
||||
assert cli_mod._estimate_tui_input_height(["abcdef"], "⚔ ", 3) == 3
|
||||
|
||||
def test_input_height_counts_wide_characters_using_cell_width(self):
|
||||
# Prompt width (2 cells) + ten CJK chars (20 cells) = 22 display cells,
|
||||
# which wraps to two rows at 14 terminal columns.
|
||||
assert cli_mod._estimate_tui_input_height(["你" * 10], "❯ ", 14) == 2
|
||||
|
||||
def test_input_height_clamps_zero_width_to_one_cell(self):
|
||||
# Some terminals briefly report zero columns during resize. Treat that
|
||||
# as a one-cell terminal rather than falling back to a fake wide width.
|
||||
assert cli_mod._estimate_tui_input_height(["abcd"], "", 0) == 4
|
||||
|
||||
def test_build_status_bar_text_no_cost_in_status_bar(self):
|
||||
cli_obj = _attach_agent(
|
||||
_make_cli(),
|
||||
prompt_tokens=10000,
|
||||
completion_tokens=5000,
|
||||
total_tokens=15000,
|
||||
api_calls=7,
|
||||
context_tokens=50000,
|
||||
context_length=200_000,
|
||||
)
|
||||
|
||||
text = cli_obj._build_status_bar_text(width=120)
|
||||
assert "$" not in text # cost is never shown in status bar
|
||||
|
||||
def test_build_status_bar_text_collapses_for_narrow_terminal(self):
|
||||
cli_obj = _attach_agent(
|
||||
_make_cli(),
|
||||
prompt_tokens=10000,
|
||||
completion_tokens=2400,
|
||||
total_tokens=12400,
|
||||
api_calls=7,
|
||||
context_tokens=12400,
|
||||
context_length=200_000,
|
||||
)
|
||||
|
||||
text = cli_obj._build_status_bar_text(width=60)
|
||||
|
||||
assert "⚕" in text
|
||||
assert "$0.06" not in text # cost hidden by default
|
||||
assert "15m" in text
|
||||
assert "200K" not in text
|
||||
|
||||
def test_build_status_bar_text_handles_missing_agent(self):
|
||||
cli_obj = _make_cli()
|
||||
|
||||
text = cli_obj._build_status_bar_text(width=100)
|
||||
|
||||
assert "⚕" in text
|
||||
assert "claude-sonnet-4-20250514" in text
|
||||
|
||||
def test_compression_count_shown_in_wide_status_bar(self):
|
||||
cli_obj = _attach_agent(
|
||||
_make_cli(),
|
||||
prompt_tokens=10_230,
|
||||
completion_tokens=2_220,
|
||||
total_tokens=12_450,
|
||||
api_calls=7,
|
||||
context_tokens=12_450,
|
||||
context_length=200_000,
|
||||
compressions=3,
|
||||
)
|
||||
|
||||
text = cli_obj._build_status_bar_text(width=120)
|
||||
|
||||
assert "🗜️ 3" in text
|
||||
|
||||
def test_compression_count_hidden_when_zero(self):
|
||||
cli_obj = _attach_agent(
|
||||
_make_cli(),
|
||||
prompt_tokens=10_230,
|
||||
completion_tokens=2_220,
|
||||
total_tokens=12_450,
|
||||
api_calls=7,
|
||||
context_tokens=12_450,
|
||||
context_length=200_000,
|
||||
compressions=0,
|
||||
)
|
||||
|
||||
text = cli_obj._build_status_bar_text(width=120)
|
||||
|
||||
assert "🗜️" not in text
|
||||
|
||||
def test_compression_count_shown_in_medium_status_bar(self):
|
||||
cli_obj = _attach_agent(
|
||||
_make_cli(),
|
||||
prompt_tokens=10_000,
|
||||
completion_tokens=2_400,
|
||||
total_tokens=12_400,
|
||||
api_calls=7,
|
||||
context_tokens=12_400,
|
||||
context_length=200_000,
|
||||
compressions=2,
|
||||
)
|
||||
|
||||
text = cli_obj._build_status_bar_text(width=60)
|
||||
|
||||
assert "🗜️ 2" in text
|
||||
|
||||
def test_compression_count_hidden_in_narrow_status_bar(self):
|
||||
cli_obj = _attach_agent(
|
||||
_make_cli(),
|
||||
prompt_tokens=10_000,
|
||||
completion_tokens=2_400,
|
||||
total_tokens=12_400,
|
||||
api_calls=7,
|
||||
context_tokens=12_400,
|
||||
context_length=200_000,
|
||||
compressions=5,
|
||||
)
|
||||
|
||||
text = cli_obj._build_status_bar_text(width=50)
|
||||
|
||||
assert "🗜️" not in text
|
||||
|
||||
def test_compression_count_style_thresholds(self):
|
||||
cli_obj = _make_cli()
|
||||
|
||||
assert cli_obj._compression_count_style(1) == "class:status-bar-dim"
|
||||
assert cli_obj._compression_count_style(4) == "class:status-bar-dim"
|
||||
assert cli_obj._compression_count_style(5) == "class:status-bar-warn"
|
||||
assert cli_obj._compression_count_style(9) == "class:status-bar-warn"
|
||||
assert cli_obj._compression_count_style(10) == "class:status-bar-bad"
|
||||
assert cli_obj._compression_count_style(25) == "class:status-bar-bad"
|
||||
|
||||
def test_compression_count_in_wide_fragments(self):
|
||||
cli_obj = _attach_agent(
|
||||
_make_cli(),
|
||||
prompt_tokens=10_230,
|
||||
completion_tokens=2_220,
|
||||
total_tokens=12_450,
|
||||
api_calls=7,
|
||||
context_tokens=12_450,
|
||||
context_length=200_000,
|
||||
compressions=7,
|
||||
)
|
||||
cli_obj._status_bar_visible = True
|
||||
|
||||
frags = cli_obj._get_status_bar_fragments()
|
||||
frag_texts = [text for _, text in frags]
|
||||
|
||||
assert "🗜️ 7" in frag_texts
|
||||
frag_styles = {text: style for style, text in frags}
|
||||
assert frag_styles["🗜️ 7"] == "class:status-bar-warn"
|
||||
|
||||
def test_compression_count_absent_from_fragments_when_zero(self):
|
||||
cli_obj = _attach_agent(
|
||||
_make_cli(),
|
||||
prompt_tokens=10_230,
|
||||
completion_tokens=2_220,
|
||||
total_tokens=12_450,
|
||||
api_calls=7,
|
||||
context_tokens=12_450,
|
||||
context_length=200_000,
|
||||
compressions=0,
|
||||
)
|
||||
cli_obj._status_bar_visible = True
|
||||
|
||||
frags = cli_obj._get_status_bar_fragments()
|
||||
frag_texts = [text for _, text in frags]
|
||||
|
||||
assert not any("🗜️" in t for t in frag_texts)
|
||||
|
||||
def test_minimal_tui_chrome_threshold(self):
|
||||
cli_obj = _make_cli()
|
||||
|
||||
assert cli_obj._use_minimal_tui_chrome(width=63) is True
|
||||
assert cli_obj._use_minimal_tui_chrome(width=64) is False
|
||||
|
||||
def test_bottom_input_rule_hides_on_narrow_terminals(self):
|
||||
cli_obj = _make_cli()
|
||||
|
||||
assert cli_obj._tui_input_rule_height("top", width=50) == 1
|
||||
assert cli_obj._tui_input_rule_height("bottom", width=50) == 0
|
||||
assert cli_obj._tui_input_rule_height("bottom", width=90) == 1
|
||||
|
||||
def test_input_rules_hide_after_resize_until_next_input(self):
|
||||
"""When _status_bar_suppressed_after_resize is set, both rules hide.
|
||||
|
||||
See _recover_after_resize — column shrink reflows already-rendered
|
||||
bars into scrollback, so we hide the separators while the reflow
|
||||
settles, then clear the flag (either via the scheduled unsuppress
|
||||
timer or the next submitted input).
|
||||
"""
|
||||
cli_obj = _make_cli()
|
||||
cli_obj._status_bar_suppressed_after_resize = True
|
||||
|
||||
assert cli_obj._tui_input_rule_height("top", width=90) == 0
|
||||
assert cli_obj._tui_input_rule_height("bottom", width=90) == 0
|
||||
|
||||
cli_obj._status_bar_suppressed_after_resize = False
|
||||
assert cli_obj._tui_input_rule_height("top", width=90) == 1
|
||||
assert cli_obj._tui_input_rule_height("bottom", width=90) == 1
|
||||
|
||||
def test_scheduled_unsuppress_clears_flag_and_repaints_without_input(self):
|
||||
"""The status bar returns during idle after a resize, without a keypress.
|
||||
|
||||
Regression: the suppression flag was only cleared on the next
|
||||
*submitted* input, so a resize/reflow followed by idle left the bar
|
||||
hidden indefinitely even while the refresh clock kept ticking. The
|
||||
scheduled unsuppress timer must clear the flag and invalidate the app
|
||||
on its own.
|
||||
"""
|
||||
cli_obj = _make_cli()
|
||||
cli_obj._status_bar_unsuppress_timer = None
|
||||
cli_obj._status_bar_suppressed_after_resize = True
|
||||
app = MagicMock()
|
||||
app.loop = None # force the synchronous _clear path
|
||||
|
||||
# Schedule with ~0 delay so the timer fires promptly under test.
|
||||
cli_obj._schedule_status_bar_unsuppress(app, delay=0.01)
|
||||
time.sleep(0.1)
|
||||
|
||||
assert cli_obj._status_bar_suppressed_after_resize is False
|
||||
app.invalidate.assert_called()
|
||||
# Bar chrome is visible again with no submitted input.
|
||||
assert cli_obj._tui_input_rule_height("top", width=90) == 1
|
||||
|
||||
def test_scheduled_unsuppress_debounces_resize_storm(self):
|
||||
"""A fresh resize cancels the pending unsuppress and restarts it."""
|
||||
cli_obj = _make_cli()
|
||||
cli_obj._status_bar_unsuppress_timer = None
|
||||
cli_obj._status_bar_suppressed_after_resize = True
|
||||
app = MagicMock()
|
||||
app.loop = None
|
||||
|
||||
# First schedule (long delay) then a second should cancel the first.
|
||||
cli_obj._schedule_status_bar_unsuppress(app, delay=5.0)
|
||||
first_timer = cli_obj._status_bar_unsuppress_timer
|
||||
assert first_timer is not None
|
||||
cli_obj._schedule_status_bar_unsuppress(app, delay=0.01)
|
||||
assert first_timer is not cli_obj._status_bar_unsuppress_timer
|
||||
assert not first_timer.is_alive() or first_timer.finished.is_set()
|
||||
time.sleep(0.1)
|
||||
assert cli_obj._status_bar_suppressed_after_resize is False
|
||||
|
||||
def test_scrollback_box_width_returns_viewport_width(self):
|
||||
"""Decorative scrollback boxes use the full viewport width.
|
||||
|
||||
The previous clamp (max 56 cols) was reverted in favour of the
|
||||
prompt_toolkit ``_output_screen_diff`` monkey-patch landed in
|
||||
#26137, which keeps chrome out of scrollback at the source.
|
||||
We accept that an aggressive column-shrink may visually reflow
|
||||
already printed Panel borders — that's a cosmetic artifact of
|
||||
stamped scrollback history, not a live-render bug.
|
||||
"""
|
||||
from cli import HermesCLI
|
||||
|
||||
# Floor at 32 — narrow terminals still get something usable
|
||||
# (avoids negative ``'─' * (w - 2)`` math).
|
||||
assert HermesCLI._scrollback_box_width(20) == 32
|
||||
assert HermesCLI._scrollback_box_width(32) == 32
|
||||
# Above the floor, return the actual viewport width — no cap.
|
||||
assert HermesCLI._scrollback_box_width(48) == 48
|
||||
assert HermesCLI._scrollback_box_width(80) == 80
|
||||
assert HermesCLI._scrollback_box_width(120) == 120
|
||||
assert HermesCLI._scrollback_box_width(200) == 200
|
||||
|
||||
def test_agent_spacer_reclaimed_on_narrow_terminals(self):
|
||||
cli_obj = _make_cli()
|
||||
cli_obj._agent_running = True
|
||||
|
||||
assert cli_obj._agent_spacer_height(width=50) == 0
|
||||
assert cli_obj._agent_spacer_height(width=90) == 1
|
||||
cli_obj._agent_running = False
|
||||
assert cli_obj._agent_spacer_height(width=90) == 0
|
||||
|
||||
def test_spinner_line_hidden_on_narrow_terminals(self):
|
||||
cli_obj = _make_cli()
|
||||
cli_obj._spinner_text = "thinking"
|
||||
|
||||
assert cli_obj._spinner_widget_height(width=50) == 0
|
||||
assert cli_obj._spinner_widget_height(width=90) == 1
|
||||
cli_obj._spinner_text = ""
|
||||
assert cli_obj._spinner_widget_height(width=90) == 0
|
||||
|
||||
def test_spinner_height_uses_display_width_for_wide_characters(self):
|
||||
cli_obj = _make_cli()
|
||||
cli_obj._spinner_text = "你" * 40
|
||||
cli_obj._tool_start_time = 0
|
||||
|
||||
assert cli_obj._spinner_widget_height(width=64) == 2
|
||||
|
||||
def test_spinner_elapsed_format_is_fixed_width_to_reduce_wrap_jitter(self):
|
||||
cli_obj = _make_cli()
|
||||
cli_obj._spinner_text = "running tool"
|
||||
|
||||
# Pin the clock: time.monotonic()'s epoch is arbitrary (often near
|
||||
# boot), so deriving _tool_start_time from the real monotonic clock
|
||||
# made the test fail on hosts where monotonic() < 65.2 — the start
|
||||
# time went negative, the (t0 > 0) guard in _render_spinner_text
|
||||
# dropped the "(elapsed)" suffix entirely, and the split below hit an
|
||||
# IndexError. A fixed clock keeps both elapsed paths deterministic.
|
||||
with patch.object(cli_mod.time, "monotonic", return_value=1000.0):
|
||||
# <60s path
|
||||
cli_obj._tool_start_time = 1000.0 - 9.2
|
||||
short = cli_obj._render_spinner_text()
|
||||
|
||||
# >=60s path
|
||||
cli_obj._tool_start_time = 1000.0 - 65.2
|
||||
long = cli_obj._render_spinner_text()
|
||||
|
||||
short_elapsed = short.split("(", 1)[1].rstrip(")")
|
||||
long_elapsed = long.split("(", 1)[1].rstrip(")")
|
||||
|
||||
assert len(short_elapsed) == len(long_elapsed)
|
||||
assert "m" in long_elapsed and "s" in long_elapsed
|
||||
|
||||
def test_voice_status_bar_compacts_on_narrow_terminals(self):
|
||||
cli_obj = _make_cli()
|
||||
cli_obj._voice_mode = True
|
||||
cli_obj._voice_recording = False
|
||||
cli_obj._voice_processing = False
|
||||
cli_obj._voice_tts = True
|
||||
cli_obj._voice_continuous = True
|
||||
|
||||
fragments = cli_obj._get_voice_status_fragments(width=50)
|
||||
|
||||
assert fragments == [("class:voice-status", " 🎤 Ctrl+B ")]
|
||||
|
||||
def test_voice_recording_status_bar_compacts_on_narrow_terminals(self):
|
||||
cli_obj = _make_cli()
|
||||
cli_obj._voice_mode = True
|
||||
cli_obj._voice_recording = True
|
||||
cli_obj._voice_processing = False
|
||||
|
||||
fragments = cli_obj._get_voice_status_fragments(width=50)
|
||||
|
||||
assert fragments == [("class:voice-status-recording", " ● REC ")]
|
||||
|
||||
# Round-13 Copilot review regressions on #19835. The label in voice
|
||||
# status bar / recording hint / placeholder must render the
|
||||
# configured ``voice.record_key`` — not hardcoded Ctrl+B. Pinning
|
||||
# the cache (``set_voice_record_key_cache``) keeps display in sync
|
||||
# with the prompt_toolkit binding without re-reading config on
|
||||
# every render.
|
||||
def test_voice_status_bar_renders_configured_ctrl_letter(self):
|
||||
cli_obj = _make_cli()
|
||||
cli_obj._voice_mode = True
|
||||
cli_obj._voice_recording = False
|
||||
cli_obj._voice_processing = False
|
||||
cli_obj._voice_tts = False
|
||||
cli_obj._voice_continuous = False
|
||||
cli_obj.set_voice_record_key_cache("ctrl+o")
|
||||
|
||||
wide = cli_obj._get_voice_status_fragments(width=120)
|
||||
assert any("Ctrl+O to record" in text for _cls, text in wide)
|
||||
|
||||
compact = cli_obj._get_voice_status_fragments(width=50)
|
||||
assert compact == [("class:voice-status", " 🎤 Ctrl+O ")]
|
||||
|
||||
def test_voice_recording_status_bar_renders_configured_named_key(self):
|
||||
cli_obj = _make_cli()
|
||||
cli_obj._voice_mode = True
|
||||
cli_obj._voice_recording = True
|
||||
cli_obj._voice_processing = False
|
||||
cli_obj.set_voice_record_key_cache("ctrl+space")
|
||||
|
||||
fragments = cli_obj._get_voice_status_fragments(width=120)
|
||||
|
||||
assert fragments == [("class:voice-status-recording", " ● REC Ctrl+Space to stop ")]
|
||||
|
||||
def test_voice_status_bar_falls_back_to_ctrl_b_without_cache(self):
|
||||
cli_obj = _make_cli()
|
||||
cli_obj._voice_mode = True
|
||||
cli_obj._voice_recording = False
|
||||
cli_obj._voice_processing = False
|
||||
cli_obj._voice_tts = False
|
||||
cli_obj._voice_continuous = False
|
||||
# No cache set — mirrors pre-startup state; fall back to
|
||||
# documented Ctrl+B default (Copilot round-13 review).
|
||||
|
||||
compact = cli_obj._get_voice_status_fragments(width=50)
|
||||
|
||||
assert compact == [("class:voice-status", " 🎤 Ctrl+B ")]
|
||||
|
||||
def test_voice_status_bar_renders_malformed_config_as_default(self):
|
||||
cli_obj = _make_cli()
|
||||
cli_obj._voice_mode = True
|
||||
cli_obj._voice_recording = False
|
||||
cli_obj._voice_processing = False
|
||||
cli_obj._voice_tts = False
|
||||
cli_obj._voice_continuous = False
|
||||
# Non-string / typoed configs fall through the formatter to the
|
||||
# documented default so the status bar never advertises an
|
||||
# invalid shortcut.
|
||||
cli_obj.set_voice_record_key_cache(True)
|
||||
|
||||
compact = cli_obj._get_voice_status_fragments(width=50)
|
||||
|
||||
assert compact == [("class:voice-status", " 🎤 Ctrl+B ")]
|
||||
|
||||
|
||||
class TestCLIUsageReport:
|
||||
def test_show_usage_omits_cost_reporting(self, capsys):
|
||||
cli_obj = _attach_agent(
|
||||
_make_cli(),
|
||||
prompt_tokens=10_230,
|
||||
completion_tokens=2_220,
|
||||
total_tokens=12_450,
|
||||
api_calls=7,
|
||||
context_tokens=12_450,
|
||||
context_length=200_000,
|
||||
compressions=1,
|
||||
)
|
||||
cli_obj.verbose = False
|
||||
|
||||
cli_obj._show_usage()
|
||||
output = capsys.readouterr().out
|
||||
|
||||
# Token counts and session metadata still shown.
|
||||
assert "Model:" in output
|
||||
assert "Input tokens:" in output
|
||||
assert "Output tokens:" in output
|
||||
assert "Total tokens:" in output
|
||||
assert "Session duration:" in output
|
||||
assert "Compressions:" in output
|
||||
# Cost and cache-hit reporting is removed everywhere.
|
||||
assert "Total cost:" not in output
|
||||
assert "Cost status:" not in output
|
||||
assert "Cost source:" not in output
|
||||
assert "Cache read tokens:" not in output
|
||||
assert "Cache write tokens:" not in output
|
||||
|
||||
|
||||
class TestStatusBarWidthSource:
|
||||
"""Ensure status bar fragments don't overflow the terminal width."""
|
||||
|
||||
def _make_wide_cli(self):
|
||||
cli_obj = _attach_agent(
|
||||
_make_cli(),
|
||||
prompt_tokens=100_000,
|
||||
completion_tokens=5_000,
|
||||
total_tokens=105_000,
|
||||
api_calls=20,
|
||||
context_tokens=100_000,
|
||||
context_length=200_000,
|
||||
)
|
||||
cli_obj._status_bar_visible = True
|
||||
return cli_obj
|
||||
|
||||
def test_fragments_fit_within_announced_width(self):
|
||||
"""Total fragment text length must not exceed the width used to build them."""
|
||||
from unittest.mock import MagicMock, patch
|
||||
cli_obj = self._make_wide_cli()
|
||||
|
||||
for width in (40, 52, 76, 80, 120, 200):
|
||||
mock_app = MagicMock()
|
||||
mock_app.output.get_size.return_value = MagicMock(columns=width)
|
||||
|
||||
with patch("prompt_toolkit.application.get_app", return_value=mock_app):
|
||||
frags = cli_obj._get_status_bar_fragments()
|
||||
|
||||
total_text = "".join(text for _, text in frags)
|
||||
display_width = cli_obj._status_bar_display_width(total_text)
|
||||
assert display_width <= width + 4, ( # +4 for minor padding chars
|
||||
f"At width={width}, fragment total {display_width} cells overflows "
|
||||
f"({total_text!r})"
|
||||
)
|
||||
|
||||
def test_fragments_use_pt_width_over_shutil(self):
|
||||
"""When prompt_toolkit reports a width, shutil.get_terminal_size must not be used."""
|
||||
from unittest.mock import MagicMock, patch
|
||||
cli_obj = self._make_wide_cli()
|
||||
|
||||
mock_app = MagicMock()
|
||||
mock_app.output.get_size.return_value = MagicMock(columns=120)
|
||||
|
||||
with patch("prompt_toolkit.application.get_app", return_value=mock_app) as mock_get_app, \
|
||||
patch("shutil.get_terminal_size") as mock_shutil:
|
||||
cli_obj._get_status_bar_fragments()
|
||||
|
||||
mock_shutil.assert_not_called()
|
||||
|
||||
def test_fragments_fall_back_to_shutil_when_no_app(self):
|
||||
"""Outside a TUI context (no running app), shutil must be used as fallback."""
|
||||
from unittest.mock import MagicMock, patch
|
||||
cli_obj = self._make_wide_cli()
|
||||
|
||||
with patch("prompt_toolkit.application.get_app", side_effect=Exception("no app")), \
|
||||
patch("shutil.get_terminal_size", return_value=MagicMock(columns=100)) as mock_shutil:
|
||||
frags = cli_obj._get_status_bar_fragments()
|
||||
|
||||
mock_shutil.assert_called()
|
||||
assert len(frags) > 0
|
||||
|
||||
def test_build_status_bar_text_uses_pt_width(self):
|
||||
"""_build_status_bar_text() must also prefer prompt_toolkit width."""
|
||||
from unittest.mock import MagicMock, patch
|
||||
cli_obj = self._make_wide_cli()
|
||||
|
||||
mock_app = MagicMock()
|
||||
mock_app.output.get_size.return_value = MagicMock(columns=80)
|
||||
|
||||
with patch("prompt_toolkit.application.get_app", return_value=mock_app), \
|
||||
patch("shutil.get_terminal_size") as mock_shutil:
|
||||
text = cli_obj._build_status_bar_text() # no explicit width
|
||||
|
||||
mock_shutil.assert_not_called()
|
||||
assert isinstance(text, str)
|
||||
assert len(text) > 0
|
||||
|
||||
def test_explicit_width_skips_pt_lookup(self):
|
||||
"""An explicit width= argument must bypass both PT and shutil lookups."""
|
||||
from unittest.mock import patch
|
||||
cli_obj = self._make_wide_cli()
|
||||
|
||||
with patch("prompt_toolkit.application.get_app") as mock_get_app, \
|
||||
patch("shutil.get_terminal_size") as mock_shutil:
|
||||
text = cli_obj._build_status_bar_text(width=100)
|
||||
|
||||
mock_get_app.assert_not_called()
|
||||
mock_shutil.assert_not_called()
|
||||
assert len(text) > 0
|
||||
|
||||
|
||||
class TestIdleSinceLastTurn:
|
||||
"""Time-since-last-final-agent-response read-out on the status bar."""
|
||||
|
||||
def test_hidden_before_first_turn(self):
|
||||
assert HermesCLI._format_idle_since(None, turn_live=False) == ""
|
||||
|
||||
def test_hidden_while_turn_is_live(self):
|
||||
assert HermesCLI._format_idle_since(time.time() - 30, turn_live=True) == ""
|
||||
|
||||
def test_shows_compact_idle_time_after_turn(self):
|
||||
label = HermesCLI._format_idle_since(time.time() - 42, turn_live=False)
|
||||
assert label.startswith("✓ ")
|
||||
assert label == "✓ 42s"
|
||||
|
||||
def test_scales_to_minutes(self):
|
||||
label = HermesCLI._format_idle_since(time.time() - 3 * 60, turn_live=False)
|
||||
assert label == "✓ 3m"
|
||||
|
||||
def test_snapshot_carries_idle_since(self):
|
||||
cli_obj = _make_cli()
|
||||
cli_obj._last_turn_finished_at = time.time() - 10
|
||||
cli_obj._prompt_start_time = None
|
||||
cli_obj._prompt_duration = 5.0
|
||||
snapshot = cli_obj._get_status_bar_snapshot()
|
||||
assert snapshot["idle_since"].startswith("✓ ")
|
||||
|
||||
def test_snapshot_idle_empty_during_live_turn(self):
|
||||
cli_obj = _make_cli()
|
||||
cli_obj._last_turn_finished_at = time.time() - 10
|
||||
cli_obj._prompt_start_time = time.time()
|
||||
cli_obj._prompt_duration = 0.0
|
||||
snapshot = cli_obj._get_status_bar_snapshot()
|
||||
assert snapshot["idle_since"] == ""
|
||||
|
||||
def test_wide_status_bar_text_includes_idle(self):
|
||||
cli_obj = _attach_agent(
|
||||
_make_cli(),
|
||||
prompt_tokens=10_230,
|
||||
completion_tokens=2_220,
|
||||
total_tokens=12_450,
|
||||
api_calls=7,
|
||||
context_tokens=12_450,
|
||||
context_length=200_000,
|
||||
)
|
||||
cli_obj._last_turn_finished_at = time.time() - 42
|
||||
cli_obj._prompt_start_time = None
|
||||
cli_obj._prompt_duration = 7.0
|
||||
text = cli_obj._build_status_bar_text(width=160)
|
||||
assert "✓ 42s" in text
|
||||
@@ -0,0 +1,101 @@
|
||||
"""Tests for CLI /status command behavior."""
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from cli import HermesCLI
|
||||
from hermes_cli.commands import resolve_command
|
||||
|
||||
|
||||
def _make_cli():
|
||||
cli_obj = HermesCLI.__new__(HermesCLI)
|
||||
cli_obj.config = {}
|
||||
cli_obj.console = MagicMock()
|
||||
cli_obj.agent = None
|
||||
cli_obj.conversation_history = []
|
||||
cli_obj.session_id = "session-123"
|
||||
cli_obj._pending_input = MagicMock()
|
||||
cli_obj._status_bar_visible = True
|
||||
cli_obj.model = "openai/gpt-5.4"
|
||||
cli_obj.provider = "openai"
|
||||
cli_obj.session_start = datetime(2026, 4, 9, 19, 24)
|
||||
cli_obj._agent_running = False
|
||||
cli_obj._session_db = MagicMock()
|
||||
cli_obj._session_db.get_session.return_value = None
|
||||
return cli_obj
|
||||
|
||||
|
||||
def test_status_command_is_available_in_cli_registry():
|
||||
cmd = resolve_command("status")
|
||||
assert cmd is not None
|
||||
assert cmd.gateway_only is False
|
||||
|
||||
|
||||
def test_process_command_status_dispatches_without_toggling_status_bar():
|
||||
cli_obj = _make_cli()
|
||||
|
||||
with patch.object(cli_obj, "_show_session_status", create=True) as mock_status:
|
||||
assert cli_obj.process_command("/status") is True
|
||||
|
||||
mock_status.assert_called_once_with()
|
||||
assert cli_obj._status_bar_visible is True
|
||||
|
||||
|
||||
def test_statusbar_still_toggles_visibility():
|
||||
cli_obj = _make_cli()
|
||||
|
||||
assert cli_obj.process_command("/statusbar") is True
|
||||
assert cli_obj._status_bar_visible is False
|
||||
|
||||
|
||||
def test_status_prefix_prefers_status_command_over_statusbar_toggle():
|
||||
cli_obj = _make_cli()
|
||||
|
||||
with patch.object(cli_obj, "_show_session_status") as mock_status:
|
||||
assert cli_obj.process_command("/sta") is True
|
||||
|
||||
mock_status.assert_called_once_with()
|
||||
assert cli_obj._status_bar_visible is True
|
||||
|
||||
|
||||
def test_show_session_status_prints_gateway_style_summary():
|
||||
cli_obj = _make_cli()
|
||||
cli_obj.agent = SimpleNamespace(
|
||||
session_total_tokens=321,
|
||||
session_api_calls=4,
|
||||
)
|
||||
cli_obj._session_db.get_session.return_value = {
|
||||
"title": "My titled session",
|
||||
"started_at": 1775791440,
|
||||
}
|
||||
|
||||
with patch("cli.display_hermes_home", return_value="~/.hermes"):
|
||||
cli_obj._show_session_status()
|
||||
|
||||
printed = "\n".join(str(call.args[0]) for call in cli_obj.console.print.call_args_list)
|
||||
assert "Hermes CLI Status" in printed
|
||||
assert "Session ID: session-123" in printed
|
||||
assert "Path: ~/.hermes" in printed
|
||||
assert "Title: My titled session" in printed
|
||||
assert "Model: openai/gpt-5.4 (openai)" in printed
|
||||
assert "Tokens: 321" in printed
|
||||
assert "Agent Running: No" in printed
|
||||
_, kwargs = cli_obj.console.print.call_args
|
||||
assert kwargs.get("highlight") is False
|
||||
assert kwargs.get("markup") is False
|
||||
|
||||
|
||||
def test_profile_command_reports_custom_root_profile(monkeypatch, tmp_path, capsys):
|
||||
"""Profile detection works for custom-root deployments (not under ~/.hermes)."""
|
||||
cli_obj = _make_cli()
|
||||
profile_home = tmp_path / "profiles" / "coder"
|
||||
|
||||
monkeypatch.setenv("HERMES_HOME", str(profile_home))
|
||||
monkeypatch.setattr(Path, "home", lambda: tmp_path / "unrelated-home")
|
||||
|
||||
cli_obj._handle_profile_command()
|
||||
|
||||
out = capsys.readouterr().out
|
||||
assert "Profile: coder" in out
|
||||
assert f"Home: {profile_home}" in out
|
||||
@@ -0,0 +1,146 @@
|
||||
"""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"])
|
||||
@@ -0,0 +1,81 @@
|
||||
"""Tests for defensive terminal control-response stripping in the CLI.
|
||||
|
||||
Covers Cursor Position Report (CPR / DSR) responses that occasionally
|
||||
leak into the input buffer after terminal resize storms or multiplexer
|
||||
tab switches — see issue #14692.
|
||||
"""
|
||||
|
||||
from cli import _strip_leaked_terminal_responses
|
||||
|
||||
|
||||
class TestStripLeakedTerminalResponses:
|
||||
def test_plain_text_unchanged(self):
|
||||
text = "hello world"
|
||||
assert _strip_leaked_terminal_responses(text) == text
|
||||
|
||||
def test_empty_text(self):
|
||||
assert _strip_leaked_terminal_responses("") == ""
|
||||
|
||||
def test_strips_canonical_dsr_response(self):
|
||||
# Reports from issue #14692
|
||||
text = "\x1b[53;1R"
|
||||
assert _strip_leaked_terminal_responses(text) == ""
|
||||
|
||||
def test_strips_dsr_response_in_middle_of_text(self):
|
||||
text = "hello\x1b[53;1Rworld"
|
||||
assert _strip_leaked_terminal_responses(text) == "helloworld"
|
||||
|
||||
def test_strips_multiple_dsr_responses(self):
|
||||
text = "a\x1b[53;1Rb\x1b[51;1Rc\x1b[50;9Rd"
|
||||
assert _strip_leaked_terminal_responses(text) == "abcd"
|
||||
|
||||
def test_strips_visible_form_dsr(self):
|
||||
# When an upstream filter has already stripped the ESC byte and
|
||||
# left the caret-escape representation in place.
|
||||
text = "^[[53;1R"
|
||||
assert _strip_leaked_terminal_responses(text) == ""
|
||||
|
||||
def test_strips_visible_form_dsr_in_middle_of_text(self):
|
||||
text = "typed^[[53;1Rmore"
|
||||
assert _strip_leaked_terminal_responses(text) == "typedmore"
|
||||
|
||||
def test_does_not_strip_user_text_with_R(self):
|
||||
# Don't over-match; user might genuinely type text containing [N;NR patterns.
|
||||
# Our regex requires the leading ESC or caret-escape, so bare
|
||||
# "[53;1R" as user text is preserved.
|
||||
text = "see section [53;1R for details"
|
||||
assert _strip_leaked_terminal_responses(text) == text
|
||||
|
||||
def test_does_not_strip_sgr_sequences(self):
|
||||
# Sanity: don't wipe legitimate terminal control sequences that
|
||||
# aren't DSR responses.
|
||||
text = "\x1b[31mred\x1b[0m"
|
||||
assert _strip_leaked_terminal_responses(text) == text
|
||||
|
||||
def test_preserves_multiline_content(self):
|
||||
text = "line 1\n\x1b[53;1Rline 2"
|
||||
assert _strip_leaked_terminal_responses(text) == "line 1\nline 2"
|
||||
|
||||
def test_strips_sgr_mouse_report_esc_form(self):
|
||||
text = "abc\x1b[<65;1;49Mdef"
|
||||
assert _strip_leaked_terminal_responses(text) == "abcdef"
|
||||
|
||||
def test_strips_sgr_mouse_report_visible_form(self):
|
||||
text = "abc^[[<65;1;49Mdef"
|
||||
assert _strip_leaked_terminal_responses(text) == "abcdef"
|
||||
|
||||
def test_strips_sgr_mouse_report_bare_form(self):
|
||||
text = "abc<65;1;49Mdef"
|
||||
assert _strip_leaked_terminal_responses(text) == "abcdef"
|
||||
|
||||
def test_strips_sgr_mouse_report_with_large_coordinates(self):
|
||||
text = "abc\x1b[<10000;12345;98765Mdef"
|
||||
assert _strip_leaked_terminal_responses(text) == "abcdef"
|
||||
|
||||
def test_strips_multiple_concatenated_sgr_mouse_reports(self):
|
||||
text = "<65;1;49M<35;1;42Mhello<64;1;40m"
|
||||
assert _strip_leaked_terminal_responses(text) == "hello"
|
||||
|
||||
def test_does_not_strip_regular_angle_bracket_text(self):
|
||||
text = "render <div class='hero'> literal"
|
||||
assert _strip_leaked_terminal_responses(text) == text
|
||||
@@ -0,0 +1,49 @@
|
||||
"""Regression tests for terminal navigation/focus escape sequences.
|
||||
|
||||
Ghostty/macOS window and tab navigation can deliver terminal focus reports
|
||||
(CSI I / CSI O) to the running TUI. These must be consumed by the input parser,
|
||||
not inserted into the prompt buffer and cleaned up later.
|
||||
"""
|
||||
|
||||
from prompt_toolkit.input.vt100_parser import Vt100Parser
|
||||
from prompt_toolkit.keys import Keys
|
||||
|
||||
from hermes_cli.pt_input_extras import install_ignored_terminal_sequences
|
||||
|
||||
|
||||
def _parse_keys(data: str):
|
||||
events = []
|
||||
parser = Vt100Parser(events.append)
|
||||
parser.feed_and_flush(data)
|
||||
return [(event.key, event.data) for event in events]
|
||||
|
||||
|
||||
def test_focus_events_are_parser_level_ignored_before_prompt_buffer():
|
||||
install_ignored_terminal_sequences()
|
||||
|
||||
assert _parse_keys("\x1b[O\x1b[Ihello") == [
|
||||
(Keys.Ignore, "\x1b[O"),
|
||||
(Keys.Ignore, "\x1b[I"),
|
||||
("h", "h"),
|
||||
("e", "e"),
|
||||
("l", "l"),
|
||||
("l", "l"),
|
||||
("o", "o"),
|
||||
]
|
||||
|
||||
|
||||
def test_regular_escape_shortcuts_still_parse_normally():
|
||||
install_ignored_terminal_sequences()
|
||||
|
||||
assert _parse_keys("\x1bg") == [(Keys.Escape, "\x1b"), ("g", "g")]
|
||||
|
||||
|
||||
def test_install_is_idempotent_and_setdefault_safe():
|
||||
"""Second call should return 0 (no new mappings); existing user
|
||||
registrations must not be overwritten."""
|
||||
first = install_ignored_terminal_sequences()
|
||||
second = install_ignored_terminal_sequences()
|
||||
# At most first should be 2 (both CSI I + CSI O), second always 0
|
||||
# since the entries are now present.
|
||||
assert second == 0
|
||||
assert first in (0, 1, 2) # 0 if a prior test in same process already installed
|
||||
@@ -0,0 +1,130 @@
|
||||
"""Tests for /tools slash command handler in the interactive CLI."""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from cli import HermesCLI
|
||||
|
||||
|
||||
def _make_cli(enabled_toolsets=None):
|
||||
"""Build a minimal HermesCLI stub without running __init__."""
|
||||
cli_obj = HermesCLI.__new__(HermesCLI)
|
||||
cli_obj.enabled_toolsets = set(enabled_toolsets or ["web", "memory"])
|
||||
cli_obj._command_running = False
|
||||
cli_obj.console = MagicMock()
|
||||
return cli_obj
|
||||
|
||||
|
||||
# ── /tools (no subcommand) ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestToolsSlashNoSubcommand:
|
||||
|
||||
def test_bare_tools_shows_tool_list(self):
|
||||
cli_obj = _make_cli()
|
||||
with patch.object(cli_obj, "show_tools") as mock_show:
|
||||
cli_obj._handle_tools_command("/tools")
|
||||
mock_show.assert_called_once()
|
||||
|
||||
def test_unknown_subcommand_falls_back_to_show_tools(self):
|
||||
cli_obj = _make_cli()
|
||||
with patch.object(cli_obj, "show_tools") as mock_show:
|
||||
cli_obj._handle_tools_command("/tools foobar")
|
||||
mock_show.assert_called_once()
|
||||
|
||||
|
||||
# ── /tools list ─────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestToolsSlashList:
|
||||
|
||||
def test_list_calls_backend(self, capsys):
|
||||
cli_obj = _make_cli()
|
||||
with patch("hermes_cli.tools_config.load_config",
|
||||
return_value={"platform_toolsets": {"cli": ["web"]}}), \
|
||||
patch("hermes_cli.tools_config.save_config"):
|
||||
cli_obj._handle_tools_command("/tools list")
|
||||
out = capsys.readouterr().out
|
||||
assert "web" in out
|
||||
|
||||
def test_list_does_not_modify_enabled_toolsets(self):
|
||||
"""List is read-only — self.enabled_toolsets must not change."""
|
||||
cli_obj = _make_cli(["web", "memory"])
|
||||
with patch("hermes_cli.tools_config.load_config",
|
||||
return_value={"platform_toolsets": {"cli": ["web"]}}):
|
||||
cli_obj._handle_tools_command("/tools list")
|
||||
assert cli_obj.enabled_toolsets == {"web", "memory"}
|
||||
|
||||
|
||||
# ── /tools disable (session reset) ──────────────────────────────────────────
|
||||
|
||||
|
||||
class TestToolsSlashDisableWithReset:
|
||||
|
||||
def test_disable_applies_directly_and_resets_session(self):
|
||||
"""Disable applies immediately (no confirmation prompt) and resets session."""
|
||||
cli_obj = _make_cli(["web", "memory"])
|
||||
with patch("hermes_cli.tools_config.load_config",
|
||||
return_value={"platform_toolsets": {"cli": ["web", "memory"]}}), \
|
||||
patch("hermes_cli.tools_config.save_config"), \
|
||||
patch("hermes_cli.tools_config._get_platform_tools", return_value={"memory"}), \
|
||||
patch("hermes_cli.config.load_config", return_value={}), \
|
||||
patch.object(cli_obj, "new_session") as mock_reset:
|
||||
cli_obj._handle_tools_command("/tools disable web")
|
||||
mock_reset.assert_called_once()
|
||||
assert "web" not in cli_obj.enabled_toolsets
|
||||
|
||||
def test_disable_does_not_prompt_for_confirmation(self):
|
||||
"""Disable no longer uses input() — it applies directly."""
|
||||
cli_obj = _make_cli(["web", "memory"])
|
||||
with patch("hermes_cli.tools_config.load_config",
|
||||
return_value={"platform_toolsets": {"cli": ["web", "memory"]}}), \
|
||||
patch("hermes_cli.tools_config.save_config"), \
|
||||
patch("hermes_cli.tools_config._get_platform_tools", return_value={"memory"}), \
|
||||
patch("hermes_cli.config.load_config", return_value={}), \
|
||||
patch.object(cli_obj, "new_session"), \
|
||||
patch("builtins.input") as mock_input:
|
||||
cli_obj._handle_tools_command("/tools disable web")
|
||||
mock_input.assert_not_called()
|
||||
|
||||
def test_disable_always_resets_session(self):
|
||||
"""Even without a confirmation prompt, disable always resets the session."""
|
||||
cli_obj = _make_cli(["web", "memory"])
|
||||
with patch("hermes_cli.tools_config.load_config",
|
||||
return_value={"platform_toolsets": {"cli": ["web", "memory"]}}), \
|
||||
patch("hermes_cli.tools_config.save_config"), \
|
||||
patch("hermes_cli.tools_config._get_platform_tools", return_value={"memory"}), \
|
||||
patch("hermes_cli.config.load_config", return_value={}), \
|
||||
patch.object(cli_obj, "new_session") as mock_reset:
|
||||
cli_obj._handle_tools_command("/tools disable web")
|
||||
mock_reset.assert_called_once()
|
||||
|
||||
def test_disable_missing_name_prints_usage(self, capsys):
|
||||
cli_obj = _make_cli()
|
||||
cli_obj._handle_tools_command("/tools disable")
|
||||
out = capsys.readouterr().out
|
||||
assert "Usage" in out
|
||||
|
||||
|
||||
# ── /tools enable (session reset) ───────────────────────────────────────────
|
||||
|
||||
|
||||
class TestToolsSlashEnableWithReset:
|
||||
|
||||
def test_enable_applies_directly_and_resets_session(self):
|
||||
"""Enable applies immediately (no confirmation prompt) and resets session."""
|
||||
cli_obj = _make_cli(["memory"])
|
||||
with patch("hermes_cli.tools_config.load_config",
|
||||
return_value={"platform_toolsets": {"cli": ["memory"]}}), \
|
||||
patch("hermes_cli.tools_config.save_config"), \
|
||||
patch("hermes_cli.tools_config._get_platform_tools", return_value={"memory", "web"}), \
|
||||
patch("hermes_cli.config.load_config", return_value={}), \
|
||||
patch.object(cli_obj, "new_session") as mock_reset:
|
||||
cli_obj._handle_tools_command("/tools enable web")
|
||||
mock_reset.assert_called_once()
|
||||
assert "web" in cli_obj.enabled_toolsets
|
||||
|
||||
def test_enable_missing_name_prints_usage(self, capsys):
|
||||
cli_obj = _make_cli()
|
||||
cli_obj._handle_tools_command("/tools enable")
|
||||
out = capsys.readouterr().out
|
||||
assert "Usage" in out
|
||||
@@ -0,0 +1,92 @@
|
||||
import importlib
|
||||
import os
|
||||
import sys
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||
|
||||
|
||||
_cli_mod = None
|
||||
|
||||
|
||||
def _make_cli(user_message_preview=None):
|
||||
global _cli_mod
|
||||
clean_config = {
|
||||
"model": {
|
||||
"default": "anthropic/claude-opus-4.6",
|
||||
"base_url": "https://openrouter.ai/api/v1",
|
||||
"provider": "auto",
|
||||
},
|
||||
"display": {
|
||||
"compact": False,
|
||||
"tool_progress": "all",
|
||||
"user_message_preview": user_message_preview or {"first_lines": 2, "last_lines": 2},
|
||||
},
|
||||
"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 mod
|
||||
|
||||
mod = importlib.reload(mod)
|
||||
_cli_mod = mod
|
||||
with patch.object(mod, "get_tool_definitions", return_value=[]), patch.dict(mod.__dict__, {"CLI_CONFIG": clean_config}):
|
||||
return mod.HermesCLI()
|
||||
|
||||
|
||||
class TestSubmittedUserMessagePreview:
|
||||
def test_default_preview_shows_first_two_lines_and_last_two_lines(self):
|
||||
cli = _make_cli()
|
||||
|
||||
rendered = cli._format_submitted_user_message_preview(
|
||||
"line1\nline2\nline3\nline4\nline5\nline6"
|
||||
)
|
||||
|
||||
assert "line1" in rendered
|
||||
assert "line2" in rendered
|
||||
assert "line5" in rendered
|
||||
assert "line6" in rendered
|
||||
assert "line3" not in rendered
|
||||
assert "line4" not in rendered
|
||||
assert "(+2 more lines)" in rendered
|
||||
|
||||
def test_preview_can_hide_last_lines(self):
|
||||
cli = _make_cli({"first_lines": 2, "last_lines": 0})
|
||||
|
||||
rendered = cli._format_submitted_user_message_preview(
|
||||
"line1\nline2\nline3\nline4\nline5\nline6"
|
||||
)
|
||||
|
||||
assert "line1" in rendered
|
||||
assert "line2" in rendered
|
||||
assert "line5" not in rendered
|
||||
assert "line6" not in rendered
|
||||
assert "(+4 more lines)" in rendered
|
||||
|
||||
def test_invalid_first_lines_value_falls_back_to_one(self):
|
||||
cli = _make_cli({"first_lines": 0, "last_lines": 2})
|
||||
|
||||
rendered = cli._format_submitted_user_message_preview("line1\nline2\nline3\nline4")
|
||||
|
||||
assert "line1" in rendered
|
||||
assert "line3" in rendered
|
||||
assert "line4" in rendered
|
||||
assert "(+1 more line)" in rendered
|
||||
@@ -0,0 +1,244 @@
|
||||
"""Regression tests for the CLI ``/yolo`` in-chat toggle.
|
||||
|
||||
Pre-fix bug (issue #33925): ``cli.HermesCLI._toggle_yolo`` mutated only
|
||||
``os.environ["HERMES_YOLO_MODE"]``. That env var is captured once at
|
||||
module-import time into ``tools.approval._YOLO_MODE_FROZEN`` (security
|
||||
hardening: stops prompt-injected skills from flipping the bypass mid-run),
|
||||
so the post-startup toggle was a silent no-op. ``/yolo`` advertised "YOLO ON"
|
||||
in the status bar while every dangerous command still hit the approval
|
||||
prompt. Only ``hermes --yolo`` (process-start env), ``HERMES_YOLO_MODE=1``,
|
||||
and ``hermes config set approvals.mode off`` actually bypassed.
|
||||
|
||||
The fix routes the CLI toggle through ``enable_session_yolo`` /
|
||||
``disable_session_yolo`` (matching the gateway and TUI ``/yolo`` paths) and
|
||||
binds ``self.session_id`` as the active approval session key around each
|
||||
``run_conversation`` call so ``is_current_session_yolo_enabled()`` resolves
|
||||
against the same key the toggle writes under.
|
||||
|
||||
We test ``_toggle_yolo`` and ``_is_session_yolo_active`` as unbound methods
|
||||
against a minimal stand-in object that exposes only the attribute they
|
||||
read (``session_id``). This avoids the heavy ``HermesCLI`` construction
|
||||
path used in ``test_cli_init.py``, which is incompatible with this test
|
||||
file's path layout — ``HermesCLI.__init__`` imports a lot of optional
|
||||
state we don't need here.
|
||||
"""
|
||||
|
||||
import os
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
import tools.approval as approval_module
|
||||
from cli import HermesCLI
|
||||
|
||||
|
||||
SESSION_KEY = "test-cli-yolo-session"
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clear_approval_state(monkeypatch):
|
||||
"""Clear the YOLO bypass + env var around every test so cases are independent."""
|
||||
monkeypatch.delenv("HERMES_YOLO_MODE", raising=False)
|
||||
approval_module.clear_session(SESSION_KEY)
|
||||
approval_module.clear_session("default")
|
||||
yield
|
||||
approval_module.clear_session(SESSION_KEY)
|
||||
approval_module.clear_session("default")
|
||||
|
||||
|
||||
def _make_stand_in(session_id: str = SESSION_KEY) -> SimpleNamespace:
|
||||
"""Minimal stand-in exposing only ``session_id``.
|
||||
|
||||
``_toggle_yolo`` and ``_is_session_yolo_active`` are both pure methods
|
||||
that only read ``self.session_id`` — no other CLI state is touched.
|
||||
Calling them as unbound functions against this stand-in is equivalent
|
||||
to invoking them on a fully-constructed ``HermesCLI`` for the
|
||||
behaviour under test, and avoids the brittle prompt_toolkit / config
|
||||
stubbing required to instantiate ``HermesCLI`` from this test file.
|
||||
"""
|
||||
return SimpleNamespace(session_id=session_id)
|
||||
|
||||
|
||||
class TestToggleYoloIsSessionScoped:
|
||||
"""The CLI /yolo handler must mutate the session-yolo set, not the env var.
|
||||
|
||||
The env var path is dead-on-arrival because ``_YOLO_MODE_FROZEN`` is
|
||||
captured once at module import, long before the CLI's ``/yolo`` command
|
||||
can run.
|
||||
"""
|
||||
|
||||
def test_toggle_yolo_enables_session_bypass(self):
|
||||
stand_in = _make_stand_in()
|
||||
|
||||
assert approval_module.is_session_yolo_enabled(SESSION_KEY) is False
|
||||
|
||||
with patch("cli._cprint"):
|
||||
HermesCLI._toggle_yolo(stand_in)
|
||||
|
||||
assert approval_module.is_session_yolo_enabled(SESSION_KEY) is True
|
||||
|
||||
def test_toggle_yolo_disables_session_bypass_on_second_call(self):
|
||||
stand_in = _make_stand_in()
|
||||
with patch("cli._cprint"):
|
||||
HermesCLI._toggle_yolo(stand_in) # ON
|
||||
assert approval_module.is_session_yolo_enabled(SESSION_KEY) is True
|
||||
HermesCLI._toggle_yolo(stand_in) # OFF
|
||||
assert approval_module.is_session_yolo_enabled(SESSION_KEY) is False
|
||||
|
||||
def test_toggle_yolo_does_not_mutate_env_var(self):
|
||||
"""Toggling /yolo must not write ``HERMES_YOLO_MODE`` — that path is
|
||||
frozen at import time and would mislead anyone reading the env later
|
||||
(subprocesses, status bars wired to the env, the relaunch flag list)."""
|
||||
stand_in = _make_stand_in()
|
||||
with patch("cli._cprint"):
|
||||
HermesCLI._toggle_yolo(stand_in)
|
||||
|
||||
assert os.environ.get("HERMES_YOLO_MODE") is None
|
||||
|
||||
def test_toggle_yolo_falls_back_to_default_when_session_id_missing(self):
|
||||
"""An edge case during CLI bootstrap: a ``/yolo`` triggered before the
|
||||
session id is set should not blow up, and should land under the
|
||||
``default`` session key so the bypass still takes effect for any code
|
||||
that resolves against the default key."""
|
||||
stand_in = _make_stand_in(session_id="")
|
||||
with patch("cli._cprint"):
|
||||
HermesCLI._toggle_yolo(stand_in)
|
||||
|
||||
assert approval_module.is_session_yolo_enabled("default") is True
|
||||
|
||||
def test_two_independent_sessions_are_isolated(self):
|
||||
"""``/yolo`` toggled in one session must not bypass approvals in
|
||||
another session — mirrors the gateway-side invariant."""
|
||||
cli_a = _make_stand_in(session_id="session-yolo-a")
|
||||
cli_b = _make_stand_in(session_id="session-yolo-b")
|
||||
|
||||
try:
|
||||
with patch("cli._cprint"):
|
||||
HermesCLI._toggle_yolo(cli_a)
|
||||
|
||||
assert approval_module.is_session_yolo_enabled("session-yolo-a") is True
|
||||
assert approval_module.is_session_yolo_enabled("session-yolo-b") is False
|
||||
finally:
|
||||
approval_module.clear_session("session-yolo-a")
|
||||
approval_module.clear_session("session-yolo-b")
|
||||
|
||||
|
||||
class TestIsSessionYoloActiveHelper:
|
||||
"""The status-bar helper must read the live session-yolo state, not the
|
||||
env var (which is the bug class this PR fixes)."""
|
||||
|
||||
def test_helper_reflects_toggle(self):
|
||||
stand_in = _make_stand_in()
|
||||
|
||||
assert HermesCLI._is_session_yolo_active(stand_in) is False
|
||||
|
||||
with patch("cli._cprint"):
|
||||
HermesCLI._toggle_yolo(stand_in)
|
||||
|
||||
assert HermesCLI._is_session_yolo_active(stand_in) is True
|
||||
|
||||
with patch("cli._cprint"):
|
||||
HermesCLI._toggle_yolo(stand_in)
|
||||
|
||||
assert HermesCLI._is_session_yolo_active(stand_in) is False
|
||||
|
||||
def test_helper_honors_frozen_yolo_mode(self):
|
||||
"""``hermes --yolo`` sets ``HERMES_YOLO_MODE`` before tool imports, so
|
||||
``_YOLO_MODE_FROZEN`` ends up True. The status bar should still
|
||||
reflect YOLO on in that case even when the session toggle is off."""
|
||||
stand_in = _make_stand_in()
|
||||
|
||||
with patch.object(approval_module, "_YOLO_MODE_FROZEN", True):
|
||||
assert HermesCLI._is_session_yolo_active(stand_in) is True
|
||||
|
||||
|
||||
class TestToggleYoloEndToEnd:
|
||||
"""End-to-end: a dangerous command must auto-approve through the same
|
||||
``check_all_command_guards`` path the terminal tool uses."""
|
||||
|
||||
def test_toggle_yolo_bypasses_dangerous_command_check(self):
|
||||
stand_in = _make_stand_in()
|
||||
|
||||
token = approval_module.set_current_session_key(SESSION_KEY)
|
||||
try:
|
||||
with patch("cli._cprint"):
|
||||
HermesCLI._toggle_yolo(stand_in) # YOLO ON
|
||||
|
||||
result = approval_module.check_all_command_guards(
|
||||
"rm -rf /tmp/scratch-xyzzy", "local",
|
||||
)
|
||||
assert result["approved"] is True, (
|
||||
f"YOLO toggle should auto-approve dangerous commands, got: {result}"
|
||||
)
|
||||
finally:
|
||||
approval_module.reset_current_session_key(token)
|
||||
|
||||
|
||||
class TestIsSessionYoloActiveAttrSafety:
|
||||
"""The status-bar helper runs against partially-constructed CLI fixtures
|
||||
(tests use ``HermesCLI.__new__(HermesCLI)`` to skip ``__init__``). It must
|
||||
not raise ``AttributeError`` when ``session_id`` is absent — the
|
||||
status-bar builders swallow exceptions silently and lose every field
|
||||
after the failure, producing a regression that's hard to track back to
|
||||
the helper."""
|
||||
|
||||
def test_helper_survives_missing_session_id_attr(self):
|
||||
# SimpleNamespace WITHOUT session_id mimics __new__-built fixtures.
|
||||
from types import SimpleNamespace
|
||||
no_attr = SimpleNamespace()
|
||||
# Must return False, not raise.
|
||||
assert HermesCLI._is_session_yolo_active(no_attr) is False
|
||||
|
||||
|
||||
class TestSessionRotationTransfersYolo:
|
||||
"""When the CLI's ``session_id`` rotates mid-run (``/branch``, auto
|
||||
compression continuation), YOLO state keyed under the old id must move
|
||||
to the new id. Otherwise the user's ``/yolo ON`` silently reverts on
|
||||
the next turn — the same UX failure mode this PR set out to fix.
|
||||
Mirrors ``tui_gateway/server.py`` ~line 1297-1305."""
|
||||
|
||||
def test_transfer_moves_yolo_to_new_session(self):
|
||||
stand_in = _make_stand_in(session_id="old-id")
|
||||
try:
|
||||
approval_module.enable_session_yolo("old-id")
|
||||
assert approval_module.is_session_yolo_enabled("old-id") is True
|
||||
|
||||
HermesCLI._transfer_session_yolo(stand_in, "old-id", "new-id")
|
||||
|
||||
assert approval_module.is_session_yolo_enabled("new-id") is True
|
||||
assert approval_module.is_session_yolo_enabled("old-id") is False
|
||||
finally:
|
||||
approval_module.clear_session("old-id")
|
||||
approval_module.clear_session("new-id")
|
||||
|
||||
def test_transfer_is_noop_when_yolo_was_off(self):
|
||||
stand_in = _make_stand_in(session_id="old-id")
|
||||
try:
|
||||
HermesCLI._transfer_session_yolo(stand_in, "old-id", "new-id")
|
||||
assert approval_module.is_session_yolo_enabled("new-id") is False
|
||||
assert approval_module.is_session_yolo_enabled("old-id") is False
|
||||
finally:
|
||||
approval_module.clear_session("old-id")
|
||||
approval_module.clear_session("new-id")
|
||||
|
||||
def test_transfer_is_noop_when_ids_match(self):
|
||||
stand_in = _make_stand_in(session_id="same-id")
|
||||
try:
|
||||
approval_module.enable_session_yolo("same-id")
|
||||
HermesCLI._transfer_session_yolo(stand_in, "same-id", "same-id")
|
||||
# Must NOT have been disabled — same-id == same-id is a no-op,
|
||||
# not a "disable then re-enable" round-trip.
|
||||
assert approval_module.is_session_yolo_enabled("same-id") is True
|
||||
finally:
|
||||
approval_module.clear_session("same-id")
|
||||
|
||||
def test_transfer_handles_empty_inputs_safely(self):
|
||||
stand_in = _make_stand_in(session_id="x")
|
||||
# Both directions of empty input should be safe no-ops; nothing
|
||||
# to transfer from "" / to "".
|
||||
HermesCLI._transfer_session_yolo(stand_in, "", "new")
|
||||
HermesCLI._transfer_session_yolo(stand_in, "old", "")
|
||||
# Neither key should have been touched.
|
||||
assert approval_module.is_session_yolo_enabled("new") is False
|
||||
assert approval_module.is_session_yolo_enabled("old") is False
|
||||
@@ -0,0 +1,155 @@
|
||||
"""Tests for /compress --preview/--dry-run/--aggressive flags and the
|
||||
/compact alias (PR #3243 salvage).
|
||||
|
||||
Covers the pure helpers in ``hermes_cli.partial_compress`` plus alias
|
||||
resolution in the command registry. The CLI and gateway surfaces both
|
||||
route through these helpers, so the flag semantics are pinned here once.
|
||||
"""
|
||||
|
||||
from hermes_cli.commands import COMMANDS, resolve_command
|
||||
from hermes_cli.partial_compress import (
|
||||
DEFAULT_KEEP_LAST,
|
||||
extract_compress_flags,
|
||||
parse_partial_compress_args,
|
||||
summarize_compress_preview,
|
||||
)
|
||||
|
||||
|
||||
def _history(n_pairs: int) -> list[dict[str, str]]:
|
||||
h: list[dict[str, str]] = []
|
||||
for i in range(n_pairs):
|
||||
h.append({"role": "user", "content": f"u{i}"})
|
||||
h.append({"role": "assistant", "content": f"a{i}"})
|
||||
return h
|
||||
|
||||
|
||||
# ── /compact alias resolution ─────────────────────────────────────────
|
||||
|
||||
|
||||
def test_compact_resolves_to_compress():
|
||||
cmd = resolve_command("compact")
|
||||
assert cmd is not None
|
||||
assert cmd.name == "compress"
|
||||
assert "compact" in cmd.aliases
|
||||
|
||||
|
||||
def test_compact_alias_with_slash():
|
||||
cmd = resolve_command("/compact")
|
||||
assert cmd is not None and cmd.name == "compress"
|
||||
|
||||
|
||||
def test_compact_listed_in_flat_commands():
|
||||
assert "/compact" in COMMANDS
|
||||
assert "alias for /compress" in COMMANDS["/compact"]
|
||||
|
||||
|
||||
def test_compress_args_hint_documents_preview():
|
||||
cmd = resolve_command("compress")
|
||||
assert cmd is not None
|
||||
assert "--preview" in (cmd.args_hint or "")
|
||||
|
||||
|
||||
# ── extract_compress_flags ────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_no_flags_passthrough():
|
||||
rest, preview, aggressive = extract_compress_flags("here 3")
|
||||
assert rest == "here 3"
|
||||
assert preview is False
|
||||
assert aggressive is False
|
||||
|
||||
|
||||
def test_preview_flag_stripped():
|
||||
rest, preview, aggressive = extract_compress_flags("--preview")
|
||||
assert rest == ""
|
||||
assert preview is True
|
||||
assert aggressive is False
|
||||
|
||||
|
||||
def test_dry_run_is_preview():
|
||||
for form in ("--dry-run", "--dryrun", "--DRY-RUN"):
|
||||
_, preview, _ = extract_compress_flags(form)
|
||||
assert preview is True, form
|
||||
|
||||
|
||||
def test_aggressive_flag_detected():
|
||||
rest, preview, aggressive = extract_compress_flags("--aggressive")
|
||||
assert rest == ""
|
||||
assert preview is False
|
||||
assert aggressive is True
|
||||
|
||||
|
||||
def test_flags_coexist_with_here_form():
|
||||
rest, preview, aggressive = extract_compress_flags("--preview here 4")
|
||||
assert rest == "here 4"
|
||||
assert preview is True
|
||||
partial, keep, focus = parse_partial_compress_args(rest)
|
||||
assert partial is True and keep == 4 and focus is None
|
||||
|
||||
|
||||
def test_flags_coexist_with_focus_topic():
|
||||
rest, preview, _ = extract_compress_flags("database schema --dry-run")
|
||||
assert rest == "database schema"
|
||||
assert preview is True
|
||||
partial, _, focus = parse_partial_compress_args(rest)
|
||||
assert partial is False and focus == "database schema"
|
||||
|
||||
|
||||
def test_aggressive_dry_run_combo():
|
||||
rest, preview, aggressive = extract_compress_flags("--aggressive --dry-run")
|
||||
assert rest == ""
|
||||
assert preview is True and aggressive is True
|
||||
|
||||
|
||||
def test_empty_args():
|
||||
rest, preview, aggressive = extract_compress_flags("")
|
||||
assert rest == "" and preview is False and aggressive is False
|
||||
|
||||
|
||||
# ── summarize_compress_preview ────────────────────────────────────────
|
||||
|
||||
|
||||
def test_preview_full_compress_counts():
|
||||
hist = _history(5)
|
||||
report = summarize_compress_preview(hist, False, DEFAULT_KEEP_LAST, None, 1234)
|
||||
assert report["head_count"] == 10
|
||||
assert report["tail_count"] == 0
|
||||
assert report["total"] == 10
|
||||
assert report["partial"] is False
|
||||
joined = "\n".join(report["lines"])
|
||||
assert "no changes made" in joined.lower()
|
||||
assert "10 of 10" in joined
|
||||
assert "1,234" in joined
|
||||
|
||||
|
||||
def test_preview_partial_boundary_counts():
|
||||
hist = _history(5)
|
||||
report = summarize_compress_preview(hist, True, 2, None, 999)
|
||||
# Keeping last 2 exchanges = 4 tail messages, 6 head messages.
|
||||
assert report["head_count"] == 6
|
||||
assert report["tail_count"] == 4
|
||||
assert report["partial"] is True
|
||||
joined = "\n".join(report["lines"])
|
||||
assert "last 2 exchange" in joined
|
||||
|
||||
|
||||
def test_preview_partial_degenerate_falls_back_to_full():
|
||||
hist = _history(2) # keep_last=5 would swallow everything
|
||||
report = summarize_compress_preview(hist, True, 5, None, 100)
|
||||
assert report["partial"] is False
|
||||
assert report["head_count"] == 4
|
||||
joined = "\n".join(report["lines"])
|
||||
assert "falling back to full compression" in joined
|
||||
|
||||
|
||||
def test_preview_includes_focus_topic():
|
||||
hist = _history(4)
|
||||
report = summarize_compress_preview(hist, False, DEFAULT_KEEP_LAST, "db schema", 50)
|
||||
assert 'Focus topic: "db schema"' in "\n".join(report["lines"])
|
||||
|
||||
|
||||
def test_preview_is_side_effect_free():
|
||||
hist = _history(4)
|
||||
before = [dict(m) for m in hist]
|
||||
summarize_compress_preview(hist, True, 1, None, 10)
|
||||
assert hist == before
|
||||
@@ -0,0 +1,118 @@
|
||||
"""Tests for /compress <focus> — guided compression with focus topic.
|
||||
|
||||
Inspired by Claude Code's /compact <focus> feature.
|
||||
"""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from tests.cli.test_cli_init import _make_cli
|
||||
|
||||
|
||||
def _make_history() -> list[dict[str, str]]:
|
||||
return [
|
||||
{"role": "user", "content": "one"},
|
||||
{"role": "assistant", "content": "two"},
|
||||
{"role": "user", "content": "three"},
|
||||
{"role": "assistant", "content": "four"},
|
||||
]
|
||||
|
||||
|
||||
def test_focus_topic_extracted_and_passed(capsys):
|
||||
"""Focus topic is extracted from the command and passed to _compress_context."""
|
||||
shell = _make_cli()
|
||||
history = _make_history()
|
||||
compressed = [history[0], history[-1]]
|
||||
shell.conversation_history = history
|
||||
shell.agent = MagicMock()
|
||||
shell.agent.compression_enabled = True
|
||||
shell.agent._cached_system_prompt = ""
|
||||
shell.agent._compress_context.return_value = (compressed, "")
|
||||
|
||||
def _estimate(messages):
|
||||
if messages is history:
|
||||
return 100
|
||||
return 50
|
||||
|
||||
with patch("agent.model_metadata.estimate_messages_tokens_rough", side_effect=_estimate):
|
||||
shell._manual_compress("/compress database schema")
|
||||
|
||||
output = capsys.readouterr().out
|
||||
assert 'focus: "database schema"' in output
|
||||
|
||||
# Verify focus_topic was passed through
|
||||
shell.agent._compress_context.assert_called_once()
|
||||
call_kwargs = shell.agent._compress_context.call_args
|
||||
assert call_kwargs.kwargs.get("focus_topic") == "database schema"
|
||||
|
||||
|
||||
def test_no_focus_topic_when_bare_command(capsys):
|
||||
"""When no focus topic is provided, None is passed."""
|
||||
shell = _make_cli()
|
||||
history = _make_history()
|
||||
shell.conversation_history = history
|
||||
shell.agent = MagicMock()
|
||||
shell.agent.compression_enabled = True
|
||||
shell.agent._cached_system_prompt = ""
|
||||
shell.agent._compress_context.return_value = (list(history), "")
|
||||
|
||||
with patch("agent.model_metadata.estimate_messages_tokens_rough", return_value=100):
|
||||
shell._manual_compress("/compress")
|
||||
|
||||
shell.agent._compress_context.assert_called_once()
|
||||
call_kwargs = shell.agent._compress_context.call_args
|
||||
assert call_kwargs.kwargs.get("focus_topic") is None
|
||||
|
||||
|
||||
def test_empty_focus_after_command_treated_as_none(capsys):
|
||||
"""Trailing whitespace after /compress does not produce a focus topic."""
|
||||
shell = _make_cli()
|
||||
history = _make_history()
|
||||
shell.conversation_history = history
|
||||
shell.agent = MagicMock()
|
||||
shell.agent.compression_enabled = True
|
||||
shell.agent._cached_system_prompt = ""
|
||||
shell.agent._compress_context.return_value = (list(history), "")
|
||||
|
||||
with patch("agent.model_metadata.estimate_messages_tokens_rough", return_value=100):
|
||||
shell._manual_compress("/compress ")
|
||||
|
||||
shell.agent._compress_context.assert_called_once()
|
||||
call_kwargs = shell.agent._compress_context.call_args
|
||||
assert call_kwargs.kwargs.get("focus_topic") is None
|
||||
|
||||
|
||||
def test_focus_topic_printed_in_compression_banner(capsys):
|
||||
"""The focus topic shows in the compression progress banner."""
|
||||
shell = _make_cli()
|
||||
history = _make_history()
|
||||
compressed = [history[0], history[-1]]
|
||||
shell.conversation_history = history
|
||||
shell.agent = MagicMock()
|
||||
shell.agent.compression_enabled = True
|
||||
shell.agent._cached_system_prompt = ""
|
||||
shell.agent._compress_context.return_value = (compressed, "")
|
||||
|
||||
with patch("agent.model_metadata.estimate_messages_tokens_rough", return_value=100):
|
||||
shell._manual_compress("/compress API endpoints")
|
||||
|
||||
output = capsys.readouterr().out
|
||||
assert 'focus: "API endpoints"' in output
|
||||
|
||||
|
||||
def test_no_focus_prints_standard_banner(capsys):
|
||||
"""Without focus, the standard banner (no focus: line) is printed."""
|
||||
shell = _make_cli()
|
||||
history = _make_history()
|
||||
compressed = [history[0], history[-1]]
|
||||
shell.conversation_history = history
|
||||
shell.agent = MagicMock()
|
||||
shell.agent.compression_enabled = True
|
||||
shell.agent._cached_system_prompt = ""
|
||||
shell.agent._compress_context.return_value = (compressed, "")
|
||||
|
||||
with patch("agent.model_metadata.estimate_messages_tokens_rough", return_value=100):
|
||||
shell._manual_compress("/compress")
|
||||
|
||||
output = capsys.readouterr().out
|
||||
assert "focus:" not in output
|
||||
assert "Compressing" in output
|
||||
@@ -0,0 +1,119 @@
|
||||
"""Tests for /compress here [N] — boundary-aware partial compression.
|
||||
|
||||
Verifies the CLI handler (_manual_compress) splits the history, compresses
|
||||
only the head, and re-appends the verbatim tail. Inspired by Claude Code's
|
||||
Rewind "Summarize up to here" action (v2.1.139, May 2026).
|
||||
"""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from tests.cli.test_cli_init import _make_cli
|
||||
|
||||
|
||||
def _make_history() -> list[dict[str, str]]:
|
||||
# 8 messages = 4 exchanges.
|
||||
h: list[dict[str, str]] = []
|
||||
for i in range(4):
|
||||
h.append({"role": "user", "content": f"u{i}"})
|
||||
h.append({"role": "assistant", "content": f"a{i}"})
|
||||
return h
|
||||
|
||||
|
||||
def _wire_agent(shell, compressed_head):
|
||||
shell.agent = MagicMock()
|
||||
shell.agent.compression_enabled = True
|
||||
shell.agent._cached_system_prompt = ""
|
||||
shell.agent.session_id = None
|
||||
shell.agent.tools = None
|
||||
shell.agent._compress_context.return_value = (compressed_head, "")
|
||||
|
||||
|
||||
def test_compress_here_compresses_head_only(capsys):
|
||||
"""/compress here 2 passes only the head to _compress_context."""
|
||||
shell = _make_cli()
|
||||
history = _make_history()
|
||||
shell.conversation_history = history
|
||||
# Pretend compression collapses the head into a single summary message.
|
||||
summary = [{"role": "user", "content": "[summary of earlier turns]"}]
|
||||
_wire_agent(shell, summary)
|
||||
|
||||
with patch("agent.model_metadata.estimate_request_tokens_rough", return_value=100):
|
||||
shell._manual_compress("/compress here 2")
|
||||
|
||||
# _compress_context should have been called with the HEAD only
|
||||
# (everything before the last 2 user-starts = first 4 messages).
|
||||
shell.agent._compress_context.assert_called_once()
|
||||
call = shell.agent._compress_context.call_args
|
||||
passed_head = call.args[0]
|
||||
assert passed_head == history[:4]
|
||||
# focus_topic must be None in partial mode (modes are exclusive).
|
||||
assert call.kwargs.get("focus_topic") is None
|
||||
|
||||
|
||||
def test_compress_here_reappends_verbatim_tail(capsys):
|
||||
"""The most recent exchanges are preserved verbatim after the summary."""
|
||||
shell = _make_cli()
|
||||
history = _make_history()
|
||||
shell.conversation_history = history
|
||||
# Head compresses to an assistant-role summary so the seam
|
||||
# (assistant -> user tail) is already valid — tail rides along whole.
|
||||
summary = [{"role": "assistant", "content": "[summary]"}]
|
||||
_wire_agent(shell, summary)
|
||||
|
||||
with patch("agent.model_metadata.estimate_request_tokens_rough", return_value=100):
|
||||
shell._manual_compress("/compress here 2")
|
||||
|
||||
# Result = compressed head + verbatim tail (last 2 exchanges).
|
||||
assert shell.conversation_history == summary + history[4:]
|
||||
# Tail boundary keeps role alternation valid (tail starts on user).
|
||||
assert history[4]["role"] == "user"
|
||||
# No consecutive same-role user/assistant messages anywhere.
|
||||
roles = [m["role"] for m in shell.conversation_history
|
||||
if m["role"] in ("user", "assistant")]
|
||||
assert all(roles[i] != roles[i + 1] for i in range(len(roles) - 1))
|
||||
|
||||
|
||||
def test_compress_here_banner_mentions_summarizing_up_to_here(capsys):
|
||||
shell = _make_cli()
|
||||
history = _make_history()
|
||||
shell.conversation_history = history
|
||||
_wire_agent(shell, [{"role": "user", "content": "[summary]"}])
|
||||
|
||||
with patch("agent.model_metadata.estimate_request_tokens_rough", return_value=100):
|
||||
shell._manual_compress("/compress here")
|
||||
|
||||
out = capsys.readouterr().out
|
||||
assert "Summarizing up to here" in out
|
||||
assert "verbatim" in out
|
||||
|
||||
|
||||
def test_bare_compress_still_full(capsys):
|
||||
"""/compress with no args compresses the whole history (full mode)."""
|
||||
shell = _make_cli()
|
||||
history = _make_history()
|
||||
shell.conversation_history = history
|
||||
_wire_agent(shell, list(history))
|
||||
|
||||
with patch("agent.model_metadata.estimate_request_tokens_rough", return_value=100):
|
||||
shell._manual_compress("/compress")
|
||||
|
||||
call = shell.agent._compress_context.call_args
|
||||
# Full mode passes the entire history as the head.
|
||||
assert call.args[0] == history
|
||||
out = capsys.readouterr().out
|
||||
assert "Summarizing up to here" not in out
|
||||
|
||||
|
||||
def test_focus_still_works(capsys):
|
||||
"""/compress <focus> keeps the existing focus behavior."""
|
||||
shell = _make_cli()
|
||||
history = _make_history()
|
||||
shell.conversation_history = history
|
||||
_wire_agent(shell, list(history))
|
||||
|
||||
with patch("agent.model_metadata.estimate_request_tokens_rough", return_value=100):
|
||||
shell._manual_compress("/compress database schema")
|
||||
|
||||
call = shell.agent._compress_context.call_args
|
||||
assert call.args[0] == history
|
||||
assert call.kwargs.get("focus_topic") == "database schema"
|
||||
@@ -0,0 +1,308 @@
|
||||
"""Tests for cli._cprint's bg-thread cooperation with prompt_toolkit.
|
||||
|
||||
Background: when a prompt_toolkit Application is running, a bg thread that
|
||||
calls ``_pt_print`` directly can race with the input-area redraw and the
|
||||
printed line can end up visually buried behind the prompt. ``_cprint`` now
|
||||
routes cross-thread prints through ``run_in_terminal`` via
|
||||
``loop.call_soon_threadsafe`` so the self-improvement background review's
|
||||
``💾 Self-improvement review: …`` summary actually surfaces to the user.
|
||||
|
||||
These tests verify the routing logic without spinning up a real PT app.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import types
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
import cli
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def reset_output_history():
|
||||
cli._configure_output_history(False, 200)
|
||||
yield
|
||||
cli._configure_output_history(True, 200)
|
||||
|
||||
|
||||
def test_cprint_no_app_direct_print(monkeypatch):
|
||||
"""No active app → direct _pt_print, no run_in_terminal involvement."""
|
||||
calls = []
|
||||
monkeypatch.setattr(cli, "_pt_print", lambda x: calls.append(("pt_print", x)))
|
||||
monkeypatch.setattr(cli, "_PT_ANSI", lambda t: ("ANSI", t))
|
||||
|
||||
# Patch the prompt_toolkit import the function performs internally.
|
||||
fake_pt_app = types.ModuleType("prompt_toolkit.application")
|
||||
fake_pt_app.get_app_or_none = lambda: None
|
||||
fake_pt_app.run_in_terminal = lambda *a, **kw: calls.append(("run_in_terminal",))
|
||||
monkeypatch.setitem(sys.modules, "prompt_toolkit.application", fake_pt_app)
|
||||
|
||||
cli._cprint("hello")
|
||||
|
||||
assert calls == [("pt_print", ("ANSI", "hello"))]
|
||||
|
||||
|
||||
def test_cprint_app_not_running_direct_print(monkeypatch):
|
||||
"""App exists but not running (e.g. teardown) → direct print."""
|
||||
calls = []
|
||||
monkeypatch.setattr(cli, "_pt_print", lambda x: calls.append(("pt_print", x)))
|
||||
monkeypatch.setattr(cli, "_PT_ANSI", lambda t: t)
|
||||
|
||||
fake_app = SimpleNamespace(_is_running=False, loop=None)
|
||||
fake_pt_app = types.ModuleType("prompt_toolkit.application")
|
||||
fake_pt_app.get_app_or_none = lambda: fake_app
|
||||
fake_pt_app.run_in_terminal = lambda *a, **kw: calls.append(("run_in_terminal",))
|
||||
monkeypatch.setitem(sys.modules, "prompt_toolkit.application", fake_pt_app)
|
||||
|
||||
cli._cprint("x")
|
||||
|
||||
assert calls == [("pt_print", "x")]
|
||||
|
||||
|
||||
def test_cprint_bg_thread_schedules_on_app_loop(monkeypatch):
|
||||
"""App running + different thread → schedules via call_soon_threadsafe."""
|
||||
scheduled = []
|
||||
direct_prints = []
|
||||
|
||||
monkeypatch.setattr(cli, "_pt_print", lambda x: direct_prints.append(x))
|
||||
monkeypatch.setattr(cli, "_PT_ANSI", lambda t: t)
|
||||
|
||||
class FakeLoop:
|
||||
def is_running(self):
|
||||
return True
|
||||
|
||||
def call_soon_threadsafe(self, cb, *args):
|
||||
scheduled.append(cb)
|
||||
|
||||
fake_loop = FakeLoop()
|
||||
|
||||
# Install a fake "current loop" that is NOT the app's loop, so the
|
||||
# cross-thread branch is taken.
|
||||
fake_current_loop = SimpleNamespace(is_running=lambda: True)
|
||||
fake_asyncio = types.ModuleType("asyncio")
|
||||
|
||||
class _Policy:
|
||||
def get_event_loop(self):
|
||||
return fake_current_loop
|
||||
|
||||
fake_asyncio.get_event_loop_policy = lambda: _Policy()
|
||||
monkeypatch.setitem(sys.modules, "asyncio", fake_asyncio)
|
||||
|
||||
fake_app = SimpleNamespace(_is_running=True, loop=fake_loop)
|
||||
fake_pt_app = types.ModuleType("prompt_toolkit.application")
|
||||
fake_pt_app.get_app_or_none = lambda: fake_app
|
||||
|
||||
run_in_terminal_calls = []
|
||||
|
||||
def _fake_run_in_terminal(func, **kw):
|
||||
run_in_terminal_calls.append(func)
|
||||
# Simulate run_in_terminal actually calling func (as the real PT
|
||||
# impl would once the app loop tick picks it up).
|
||||
func()
|
||||
return None
|
||||
|
||||
fake_pt_app.run_in_terminal = _fake_run_in_terminal
|
||||
monkeypatch.setitem(sys.modules, "prompt_toolkit.application", fake_pt_app)
|
||||
|
||||
cli._cprint("💾 Self-improvement review: Skill updated")
|
||||
|
||||
# call_soon_threadsafe must have been called with a scheduling cb.
|
||||
assert len(scheduled) == 1
|
||||
|
||||
# Invoking the scheduled callback should hit run_in_terminal.
|
||||
scheduled[0]()
|
||||
assert len(run_in_terminal_calls) == 1
|
||||
|
||||
# And run_in_terminal's inner func should have emitted a pt_print.
|
||||
assert direct_prints == ["💾 Self-improvement review: Skill updated"]
|
||||
|
||||
|
||||
def test_cprint_same_thread_as_app_loop_direct_print(monkeypatch):
|
||||
"""App running on same thread → direct print (no scheduling)."""
|
||||
direct_prints = []
|
||||
monkeypatch.setattr(cli, "_pt_print", lambda x: direct_prints.append(x))
|
||||
monkeypatch.setattr(cli, "_PT_ANSI", lambda t: t)
|
||||
|
||||
class FakeLoop:
|
||||
def is_running(self):
|
||||
return True
|
||||
|
||||
def call_soon_threadsafe(self, cb, *args):
|
||||
raise AssertionError(
|
||||
"call_soon_threadsafe must not be used on the app's own thread"
|
||||
)
|
||||
|
||||
fake_loop = FakeLoop()
|
||||
fake_asyncio = types.ModuleType("asyncio")
|
||||
|
||||
class _Policy:
|
||||
def get_event_loop(self):
|
||||
return fake_loop # same as app loop
|
||||
|
||||
fake_asyncio.get_event_loop_policy = lambda: _Policy()
|
||||
monkeypatch.setitem(sys.modules, "asyncio", fake_asyncio)
|
||||
|
||||
fake_app = SimpleNamespace(_is_running=True, loop=fake_loop)
|
||||
fake_pt_app = types.ModuleType("prompt_toolkit.application")
|
||||
fake_pt_app.get_app_or_none = lambda: fake_app
|
||||
fake_pt_app.run_in_terminal = lambda *a, **kw: None
|
||||
monkeypatch.setitem(sys.modules, "prompt_toolkit.application", fake_pt_app)
|
||||
|
||||
cli._cprint("x")
|
||||
|
||||
assert direct_prints == ["x"]
|
||||
|
||||
|
||||
def test_cprint_swallows_app_loop_attr_error(monkeypatch):
|
||||
"""Loop missing on app → fall back to direct print, no crash."""
|
||||
direct_prints = []
|
||||
monkeypatch.setattr(cli, "_pt_print", lambda x: direct_prints.append(x))
|
||||
monkeypatch.setattr(cli, "_PT_ANSI", lambda t: t)
|
||||
|
||||
class WeirdApp:
|
||||
_is_running = True
|
||||
|
||||
@property
|
||||
def loop(self):
|
||||
raise RuntimeError("no loop for you")
|
||||
|
||||
fake_pt_app = types.ModuleType("prompt_toolkit.application")
|
||||
fake_pt_app.get_app_or_none = lambda: WeirdApp()
|
||||
fake_pt_app.run_in_terminal = lambda *a, **kw: None
|
||||
monkeypatch.setitem(sys.modules, "prompt_toolkit.application", fake_pt_app)
|
||||
|
||||
cli._cprint("fallback")
|
||||
|
||||
assert direct_prints == ["fallback"]
|
||||
|
||||
|
||||
def test_cprint_swallows_prompt_toolkit_import_error(monkeypatch):
|
||||
"""If prompt_toolkit.application itself fails to import, fall back."""
|
||||
direct_prints = []
|
||||
monkeypatch.setattr(cli, "_pt_print", lambda x: direct_prints.append(x))
|
||||
monkeypatch.setattr(cli, "_PT_ANSI", lambda t: t)
|
||||
|
||||
# Drop cached prompt_toolkit.application AND install a meta-path finder
|
||||
# that raises ImportError on re-import.
|
||||
monkeypatch.delitem(sys.modules, "prompt_toolkit.application", raising=False)
|
||||
|
||||
class _BlockFinder:
|
||||
def find_module(self, name, path=None):
|
||||
if name == "prompt_toolkit.application":
|
||||
return self
|
||||
return None
|
||||
|
||||
def load_module(self, name):
|
||||
raise ImportError("blocked for test")
|
||||
|
||||
def find_spec(self, name, path=None, target=None):
|
||||
if name == "prompt_toolkit.application":
|
||||
# Returning a bogus spec that will fail on load works too,
|
||||
# but raising here keeps the test simple.
|
||||
raise ImportError("blocked for test")
|
||||
return None
|
||||
|
||||
blocker = _BlockFinder()
|
||||
sys.meta_path.insert(0, blocker)
|
||||
try:
|
||||
cli._cprint("fallback2")
|
||||
finally:
|
||||
sys.meta_path.remove(blocker)
|
||||
|
||||
assert direct_prints == ["fallback2"]
|
||||
|
||||
|
||||
def test_output_history_preserves_ansi_and_keeps_recent_lines():
|
||||
cli._configure_output_history(True, 10)
|
||||
|
||||
for idx in range(12):
|
||||
cli._record_output_history(f"\x1b[31mline-{idx}\x1b[0m")
|
||||
|
||||
assert list(cli._OUTPUT_HISTORY) == [
|
||||
f"\x1b[31mline-{idx}\x1b[0m" for idx in range(2, 12)
|
||||
]
|
||||
|
||||
|
||||
def test_replay_output_history_does_not_record_replayed_lines(monkeypatch):
|
||||
cli._configure_output_history(True, 10)
|
||||
cli._record_output_history("visible output")
|
||||
printed = []
|
||||
|
||||
def _fake_print(value):
|
||||
printed.append(value)
|
||||
cli._record_output_history("duplicated replay")
|
||||
|
||||
monkeypatch.setattr(cli, "_pt_print", _fake_print)
|
||||
monkeypatch.setattr(cli, "_PT_ANSI", lambda text: text)
|
||||
|
||||
cli._replay_output_history()
|
||||
|
||||
assert printed == ["visible output"]
|
||||
assert list(cli._OUTPUT_HISTORY) == ["visible output"]
|
||||
|
||||
|
||||
def test_replay_output_history_rerenders_callable_entries(monkeypatch):
|
||||
cli._configure_output_history(True, 10)
|
||||
widths_seen = []
|
||||
printed = []
|
||||
|
||||
def _render_current_width():
|
||||
widths_seen.append("called")
|
||||
return ["top border", "body"]
|
||||
|
||||
cli._record_output_history_entry(_render_current_width)
|
||||
monkeypatch.setattr(cli, "_pt_print", lambda value: printed.append(value))
|
||||
monkeypatch.setattr(cli, "_PT_ANSI", lambda text: text)
|
||||
|
||||
cli._replay_output_history()
|
||||
|
||||
assert widths_seen == ["called"]
|
||||
assert printed == ["top border\nbody"]
|
||||
assert list(cli._OUTPUT_HISTORY) == [_render_current_width]
|
||||
|
||||
|
||||
def test_replay_output_history_batches_rendered_lines_into_one_print(monkeypatch):
|
||||
cli._configure_output_history(True, 10)
|
||||
cli._record_output_history("first line")
|
||||
cli._record_output_history("second line")
|
||||
cli._record_output_history_entry(lambda: ["third line", "fourth line"])
|
||||
printed = []
|
||||
|
||||
monkeypatch.setattr(cli, "_pt_print", lambda value: printed.append(value))
|
||||
monkeypatch.setattr(cli, "_PT_ANSI", lambda text: text)
|
||||
|
||||
cli._replay_output_history()
|
||||
|
||||
assert printed == ["first line\nsecond line\nthird line\nfourth line"]
|
||||
|
||||
|
||||
def test_chat_console_records_rich_ansi_for_resize_replay(monkeypatch):
|
||||
cli._configure_output_history(True, 10)
|
||||
monkeypatch.setattr(cli, "_pt_print", lambda *_args, **_kwargs: None)
|
||||
|
||||
cli.ChatConsole().print("[bold red]Hello[/]")
|
||||
|
||||
assert cli._OUTPUT_HISTORY
|
||||
assert any("\x1b[" in line for line in cli._OUTPUT_HISTORY)
|
||||
|
||||
|
||||
def test_suspend_output_history_blocks_recording():
|
||||
cli._configure_output_history(True, 10)
|
||||
|
||||
with cli._suspend_output_history():
|
||||
cli._record_output_history("hidden")
|
||||
cli._record_output_history_entry("also hidden")
|
||||
|
||||
assert list(cli._OUTPUT_HISTORY) == []
|
||||
|
||||
|
||||
def test_clear_output_history_removes_replayable_lines():
|
||||
cli._configure_output_history(True, 10)
|
||||
cli._record_output_history("before clear")
|
||||
|
||||
cli._clear_output_history()
|
||||
|
||||
assert list(cli._OUTPUT_HISTORY) == []
|
||||
@@ -0,0 +1,117 @@
|
||||
"""Regression tests for issue #22379 — Ctrl+Enter newline over SSH/WSL.
|
||||
|
||||
prompt_toolkit treats c-j (LF) as Enter on POSIX so thin PTYs (docker exec,
|
||||
some BSD ssh) that send LF for plain Enter still work. But Windows Terminal
|
||||
(native, WSL, and SSH-forwarded sessions) sends Ctrl+Enter as bare LF — same
|
||||
byte. Without environment-aware gating, binding c-j to submit means
|
||||
Ctrl+Enter submits instead of inserting a newline.
|
||||
|
||||
These tests pin the gating predicate and the resulting binding behavior.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
from unittest.mock import patch
|
||||
|
||||
|
||||
def test_native_windows_preserves_newline():
|
||||
import cli as cli_mod
|
||||
with patch.object(sys, "platform", "win32"):
|
||||
assert cli_mod._preserve_ctrl_enter_newline() is True
|
||||
|
||||
|
||||
def test_ssh_session_preserves_newline_on_linux():
|
||||
import cli as cli_mod
|
||||
with patch.object(sys, "platform", "linux"):
|
||||
with patch.dict(os.environ, {"SSH_CONNECTION": "1.2.3.4 5 6.7.8.9 22"}, clear=False):
|
||||
assert cli_mod._preserve_ctrl_enter_newline() is True
|
||||
|
||||
|
||||
def test_ssh_tty_alone_preserves_newline():
|
||||
import cli as cli_mod
|
||||
with patch.object(sys, "platform", "linux"):
|
||||
# Strip out anything that might leak truth
|
||||
with patch.dict(os.environ, {"SSH_TTY": "/dev/pts/0"}, clear=True):
|
||||
assert cli_mod._preserve_ctrl_enter_newline() is True
|
||||
|
||||
|
||||
def test_wsl_distro_name_preserves_newline():
|
||||
import cli as cli_mod
|
||||
with patch.object(sys, "platform", "linux"):
|
||||
with patch.dict(os.environ, {"WSL_DISTRO_NAME": "Ubuntu-Microsoft"}, clear=True):
|
||||
assert cli_mod._preserve_ctrl_enter_newline() is True
|
||||
|
||||
|
||||
def test_windows_terminal_session_preserves_newline():
|
||||
import cli as cli_mod
|
||||
with patch.object(sys, "platform", "linux"):
|
||||
with patch.dict(os.environ, {"WT_SESSION": "abc-def"}, clear=True):
|
||||
assert cli_mod._preserve_ctrl_enter_newline() is True
|
||||
|
||||
|
||||
def test_ghostty_tmux_session_preserves_ctrl_j_newline():
|
||||
"""Ghostty-inherited env survives tmux even when TERM_PROGRAM becomes tmux."""
|
||||
import cli as cli_mod
|
||||
with patch.object(sys, "platform", "linux"):
|
||||
with patch.dict(
|
||||
os.environ,
|
||||
{"TERM": "tmux-256color", "TERM_PROGRAM": "tmux", "GHOSTTY_RESOURCES_DIR": "/usr/share/ghostty"},
|
||||
clear=True,
|
||||
):
|
||||
assert cli_mod._preserve_ctrl_enter_newline() is True
|
||||
|
||||
|
||||
def test_pure_local_linux_does_not_preserve():
|
||||
"""A bare local Linux TTY (no SSH/WSL/WT/Ghostty) keeps c-j → submit so docker exec
|
||||
style Enter-as-LF stays usable."""
|
||||
import cli as cli_mod
|
||||
# Stub out /proc reads — those are the WSL fallback signal.
|
||||
with patch.object(sys, "platform", "linux"):
|
||||
with patch.dict(os.environ, {}, clear=True):
|
||||
with patch("builtins.open", side_effect=OSError("no /proc")):
|
||||
assert cli_mod._preserve_ctrl_enter_newline() is False
|
||||
|
||||
|
||||
def test_proc_version_microsoft_marker_preserves_newline():
|
||||
"""WSL detection via /proc when env vars are scrubbed (sudo etc.)."""
|
||||
import cli as cli_mod
|
||||
from io import StringIO
|
||||
with patch.object(sys, "platform", "linux"):
|
||||
with patch.dict(os.environ, {}, clear=True):
|
||||
real_open = open
|
||||
def _fake_open(path, *args, **kwargs):
|
||||
if "/proc/version" in str(path) or "/proc/sys/kernel/osrelease" in str(path):
|
||||
return StringIO("Linux version 5.15.167.4-microsoft-standard-WSL2")
|
||||
return real_open(path, *args, **kwargs)
|
||||
with patch("builtins.open", side_effect=_fake_open):
|
||||
assert cli_mod._preserve_ctrl_enter_newline() is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# install_ctrl_enter_alias() — ANSI sequence mappings for enhanced terminals
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_install_ctrl_enter_alias_maps_csi_u_sequences():
|
||||
"""Kitty / xterm modifyOtherKeys / mintty Ctrl+Enter sequences alias to
|
||||
Alt+Enter (Escape, ControlM) so the existing newline handler fires."""
|
||||
from hermes_cli.pt_input_extras import install_ctrl_enter_alias
|
||||
from prompt_toolkit.input.ansi_escape_sequences import ANSI_SEQUENCES
|
||||
from prompt_toolkit.keys import Keys
|
||||
|
||||
install_ctrl_enter_alias()
|
||||
alt_enter = (Keys.Escape, Keys.ControlM)
|
||||
for seq in ("\x1b[13;5u", "\x1b[27;5;13~", "\x1b[27;5;13u"):
|
||||
assert ANSI_SEQUENCES.get(seq) == alt_enter, (
|
||||
f"Ctrl+Enter sequence {seq!r} not mapped to Alt+Enter tuple"
|
||||
)
|
||||
|
||||
|
||||
def test_install_ctrl_enter_alias_idempotent():
|
||||
"""Running it twice doesn't double-count or break."""
|
||||
from hermes_cli.pt_input_extras import install_ctrl_enter_alias
|
||||
install_ctrl_enter_alias()
|
||||
second = install_ctrl_enter_alias()
|
||||
assert second == 0 # no further changes after first install
|
||||
@@ -0,0 +1,99 @@
|
||||
"""Tests for CLI/TUI CWD resolution in load_cli_config().
|
||||
|
||||
Rules:
|
||||
- Local backend CLI/TUI: always os.getcwd(), ignoring config and inherited env.
|
||||
- Non-local with placeholder: pop cwd for backend default.
|
||||
- Non-local with explicit path: keep as-is.
|
||||
"""
|
||||
|
||||
|
||||
_CWD_PLACEHOLDERS = (".", "auto", "cwd")
|
||||
|
||||
|
||||
def _resolve_cwd(terminal_config: dict, defaults: dict, env: dict):
|
||||
"""Mirror the CWD resolution logic from cli.py load_cli_config()."""
|
||||
effective_backend = terminal_config.get("env_type", "local")
|
||||
|
||||
if effective_backend == "local":
|
||||
terminal_config["cwd"] = "/fake/getcwd"
|
||||
defaults["terminal"]["cwd"] = terminal_config["cwd"]
|
||||
elif terminal_config.get("cwd") in _CWD_PLACEHOLDERS:
|
||||
terminal_config.pop("cwd", None)
|
||||
|
||||
# Bridge: TERMINAL_CWD always exported in CLI, skipped in gateway
|
||||
_is_gateway = env.get("_HERMES_GATEWAY") == "1"
|
||||
if "cwd" in terminal_config:
|
||||
if _is_gateway:
|
||||
pass # don't touch env
|
||||
else:
|
||||
env["TERMINAL_CWD"] = str(terminal_config["cwd"])
|
||||
|
||||
return env.get("TERMINAL_CWD", "")
|
||||
|
||||
|
||||
class TestLocalBackendCli:
|
||||
"""Local backend always uses os.getcwd()."""
|
||||
|
||||
def test_explicit_config_ignored(self):
|
||||
env = {}
|
||||
tc = {"cwd": "/explicit/path", "env_type": "local"}
|
||||
d = {"terminal": {"cwd": "/explicit/path"}}
|
||||
assert _resolve_cwd(tc, d, env) == "/fake/getcwd"
|
||||
|
||||
def test_inherited_env_overwritten(self):
|
||||
env = {"TERMINAL_CWD": "/parent/hermes"}
|
||||
tc = {"cwd": "/home/user", "env_type": "local"}
|
||||
d = {"terminal": {"cwd": "/home/user"}}
|
||||
assert _resolve_cwd(tc, d, env) == "/fake/getcwd"
|
||||
|
||||
def test_placeholder_resolved(self):
|
||||
env = {}
|
||||
tc = {"cwd": "."}
|
||||
d = {"terminal": {"cwd": "."}}
|
||||
assert _resolve_cwd(tc, d, env) == "/fake/getcwd"
|
||||
|
||||
def test_env_and_no_config_file(self):
|
||||
env = {"TERMINAL_CWD": "/stale/value"}
|
||||
tc = {"cwd": ".", "env_type": "local"}
|
||||
d = {"terminal": {"cwd": "."}}
|
||||
assert _resolve_cwd(tc, d, env) == "/fake/getcwd"
|
||||
|
||||
|
||||
class TestNonLocalBackends:
|
||||
"""Non-local backends use config or per-backend defaults."""
|
||||
|
||||
def test_placeholder_popped(self):
|
||||
env = {}
|
||||
tc = {"cwd": ".", "env_type": "docker"}
|
||||
d = {"terminal": {"cwd": "."}}
|
||||
assert _resolve_cwd(tc, d, env) == ""
|
||||
|
||||
def test_explicit_path_kept(self):
|
||||
env = {}
|
||||
tc = {"cwd": "/srv/app", "env_type": "ssh"}
|
||||
d = {"terminal": {"cwd": "/srv/app"}}
|
||||
assert _resolve_cwd(tc, d, env) == "/srv/app"
|
||||
|
||||
def test_auto_placeholder_popped(self):
|
||||
env = {}
|
||||
tc = {"cwd": "auto", "env_type": "modal"}
|
||||
d = {"terminal": {"cwd": "auto"}}
|
||||
assert _resolve_cwd(tc, d, env) == ""
|
||||
|
||||
|
||||
class TestGatewayLazyImport:
|
||||
"""Gateway lazy import of cli.py must not clobber TERMINAL_CWD."""
|
||||
|
||||
def test_gateway_cwd_preserved(self):
|
||||
env = {"_HERMES_GATEWAY": "1", "TERMINAL_CWD": "/home/user/project"}
|
||||
tc = {"cwd": "/home/user", "env_type": "local"}
|
||||
d = {"terminal": {"cwd": "/home/user"}}
|
||||
result = _resolve_cwd(tc, d, env)
|
||||
assert result == "/home/user/project"
|
||||
|
||||
def test_cli_overwrites_stale_env(self):
|
||||
env = {"TERMINAL_CWD": "/stale/from/dotenv"}
|
||||
tc = {"cwd": "/home/user", "env_type": "local"}
|
||||
d = {"terminal": {"cwd": "/home/user"}}
|
||||
result = _resolve_cwd(tc, d, env)
|
||||
assert result == "/fake/getcwd"
|
||||
@@ -0,0 +1,331 @@
|
||||
"""Tests for cli.HermesCLI._confirm_destructive_slash.
|
||||
|
||||
Drives the helper directly via __get__ on a SimpleNamespace stand-in so we
|
||||
don't have to construct a full HermesCLI (which requires extensive setup).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import queue
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
|
||||
def _bound(fn, instance):
|
||||
"""Bind an unbound method to a stand-in instance."""
|
||||
return fn.__get__(instance, type(instance))
|
||||
|
||||
|
||||
def _make_self(prompt_response):
|
||||
"""Build a minimal stand-in 'self' for _confirm_destructive_slash."""
|
||||
from cli import HermesCLI
|
||||
|
||||
self_ = SimpleNamespace(
|
||||
_app=None,
|
||||
_prompt_text_input=lambda _prompt: prompt_response,
|
||||
_prompt_text_input_modal=lambda **_kw: prompt_response,
|
||||
)
|
||||
self_._normalize_slash_confirm_choice = _bound(
|
||||
HermesCLI._normalize_slash_confirm_choice, self_,
|
||||
)
|
||||
return self_
|
||||
|
||||
|
||||
def test_gate_off_returns_once_without_prompting():
|
||||
"""When approvals.destructive_slash_confirm is False, return 'once'
|
||||
immediately (caller proceeds without showing a prompt)."""
|
||||
from cli import HermesCLI
|
||||
|
||||
self_ = _make_self(prompt_response="should not be called")
|
||||
|
||||
with patch(
|
||||
"cli.load_cli_config",
|
||||
return_value={"approvals": {"destructive_slash_confirm": False}},
|
||||
):
|
||||
result = _bound(HermesCLI._confirm_destructive_slash, self_)(
|
||||
"clear", "detail",
|
||||
)
|
||||
|
||||
assert result == "once"
|
||||
|
||||
|
||||
def test_gate_on_choice_once_returns_once():
|
||||
"""When the gate is on and the user picks '1', return 'once'."""
|
||||
from cli import HermesCLI
|
||||
|
||||
self_ = _make_self(prompt_response="1")
|
||||
|
||||
with patch(
|
||||
"cli.load_cli_config",
|
||||
return_value={"approvals": {"destructive_slash_confirm": True}},
|
||||
):
|
||||
result = _bound(HermesCLI._confirm_destructive_slash, self_)(
|
||||
"clear", "detail",
|
||||
)
|
||||
|
||||
assert result == "once"
|
||||
|
||||
|
||||
def test_gate_on_choice_cancel_returns_none():
|
||||
"""When the user picks '3' (cancel), return None — caller must abort."""
|
||||
from cli import HermesCLI
|
||||
|
||||
self_ = _make_self(prompt_response="3")
|
||||
|
||||
with patch(
|
||||
"cli.load_cli_config",
|
||||
return_value={"approvals": {"destructive_slash_confirm": True}},
|
||||
):
|
||||
result = _bound(HermesCLI._confirm_destructive_slash, self_)(
|
||||
"clear", "detail",
|
||||
)
|
||||
|
||||
assert result is None
|
||||
|
||||
|
||||
def test_gate_on_no_input_returns_none():
|
||||
"""No input (None / EOF / Ctrl-C) treated as cancel."""
|
||||
from cli import HermesCLI
|
||||
|
||||
self_ = _make_self(prompt_response=None)
|
||||
|
||||
with patch(
|
||||
"cli.load_cli_config",
|
||||
return_value={"approvals": {"destructive_slash_confirm": True}},
|
||||
):
|
||||
result = _bound(HermesCLI._confirm_destructive_slash, self_)(
|
||||
"clear", "detail",
|
||||
)
|
||||
|
||||
assert result is None
|
||||
|
||||
|
||||
def test_gate_on_unknown_choice_returns_none():
|
||||
"""Garbage input is treated as cancel — fail safe, don't destroy state."""
|
||||
from cli import HermesCLI
|
||||
|
||||
self_ = _make_self(prompt_response="maybe")
|
||||
|
||||
with patch(
|
||||
"cli.load_cli_config",
|
||||
return_value={"approvals": {"destructive_slash_confirm": True}},
|
||||
):
|
||||
result = _bound(HermesCLI._confirm_destructive_slash, self_)(
|
||||
"clear", "detail",
|
||||
)
|
||||
|
||||
assert result is None
|
||||
|
||||
|
||||
def test_gate_on_choice_always_persists_and_returns_always():
|
||||
"""User picks 'always' → returns 'always' AND
|
||||
save_config_value('approvals.destructive_slash_confirm', False) was called."""
|
||||
from cli import HermesCLI
|
||||
|
||||
self_ = _make_self(prompt_response="2")
|
||||
|
||||
saves = []
|
||||
def _fake_save(key, value):
|
||||
saves.append((key, value))
|
||||
return True
|
||||
|
||||
with patch(
|
||||
"cli.load_cli_config",
|
||||
return_value={"approvals": {"destructive_slash_confirm": True}},
|
||||
), patch("cli.save_config_value", _fake_save):
|
||||
result = _bound(HermesCLI._confirm_destructive_slash, self_)(
|
||||
"clear", "detail",
|
||||
)
|
||||
|
||||
assert result == "always"
|
||||
assert ("approvals.destructive_slash_confirm", False) in saves
|
||||
|
||||
|
||||
def test_gate_default_true_when_config_missing():
|
||||
"""If load_cli_config raises or returns malformed data, treat as
|
||||
'gate on' (default safe) — must prompt."""
|
||||
from cli import HermesCLI
|
||||
|
||||
self_ = _make_self(prompt_response="3") # cancel
|
||||
|
||||
with patch("cli.load_cli_config", side_effect=Exception("boom")):
|
||||
result = _bound(HermesCLI._confirm_destructive_slash, self_)(
|
||||
"clear", "detail",
|
||||
)
|
||||
|
||||
# Got prompted (returned None from cancel) — meaning the gate was
|
||||
# treated as on despite the config error. If the gate had been off
|
||||
# this would have returned 'once' without consulting the prompt.
|
||||
assert result is None
|
||||
|
||||
|
||||
def test_slash_confirm_modal_number_selection_submits_without_raw_input():
|
||||
"""Pressing 2 in the TUI modal should resolve to Always Approve directly."""
|
||||
from cli import HermesCLI
|
||||
|
||||
q = queue.Queue()
|
||||
self_ = SimpleNamespace(
|
||||
_slash_confirm_state={
|
||||
"choices": [
|
||||
("once", "Approve Once", "proceed once"),
|
||||
("always", "Always Approve", "persist opt-out"),
|
||||
("cancel", "Cancel", "abort"),
|
||||
],
|
||||
"selected": 0,
|
||||
"response_queue": q,
|
||||
},
|
||||
_slash_confirm_deadline=123,
|
||||
_invalidate=lambda: None,
|
||||
)
|
||||
|
||||
_bound(HermesCLI._submit_slash_confirm_response, self_)("always")
|
||||
|
||||
assert q.get_nowait() == "always"
|
||||
assert self_._slash_confirm_state is None
|
||||
assert self_._slash_confirm_deadline == 0
|
||||
|
||||
|
||||
def test_slash_confirm_display_fragments_include_choice_mapping():
|
||||
"""The modal itself must show what 1/2/3 mean, not only 'Choice [1/2/3]'."""
|
||||
from cli import HermesCLI
|
||||
|
||||
self_ = SimpleNamespace(
|
||||
_slash_confirm_state={
|
||||
"title": "⚠️ /new — destroys conversation state",
|
||||
"detail": "This starts a fresh session.",
|
||||
"choices": [
|
||||
("once", "Approve Once", "proceed once"),
|
||||
("always", "Always Approve", "persist opt-out"),
|
||||
("cancel", "Cancel", "abort"),
|
||||
],
|
||||
"selected": 1,
|
||||
},
|
||||
)
|
||||
|
||||
fragments = _bound(HermesCLI._get_slash_confirm_display_fragments, self_)()
|
||||
rendered = "".join(fragment for _style, fragment in fragments)
|
||||
|
||||
assert "[1] Approve Once" in rendered
|
||||
assert "[2] Always Approve" in rendered
|
||||
assert "[3] Cancel" in rendered
|
||||
assert "Type 1/2/3" in rendered
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Inline-skip escape hatch (issue #30768)
|
||||
#
|
||||
# Users on platforms where the prompt_toolkit modal doesn't dispatch keys
|
||||
# (currently native Windows PowerShell) need a way to bypass the confirmation
|
||||
# without flipping the config gate. ``/reset now``, ``/new --yes``, ``/clear
|
||||
# -y`` all skip the modal and return "once" immediately.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_split_destructive_skip_recognized_tokens():
|
||||
"""``now``, ``--yes``, and ``-y`` are recognized as skip tokens."""
|
||||
from cli import HermesCLI
|
||||
|
||||
assert HermesCLI._split_destructive_skip("/reset now") == ("", True)
|
||||
assert HermesCLI._split_destructive_skip("/clear --yes") == ("", True)
|
||||
assert HermesCLI._split_destructive_skip("/undo -y") == ("", True)
|
||||
|
||||
|
||||
def test_split_destructive_skip_strips_command_word():
|
||||
"""Leading ``/cmd`` token is stripped; remaining args survive."""
|
||||
from cli import HermesCLI
|
||||
|
||||
assert HermesCLI._split_destructive_skip("/new My title") == ("My title", False)
|
||||
assert HermesCLI._split_destructive_skip("/new --yes My title") == ("My title", True)
|
||||
|
||||
|
||||
def test_split_destructive_skip_case_insensitive():
|
||||
"""Token matching is case-insensitive but not a substring match."""
|
||||
from cli import HermesCLI
|
||||
|
||||
assert HermesCLI._split_destructive_skip("/new NOW") == ("", True)
|
||||
# Substring match must NOT trigger — "Now-Title" is a literal title token.
|
||||
assert HermesCLI._split_destructive_skip("/new Now-Title") == ("Now-Title", False)
|
||||
|
||||
|
||||
def test_split_destructive_skip_handles_empty_and_none():
|
||||
"""Defensive against missing/empty input."""
|
||||
from cli import HermesCLI
|
||||
|
||||
assert HermesCLI._split_destructive_skip(None) == ("", False)
|
||||
assert HermesCLI._split_destructive_skip("") == ("", False)
|
||||
assert HermesCLI._split_destructive_skip(" ") == ("", False)
|
||||
|
||||
|
||||
def test_confirm_destructive_slash_now_skips_modal():
|
||||
"""``/reset now`` skips the modal even when the gate is on."""
|
||||
from cli import HermesCLI
|
||||
|
||||
# Build a prompt stub that fails the test if invoked — proving the modal
|
||||
# was never reached.
|
||||
def _explode(**_kw):
|
||||
raise AssertionError("modal must not be invoked when inline-skip present")
|
||||
|
||||
self_ = SimpleNamespace(
|
||||
_app=None,
|
||||
_prompt_text_input_modal=_explode,
|
||||
)
|
||||
self_._normalize_slash_confirm_choice = _bound(
|
||||
HermesCLI._normalize_slash_confirm_choice, self_,
|
||||
)
|
||||
self_._split_destructive_skip = HermesCLI._split_destructive_skip # classmethod
|
||||
|
||||
with patch(
|
||||
"cli.load_cli_config",
|
||||
return_value={"approvals": {"destructive_slash_confirm": True}},
|
||||
):
|
||||
result = _bound(HermesCLI._confirm_destructive_slash, self_)(
|
||||
"new", "detail", cmd_original="/reset now",
|
||||
)
|
||||
|
||||
assert result == "once"
|
||||
|
||||
|
||||
def test_confirm_destructive_slash_yes_flag_skips_modal():
|
||||
"""``--yes`` flag is equivalent to ``now``."""
|
||||
from cli import HermesCLI
|
||||
|
||||
def _explode(**_kw):
|
||||
raise AssertionError("modal must not be invoked when --yes present")
|
||||
|
||||
self_ = SimpleNamespace(
|
||||
_app=None,
|
||||
_prompt_text_input_modal=_explode,
|
||||
)
|
||||
self_._normalize_slash_confirm_choice = _bound(
|
||||
HermesCLI._normalize_slash_confirm_choice, self_,
|
||||
)
|
||||
self_._split_destructive_skip = HermesCLI._split_destructive_skip
|
||||
|
||||
with patch(
|
||||
"cli.load_cli_config",
|
||||
return_value={"approvals": {"destructive_slash_confirm": True}},
|
||||
):
|
||||
result = _bound(HermesCLI._confirm_destructive_slash, self_)(
|
||||
"new", "detail", cmd_original="/new --yes My Session",
|
||||
)
|
||||
|
||||
assert result == "once"
|
||||
|
||||
|
||||
def test_confirm_destructive_slash_no_skip_token_still_prompts():
|
||||
"""Without a skip token the gate-on path still consults the modal."""
|
||||
from cli import HermesCLI
|
||||
|
||||
self_ = _make_self(prompt_response="3") # cancel
|
||||
self_._split_destructive_skip = HermesCLI._split_destructive_skip
|
||||
|
||||
with patch(
|
||||
"cli.load_cli_config",
|
||||
return_value={"approvals": {"destructive_slash_confirm": True}},
|
||||
):
|
||||
result = _bound(HermesCLI._confirm_destructive_slash, self_)(
|
||||
"new", "detail", cmd_original="/new My Session",
|
||||
)
|
||||
|
||||
# Prompt was reached and returned cancel → None.
|
||||
assert result is None
|
||||
@@ -0,0 +1,129 @@
|
||||
"""End-to-end integration test for the destructive-slash inline-skip path.
|
||||
|
||||
Drives ``HermesCLI.process_command("/reset now")`` against a minimal stand-in
|
||||
and verifies:
|
||||
|
||||
1. ``new_session`` was invoked (the command actually ran)
|
||||
2. ``_prompt_text_input_modal`` was NOT invoked (modal bypassed)
|
||||
3. The skip token did not leak into the session title
|
||||
|
||||
This is the regression test for issue #30768 — the inline-skip escape hatch
|
||||
must work without ever touching the modal, on every platform.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
|
||||
def _make_cli_stub():
|
||||
"""Build a minimal HermesCLI-shaped object that can run ``process_command``
|
||||
for the destructive-slash branches without spinning up a real TUI."""
|
||||
from cli import HermesCLI
|
||||
|
||||
new_session_calls = []
|
||||
|
||||
def _capture_new_session(self_, title=None, silent=False):
|
||||
new_session_calls.append({"title": title, "silent": silent})
|
||||
|
||||
self_ = SimpleNamespace(
|
||||
_app=None,
|
||||
_prompt_text_input_modal=lambda **_kw: (_ for _ in ()).throw(
|
||||
AssertionError("modal must not be invoked when inline-skip token present")
|
||||
),
|
||||
new_session=lambda **kw: _capture_new_session(self_, **kw),
|
||||
# Stub out side-effects the destructive-slash branches reach for.
|
||||
console=SimpleNamespace(clear=lambda: None),
|
||||
compact=False,
|
||||
model="stub-model",
|
||||
session_id="stub-session",
|
||||
enabled_toolsets=[],
|
||||
_pending_title=None,
|
||||
_session_db=None,
|
||||
)
|
||||
# Bind the methods we need under test.
|
||||
self_._split_destructive_skip = HermesCLI._split_destructive_skip
|
||||
self_._confirm_destructive_slash = HermesCLI._confirm_destructive_slash.__get__(
|
||||
self_, type(self_)
|
||||
)
|
||||
self_.process_command = HermesCLI.process_command.__get__(self_, type(self_))
|
||||
return self_, new_session_calls
|
||||
|
||||
|
||||
def test_reset_now_invokes_new_session_without_modal():
|
||||
"""``/reset now`` runs ``new_session`` and never touches the modal."""
|
||||
self_, calls = _make_cli_stub()
|
||||
|
||||
with patch(
|
||||
"cli.load_cli_config",
|
||||
return_value={"approvals": {"destructive_slash_confirm": True}},
|
||||
):
|
||||
self_.process_command("/reset now")
|
||||
|
||||
assert calls, "new_session was never invoked"
|
||||
# The /new branch passes title=None when there's no non-skip remainder.
|
||||
assert calls[0]["title"] is None
|
||||
|
||||
|
||||
def test_new_yes_with_title_preserves_title():
|
||||
"""``/new --yes My Session`` runs ``new_session(title='My Session')``."""
|
||||
self_, calls = _make_cli_stub()
|
||||
|
||||
with patch(
|
||||
"cli.load_cli_config",
|
||||
return_value={"approvals": {"destructive_slash_confirm": True}},
|
||||
):
|
||||
self_.process_command("/new --yes My Session")
|
||||
|
||||
assert calls, "new_session was never invoked"
|
||||
assert calls[0]["title"] == "My Session"
|
||||
|
||||
|
||||
def test_new_without_skip_token_still_consults_modal():
|
||||
"""``/new My Session`` (no skip token) must reach the modal.
|
||||
|
||||
Sanity check that we haven't accidentally short-circuited the normal path.
|
||||
"""
|
||||
from cli import HermesCLI
|
||||
|
||||
new_session_calls = []
|
||||
modal_calls = []
|
||||
|
||||
def _capture_new_session(self_, title=None, silent=False):
|
||||
new_session_calls.append({"title": title, "silent": silent})
|
||||
|
||||
def _record_modal(**kw):
|
||||
modal_calls.append(kw)
|
||||
# Simulate user cancelling so new_session is not called.
|
||||
return "3"
|
||||
|
||||
self_ = SimpleNamespace(
|
||||
_app=None,
|
||||
_prompt_text_input_modal=_record_modal,
|
||||
new_session=lambda **kw: _capture_new_session(self_, **kw),
|
||||
console=SimpleNamespace(clear=lambda: None),
|
||||
compact=False,
|
||||
model="stub-model",
|
||||
session_id="stub-session",
|
||||
enabled_toolsets=[],
|
||||
_pending_title=None,
|
||||
_session_db=None,
|
||||
)
|
||||
self_._split_destructive_skip = HermesCLI._split_destructive_skip
|
||||
self_._normalize_slash_confirm_choice = HermesCLI._normalize_slash_confirm_choice.__get__(
|
||||
self_, type(self_)
|
||||
)
|
||||
self_._confirm_destructive_slash = HermesCLI._confirm_destructive_slash.__get__(
|
||||
self_, type(self_)
|
||||
)
|
||||
self_.process_command = HermesCLI.process_command.__get__(self_, type(self_))
|
||||
|
||||
with patch(
|
||||
"cli.load_cli_config",
|
||||
return_value={"approvals": {"destructive_slash_confirm": True}},
|
||||
):
|
||||
self_.process_command("/new My Session")
|
||||
|
||||
assert modal_calls, "modal must be reached when no skip token is present"
|
||||
assert not new_session_calls, "user cancelled — new_session must not run"
|
||||
@@ -0,0 +1,119 @@
|
||||
"""Tests for `/exit --delete` and `/quit --delete` session deletion.
|
||||
|
||||
Ports the behavior from google-gemini/gemini-cli#19332: running `/exit` or
|
||||
`/quit` with the `--delete` flag arms a one-shot `_delete_session_on_exit`
|
||||
flag that the CLI shutdown path uses to remove the current session from
|
||||
SQLite + on-disk transcripts before exit.
|
||||
"""
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
|
||||
def _make_cli():
|
||||
"""Bare HermesCLI suitable for process_command() tests.
|
||||
|
||||
Uses ``__new__`` to skip the heavy __init__; only sets the attributes
|
||||
the /exit branch touches.
|
||||
"""
|
||||
from cli import HermesCLI
|
||||
cli = HermesCLI.__new__(HermesCLI)
|
||||
cli.config = {}
|
||||
cli.console = MagicMock()
|
||||
cli.agent = None
|
||||
cli.conversation_history = []
|
||||
cli.session_id = "test-session"
|
||||
cli._delete_session_on_exit = False
|
||||
return cli
|
||||
|
||||
|
||||
class TestExitDeleteFlag:
|
||||
def test_plain_exit_does_not_arm_delete(self):
|
||||
cli = _make_cli()
|
||||
result = cli.process_command("/exit")
|
||||
assert result is False
|
||||
assert cli._delete_session_on_exit is False
|
||||
|
||||
def test_plain_quit_does_not_arm_delete(self):
|
||||
cli = _make_cli()
|
||||
result = cli.process_command("/quit")
|
||||
assert result is False
|
||||
assert cli._delete_session_on_exit is False
|
||||
|
||||
def test_exit_delete_arms_flag(self):
|
||||
cli = _make_cli()
|
||||
result = cli.process_command("/exit --delete")
|
||||
assert result is False
|
||||
assert cli._delete_session_on_exit is True
|
||||
|
||||
def test_quit_delete_arms_flag(self):
|
||||
cli = _make_cli()
|
||||
result = cli.process_command("/quit --delete")
|
||||
assert result is False
|
||||
assert cli._delete_session_on_exit is True
|
||||
|
||||
def test_exit_delete_short_form(self):
|
||||
"""`-d` is a convenience alias for `--delete`."""
|
||||
cli = _make_cli()
|
||||
result = cli.process_command("/exit -d")
|
||||
assert result is False
|
||||
assert cli._delete_session_on_exit is True
|
||||
|
||||
def test_quit_alias_q_is_not_quit(self):
|
||||
"""`/q` is the alias for `/queue`, not `/quit`. This test documents
|
||||
that /q --delete does NOT arm session deletion — it would dispatch
|
||||
to /queue instead."""
|
||||
cli = _make_cli()
|
||||
cli._pending_input = __import__("queue").Queue()
|
||||
# /q with no args shows a usage error and keeps the CLI running.
|
||||
result = cli.process_command("/q")
|
||||
assert result is not False # queue command doesn't exit
|
||||
assert cli._delete_session_on_exit is False
|
||||
|
||||
def test_delete_flag_is_case_insensitive(self):
|
||||
cli = _make_cli()
|
||||
result = cli.process_command("/exit --DELETE")
|
||||
assert result is False
|
||||
assert cli._delete_session_on_exit is True
|
||||
|
||||
def test_delete_flag_trims_whitespace(self):
|
||||
cli = _make_cli()
|
||||
result = cli.process_command("/exit --delete ")
|
||||
assert result is False
|
||||
assert cli._delete_session_on_exit is True
|
||||
|
||||
def test_unknown_exit_argument_does_not_exit(self):
|
||||
"""Unrecognised args should NOT exit the CLI — they surface an
|
||||
error message and stay in the session. This prevents accidental
|
||||
session destruction from typos like `/exit -delete`."""
|
||||
cli = _make_cli()
|
||||
result = cli.process_command("/exit --delte")
|
||||
# process_command returns True = keep running
|
||||
assert result is True
|
||||
assert cli._delete_session_on_exit is False
|
||||
|
||||
def test_unknown_exit_argument_prints_help(self):
|
||||
cli = _make_cli()
|
||||
# _cprint goes through module-level print, so capture via console.
|
||||
# We can't patch _cprint directly without import juggling; the
|
||||
# previous assertion already proves the unknown-arg branch is
|
||||
# reached (result True + flag False).
|
||||
result = cli.process_command("/exit garbage")
|
||||
assert result is True
|
||||
assert cli._delete_session_on_exit is False
|
||||
|
||||
|
||||
class TestCommandRegistry:
|
||||
def test_quit_command_advertises_delete_flag(self):
|
||||
"""The CommandDef args_hint should surface `--delete` in /help and
|
||||
CLI autocomplete."""
|
||||
from hermes_cli.commands import resolve_command
|
||||
cmd = resolve_command("quit")
|
||||
assert cmd is not None
|
||||
assert cmd.args_hint == "[--delete]"
|
||||
|
||||
def test_exit_alias_resolves_to_quit_with_hint(self):
|
||||
from hermes_cli.commands import resolve_command
|
||||
cmd = resolve_command("exit")
|
||||
assert cmd is not None
|
||||
assert cmd.name == "quit"
|
||||
assert cmd.args_hint == "[--delete]"
|
||||
@@ -0,0 +1,83 @@
|
||||
"""Tests for the CLI exit summary's resume hint, including profile-flag support."""
|
||||
|
||||
from datetime import datetime
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from cli import HermesCLI
|
||||
|
||||
|
||||
def _make_cli(session_id="20260524_000001_abc123"):
|
||||
cli_obj = HermesCLI.__new__(HermesCLI)
|
||||
cli_obj.session_id = session_id
|
||||
# _print_exit_summary requires a populated conversation history (msg_count > 0)
|
||||
# to print the resume hint at all. One synthetic user turn is enough.
|
||||
cli_obj.conversation_history = [{"role": "user", "content": "hi"}]
|
||||
cli_obj.agent = None
|
||||
cli_obj._session_db = None
|
||||
cli_obj.session_start = datetime.now()
|
||||
return cli_obj
|
||||
|
||||
|
||||
class TestExitSummaryResumeHint:
|
||||
"""The exit-line ``Resume this session with:`` hint must include the
|
||||
active profile (`-p <name>`) so session IDs round-trip across
|
||||
profile boundaries — sessions live under `~/.hermes-profiles/<profile>/`,
|
||||
so a hint copied without `-p` from a non-default profile won't find
|
||||
the session.
|
||||
"""
|
||||
|
||||
def test_resume_hint_no_profile_flag_on_default(self, capsys):
|
||||
cli_obj = _make_cli()
|
||||
with patch("hermes_cli.profiles.get_active_profile_name", return_value="default"):
|
||||
cli_obj._print_exit_summary()
|
||||
out = capsys.readouterr().out
|
||||
# No `-p` for the default profile.
|
||||
assert "hermes --resume 20260524_000001_abc123" in out
|
||||
assert " -p " not in out
|
||||
|
||||
def test_resume_hint_no_profile_flag_on_custom(self, capsys):
|
||||
cli_obj = _make_cli()
|
||||
with patch("hermes_cli.profiles.get_active_profile_name", return_value="custom"):
|
||||
cli_obj._print_exit_summary()
|
||||
out = capsys.readouterr().out
|
||||
# "custom" is the standard HERMES_HOME indicator — no -p needed.
|
||||
assert "hermes --resume 20260524_000001_abc123" in out
|
||||
assert " -p " not in out
|
||||
|
||||
def test_resume_hint_includes_profile_flag_for_named_profile(self, capsys):
|
||||
cli_obj = _make_cli()
|
||||
with patch("hermes_cli.profiles.get_active_profile_name", return_value="dev"):
|
||||
cli_obj._print_exit_summary()
|
||||
out = capsys.readouterr().out
|
||||
assert "hermes --resume 20260524_000001_abc123 -p dev" in out
|
||||
|
||||
def test_resume_hint_includes_profile_flag_on_title_hint_too(self, capsys, tmp_path):
|
||||
"""When a session title is available, the `hermes -c "title"` hint
|
||||
must also include the `-p` flag for non-default profiles.
|
||||
"""
|
||||
cli_obj = _make_cli()
|
||||
fake_db = MagicMock()
|
||||
fake_db.get_session_title.return_value = "My Cool Session"
|
||||
cli_obj._session_db = fake_db
|
||||
|
||||
with patch("hermes_cli.profiles.get_active_profile_name", return_value="dev"):
|
||||
cli_obj._print_exit_summary()
|
||||
out = capsys.readouterr().out
|
||||
assert 'hermes -c "My Cool Session" -p dev' in out
|
||||
assert "hermes --resume 20260524_000001_abc123 -p dev" in out
|
||||
|
||||
def test_resume_hint_falls_back_when_profile_lookup_fails(self, capsys):
|
||||
"""If `get_active_profile_name` raises (e.g. profiles module
|
||||
missing during ``hermes update`` mid-flight), fall back to no
|
||||
flag rather than crashing the exit summary.
|
||||
"""
|
||||
cli_obj = _make_cli()
|
||||
with patch(
|
||||
"hermes_cli.profiles.get_active_profile_name",
|
||||
side_effect=RuntimeError("profiles unavailable"),
|
||||
):
|
||||
cli_obj._print_exit_summary()
|
||||
out = capsys.readouterr().out
|
||||
# Resume hint still printed without -p.
|
||||
assert "hermes --resume 20260524_000001_abc123" in out
|
||||
assert " -p " not in out
|
||||
@@ -0,0 +1,485 @@
|
||||
"""Tests for the /fast CLI command and service-tier config handling."""
|
||||
|
||||
import unittest
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
|
||||
def _import_cli():
|
||||
import hermes_cli.config as config_mod
|
||||
|
||||
if not hasattr(config_mod, "save_env_value_secure"):
|
||||
config_mod.save_env_value_secure = lambda key, value: {
|
||||
"success": True,
|
||||
"stored_as": key,
|
||||
"validated": False,
|
||||
}
|
||||
|
||||
import cli as cli_mod
|
||||
|
||||
return cli_mod
|
||||
|
||||
|
||||
class TestParseServiceTierConfig(unittest.TestCase):
|
||||
def _parse(self, raw):
|
||||
cli_mod = _import_cli()
|
||||
return cli_mod._parse_service_tier_config(raw)
|
||||
|
||||
def test_fast_maps_to_priority(self):
|
||||
self.assertEqual(self._parse("fast"), "priority")
|
||||
self.assertEqual(self._parse("priority"), "priority")
|
||||
|
||||
def test_normal_disables_service_tier(self):
|
||||
self.assertIsNone(self._parse("normal"))
|
||||
self.assertIsNone(self._parse("off"))
|
||||
self.assertIsNone(self._parse(""))
|
||||
|
||||
|
||||
class TestHandleFastCommand(unittest.TestCase):
|
||||
def _make_cli(self, service_tier=None):
|
||||
return SimpleNamespace(
|
||||
service_tier=service_tier,
|
||||
provider="openai-codex",
|
||||
requested_provider="openai-codex",
|
||||
model="gpt-5.4",
|
||||
_fast_command_available=lambda: True,
|
||||
agent=MagicMock(),
|
||||
)
|
||||
|
||||
def test_no_args_shows_status(self):
|
||||
cli_mod = _import_cli()
|
||||
stub = self._make_cli(service_tier=None)
|
||||
with (
|
||||
patch.object(cli_mod, "_cprint") as mock_cprint,
|
||||
patch.object(cli_mod, "save_config_value") as mock_save,
|
||||
):
|
||||
cli_mod.HermesCLI._handle_fast_command(stub, "/fast")
|
||||
|
||||
# Bare /fast shows status, does not change config
|
||||
mock_save.assert_not_called()
|
||||
# Should have printed the status line
|
||||
printed = " ".join(str(c) for c in mock_cprint.call_args_list)
|
||||
self.assertIn("normal", printed)
|
||||
|
||||
def test_no_args_shows_fast_when_enabled(self):
|
||||
cli_mod = _import_cli()
|
||||
stub = self._make_cli(service_tier="priority")
|
||||
with (
|
||||
patch.object(cli_mod, "_cprint") as mock_cprint,
|
||||
patch.object(cli_mod, "save_config_value") as mock_save,
|
||||
):
|
||||
cli_mod.HermesCLI._handle_fast_command(stub, "/fast")
|
||||
|
||||
mock_save.assert_not_called()
|
||||
printed = " ".join(str(c) for c in mock_cprint.call_args_list)
|
||||
self.assertIn("fast", printed)
|
||||
|
||||
def test_normal_argument_clears_service_tier(self):
|
||||
cli_mod = _import_cli()
|
||||
stub = self._make_cli(service_tier="priority")
|
||||
with (
|
||||
patch.object(cli_mod, "_cprint"),
|
||||
patch.object(cli_mod, "save_config_value", return_value=True) as mock_save,
|
||||
):
|
||||
cli_mod.HermesCLI._handle_fast_command(stub, "/fast normal")
|
||||
|
||||
mock_save.assert_called_once_with("agent.service_tier", "normal")
|
||||
self.assertIsNone(stub.service_tier)
|
||||
self.assertIsNone(stub.agent)
|
||||
|
||||
def test_unsupported_model_does_not_expose_fast(self):
|
||||
cli_mod = _import_cli()
|
||||
stub = SimpleNamespace(
|
||||
service_tier=None,
|
||||
provider="openai-codex",
|
||||
requested_provider="openai-codex",
|
||||
model="gpt-5.3-codex",
|
||||
_fast_command_available=lambda: False,
|
||||
agent=MagicMock(),
|
||||
)
|
||||
|
||||
with (
|
||||
patch.object(cli_mod, "_cprint") as mock_cprint,
|
||||
patch.object(cli_mod, "save_config_value") as mock_save,
|
||||
):
|
||||
cli_mod.HermesCLI._handle_fast_command(stub, "/fast")
|
||||
|
||||
mock_save.assert_not_called()
|
||||
self.assertTrue(mock_cprint.called)
|
||||
|
||||
|
||||
class TestPriorityProcessingModels(unittest.TestCase):
|
||||
"""Verify the expanded Priority Processing model registry."""
|
||||
|
||||
def test_all_documented_models_supported(self):
|
||||
from hermes_cli.models import model_supports_fast_mode
|
||||
|
||||
# All OpenAI flagship models support Priority Processing — including
|
||||
# future releases (gpt-5.5, 5.6...) via pattern matching.
|
||||
supported = [
|
||||
"gpt-5.5", "gpt-5.5-mini",
|
||||
"gpt-5.4", "gpt-5.4-mini", "gpt-5.2",
|
||||
"gpt-5.1", "gpt-5", "gpt-5-mini",
|
||||
"gpt-4.1", "gpt-4.1-mini", "gpt-4.1-nano",
|
||||
"gpt-4o", "gpt-4o-mini",
|
||||
"o1", "o1-mini", "o3", "o3-mini", "o4-mini",
|
||||
]
|
||||
for model in supported:
|
||||
assert model_supports_fast_mode(model), f"{model} should support fast mode"
|
||||
|
||||
def test_all_anthropic_models_supported(self):
|
||||
"""The speed=fast parameter is gated to Opus 4.6.
|
||||
|
||||
Sending speed=fast to Opus 4.7, Sonnet, or Haiku returns HTTP 400.
|
||||
(Opus 4.8's fast offering is a separate ``…-fast`` model id selected
|
||||
via the model field, not this parameter — see the adapter test.)
|
||||
"""
|
||||
from hermes_cli.models import model_supports_fast_mode
|
||||
|
||||
# Supported: Opus 4.6 in any form
|
||||
supported = [
|
||||
"claude-opus-4-6", "claude-opus-4.6",
|
||||
"anthropic/claude-opus-4-6", "anthropic/claude-opus-4.6",
|
||||
]
|
||||
for model in supported:
|
||||
assert model_supports_fast_mode(model), f"{model} should support fast mode"
|
||||
|
||||
# Unsupported per Anthropic API: Opus 4.7/4.8, Sonnet, Haiku
|
||||
unsupported = [
|
||||
"claude-opus-4-7", "claude-opus-4-8", "claude-opus-4.8",
|
||||
"claude-sonnet-4-6", "claude-sonnet-4.6", "claude-sonnet-4",
|
||||
"claude-haiku-4-5", "claude-3-5-haiku",
|
||||
]
|
||||
for model in unsupported:
|
||||
assert not model_supports_fast_mode(model), (
|
||||
f"{model} should NOT support the speed=fast parameter"
|
||||
)
|
||||
|
||||
def test_codex_models_excluded(self):
|
||||
"""Codex models route through Responses API and don't accept service_tier."""
|
||||
from hermes_cli.models import model_supports_fast_mode
|
||||
|
||||
for model in ["gpt-5-codex", "gpt-5.2-codex", "gpt-5.3-codex", "gpt-5.1-codex-max"]:
|
||||
assert not model_supports_fast_mode(model), f"{model} is codex — should not expose /fast"
|
||||
|
||||
def test_vendor_prefix_stripped(self):
|
||||
from hermes_cli.models import model_supports_fast_mode
|
||||
|
||||
assert model_supports_fast_mode("openai/gpt-5.4") is True
|
||||
assert model_supports_fast_mode("openai/gpt-4.1") is True
|
||||
assert model_supports_fast_mode("openai/o3") is True
|
||||
|
||||
def test_non_priority_models_rejected(self):
|
||||
from hermes_cli.models import model_supports_fast_mode
|
||||
|
||||
# Codex-series models route through the Codex Responses API and
|
||||
# don't accept service_tier, so they're excluded.
|
||||
assert model_supports_fast_mode("gpt-5.3-codex") is False
|
||||
assert model_supports_fast_mode("gpt-5.2-codex") is False
|
||||
assert model_supports_fast_mode("gpt-5-codex") is False
|
||||
# Non-OpenAI, non-Anthropic models
|
||||
assert model_supports_fast_mode("gemini-3-pro-preview") is False
|
||||
assert model_supports_fast_mode("kimi-k2-thinking") is False
|
||||
assert model_supports_fast_mode("deepseek-chat") is False
|
||||
assert model_supports_fast_mode("") is False
|
||||
assert model_supports_fast_mode(None) is False
|
||||
|
||||
def test_resolve_overrides_returns_service_tier(self):
|
||||
from hermes_cli.models import resolve_fast_mode_overrides
|
||||
|
||||
result = resolve_fast_mode_overrides("gpt-5.4")
|
||||
assert result == {"service_tier": "priority"}
|
||||
|
||||
result = resolve_fast_mode_overrides("gpt-4.1")
|
||||
assert result == {"service_tier": "priority"}
|
||||
|
||||
def test_resolve_overrides_none_for_unsupported(self):
|
||||
from hermes_cli.models import resolve_fast_mode_overrides
|
||||
|
||||
assert resolve_fast_mode_overrides("gpt-5.3-codex") is None
|
||||
assert resolve_fast_mode_overrides("gemini-3-pro-preview") is None
|
||||
assert resolve_fast_mode_overrides("kimi-k2-thinking") is None
|
||||
|
||||
|
||||
class TestFastModeRouting(unittest.TestCase):
|
||||
def test_fast_command_exposed_for_model_even_when_provider_is_auto(self):
|
||||
cli_mod = _import_cli()
|
||||
stub = SimpleNamespace(provider="auto", requested_provider="auto", model="gpt-5.4", agent=None)
|
||||
|
||||
assert cli_mod.HermesCLI._fast_command_available(stub) is True
|
||||
|
||||
def test_fast_command_exposed_for_non_codex_models(self):
|
||||
cli_mod = _import_cli()
|
||||
stub = SimpleNamespace(provider="openai", requested_provider="openai", model="gpt-4.1", agent=None)
|
||||
assert cli_mod.HermesCLI._fast_command_available(stub) is True
|
||||
|
||||
stub = SimpleNamespace(provider="openrouter", requested_provider="openrouter", model="o3", agent=None)
|
||||
assert cli_mod.HermesCLI._fast_command_available(stub) is True
|
||||
|
||||
def test_turn_route_injects_overrides_without_provider_switch(self):
|
||||
"""Fast mode should add request_overrides but NOT change the provider/runtime."""
|
||||
cli_mod = _import_cli()
|
||||
stub = SimpleNamespace(
|
||||
model="gpt-5.4",
|
||||
api_key="primary-key",
|
||||
base_url="https://openrouter.ai/api/v1",
|
||||
provider="openrouter",
|
||||
api_mode="chat_completions",
|
||||
acp_command=None,
|
||||
acp_args=[],
|
||||
_credential_pool=None,
|
||||
service_tier="priority",
|
||||
)
|
||||
|
||||
route = cli_mod.HermesCLI._resolve_turn_agent_config(stub, "hi")
|
||||
|
||||
# Provider should NOT have changed
|
||||
assert route["runtime"]["provider"] == "openrouter"
|
||||
assert route["runtime"]["api_mode"] == "chat_completions"
|
||||
# But request_overrides should be set
|
||||
assert route["request_overrides"] == {"service_tier": "priority"}
|
||||
|
||||
def test_turn_route_keeps_primary_runtime_when_model_has_no_fast_backend(self):
|
||||
cli_mod = _import_cli()
|
||||
stub = SimpleNamespace(
|
||||
model="gpt-5.3-codex",
|
||||
api_key="primary-key",
|
||||
base_url="https://openrouter.ai/api/v1",
|
||||
provider="openrouter",
|
||||
api_mode="chat_completions",
|
||||
acp_command=None,
|
||||
acp_args=[],
|
||||
_credential_pool=None,
|
||||
service_tier="priority",
|
||||
)
|
||||
|
||||
route = cli_mod.HermesCLI._resolve_turn_agent_config(stub, "hi")
|
||||
|
||||
assert route["runtime"]["provider"] == "openrouter"
|
||||
assert route.get("request_overrides") is None
|
||||
|
||||
|
||||
class TestAnthropicFastMode(unittest.TestCase):
|
||||
"""Verify Anthropic Fast Mode model support and override resolution."""
|
||||
|
||||
def test_anthropic_opus_supported(self):
|
||||
from hermes_cli.models import model_supports_fast_mode
|
||||
|
||||
# Native Anthropic format (hyphens)
|
||||
assert model_supports_fast_mode("claude-opus-4-6") is True
|
||||
# OpenRouter format (dots)
|
||||
assert model_supports_fast_mode("claude-opus-4.6") is True
|
||||
# With vendor prefix
|
||||
assert model_supports_fast_mode("anthropic/claude-opus-4-6") is True
|
||||
assert model_supports_fast_mode("anthropic/claude-opus-4.6") is True
|
||||
|
||||
def test_anthropic_non_opus46_models_excluded(self):
|
||||
"""The speed=fast parameter is gated to Opus 4.6 — others excluded.
|
||||
|
||||
Per https://platform.claude.com/docs/en/build-with-claude/fast-mode,
|
||||
sending speed=fast to Opus 4.7, Sonnet, or Haiku returns HTTP 400.
|
||||
Opus 4.8 uses a separate ``…-fast`` model id, not this parameter.
|
||||
"""
|
||||
from hermes_cli.models import model_supports_fast_mode
|
||||
|
||||
assert model_supports_fast_mode("claude-sonnet-4-6") is False
|
||||
assert model_supports_fast_mode("claude-sonnet-4.6") is False
|
||||
assert model_supports_fast_mode("claude-haiku-4-5") is False
|
||||
assert model_supports_fast_mode("claude-opus-4-7") is False
|
||||
assert model_supports_fast_mode("claude-opus-4-8") is False
|
||||
assert model_supports_fast_mode("anthropic/claude-sonnet-4.6") is False
|
||||
assert model_supports_fast_mode("anthropic/claude-opus-4-7") is False
|
||||
|
||||
def test_non_claude_models_not_anthropic_fast(self):
|
||||
"""Non-Claude models should not be treated as Anthropic fast-mode."""
|
||||
from hermes_cli.models import _is_anthropic_fast_model
|
||||
|
||||
assert _is_anthropic_fast_model("gpt-5.4") is False
|
||||
assert _is_anthropic_fast_model("gemini-3-pro") is False
|
||||
assert _is_anthropic_fast_model("kimi-k2-thinking") is False
|
||||
|
||||
def test_anthropic_variant_tags_stripped(self):
|
||||
from hermes_cli.models import model_supports_fast_mode
|
||||
|
||||
# OpenRouter variant tags after colon should be stripped
|
||||
assert model_supports_fast_mode("claude-opus-4.6:fast") is True
|
||||
assert model_supports_fast_mode("claude-opus-4.6:beta") is True
|
||||
|
||||
def test_resolve_overrides_returns_speed_for_anthropic(self):
|
||||
from hermes_cli.models import resolve_fast_mode_overrides
|
||||
|
||||
result = resolve_fast_mode_overrides("claude-opus-4-6")
|
||||
assert result == {"speed": "fast"}
|
||||
|
||||
result = resolve_fast_mode_overrides("anthropic/claude-opus-4.6")
|
||||
assert result == {"speed": "fast"}
|
||||
|
||||
def test_resolve_overrides_returns_none_for_unsupported_claude(self):
|
||||
"""Opus 4.7/4.8 and other Claude models don't take the speed param.
|
||||
|
||||
The speed=fast parameter is Opus 4.6 only (Opus 4.8 uses a separate
|
||||
``…-fast`` model id instead).
|
||||
"""
|
||||
from hermes_cli.models import resolve_fast_mode_overrides
|
||||
|
||||
assert resolve_fast_mode_overrides("claude-opus-4-7") is None
|
||||
assert resolve_fast_mode_overrides("claude-opus-4-8") is None
|
||||
assert resolve_fast_mode_overrides("claude-sonnet-4-6") is None
|
||||
assert resolve_fast_mode_overrides("claude-haiku-4-5") is None
|
||||
|
||||
def test_resolve_overrides_returns_service_tier_for_openai(self):
|
||||
"""OpenAI models should still get service_tier, not speed."""
|
||||
from hermes_cli.models import resolve_fast_mode_overrides
|
||||
|
||||
result = resolve_fast_mode_overrides("gpt-5.4")
|
||||
assert result == {"service_tier": "priority"}
|
||||
|
||||
def test_is_anthropic_fast_model(self):
|
||||
"""The speed=fast parameter is Opus 4.6 only — other Claude excluded."""
|
||||
from hermes_cli.models import _is_anthropic_fast_model
|
||||
|
||||
# Supported: Opus 4.6 in any form
|
||||
assert _is_anthropic_fast_model("claude-opus-4-6") is True
|
||||
assert _is_anthropic_fast_model("claude-opus-4.6") is True
|
||||
assert _is_anthropic_fast_model("anthropic/claude-opus-4-6") is True
|
||||
assert _is_anthropic_fast_model("claude-opus-4.6:fast") is True
|
||||
|
||||
# Unsupported — would 400 (4.7) or uses a separate model id (4.8)
|
||||
assert _is_anthropic_fast_model("claude-opus-4-7") is False
|
||||
assert _is_anthropic_fast_model("claude-opus-4-8") is False
|
||||
assert _is_anthropic_fast_model("claude-sonnet-4-6") is False
|
||||
assert _is_anthropic_fast_model("claude-haiku-4-5") is False
|
||||
|
||||
# Non-Claude
|
||||
assert _is_anthropic_fast_model("gpt-5.4") is False
|
||||
assert _is_anthropic_fast_model("") is False
|
||||
|
||||
def test_fast_command_exposed_for_anthropic_model(self):
|
||||
cli_mod = _import_cli()
|
||||
stub = SimpleNamespace(
|
||||
provider="anthropic", requested_provider="anthropic",
|
||||
model="claude-opus-4-6", agent=None,
|
||||
)
|
||||
assert cli_mod.HermesCLI._fast_command_available(stub) is True
|
||||
|
||||
def test_fast_command_hidden_for_anthropic_sonnet(self):
|
||||
"""Sonnet doesn't support fast mode (Opus 4.6 only) — /fast must be hidden."""
|
||||
cli_mod = _import_cli()
|
||||
stub = SimpleNamespace(
|
||||
provider="anthropic", requested_provider="anthropic",
|
||||
model="claude-sonnet-4-6", agent=None,
|
||||
)
|
||||
assert cli_mod.HermesCLI._fast_command_available(stub) is False
|
||||
|
||||
def test_fast_command_hidden_for_anthropic_opus_47(self):
|
||||
"""Opus 4.7 doesn't take the speed=fast parameter — /fast must hide."""
|
||||
cli_mod = _import_cli()
|
||||
stub = SimpleNamespace(
|
||||
provider="anthropic", requested_provider="anthropic",
|
||||
model="claude-opus-4-7", agent=None,
|
||||
)
|
||||
assert cli_mod.HermesCLI._fast_command_available(stub) is False
|
||||
|
||||
def test_fast_command_hidden_for_non_claude_non_openai(self):
|
||||
"""Non-Claude, non-OpenAI models should not expose /fast."""
|
||||
cli_mod = _import_cli()
|
||||
stub = SimpleNamespace(
|
||||
provider="gemini", requested_provider="gemini",
|
||||
model="gemini-3-pro-preview", agent=None,
|
||||
)
|
||||
assert cli_mod.HermesCLI._fast_command_available(stub) is False
|
||||
|
||||
def test_turn_route_injects_speed_for_anthropic(self):
|
||||
"""Anthropic models should get speed:'fast' override, not service_tier."""
|
||||
cli_mod = _import_cli()
|
||||
stub = SimpleNamespace(
|
||||
model="claude-opus-4-6",
|
||||
api_key="sk-ant-test",
|
||||
base_url="https://api.anthropic.com",
|
||||
provider="anthropic",
|
||||
api_mode="anthropic_messages",
|
||||
acp_command=None,
|
||||
acp_args=[],
|
||||
_credential_pool=None,
|
||||
service_tier="priority",
|
||||
)
|
||||
|
||||
route = cli_mod.HermesCLI._resolve_turn_agent_config(stub, "hi")
|
||||
|
||||
assert route["runtime"]["provider"] == "anthropic"
|
||||
assert route["request_overrides"] == {"speed": "fast"}
|
||||
|
||||
|
||||
class TestAnthropicFastModeAdapter(unittest.TestCase):
|
||||
"""Verify build_anthropic_kwargs handles fast_mode parameter."""
|
||||
|
||||
def test_fast_mode_adds_speed_and_beta(self):
|
||||
from agent.anthropic_adapter import build_anthropic_kwargs, _FAST_MODE_BETA
|
||||
|
||||
kwargs = build_anthropic_kwargs(
|
||||
model="claude-opus-4-6",
|
||||
messages=[{"role": "user", "content": [{"type": "text", "text": "hi"}]}],
|
||||
tools=None,
|
||||
max_tokens=None,
|
||||
reasoning_config=None,
|
||||
fast_mode=True,
|
||||
)
|
||||
assert kwargs.get("extra_body", {}).get("speed") == "fast"
|
||||
assert "speed" not in kwargs
|
||||
assert "extra_headers" in kwargs
|
||||
assert _FAST_MODE_BETA in kwargs["extra_headers"].get("anthropic-beta", "")
|
||||
|
||||
def test_fast_mode_off_no_speed(self):
|
||||
from agent.anthropic_adapter import build_anthropic_kwargs
|
||||
|
||||
kwargs = build_anthropic_kwargs(
|
||||
model="claude-opus-4-6",
|
||||
messages=[{"role": "user", "content": [{"type": "text", "text": "hi"}]}],
|
||||
tools=None,
|
||||
max_tokens=None,
|
||||
reasoning_config=None,
|
||||
fast_mode=False,
|
||||
)
|
||||
assert kwargs.get("extra_body", {}).get("speed") is None
|
||||
assert "speed" not in kwargs
|
||||
assert "extra_headers" not in kwargs
|
||||
|
||||
def test_fast_mode_skipped_for_third_party_endpoint(self):
|
||||
from agent.anthropic_adapter import build_anthropic_kwargs
|
||||
|
||||
kwargs = build_anthropic_kwargs(
|
||||
model="claude-opus-4-6",
|
||||
messages=[{"role": "user", "content": [{"type": "text", "text": "hi"}]}],
|
||||
tools=None,
|
||||
max_tokens=None,
|
||||
reasoning_config=None,
|
||||
fast_mode=True,
|
||||
base_url="https://api.minimax.io/anthropic/v1",
|
||||
)
|
||||
# Third-party endpoints should NOT get speed or fast-mode beta
|
||||
assert kwargs.get("extra_body", {}).get("speed") is None
|
||||
assert "speed" not in kwargs
|
||||
assert "extra_headers" not in kwargs
|
||||
|
||||
def test_fast_mode_kwargs_are_safe_for_sdk_unpacking(self):
|
||||
from agent.anthropic_adapter import build_anthropic_kwargs
|
||||
|
||||
kwargs = build_anthropic_kwargs(
|
||||
model="claude-opus-4-6",
|
||||
messages=[{"role": "user", "content": [{"type": "text", "text": "hi"}]}],
|
||||
tools=None,
|
||||
max_tokens=None,
|
||||
reasoning_config=None,
|
||||
fast_mode=True,
|
||||
)
|
||||
assert "speed" not in kwargs
|
||||
assert kwargs.get("extra_body", {}).get("speed") == "fast"
|
||||
|
||||
|
||||
class TestConfigDefault(unittest.TestCase):
|
||||
def test_default_config_has_service_tier(self):
|
||||
from hermes_cli.config import DEFAULT_CONFIG
|
||||
|
||||
agent = DEFAULT_CONFIG.get("agent", {})
|
||||
self.assertIn("service_tier", agent)
|
||||
self.assertEqual(agent["service_tier"], "")
|
||||
@@ -0,0 +1,184 @@
|
||||
"""Tests for CLI manual compression messaging."""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from tests.cli.test_cli_init import _make_cli
|
||||
|
||||
|
||||
def _make_history() -> list[dict[str, str]]:
|
||||
return [
|
||||
{"role": "user", "content": "one"},
|
||||
{"role": "assistant", "content": "two"},
|
||||
{"role": "user", "content": "three"},
|
||||
{"role": "assistant", "content": "four"},
|
||||
]
|
||||
|
||||
|
||||
def test_manual_compress_reports_noop_without_success_banner(capsys):
|
||||
shell = _make_cli()
|
||||
history = _make_history()
|
||||
shell.conversation_history = history
|
||||
shell.agent = MagicMock()
|
||||
shell.agent.compression_enabled = True
|
||||
shell.agent._cached_system_prompt = ""
|
||||
shell.agent.tools = None
|
||||
shell.agent.session_id = shell.session_id # no-op compression: no split
|
||||
shell.agent._compress_context.return_value = (list(history), "")
|
||||
|
||||
def _estimate(messages, **_kwargs):
|
||||
assert messages == history
|
||||
return 100
|
||||
|
||||
with patch("agent.model_metadata.estimate_request_tokens_rough", side_effect=_estimate):
|
||||
shell._manual_compress()
|
||||
|
||||
output = capsys.readouterr().out
|
||||
assert "No changes from compression" in output
|
||||
assert "✅ Compressed" not in output
|
||||
assert "Approx request size: ~100 tokens (unchanged)" in output
|
||||
|
||||
|
||||
def test_manual_compress_explains_when_token_estimate_rises(capsys):
|
||||
shell = _make_cli()
|
||||
history = _make_history()
|
||||
compressed = [
|
||||
history[0],
|
||||
{"role": "assistant", "content": "Dense summary that still counts as more tokens."},
|
||||
history[-1],
|
||||
]
|
||||
shell.conversation_history = history
|
||||
shell.agent = MagicMock()
|
||||
shell.agent.compression_enabled = True
|
||||
shell.agent._cached_system_prompt = ""
|
||||
shell.agent.tools = None
|
||||
shell.agent.session_id = shell.session_id # no-op: no split
|
||||
shell.agent._compress_context.return_value = (compressed, "")
|
||||
|
||||
def _estimate(messages, **_kwargs):
|
||||
if messages == history:
|
||||
return 100
|
||||
if messages == compressed:
|
||||
return 120
|
||||
raise AssertionError(f"unexpected transcript: {messages!r}")
|
||||
|
||||
with patch("agent.model_metadata.estimate_request_tokens_rough", side_effect=_estimate):
|
||||
shell._manual_compress()
|
||||
|
||||
output = capsys.readouterr().out
|
||||
assert "✅ Compressed: 4 → 3 messages" in output
|
||||
assert "Approx request size: ~100 → ~120 tokens" in output
|
||||
assert "denser summaries" in output
|
||||
|
||||
|
||||
def test_manual_compress_syncs_session_id_after_split():
|
||||
"""Regression for cli.session_id desync after /compress.
|
||||
|
||||
_compress_context ends the parent session and creates a new child session,
|
||||
mutating agent.session_id. Without syncing, cli.session_id still points
|
||||
at the ended parent — causing /status, /resume, exit summary, and the
|
||||
next end_session() call (e.g. from /resume <id>) to target the wrong row.
|
||||
"""
|
||||
shell = _make_cli()
|
||||
history = _make_history()
|
||||
old_id = shell.session_id
|
||||
new_child_id = "20260101_000000_child1"
|
||||
|
||||
compressed = [
|
||||
{"role": "user", "content": "[summary]"},
|
||||
history[-1],
|
||||
]
|
||||
shell.conversation_history = history
|
||||
shell.agent = MagicMock()
|
||||
shell.agent.compression_enabled = True
|
||||
shell.agent._cached_system_prompt = ""
|
||||
shell.agent.tools = None
|
||||
# Simulate _compress_context mutating agent.session_id as a side effect.
|
||||
def _fake_compress(*args, **kwargs):
|
||||
shell.agent.session_id = new_child_id
|
||||
return (compressed, "")
|
||||
shell.agent._compress_context.side_effect = _fake_compress
|
||||
shell.agent.session_id = old_id # starts in sync
|
||||
shell._pending_title = "stale title"
|
||||
|
||||
with patch("agent.model_metadata.estimate_request_tokens_rough", return_value=100):
|
||||
shell._manual_compress()
|
||||
|
||||
# CLI session_id must now point at the continuation child, not the parent.
|
||||
assert shell.session_id == new_child_id
|
||||
assert shell.session_id != old_id
|
||||
# Pending title must be cleared — titles belong to the parent lineage and
|
||||
# get regenerated for the continuation.
|
||||
assert shell._pending_title is None
|
||||
|
||||
|
||||
def test_manual_compress_flushes_compressed_history_to_child_session_db():
|
||||
"""Manual /compress must persist the handoff in the continuation DB.
|
||||
|
||||
_compress_context rotates the agent to a new child session and returns a
|
||||
compressed transcript whose first messages include the handoff summary. The
|
||||
CLI then replaces its in-memory conversation_history with that transcript.
|
||||
Because the child DB starts empty, the flush must start from offset 0 rather
|
||||
than treating the compressed history as already persisted.
|
||||
"""
|
||||
shell = _make_cli()
|
||||
history = _make_history()
|
||||
old_id = shell.session_id
|
||||
new_child_id = "20260101_000000_child1"
|
||||
compressed = [
|
||||
{"role": "user", "content": "[CONTEXT COMPACTION — REFERENCE ONLY] compacted"},
|
||||
history[-1],
|
||||
]
|
||||
shell.conversation_history = history
|
||||
shell.agent = MagicMock()
|
||||
shell.agent.compression_enabled = True
|
||||
shell.agent._cached_system_prompt = ""
|
||||
shell.agent.session_id = old_id
|
||||
|
||||
def _fake_compress(*args, **kwargs):
|
||||
shell.agent.session_id = new_child_id
|
||||
return (compressed, "")
|
||||
|
||||
shell.agent._compress_context.side_effect = _fake_compress
|
||||
|
||||
with patch("agent.model_metadata.estimate_messages_tokens_rough", return_value=100):
|
||||
shell._manual_compress()
|
||||
|
||||
shell.agent._flush_messages_to_session_db.assert_called_once_with(compressed, None)
|
||||
|
||||
|
||||
def test_manual_compress_does_not_flush_full_history_when_session_id_unchanged():
|
||||
shell = _make_cli()
|
||||
history = _make_history()
|
||||
shell.conversation_history = history
|
||||
shell.agent = MagicMock()
|
||||
shell.agent.compression_enabled = True
|
||||
shell.agent._cached_system_prompt = ""
|
||||
shell.agent.session_id = shell.session_id
|
||||
shell.agent._compress_context.return_value = (list(history), "")
|
||||
|
||||
with patch("agent.model_metadata.estimate_messages_tokens_rough", return_value=100):
|
||||
shell._manual_compress()
|
||||
|
||||
shell.agent._flush_messages_to_session_db.assert_not_called()
|
||||
|
||||
|
||||
def test_manual_compress_no_sync_when_session_id_unchanged():
|
||||
"""If compression is a no-op (agent.session_id didn't change), the CLI
|
||||
must NOT clear _pending_title or otherwise disturb session state.
|
||||
"""
|
||||
shell = _make_cli()
|
||||
history = _make_history()
|
||||
shell.conversation_history = history
|
||||
shell.agent = MagicMock()
|
||||
shell.agent.compression_enabled = True
|
||||
shell.agent._cached_system_prompt = ""
|
||||
shell.agent.tools = None
|
||||
shell.agent.session_id = shell.session_id
|
||||
shell.agent._compress_context.return_value = (list(history), "")
|
||||
shell._pending_title = "keep me"
|
||||
|
||||
with patch("agent.model_metadata.estimate_request_tokens_rough", return_value=100):
|
||||
shell._manual_compress()
|
||||
|
||||
# No split → pending title untouched.
|
||||
assert shell._pending_title == "keep me"
|
||||
@@ -0,0 +1,82 @@
|
||||
import queue
|
||||
from unittest.mock import patch
|
||||
|
||||
from cli import HermesCLI
|
||||
from hermes_cli.moa_config import decode_moa_turn
|
||||
|
||||
|
||||
def _make_cli():
|
||||
cli = HermesCLI.__new__(HermesCLI)
|
||||
cli.config = {
|
||||
"moa": {
|
||||
"default_preset": "default",
|
||||
"presets": {
|
||||
"default": {
|
||||
"reference_models": [{"provider": "openai-codex", "model": "gpt-5.5"}],
|
||||
"aggregator": {"provider": "openrouter", "model": "anthropic/claude-opus-4.8"},
|
||||
},
|
||||
"review": {
|
||||
"reference_models": [{"provider": "openrouter", "model": "deepseek/deepseek-v4-pro"}],
|
||||
"aggregator": {"provider": "openrouter", "model": "anthropic/claude-opus-4.8"},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
cli._pending_input = queue.Queue()
|
||||
cli._pending_agent_seed = None
|
||||
cli._pending_moa_config = None
|
||||
cli._pending_moa_disable_after_turn = False
|
||||
cli._pending_moa_restore_model = None
|
||||
cli._agent_running = False
|
||||
cli.agent = None
|
||||
cli.provider = "openrouter"
|
||||
cli.requested_provider = "openrouter"
|
||||
cli.model = "anthropic/claude-opus-4.8"
|
||||
cli.api_key = "test-key"
|
||||
cli.base_url = "https://openrouter.ai/api/v1"
|
||||
cli.api_mode = "chat_completions"
|
||||
return cli
|
||||
|
||||
|
||||
def test_moa_bare_shows_usage_no_switch():
|
||||
# /moa with no prompt is usage-only now; switching to a preset for the
|
||||
# session is done via the model picker, not /moa.
|
||||
cli = _make_cli()
|
||||
cli._pending_moa_disable_after_turn = False
|
||||
with patch("cli._cprint"):
|
||||
assert cli.process_command("/moa") is True
|
||||
assert cli.provider != "moa"
|
||||
assert cli._pending_agent_seed is None
|
||||
assert cli._pending_moa_disable_after_turn is False
|
||||
|
||||
|
||||
def test_moa_arg_is_always_one_shot_prompt():
|
||||
# Any argument (even a string that matches a preset name) is treated as a
|
||||
# one-shot prompt through the DEFAULT preset, then the model is restored.
|
||||
cli = _make_cli()
|
||||
with patch("cli._cprint"):
|
||||
cli.process_command("/moa review")
|
||||
assert cli._pending_agent_seed == "review"
|
||||
assert cli._pending_moa_disable_after_turn is True
|
||||
assert cli.provider == "moa"
|
||||
assert cli.model == "default"
|
||||
|
||||
|
||||
def test_moa_non_preset_is_one_shot_prompt():
|
||||
cli = _make_cli()
|
||||
with patch("cli._cprint"):
|
||||
cli.process_command("/moa inspect the flaky test")
|
||||
assert cli._pending_agent_seed == "inspect the flaky test"
|
||||
assert cli._pending_moa_disable_after_turn is True
|
||||
assert cli.provider == "moa"
|
||||
assert cli.model == "default"
|
||||
assert cli._pending_moa_restore_model["provider"] != "moa"
|
||||
|
||||
|
||||
def test_decode_legacy_encoded_moa_turn_still_works():
|
||||
from hermes_cli.moa_config import build_moa_turn_prompt
|
||||
|
||||
encoded = build_moa_turn_prompt("hello", _make_cli().config["moa"], preset="review")
|
||||
prompt, cfg = decode_moa_turn(encoded)
|
||||
assert prompt == "hello"
|
||||
assert cfg["reference_models"] == [{"provider": "openrouter", "model": "deepseek/deepseek-v4-pro"}]
|
||||
@@ -0,0 +1,198 @@
|
||||
"""Tests for hermes_cli.partial_compress — the pure split/parse helpers
|
||||
behind ``/compress here [N]`` (boundary-aware "summarize up to here").
|
||||
|
||||
Inspired by Claude Code's Rewind "Summarize up to here" action.
|
||||
"""
|
||||
|
||||
from hermes_cli.partial_compress import (
|
||||
DEFAULT_KEEP_LAST,
|
||||
MAX_KEEP_LAST,
|
||||
parse_partial_compress_args,
|
||||
rejoin_compressed_head_and_tail,
|
||||
split_history_for_partial_compress,
|
||||
)
|
||||
|
||||
|
||||
def _history(n_pairs: int) -> list[dict[str, str]]:
|
||||
"""Build n_pairs of (user, assistant) exchanges."""
|
||||
h: list[dict[str, str]] = []
|
||||
for i in range(n_pairs):
|
||||
h.append({"role": "user", "content": f"u{i}"})
|
||||
h.append({"role": "assistant", "content": f"a{i}"})
|
||||
return h
|
||||
|
||||
|
||||
# ── parse_partial_compress_args ──────────────────────────────────────
|
||||
|
||||
|
||||
def test_empty_args_is_full_compress():
|
||||
partial, keep, focus = parse_partial_compress_args("")
|
||||
assert partial is False
|
||||
assert keep == DEFAULT_KEEP_LAST
|
||||
assert focus is None
|
||||
|
||||
|
||||
def test_here_defaults_keep_last():
|
||||
partial, keep, focus = parse_partial_compress_args("here")
|
||||
assert partial is True
|
||||
assert keep == DEFAULT_KEEP_LAST
|
||||
assert focus is None
|
||||
|
||||
|
||||
def test_here_with_count():
|
||||
partial, keep, focus = parse_partial_compress_args("here 4")
|
||||
assert partial is True
|
||||
assert keep == 4
|
||||
assert focus is None
|
||||
|
||||
|
||||
def test_up_to_here_alias():
|
||||
partial, keep, focus = parse_partial_compress_args("up to here 3")
|
||||
assert partial is True
|
||||
assert keep == 3
|
||||
assert focus is None
|
||||
|
||||
|
||||
def test_keep_flag_forms():
|
||||
for arg in ("--keep 5", "-k 5", "--keep=5"):
|
||||
partial, keep, focus = parse_partial_compress_args(arg)
|
||||
assert partial is True, arg
|
||||
assert keep == 5, arg
|
||||
assert focus is None, arg
|
||||
|
||||
|
||||
def test_focus_topic_when_not_boundary_form():
|
||||
partial, keep, focus = parse_partial_compress_args("database schema")
|
||||
assert partial is False
|
||||
assert focus == "database schema"
|
||||
|
||||
|
||||
def test_here_count_clamped_low_and_high():
|
||||
_, keep_low, _ = parse_partial_compress_args("here 0")
|
||||
assert keep_low == 1
|
||||
_, keep_high, _ = parse_partial_compress_args(f"here {MAX_KEEP_LAST + 50}")
|
||||
assert keep_high == MAX_KEEP_LAST
|
||||
|
||||
|
||||
def test_here_garbage_count_falls_back_to_default():
|
||||
partial, keep, focus = parse_partial_compress_args("here lots")
|
||||
assert partial is True
|
||||
assert keep == DEFAULT_KEEP_LAST
|
||||
|
||||
|
||||
# ── split_history_for_partial_compress ───────────────────────────────
|
||||
|
||||
|
||||
def test_split_keeps_last_n_exchanges():
|
||||
h = _history(5) # 10 messages: u0 a0 u1 a1 u2 a2 u3 a3 u4 a4
|
||||
head, tail = split_history_for_partial_compress(h, keep_last=2)
|
||||
# Keep last 2 user-starts → tail begins at u3 (index 6).
|
||||
assert tail == h[6:]
|
||||
assert head == h[:6]
|
||||
# Tail must begin on a user turn (role-alternation safety).
|
||||
assert tail[0]["role"] == "user"
|
||||
|
||||
|
||||
def test_split_default_keep():
|
||||
h = _history(4) # 8 messages
|
||||
head, tail = split_history_for_partial_compress(h, keep_last=DEFAULT_KEEP_LAST)
|
||||
assert tail[0]["role"] == "user"
|
||||
assert head + tail == h
|
||||
assert len(head) > 0
|
||||
|
||||
|
||||
def test_split_tail_always_starts_on_user():
|
||||
# Tool messages interleaved — tail must still snap to a user turn.
|
||||
h = [
|
||||
{"role": "user", "content": "u0"},
|
||||
{"role": "assistant", "content": "a0"},
|
||||
{"role": "user", "content": "u1"},
|
||||
{"role": "assistant", "content": "a1"},
|
||||
{"role": "tool", "content": "t1"},
|
||||
{"role": "assistant", "content": "a1b"},
|
||||
{"role": "user", "content": "u2"},
|
||||
{"role": "assistant", "content": "a2"},
|
||||
]
|
||||
head, tail = split_history_for_partial_compress(h, keep_last=1)
|
||||
assert tail[0]["role"] == "user"
|
||||
assert tail[0]["content"] == "u2"
|
||||
assert head + tail == h
|
||||
|
||||
|
||||
def test_split_degenerate_returns_no_tail():
|
||||
# keep_last larger than the number of exchanges → nothing to compress.
|
||||
h = _history(2) # 4 messages, 2 user turns
|
||||
head, tail = split_history_for_partial_compress(h, keep_last=5)
|
||||
# Boundary lands at the first user turn → head empty → signal full.
|
||||
assert tail == []
|
||||
assert head == h
|
||||
|
||||
|
||||
def test_split_empty_history():
|
||||
head, tail = split_history_for_partial_compress([], keep_last=2)
|
||||
assert head == []
|
||||
assert tail == []
|
||||
|
||||
|
||||
def test_split_rejoin_preserves_all_messages():
|
||||
h = _history(6)
|
||||
head, tail = split_history_for_partial_compress(h, keep_last=3)
|
||||
assert head + tail == h
|
||||
|
||||
|
||||
# ── rejoin_compressed_head_and_tail (seam-alternation guard) ─────────
|
||||
|
||||
|
||||
def _roles(msgs):
|
||||
return [m["role"] for m in msgs if m["role"] in ("user", "assistant")]
|
||||
|
||||
|
||||
def _no_consecutive_dupes(msgs):
|
||||
r = _roles(msgs)
|
||||
return all(r[i] != r[i + 1] for i in range(len(r) - 1))
|
||||
|
||||
|
||||
def test_rejoin_valid_seam_assistant_then_user():
|
||||
# Normal case: head ends on assistant, tail starts on user → valid.
|
||||
head = [{"role": "user", "content": "[summary]"},
|
||||
{"role": "assistant", "content": "ack"}]
|
||||
tail = [{"role": "user", "content": "next"},
|
||||
{"role": "assistant", "content": "reply"}]
|
||||
out = rejoin_compressed_head_and_tail(head, tail)
|
||||
assert out == head + tail
|
||||
assert _no_consecutive_dupes(out)
|
||||
|
||||
|
||||
def test_rejoin_user_user_seam_merges():
|
||||
# Degenerate head ending on a user summary; tail starts on user.
|
||||
head = [{"role": "user", "content": "[summary of head]"}]
|
||||
tail = [{"role": "user", "content": "latest question"},
|
||||
{"role": "assistant", "content": "answer"}]
|
||||
out = rejoin_compressed_head_and_tail(head, tail)
|
||||
assert _no_consecutive_dupes(out), out
|
||||
# The two user messages were merged into one.
|
||||
assert out[0]["content"] == "[summary of head]\n\nlatest question"
|
||||
assert out[1] == {"role": "assistant", "content": "answer"}
|
||||
|
||||
|
||||
def test_rejoin_assistant_assistant_seam_merges():
|
||||
head = [{"role": "user", "content": "q"},
|
||||
{"role": "assistant", "content": "head end"}]
|
||||
tail = [{"role": "assistant", "content": "tail start"},
|
||||
{"role": "user", "content": "u"}]
|
||||
out = rejoin_compressed_head_and_tail(head, tail)
|
||||
assert _no_consecutive_dupes(out), out
|
||||
assert out[-2]["content"] == "head end\n\ntail start"
|
||||
|
||||
|
||||
def test_rejoin_empty_tail_returns_head():
|
||||
head = [{"role": "user", "content": "x"}]
|
||||
assert rejoin_compressed_head_and_tail(head, []) == head
|
||||
|
||||
|
||||
def test_rejoin_tool_seam_left_alone():
|
||||
# tool->tool is the one legal repetition; don't merge.
|
||||
head = [{"role": "user", "content": "q"}, {"role": "tool", "content": "t1"}]
|
||||
tail = [{"role": "user", "content": "u"}]
|
||||
out = rejoin_compressed_head_and_tail(head, tail)
|
||||
assert out == head + tail
|
||||
@@ -0,0 +1,224 @@
|
||||
"""Tests for /personality none — clearing personality overlay."""
|
||||
import pytest
|
||||
from unittest.mock import MagicMock, patch
|
||||
import yaml
|
||||
|
||||
|
||||
# ── CLI tests ──────────────────────────────────────────────────────────────
|
||||
|
||||
class TestCLIPersonalityNone:
|
||||
|
||||
def _make_cli(self, personalities=None):
|
||||
from cli import HermesCLI
|
||||
cli = HermesCLI.__new__(HermesCLI)
|
||||
cli.personalities = personalities or {
|
||||
"helpful": "You are helpful.",
|
||||
"concise": "You are concise.",
|
||||
}
|
||||
cli.system_prompt = "You are kawaii~"
|
||||
cli.agent = MagicMock()
|
||||
cli.console = MagicMock()
|
||||
return cli
|
||||
|
||||
def test_none_clears_system_prompt(self):
|
||||
cli = self._make_cli()
|
||||
with patch("cli.save_config_value", return_value=True):
|
||||
cli._handle_personality_command("/personality none")
|
||||
assert cli.system_prompt == ""
|
||||
|
||||
def test_default_clears_system_prompt(self):
|
||||
cli = self._make_cli()
|
||||
with patch("cli.save_config_value", return_value=True):
|
||||
cli._handle_personality_command("/personality default")
|
||||
assert cli.system_prompt == ""
|
||||
|
||||
def test_neutral_clears_system_prompt(self):
|
||||
cli = self._make_cli()
|
||||
with patch("cli.save_config_value", return_value=True):
|
||||
cli._handle_personality_command("/personality neutral")
|
||||
assert cli.system_prompt == ""
|
||||
|
||||
def test_none_forces_agent_reinit(self):
|
||||
cli = self._make_cli()
|
||||
with patch("cli.save_config_value", return_value=True):
|
||||
cli._handle_personality_command("/personality none")
|
||||
assert cli.agent is None
|
||||
|
||||
def test_none_saves_to_config(self):
|
||||
cli = self._make_cli()
|
||||
with patch("cli.save_config_value", return_value=True) as mock_save:
|
||||
cli._handle_personality_command("/personality none")
|
||||
mock_save.assert_called_once_with("agent.system_prompt", "")
|
||||
|
||||
def test_known_personality_still_works(self):
|
||||
cli = self._make_cli()
|
||||
with patch("cli.save_config_value", return_value=True):
|
||||
cli._handle_personality_command("/personality helpful")
|
||||
assert cli.system_prompt == "You are helpful."
|
||||
|
||||
def test_unknown_personality_shows_none_in_available(self, capsys):
|
||||
cli = self._make_cli()
|
||||
cli._handle_personality_command("/personality nonexistent")
|
||||
output = capsys.readouterr().out
|
||||
assert "none" in output.lower()
|
||||
|
||||
def test_list_shows_none_option(self):
|
||||
cli = self._make_cli()
|
||||
with patch("builtins.print") as mock_print:
|
||||
cli._handle_personality_command("/personality")
|
||||
output = " ".join(str(c) for c in mock_print.call_args_list)
|
||||
assert "none" in output.lower()
|
||||
|
||||
|
||||
# ── Gateway tests ──────────────────────────────────────────────────────────
|
||||
|
||||
class TestGatewayPersonalityNone:
|
||||
|
||||
def _make_event(self, args=""):
|
||||
event = MagicMock()
|
||||
event.get_command.return_value = "personality"
|
||||
event.get_command_args.return_value = args
|
||||
return event
|
||||
|
||||
def _make_runner(self, personalities=None):
|
||||
from gateway.run import GatewayRunner
|
||||
runner = GatewayRunner.__new__(GatewayRunner)
|
||||
runner._ephemeral_system_prompt = "You are kawaii~"
|
||||
runner.config = {
|
||||
"agent": {
|
||||
"personalities": personalities or {"helpful": "You are helpful."}
|
||||
}
|
||||
}
|
||||
return runner
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_none_clears_ephemeral_prompt(self, tmp_path):
|
||||
runner = self._make_runner()
|
||||
config_data = {"agent": {"personalities": {"helpful": "You are helpful."}, "system_prompt": "kawaii"}}
|
||||
config_file = tmp_path / "config.yaml"
|
||||
config_file.write_text(yaml.dump(config_data))
|
||||
|
||||
with patch("gateway.run._hermes_home", tmp_path):
|
||||
event = self._make_event("none")
|
||||
result = await runner._handle_personality_command(event)
|
||||
|
||||
assert runner._ephemeral_system_prompt == ""
|
||||
assert "cleared" in result.lower()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_default_clears_ephemeral_prompt(self, tmp_path):
|
||||
runner = self._make_runner()
|
||||
config_data = {"agent": {"personalities": {"helpful": "You are helpful."}}}
|
||||
config_file = tmp_path / "config.yaml"
|
||||
config_file.write_text(yaml.dump(config_data))
|
||||
|
||||
with patch("gateway.run._hermes_home", tmp_path):
|
||||
event = self._make_event("default")
|
||||
result = await runner._handle_personality_command(event)
|
||||
|
||||
assert runner._ephemeral_system_prompt == ""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_includes_none(self, tmp_path):
|
||||
runner = self._make_runner()
|
||||
config_data = {"agent": {"personalities": {"helpful": "You are helpful."}}}
|
||||
config_file = tmp_path / "config.yaml"
|
||||
config_file.write_text(yaml.dump(config_data))
|
||||
|
||||
with patch("gateway.run._hermes_home", tmp_path):
|
||||
event = self._make_event("")
|
||||
result = await runner._handle_personality_command(event)
|
||||
|
||||
assert "none" in result.lower()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unknown_shows_none_in_available(self, tmp_path):
|
||||
runner = self._make_runner()
|
||||
config_data = {"agent": {"personalities": {"helpful": "You are helpful."}}}
|
||||
config_file = tmp_path / "config.yaml"
|
||||
config_file.write_text(yaml.dump(config_data))
|
||||
|
||||
with patch("gateway.run._hermes_home", tmp_path):
|
||||
event = self._make_event("nonexistent")
|
||||
result = await runner._handle_personality_command(event)
|
||||
|
||||
assert "none" in result.lower()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_empty_personality_list_uses_profile_display_path(self, tmp_path):
|
||||
runner = self._make_runner(personalities={})
|
||||
(tmp_path / "config.yaml").write_text(yaml.dump({"agent": {"personalities": {}}}))
|
||||
|
||||
with patch("gateway.run._hermes_home", tmp_path), \
|
||||
patch("hermes_constants.display_hermes_home", return_value="~/.hermes/profiles/coder"):
|
||||
event = self._make_event("")
|
||||
result = await runner._handle_personality_command(event)
|
||||
|
||||
assert result == "No personalities configured in `~/.hermes/profiles/coder/config.yaml`"
|
||||
|
||||
|
||||
class TestPersonalityDictFormat:
|
||||
"""Test dict-format custom personalities with description, tone, style."""
|
||||
|
||||
def _make_cli(self, personalities):
|
||||
from cli import HermesCLI
|
||||
cli = HermesCLI.__new__(HermesCLI)
|
||||
cli.personalities = personalities
|
||||
cli.system_prompt = ""
|
||||
cli.agent = None
|
||||
cli.console = MagicMock()
|
||||
return cli
|
||||
|
||||
def test_dict_personality_uses_system_prompt(self):
|
||||
cli = self._make_cli({
|
||||
"coder": {
|
||||
"description": "Expert programmer",
|
||||
"system_prompt": "You are an expert programmer.",
|
||||
"tone": "technical",
|
||||
"style": "concise",
|
||||
}
|
||||
})
|
||||
with patch("cli.save_config_value", return_value=True):
|
||||
cli._handle_personality_command("/personality coder")
|
||||
assert "You are an expert programmer." in cli.system_prompt
|
||||
|
||||
def test_dict_personality_includes_tone(self):
|
||||
cli = self._make_cli({
|
||||
"coder": {
|
||||
"system_prompt": "You are an expert programmer.",
|
||||
"tone": "technical and precise",
|
||||
}
|
||||
})
|
||||
with patch("cli.save_config_value", return_value=True):
|
||||
cli._handle_personality_command("/personality coder")
|
||||
assert "Tone: technical and precise" in cli.system_prompt
|
||||
|
||||
def test_dict_personality_includes_style(self):
|
||||
cli = self._make_cli({
|
||||
"coder": {
|
||||
"system_prompt": "You are an expert programmer.",
|
||||
"style": "use code examples",
|
||||
}
|
||||
})
|
||||
with patch("cli.save_config_value", return_value=True):
|
||||
cli._handle_personality_command("/personality coder")
|
||||
assert "Style: use code examples" in cli.system_prompt
|
||||
|
||||
def test_string_personality_still_works(self):
|
||||
cli = self._make_cli({"helper": "You are helpful."})
|
||||
with patch("cli.save_config_value", return_value=True):
|
||||
cli._handle_personality_command("/personality helper")
|
||||
assert cli.system_prompt == "You are helpful."
|
||||
|
||||
def test_resolve_prompt_dict_no_tone_no_style(self):
|
||||
from cli import HermesCLI
|
||||
result = HermesCLI._resolve_personality_prompt({
|
||||
"description": "A helper",
|
||||
"system_prompt": "You are helpful.",
|
||||
})
|
||||
assert result == "You are helpful."
|
||||
|
||||
def test_resolve_prompt_string(self):
|
||||
from cli import HermesCLI
|
||||
result = HermesCLI._resolve_personality_prompt("You are helpful.")
|
||||
assert result == "You are helpful."
|
||||
@@ -0,0 +1,35 @@
|
||||
"""Regression tests for CLI prefill config key compatibility."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import cli
|
||||
|
||||
|
||||
def test_resolve_prefill_messages_file_uses_top_level(monkeypatch):
|
||||
monkeypatch.delenv("HERMES_PREFILL_MESSAGES_FILE", raising=False)
|
||||
|
||||
assert cli._resolve_prefill_messages_file(
|
||||
{
|
||||
"prefill_messages_file": "top.json",
|
||||
"agent": {"prefill_messages_file": "legacy.json"},
|
||||
}
|
||||
) == "top.json"
|
||||
|
||||
|
||||
def test_resolve_prefill_messages_file_accepts_legacy_agent_key(monkeypatch):
|
||||
monkeypatch.delenv("HERMES_PREFILL_MESSAGES_FILE", raising=False)
|
||||
|
||||
assert cli._resolve_prefill_messages_file(
|
||||
{"agent": {"prefill_messages_file": "legacy.json"}}
|
||||
) == "legacy.json"
|
||||
|
||||
|
||||
def test_resolve_prefill_messages_file_prefers_env(monkeypatch):
|
||||
monkeypatch.setenv("HERMES_PREFILL_MESSAGES_FILE", "env.json")
|
||||
|
||||
assert cli._resolve_prefill_messages_file(
|
||||
{
|
||||
"prefill_messages_file": "top.json",
|
||||
"agent": {"prefill_messages_file": "legacy.json"},
|
||||
}
|
||||
) == "env.json"
|
||||
@@ -0,0 +1,80 @@
|
||||
"""Tests for cli._prepend_note_to_message.
|
||||
|
||||
Regression coverage for the TypeError raised when a queued /model or
|
||||
/reload-skills note was prepended to a multimodal (image-attached) message:
|
||||
``can only concatenate str (not "list") to str``.
|
||||
"""
|
||||
|
||||
from cli import _prepend_note_to_message
|
||||
|
||||
|
||||
def test_string_message_gets_note_prepended():
|
||||
assert _prepend_note_to_message("hello", "NOTE") == "NOTE\n\nhello"
|
||||
|
||||
|
||||
def test_empty_note_returns_message_unchanged():
|
||||
assert _prepend_note_to_message("hello", "") == "hello"
|
||||
assert _prepend_note_to_message("hello", " ") == "hello"
|
||||
parts = [{"type": "text", "text": "hi"}]
|
||||
assert _prepend_note_to_message(parts, "") == parts
|
||||
|
||||
|
||||
def test_note_is_stripped():
|
||||
assert _prepend_note_to_message("hello", " NOTE ") == "NOTE\n\nhello"
|
||||
|
||||
|
||||
def test_empty_string_message_yields_just_note():
|
||||
# No trailing blank lines when the user message is empty.
|
||||
assert _prepend_note_to_message("", "NOTE") == "NOTE"
|
||||
|
||||
|
||||
def test_empty_text_part_yields_just_note():
|
||||
message = [
|
||||
{"type": "text", "text": ""},
|
||||
{"type": "image_url", "image_url": {"url": "x"}},
|
||||
]
|
||||
result = _prepend_note_to_message(message, "NOTE")
|
||||
assert result[0]["text"] == "NOTE"
|
||||
assert result[1]["type"] == "image_url"
|
||||
|
||||
|
||||
def test_list_message_folds_note_into_first_text_part():
|
||||
message = [
|
||||
{"type": "text", "text": "describe this"},
|
||||
{"type": "image_url", "image_url": {"url": "data:..."}},
|
||||
]
|
||||
result = _prepend_note_to_message(message, "NOTE")
|
||||
|
||||
assert result[0]["type"] == "text"
|
||||
assert result[0]["text"] == "NOTE\n\ndescribe this"
|
||||
# Image part is preserved untouched.
|
||||
assert result[1] == {"type": "image_url", "image_url": {"url": "data:..."}}
|
||||
# Original message is not mutated.
|
||||
assert message[0]["text"] == "describe this"
|
||||
|
||||
|
||||
def test_image_only_list_gets_leading_text_part():
|
||||
message = [{"type": "image_url", "image_url": {"url": "data:..."}}]
|
||||
result = _prepend_note_to_message(message, "NOTE")
|
||||
|
||||
assert result[0] == {"type": "text", "text": "NOTE"}
|
||||
assert result[1]["type"] == "image_url"
|
||||
|
||||
|
||||
def test_list_message_does_not_raise_typeerror():
|
||||
# The exact #repro shape: multimodal list + queued note must not raise
|
||||
# "can only concatenate str (not 'list') to str".
|
||||
message = [
|
||||
{"type": "text", "text": "look"},
|
||||
{"type": "image_url", "image_url": {"url": "x"}},
|
||||
]
|
||||
result = _prepend_note_to_message(
|
||||
message, "Model switched to gpt-5.5 (provider: openai-codex)."
|
||||
)
|
||||
assert isinstance(result, list)
|
||||
assert result[0]["text"].startswith("Model switched to gpt-5.5")
|
||||
|
||||
|
||||
def test_unknown_shape_returned_unchanged():
|
||||
assert _prepend_note_to_message(123, "NOTE") == 123
|
||||
assert _prepend_note_to_message(None, "NOTE") is None
|
||||
@@ -0,0 +1,99 @@
|
||||
"""Tests for ``HermesCLI._prompt_text_input`` thread-safe input dispatch.
|
||||
|
||||
Raw ``input()`` prompts can race with prompt_toolkit when called from the TUI.
|
||||
The normal slash confirmations now use a prompt_toolkit-native modal, but
|
||||
``_prompt_text_input`` remains as a fallback for non-interactive calls and edge
|
||||
cases.
|
||||
"""
|
||||
|
||||
import threading
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
|
||||
def _make_cli():
|
||||
"""Minimal HermesCLI shell exposing prompt fallback helpers."""
|
||||
import cli as cli_mod
|
||||
|
||||
obj = object.__new__(cli_mod.HermesCLI)
|
||||
obj._app = MagicMock()
|
||||
obj._status_bar_visible = True
|
||||
return obj
|
||||
|
||||
|
||||
class TestPromptTextInputThreadSafety:
|
||||
def test_main_thread_uses_run_in_terminal(self):
|
||||
"""On the main thread with an active app, route through run_in_terminal."""
|
||||
cli = _make_cli()
|
||||
|
||||
with patch("prompt_toolkit.application.run_in_terminal") as mock_rit, \
|
||||
patch("builtins.input", return_value="2"):
|
||||
cli._prompt_text_input("Choice: ")
|
||||
|
||||
# run_in_terminal was invoked; the _ask closure passed to it would
|
||||
# call input() when driven by the event loop. We assert dispatch path,
|
||||
# not the orphaned-coroutine result.
|
||||
assert mock_rit.called
|
||||
|
||||
def test_background_thread_cancels_instead_of_hanging(self):
|
||||
"""On a daemon thread with an active app, cancel cleanly (return None).
|
||||
|
||||
stdin is owned by the prompt_toolkit event loop / JSON-RPC pipe on the
|
||||
non-main (process_loop / slash-worker) thread, so a bare input() there
|
||||
would block until the worker's timeout (#23185 / billing auto-reload
|
||||
hang). The guard cancels to None instead of hanging — it must NOT call
|
||||
run_in_terminal (orphaned coroutine) and must NOT call input().
|
||||
"""
|
||||
cli = _make_cli()
|
||||
|
||||
result_holder = {}
|
||||
|
||||
def run_on_daemon():
|
||||
with patch("prompt_toolkit.application.run_in_terminal") as mock_rit, \
|
||||
patch("builtins.input", side_effect=AssertionError("input() must not be called off-main-thread")) as mock_input:
|
||||
result_holder["value"] = cli._prompt_text_input("Choice [1/2/3]: ")
|
||||
result_holder["rit_called"] = mock_rit.called
|
||||
result_holder["input_called"] = mock_input.called
|
||||
|
||||
t = threading.Thread(target=run_on_daemon, daemon=True)
|
||||
t.start()
|
||||
t.join(timeout=2.0)
|
||||
assert not t.is_alive(), "daemon thread hung — guard did not cancel cleanly"
|
||||
|
||||
# Cancelled cleanly: None returned, neither run_in_terminal nor input() called.
|
||||
assert result_holder["value"] is None
|
||||
assert result_holder["rit_called"] is False
|
||||
assert result_holder["input_called"] is False
|
||||
|
||||
def test_no_app_uses_direct_input(self):
|
||||
"""Without an active prompt_toolkit app, always call input() directly."""
|
||||
cli = _make_cli()
|
||||
cli._app = None
|
||||
|
||||
with patch("builtins.input", return_value="cancel") as mock_input:
|
||||
result = cli._prompt_text_input("Choice: ")
|
||||
|
||||
assert mock_input.called
|
||||
assert result == "cancel"
|
||||
|
||||
def test_run_in_terminal_exception_falls_back(self):
|
||||
"""If run_in_terminal raises (WSL / Warp edge cases), fall back to input()."""
|
||||
cli = _make_cli()
|
||||
|
||||
with patch(
|
||||
"prompt_toolkit.application.run_in_terminal",
|
||||
side_effect=RuntimeError("event loop dropped the coroutine"),
|
||||
), patch("builtins.input", return_value="3") as mock_input:
|
||||
result = cli._prompt_text_input("Choice: ")
|
||||
|
||||
assert mock_input.called
|
||||
assert result == "3"
|
||||
|
||||
def test_eof_returns_none(self):
|
||||
"""EOFError from input() yields None, not an unhandled exception."""
|
||||
cli = _make_cli()
|
||||
cli._app = None
|
||||
|
||||
with patch("builtins.input", side_effect=EOFError()):
|
||||
result = cli._prompt_text_input("Choice: ")
|
||||
|
||||
assert result is None
|
||||
@@ -0,0 +1,247 @@
|
||||
"""Tests for user-defined quick commands that bypass the agent loop."""
|
||||
import os
|
||||
import subprocess
|
||||
from unittest.mock import MagicMock, patch
|
||||
from rich.text import Text
|
||||
import pytest
|
||||
|
||||
|
||||
# ── CLI tests ──────────────────────────────────────────────────────────────
|
||||
|
||||
class TestCLIQuickCommands:
|
||||
"""Test quick command dispatch in HermesCLI.process_command."""
|
||||
|
||||
@staticmethod
|
||||
def _printed_plain(call_arg):
|
||||
if isinstance(call_arg, Text):
|
||||
return call_arg.plain
|
||||
return str(call_arg)
|
||||
|
||||
def _make_cli(self, quick_commands):
|
||||
from cli import HermesCLI
|
||||
cli = HermesCLI.__new__(HermesCLI)
|
||||
cli.config = {"quick_commands": quick_commands}
|
||||
cli.console = MagicMock()
|
||||
cli.agent = None
|
||||
cli.conversation_history = []
|
||||
# session_id is accessed by the fallback skill/fuzzy-match path in
|
||||
# process_command; without it, tests that exercise `/alias args`
|
||||
# can trip an AttributeError when cross-test state leaks a skill
|
||||
# command matching the alias target.
|
||||
cli.session_id = "test-session"
|
||||
return cli
|
||||
|
||||
def test_exec_command_runs_and_prints_output(self):
|
||||
cli = self._make_cli({"dn": {"type": "exec", "command": "echo daily-note"}})
|
||||
result = cli.process_command("/dn")
|
||||
assert result is True
|
||||
cli.console.print.assert_called_once()
|
||||
printed = self._printed_plain(cli.console.print.call_args[0][0])
|
||||
assert printed == "daily-note"
|
||||
|
||||
def test_exec_command_uses_chat_console_when_tui_is_live(self):
|
||||
cli = self._make_cli({"dn": {"type": "exec", "command": "echo daily-note"}})
|
||||
cli._app = object()
|
||||
live_console = MagicMock()
|
||||
|
||||
with patch("cli.ChatConsole", return_value=live_console):
|
||||
result = cli.process_command("/dn")
|
||||
|
||||
assert result is True
|
||||
live_console.print.assert_called_once()
|
||||
printed = self._printed_plain(live_console.print.call_args[0][0])
|
||||
assert printed == "daily-note"
|
||||
cli.console.print.assert_not_called()
|
||||
|
||||
def test_exec_command_stderr_shown_on_no_stdout(self):
|
||||
cli = self._make_cli({"err": {"type": "exec", "command": "echo error >&2"}})
|
||||
result = cli.process_command("/err")
|
||||
assert result is True
|
||||
# stderr fallback — should print something
|
||||
cli.console.print.assert_called_once()
|
||||
|
||||
def test_exec_command_no_output_shows_fallback(self):
|
||||
cli = self._make_cli({"empty": {"type": "exec", "command": "true"}})
|
||||
cli.process_command("/empty")
|
||||
cli.console.print.assert_called_once()
|
||||
args = cli.console.print.call_args[0][0]
|
||||
assert "no output" in args.lower()
|
||||
|
||||
def test_alias_command_routes_to_target(self):
|
||||
"""Alias quick commands rewrite to the target command."""
|
||||
cli = self._make_cli({"shortcut": {"type": "alias", "target": "/help"}})
|
||||
with patch.object(cli, "process_command", wraps=cli.process_command) as spy:
|
||||
cli.process_command("/shortcut")
|
||||
# Should recursively call process_command with /help
|
||||
spy.assert_any_call("/help")
|
||||
|
||||
def test_alias_command_passes_args(self):
|
||||
"""Alias quick commands forward user arguments to the target."""
|
||||
cli = self._make_cli({"sc": {"type": "alias", "target": "/context"}})
|
||||
with patch.object(cli, "process_command", wraps=cli.process_command) as spy:
|
||||
cli.process_command("/sc some args")
|
||||
spy.assert_any_call("/context some args")
|
||||
|
||||
def test_alias_no_target_shows_error(self):
|
||||
cli = self._make_cli({"broken": {"type": "alias", "target": ""}})
|
||||
cli.process_command("/broken")
|
||||
cli.console.print.assert_called_once()
|
||||
args = cli.console.print.call_args[0][0]
|
||||
assert "no target defined" in args.lower()
|
||||
|
||||
def test_unsupported_type_shows_error(self):
|
||||
cli = self._make_cli({"bad": {"type": "prompt", "command": "echo hi"}})
|
||||
cli.process_command("/bad")
|
||||
cli.console.print.assert_called_once()
|
||||
args = cli.console.print.call_args[0][0]
|
||||
assert "unsupported type" in args.lower()
|
||||
|
||||
def test_missing_command_field_shows_error(self):
|
||||
cli = self._make_cli({"oops": {"type": "exec"}})
|
||||
cli.process_command("/oops")
|
||||
cli.console.print.assert_called_once()
|
||||
args = cli.console.print.call_args[0][0]
|
||||
assert "no command defined" in args.lower()
|
||||
|
||||
def test_quick_command_takes_priority_over_skill_commands(self):
|
||||
"""Quick commands must be checked before skill slash commands."""
|
||||
cli = self._make_cli({"mygif": {"type": "exec", "command": "echo overridden"}})
|
||||
with patch("cli._skill_commands", {"/mygif": {"name": "gif-search"}}):
|
||||
cli.process_command("/mygif")
|
||||
cli.console.print.assert_called_once()
|
||||
printed = self._printed_plain(cli.console.print.call_args[0][0])
|
||||
assert printed == "overridden"
|
||||
|
||||
def test_unknown_command_still_shows_error(self):
|
||||
cli = self._make_cli({})
|
||||
with patch("cli._cprint") as mock_cprint:
|
||||
cli.process_command("/nonexistent")
|
||||
mock_cprint.assert_called()
|
||||
printed = " ".join(str(c) for c in mock_cprint.call_args_list)
|
||||
assert "unknown command" in printed.lower()
|
||||
|
||||
def test_timeout_shows_error(self):
|
||||
cli = self._make_cli({"slow": {"type": "exec", "command": "sleep 100"}})
|
||||
with patch("subprocess.run", side_effect=subprocess.TimeoutExpired("sleep", 30)):
|
||||
cli.process_command("/slow")
|
||||
cli.console.print.assert_called_once()
|
||||
args = cli.console.print.call_args[0][0]
|
||||
assert "timed out" in args.lower()
|
||||
|
||||
|
||||
# ── Gateway tests ──────────────────────────────────────────────────────────
|
||||
|
||||
class TestGatewayQuickCommands:
|
||||
"""Test quick command dispatch in GatewayRunner._handle_message."""
|
||||
|
||||
def _make_event(self, command, args=""):
|
||||
event = MagicMock()
|
||||
event.get_command.return_value = command
|
||||
event.get_command_args.return_value = args
|
||||
event.text = f"/{command} {args}".strip()
|
||||
event.source = MagicMock()
|
||||
event.source.user_id = "test_user"
|
||||
event.source.user_name = "Test User"
|
||||
event.source.platform.value = "telegram"
|
||||
event.source.chat_type = "dm"
|
||||
event.source.chat_id = "123"
|
||||
return event
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_exec_command_returns_output(self):
|
||||
from gateway.run import GatewayRunner
|
||||
runner = GatewayRunner.__new__(GatewayRunner)
|
||||
runner.config = {"quick_commands": {"limits": {"type": "exec", "command": "echo ok"}}}
|
||||
runner._running_agents = {}
|
||||
runner._pending_messages = {}
|
||||
runner._is_user_authorized = MagicMock(return_value=True)
|
||||
|
||||
event = self._make_event("limits")
|
||||
result = await runner._handle_message(event)
|
||||
assert result == "ok"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_exec_command_does_not_leak_credentials(self):
|
||||
"""Quick command exec must sanitize env — API keys must not appear in output."""
|
||||
from gateway.run import GatewayRunner
|
||||
|
||||
runner = GatewayRunner.__new__(GatewayRunner)
|
||||
runner.config = {"quick_commands": {"leak": {"type": "exec", "command": "env"}}}
|
||||
runner._running_agents = {}
|
||||
runner._pending_messages = {}
|
||||
runner._is_user_authorized = MagicMock(return_value=True)
|
||||
|
||||
event = self._make_event("leak")
|
||||
with patch.dict(os.environ, {"OPENROUTER_API_KEY": "sk-or-secret-12345"}):
|
||||
result = await runner._handle_message(event)
|
||||
|
||||
assert "sk-or-secret-12345" not in result, \
|
||||
"Quick command leaked OPENROUTER_API_KEY — exec runs without env sanitization"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_exec_command_output_is_redacted(self, monkeypatch):
|
||||
"""Quick command output must redact sensitive patterns before returning."""
|
||||
from gateway.run import GatewayRunner
|
||||
|
||||
# Ensure redaction is active regardless of host HERMES_REDACT_SECRETS state
|
||||
# or test ordering
|
||||
monkeypatch.setattr("agent.redact._REDACT_ENABLED", True)
|
||||
|
||||
runner = GatewayRunner.__new__(GatewayRunner)
|
||||
runner.config = {"quick_commands": {"token": {"type": "exec", "command": "echo sk-ant-api03-supersecretkey1234567890"}}}
|
||||
runner._running_agents = {}
|
||||
runner._pending_messages = {}
|
||||
runner._is_user_authorized = MagicMock(return_value=True)
|
||||
|
||||
event = self._make_event("token")
|
||||
result = await runner._handle_message(event)
|
||||
|
||||
assert "supersecretkey1234567890" not in result, \
|
||||
"Quick command output not redacted — raw API key returned to user"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unsupported_type_returns_error(self):
|
||||
from gateway.run import GatewayRunner
|
||||
runner = GatewayRunner.__new__(GatewayRunner)
|
||||
runner.config = {"quick_commands": {"bad": {"type": "prompt", "command": "echo hi"}}}
|
||||
runner._running_agents = {}
|
||||
runner._pending_messages = {}
|
||||
runner._is_user_authorized = MagicMock(return_value=True)
|
||||
|
||||
event = self._make_event("bad")
|
||||
result = await runner._handle_message(event)
|
||||
assert result is not None
|
||||
assert "unsupported type" in result.lower()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_timeout_returns_error(self):
|
||||
from gateway.run import GatewayRunner
|
||||
import asyncio
|
||||
runner = GatewayRunner.__new__(GatewayRunner)
|
||||
runner.config = {"quick_commands": {"slow": {"type": "exec", "command": "sleep 100"}}}
|
||||
runner._running_agents = {}
|
||||
runner._pending_messages = {}
|
||||
runner._is_user_authorized = MagicMock(return_value=True)
|
||||
|
||||
event = self._make_event("slow")
|
||||
with patch("asyncio.wait_for", side_effect=asyncio.TimeoutError):
|
||||
result = await runner._handle_message(event)
|
||||
assert result is not None
|
||||
assert "timed out" in result.lower()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_gateway_config_object_supports_quick_commands(self):
|
||||
from gateway.config import GatewayConfig
|
||||
from gateway.run import GatewayRunner
|
||||
|
||||
runner = GatewayRunner.__new__(GatewayRunner)
|
||||
runner.config = GatewayConfig(
|
||||
quick_commands={"limits": {"type": "exec", "command": "echo ok"}}
|
||||
)
|
||||
runner._running_agents = {}
|
||||
runner._pending_messages = {}
|
||||
runner._is_user_authorized = MagicMock(return_value=True)
|
||||
|
||||
event = self._make_event("limits")
|
||||
result = await runner._handle_message(event)
|
||||
assert result == "ok"
|
||||
@@ -0,0 +1,772 @@
|
||||
"""Tests for the combined /reasoning command.
|
||||
|
||||
Covers both reasoning effort level management and reasoning display toggle,
|
||||
plus the reasoning extraction and display pipeline from run_agent through CLI.
|
||||
|
||||
Combines functionality from:
|
||||
- PR #789 (Aum08Desai): reasoning effort level management
|
||||
- PR #790 (0xbyt4): reasoning display toggle and rendering
|
||||
"""
|
||||
|
||||
import unittest
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, patch
|
||||
import re
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Effort level parsing
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestParseReasoningConfig(unittest.TestCase):
|
||||
"""Verify _parse_reasoning_config handles all effort levels."""
|
||||
|
||||
def _parse(self, effort):
|
||||
from cli import _parse_reasoning_config
|
||||
return _parse_reasoning_config(effort)
|
||||
|
||||
def test_none_disables(self):
|
||||
result = self._parse("none")
|
||||
self.assertEqual(result, {"enabled": False})
|
||||
|
||||
def test_valid_levels(self):
|
||||
for level in ("low", "medium", "high", "xhigh", "max", "ultra", "minimal"):
|
||||
result = self._parse(level)
|
||||
self.assertIsNotNone(result)
|
||||
self.assertTrue(result.get("enabled"))
|
||||
self.assertEqual(result["effort"], level)
|
||||
|
||||
def test_empty_returns_none(self):
|
||||
self.assertIsNone(self._parse(""))
|
||||
self.assertIsNone(self._parse(" "))
|
||||
|
||||
def test_unknown_returns_none(self):
|
||||
self.assertIsNone(self._parse("turbo"))
|
||||
|
||||
def test_case_insensitive(self):
|
||||
result = self._parse("HIGH")
|
||||
self.assertIsNotNone(result)
|
||||
self.assertEqual(result["effort"], "high")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# /reasoning command handler (combined effort + display)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestHandleReasoningCommand(unittest.TestCase):
|
||||
"""Test the combined _handle_reasoning_command method."""
|
||||
|
||||
def _make_cli(self, reasoning_config=None, show_reasoning=False):
|
||||
"""Create a minimal CLI stub with the reasoning attributes."""
|
||||
stub = SimpleNamespace(
|
||||
reasoning_config=reasoning_config,
|
||||
show_reasoning=show_reasoning,
|
||||
agent=MagicMock(),
|
||||
)
|
||||
return stub
|
||||
|
||||
def test_show_enables_display(self):
|
||||
stub = self._make_cli(show_reasoning=False)
|
||||
# Simulate /reasoning show
|
||||
arg = "show"
|
||||
if arg in {"show", "on"}:
|
||||
stub.show_reasoning = True
|
||||
stub.agent.reasoning_callback = lambda x: None
|
||||
self.assertTrue(stub.show_reasoning)
|
||||
|
||||
def test_hide_disables_display(self):
|
||||
stub = self._make_cli(show_reasoning=True)
|
||||
# Simulate /reasoning hide
|
||||
arg = "hide"
|
||||
if arg in {"hide", "off"}:
|
||||
stub.show_reasoning = False
|
||||
stub.agent.reasoning_callback = None
|
||||
self.assertFalse(stub.show_reasoning)
|
||||
self.assertIsNone(stub.agent.reasoning_callback)
|
||||
|
||||
def test_on_enables_display(self):
|
||||
stub = self._make_cli(show_reasoning=False)
|
||||
arg = "on"
|
||||
if arg in {"show", "on"}:
|
||||
stub.show_reasoning = True
|
||||
self.assertTrue(stub.show_reasoning)
|
||||
|
||||
def test_off_disables_display(self):
|
||||
stub = self._make_cli(show_reasoning=True)
|
||||
arg = "off"
|
||||
if arg in {"hide", "off"}:
|
||||
stub.show_reasoning = False
|
||||
self.assertFalse(stub.show_reasoning)
|
||||
|
||||
def test_effort_level_sets_config(self):
|
||||
"""Setting an effort level should update reasoning_config."""
|
||||
from cli import _parse_reasoning_config
|
||||
stub = self._make_cli()
|
||||
arg = "high"
|
||||
parsed = _parse_reasoning_config(arg)
|
||||
stub.reasoning_config = parsed
|
||||
self.assertEqual(stub.reasoning_config, {"enabled": True, "effort": "high"})
|
||||
|
||||
def test_effort_none_disables_reasoning(self):
|
||||
from cli import _parse_reasoning_config
|
||||
stub = self._make_cli()
|
||||
parsed = _parse_reasoning_config("none")
|
||||
stub.reasoning_config = parsed
|
||||
self.assertEqual(stub.reasoning_config, {"enabled": False})
|
||||
|
||||
def test_invalid_argument_rejected(self):
|
||||
"""Invalid arguments should be rejected (parsed returns None)."""
|
||||
from cli import _parse_reasoning_config
|
||||
parsed = _parse_reasoning_config("turbo")
|
||||
self.assertIsNone(parsed)
|
||||
|
||||
def test_no_args_shows_status(self):
|
||||
"""With no args, should show current state (no crash)."""
|
||||
stub = self._make_cli(reasoning_config=None, show_reasoning=False)
|
||||
rc = stub.reasoning_config
|
||||
if rc is None:
|
||||
level = "medium (default)"
|
||||
elif rc.get("enabled") is False:
|
||||
level = "none (disabled)"
|
||||
else:
|
||||
level = rc.get("effort", "medium")
|
||||
display_state = "on" if stub.show_reasoning else "off"
|
||||
self.assertEqual(level, "medium (default)")
|
||||
self.assertEqual(display_state, "off")
|
||||
|
||||
def test_status_with_disabled_reasoning(self):
|
||||
stub = self._make_cli(reasoning_config={"enabled": False}, show_reasoning=True)
|
||||
rc = stub.reasoning_config
|
||||
if rc is None:
|
||||
level = "medium (default)"
|
||||
elif rc.get("enabled") is False:
|
||||
level = "none (disabled)"
|
||||
else:
|
||||
level = rc.get("effort", "medium")
|
||||
self.assertEqual(level, "none (disabled)")
|
||||
|
||||
def test_status_with_explicit_level(self):
|
||||
stub = self._make_cli(
|
||||
reasoning_config={"enabled": True, "effort": "xhigh"},
|
||||
show_reasoning=True,
|
||||
)
|
||||
rc = stub.reasoning_config
|
||||
level = rc.get("effort", "medium")
|
||||
self.assertEqual(level, "xhigh")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Reasoning extraction and result dict
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestLastReasoningInResult(unittest.TestCase):
|
||||
"""Verify reasoning extraction from the messages list."""
|
||||
|
||||
def _build_messages(self, reasoning=None):
|
||||
return [
|
||||
{"role": "user", "content": "hello"},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "Hi there!",
|
||||
"reasoning": reasoning,
|
||||
"finish_reason": "stop",
|
||||
},
|
||||
]
|
||||
|
||||
def test_reasoning_present(self):
|
||||
messages = self._build_messages(reasoning="Let me think...")
|
||||
last_reasoning = None
|
||||
for msg in reversed(messages):
|
||||
if msg.get("role") == "user":
|
||||
break
|
||||
if msg.get("role") == "assistant" and msg.get("reasoning"):
|
||||
last_reasoning = msg["reasoning"]
|
||||
break
|
||||
self.assertEqual(last_reasoning, "Let me think...")
|
||||
|
||||
def test_reasoning_none(self):
|
||||
messages = self._build_messages(reasoning=None)
|
||||
last_reasoning = None
|
||||
for msg in reversed(messages):
|
||||
if msg.get("role") == "user":
|
||||
break
|
||||
if msg.get("role") == "assistant" and msg.get("reasoning"):
|
||||
last_reasoning = msg["reasoning"]
|
||||
break
|
||||
self.assertIsNone(last_reasoning)
|
||||
|
||||
def test_picks_last_assistant(self):
|
||||
messages = [
|
||||
{"role": "user", "content": "hello"},
|
||||
{"role": "assistant", "content": "...", "reasoning": "first thought"},
|
||||
{"role": "tool", "content": "result"},
|
||||
{"role": "assistant", "content": "done!", "reasoning": "final thought"},
|
||||
]
|
||||
last_reasoning = None
|
||||
for msg in reversed(messages):
|
||||
if msg.get("role") == "user":
|
||||
break
|
||||
if msg.get("role") == "assistant" and msg.get("reasoning"):
|
||||
last_reasoning = msg["reasoning"]
|
||||
break
|
||||
self.assertEqual(last_reasoning, "final thought")
|
||||
|
||||
def test_empty_reasoning_treated_as_none(self):
|
||||
messages = self._build_messages(reasoning="")
|
||||
last_reasoning = None
|
||||
for msg in reversed(messages):
|
||||
if msg.get("role") == "user":
|
||||
break
|
||||
if msg.get("role") == "assistant" and msg.get("reasoning"):
|
||||
last_reasoning = msg["reasoning"]
|
||||
break
|
||||
self.assertIsNone(last_reasoning)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Reasoning display collapse
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestReasoningCollapse(unittest.TestCase):
|
||||
"""Verify long reasoning is collapsed to 10 lines in the box."""
|
||||
|
||||
def test_short_reasoning_not_collapsed(self):
|
||||
reasoning = "\n".join(f"Line {i}" for i in range(5))
|
||||
lines = reasoning.strip().splitlines()
|
||||
self.assertLessEqual(len(lines), 10)
|
||||
|
||||
def test_long_reasoning_collapsed(self):
|
||||
reasoning = "\n".join(f"Line {i}" for i in range(25))
|
||||
lines = reasoning.strip().splitlines()
|
||||
self.assertTrue(len(lines) > 10)
|
||||
if len(lines) > 10:
|
||||
display = "\n".join(lines[:10])
|
||||
display += f"\n ... ({len(lines) - 10} more lines)"
|
||||
display_lines = display.splitlines()
|
||||
self.assertEqual(len(display_lines), 11)
|
||||
self.assertIn("15 more lines", display_lines[-1])
|
||||
|
||||
def test_exactly_10_lines_not_collapsed(self):
|
||||
reasoning = "\n".join(f"Line {i}" for i in range(10))
|
||||
lines = reasoning.strip().splitlines()
|
||||
self.assertEqual(len(lines), 10)
|
||||
self.assertFalse(len(lines) > 10)
|
||||
|
||||
def test_intermediate_callback_collapses_to_5(self):
|
||||
"""_on_reasoning shows max 5 lines."""
|
||||
reasoning = "\n".join(f"Step {i}" for i in range(12))
|
||||
lines = reasoning.strip().splitlines()
|
||||
if len(lines) > 5:
|
||||
preview = "\n".join(lines[:5])
|
||||
preview += f"\n ... ({len(lines) - 5} more lines)"
|
||||
else:
|
||||
preview = reasoning.strip()
|
||||
preview_lines = preview.splitlines()
|
||||
self.assertEqual(len(preview_lines), 6)
|
||||
self.assertIn("7 more lines", preview_lines[-1])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Reasoning callback
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestReasoningCallback(unittest.TestCase):
|
||||
"""Verify reasoning_callback invocation."""
|
||||
|
||||
def test_callback_invoked_with_reasoning(self):
|
||||
captured = []
|
||||
agent = MagicMock()
|
||||
agent.reasoning_callback = lambda t: captured.append(t)
|
||||
agent._extract_reasoning = MagicMock(return_value="deep thought")
|
||||
|
||||
reasoning_text = agent._extract_reasoning(MagicMock())
|
||||
if reasoning_text and agent.reasoning_callback:
|
||||
agent.reasoning_callback(reasoning_text)
|
||||
self.assertEqual(captured, ["deep thought"])
|
||||
|
||||
def test_callback_not_invoked_without_reasoning(self):
|
||||
captured = []
|
||||
agent = MagicMock()
|
||||
agent.reasoning_callback = lambda t: captured.append(t)
|
||||
agent._extract_reasoning = MagicMock(return_value=None)
|
||||
|
||||
reasoning_text = agent._extract_reasoning(MagicMock())
|
||||
if reasoning_text and agent.reasoning_callback:
|
||||
agent.reasoning_callback(reasoning_text)
|
||||
self.assertEqual(captured, [])
|
||||
|
||||
def test_callback_none_does_not_crash(self):
|
||||
reasoning_text = "some thought"
|
||||
callback = None
|
||||
if reasoning_text and callback:
|
||||
callback(reasoning_text)
|
||||
# No exception = pass
|
||||
|
||||
|
||||
class TestReasoningPreviewBuffering(unittest.TestCase):
|
||||
def _make_cli(self):
|
||||
from cli import HermesCLI
|
||||
|
||||
cli = HermesCLI.__new__(HermesCLI)
|
||||
cli.verbose = True
|
||||
cli._spinner_text = ""
|
||||
cli._reasoning_preview_buf = ""
|
||||
cli._invalidate = lambda *args, **kwargs: None
|
||||
return cli
|
||||
|
||||
@patch("cli._cprint")
|
||||
def test_streamed_reasoning_chunks_wait_for_boundary(self, mock_cprint):
|
||||
cli = self._make_cli()
|
||||
|
||||
cli._on_reasoning("Let")
|
||||
cli._on_reasoning(" me")
|
||||
cli._on_reasoning(" think")
|
||||
|
||||
self.assertEqual(mock_cprint.call_count, 0)
|
||||
|
||||
cli._on_reasoning(" about this.\n")
|
||||
|
||||
self.assertEqual(mock_cprint.call_count, 1)
|
||||
rendered = mock_cprint.call_args[0][0]
|
||||
self.assertIn("[thinking] Let me think about this.", rendered)
|
||||
|
||||
@patch("cli._cprint")
|
||||
def test_pending_reasoning_flushes_when_thinking_stops(self, mock_cprint):
|
||||
cli = self._make_cli()
|
||||
|
||||
cli._on_reasoning("see")
|
||||
cli._on_reasoning(" how")
|
||||
cli._on_reasoning(" this")
|
||||
cli._on_reasoning(" plays")
|
||||
cli._on_reasoning(" out")
|
||||
|
||||
self.assertEqual(mock_cprint.call_count, 0)
|
||||
|
||||
cli._on_thinking("")
|
||||
|
||||
self.assertEqual(mock_cprint.call_count, 1)
|
||||
rendered = mock_cprint.call_args[0][0]
|
||||
self.assertIn("[thinking] see how this plays out", rendered)
|
||||
|
||||
@patch("cli._cprint")
|
||||
@patch("cli.shutil.get_terminal_size", return_value=SimpleNamespace(columns=50))
|
||||
def test_reasoning_preview_compacts_newlines_and_wraps_to_terminal(self, _mock_term, mock_cprint):
|
||||
cli = self._make_cli()
|
||||
|
||||
cli._emit_reasoning_preview(
|
||||
"First line\nstill same thought\n\n\nSecond paragraph with more detail here."
|
||||
)
|
||||
|
||||
rendered = mock_cprint.call_args[0][0]
|
||||
plain = re.sub(r"\x1b\[[0-9;]*m", "", rendered)
|
||||
normalized = " ".join(plain.split())
|
||||
self.assertIn("[thinking] First line still same thought", plain)
|
||||
self.assertIn("Second paragraph with more detail here.", normalized)
|
||||
self.assertNotIn("\n\n\n", plain)
|
||||
|
||||
@patch("cli.shutil.get_terminal_size", return_value=SimpleNamespace(columns=60))
|
||||
def test_reasoning_flush_threshold_tracks_terminal_width(self, _mock_term):
|
||||
cli = self._make_cli()
|
||||
|
||||
cli._reasoning_preview_buf = "a" * 30
|
||||
cli._flush_reasoning_preview(force=False)
|
||||
self.assertEqual(cli._reasoning_preview_buf, "a" * 30)
|
||||
|
||||
|
||||
class TestReasoningDisplayModeSelection(unittest.TestCase):
|
||||
def _make_cli(self, *, show_reasoning=False, streaming_enabled=False, verbose=False):
|
||||
from cli import HermesCLI
|
||||
|
||||
cli = HermesCLI.__new__(HermesCLI)
|
||||
cli.show_reasoning = show_reasoning
|
||||
cli.streaming_enabled = streaming_enabled
|
||||
cli.verbose = verbose
|
||||
cli._stream_reasoning_delta = lambda text: ("stream", text)
|
||||
cli._on_reasoning = lambda text: ("preview", text)
|
||||
return cli
|
||||
|
||||
def test_show_reasoning_non_streaming_uses_final_box_only(self):
|
||||
cli = self._make_cli(show_reasoning=True, streaming_enabled=False, verbose=False)
|
||||
|
||||
self.assertIsNone(cli._current_reasoning_callback())
|
||||
|
||||
def test_show_reasoning_streaming_uses_live_reasoning_box(self):
|
||||
cli = self._make_cli(show_reasoning=True, streaming_enabled=True, verbose=False)
|
||||
|
||||
callback = cli._current_reasoning_callback()
|
||||
self.assertIsNotNone(callback)
|
||||
self.assertEqual(callback("x"), ("stream", "x"))
|
||||
|
||||
def test_verbose_without_show_reasoning_uses_preview_callback(self):
|
||||
cli = self._make_cli(show_reasoning=False, streaming_enabled=False, verbose=True)
|
||||
|
||||
callback = cli._current_reasoning_callback()
|
||||
self.assertIsNotNone(callback)
|
||||
self.assertEqual(callback("x"), ("preview", "x"))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Real provider format extraction
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestExtractReasoningFormats(unittest.TestCase):
|
||||
"""Test _extract_reasoning with real provider response formats."""
|
||||
|
||||
def _get_extractor(self):
|
||||
from run_agent import AIAgent
|
||||
return AIAgent._extract_reasoning
|
||||
|
||||
def test_openrouter_reasoning_details(self):
|
||||
extract = self._get_extractor()
|
||||
msg = SimpleNamespace(
|
||||
reasoning=None,
|
||||
reasoning_content=None,
|
||||
reasoning_details=[
|
||||
{"type": "reasoning.summary", "summary": "Analyzing Python lists."},
|
||||
],
|
||||
)
|
||||
result = extract(None, msg)
|
||||
self.assertIn("Python lists", result)
|
||||
|
||||
def test_deepseek_reasoning_field(self):
|
||||
extract = self._get_extractor()
|
||||
msg = SimpleNamespace(
|
||||
reasoning="Solving step by step.\nx + y = 8.",
|
||||
reasoning_content=None,
|
||||
)
|
||||
result = extract(None, msg)
|
||||
self.assertIn("x + y = 8", result)
|
||||
|
||||
def test_moonshot_reasoning_content(self):
|
||||
extract = self._get_extractor()
|
||||
msg = SimpleNamespace(
|
||||
reasoning_content="Explaining async/await.",
|
||||
)
|
||||
result = extract(None, msg)
|
||||
self.assertIn("async/await", result)
|
||||
|
||||
def test_no_reasoning_returns_none(self):
|
||||
extract = self._get_extractor()
|
||||
msg = SimpleNamespace(content="Hello!")
|
||||
result = extract(None, msg)
|
||||
self.assertIsNone(result)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Inline <think> block extraction fallback
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestInlineThinkBlockExtraction(unittest.TestCase):
|
||||
"""Test _build_assistant_message extracts inline <think> blocks as reasoning
|
||||
when no structured API-level reasoning fields are present."""
|
||||
|
||||
def _build_msg(self, content, reasoning=None, reasoning_content=None, reasoning_details=None, tool_calls=None):
|
||||
"""Create a mock API response message."""
|
||||
msg = SimpleNamespace(content=content, tool_calls=tool_calls)
|
||||
if reasoning is not None:
|
||||
msg.reasoning = reasoning
|
||||
if reasoning_content is not None:
|
||||
msg.reasoning_content = reasoning_content
|
||||
if reasoning_details is not None:
|
||||
msg.reasoning_details = reasoning_details
|
||||
return msg
|
||||
|
||||
def _make_agent(self):
|
||||
"""Create a minimal agent with _build_assistant_message."""
|
||||
from run_agent import AIAgent
|
||||
agent = MagicMock(spec=AIAgent)
|
||||
agent._build_assistant_message = AIAgent._build_assistant_message.__get__(agent)
|
||||
agent._extract_reasoning = AIAgent._extract_reasoning.__get__(agent)
|
||||
agent.verbose_logging = False
|
||||
agent.reasoning_callback = None
|
||||
agent.stream_delta_callback = None # non-streaming by default
|
||||
agent._stream_callback = None # non-streaming by default
|
||||
return agent
|
||||
|
||||
def test_single_think_block_extracted(self):
|
||||
agent = self._make_agent()
|
||||
api_msg = self._build_msg("<think>Let me calculate 2+2=4.</think>The answer is 4.")
|
||||
result = agent._build_assistant_message(api_msg, "stop")
|
||||
self.assertEqual(result["reasoning"], "Let me calculate 2+2=4.")
|
||||
|
||||
def test_multiple_think_blocks_extracted(self):
|
||||
agent = self._make_agent()
|
||||
api_msg = self._build_msg("<think>First thought.</think>Some text<think>Second thought.</think>More text")
|
||||
result = agent._build_assistant_message(api_msg, "stop")
|
||||
self.assertIn("First thought.", result["reasoning"])
|
||||
self.assertIn("Second thought.", result["reasoning"])
|
||||
|
||||
def test_no_think_blocks_no_reasoning(self):
|
||||
agent = self._make_agent()
|
||||
api_msg = self._build_msg("Just a plain response.")
|
||||
result = agent._build_assistant_message(api_msg, "stop")
|
||||
# No structured reasoning AND no inline think blocks → None
|
||||
self.assertIsNone(result["reasoning"])
|
||||
|
||||
def test_structured_reasoning_takes_priority(self):
|
||||
"""When structured API reasoning exists, inline think blocks should NOT override."""
|
||||
agent = self._make_agent()
|
||||
api_msg = self._build_msg(
|
||||
"<think>Inline thought.</think>Response text.",
|
||||
reasoning="Structured reasoning from API.",
|
||||
)
|
||||
result = agent._build_assistant_message(api_msg, "stop")
|
||||
self.assertEqual(result["reasoning"], "Structured reasoning from API.")
|
||||
|
||||
def test_empty_think_block_ignored(self):
|
||||
agent = self._make_agent()
|
||||
api_msg = self._build_msg("<think></think>Hello!")
|
||||
result = agent._build_assistant_message(api_msg, "stop")
|
||||
# Empty think block should not produce reasoning
|
||||
self.assertIsNone(result["reasoning"])
|
||||
|
||||
def test_multiline_think_block(self):
|
||||
agent = self._make_agent()
|
||||
api_msg = self._build_msg("<think>\nStep 1: Analyze.\nStep 2: Solve.\n</think>Done.")
|
||||
result = agent._build_assistant_message(api_msg, "stop")
|
||||
self.assertIn("Step 1: Analyze.", result["reasoning"])
|
||||
self.assertIn("Step 2: Solve.", result["reasoning"])
|
||||
|
||||
def test_callback_fires_for_inline_think(self):
|
||||
"""Reasoning callback should fire when reasoning is extracted from inline think blocks."""
|
||||
agent = self._make_agent()
|
||||
captured = []
|
||||
agent.reasoning_callback = lambda t: captured.append(t)
|
||||
api_msg = self._build_msg("<think>Deep analysis here.</think>Answer.")
|
||||
agent._build_assistant_message(api_msg, "stop")
|
||||
self.assertEqual(len(captured), 1)
|
||||
self.assertIn("Deep analysis", captured[0])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Config defaults
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestConfigDefault(unittest.TestCase):
|
||||
"""Verify config default for show_reasoning."""
|
||||
|
||||
def test_default_config_has_show_reasoning(self):
|
||||
from hermes_cli.config import DEFAULT_CONFIG
|
||||
display = DEFAULT_CONFIG.get("display", {})
|
||||
self.assertIn("show_reasoning", display)
|
||||
# Default ON (July 2026 TTFT-perception change): thinking models
|
||||
# stream reasoning for tens of seconds; hiding it left users staring
|
||||
# at a spinner. The key must exist and be a bool.
|
||||
self.assertTrue(display["show_reasoning"])
|
||||
|
||||
|
||||
class TestCommandRegistered(unittest.TestCase):
|
||||
"""Verify /reasoning is in the COMMANDS dict."""
|
||||
|
||||
def test_reasoning_in_commands(self):
|
||||
from hermes_cli.commands import COMMANDS
|
||||
self.assertIn("/reasoning", COMMANDS)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# End-to-end pipeline
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestEndToEndPipeline(unittest.TestCase):
|
||||
"""Simulate the full pipeline: extraction -> result dict -> display."""
|
||||
|
||||
def test_openrouter_claude_pipeline(self):
|
||||
from run_agent import AIAgent
|
||||
|
||||
api_message = SimpleNamespace(
|
||||
role="assistant",
|
||||
content="Lists support append().",
|
||||
tool_calls=None,
|
||||
reasoning=None,
|
||||
reasoning_content=None,
|
||||
reasoning_details=[
|
||||
{"type": "reasoning.summary", "summary": "Python list methods."},
|
||||
],
|
||||
)
|
||||
|
||||
reasoning = AIAgent._extract_reasoning(None, api_message)
|
||||
self.assertIsNotNone(reasoning)
|
||||
|
||||
messages = [
|
||||
{"role": "user", "content": "How do I add items?"},
|
||||
{"role": "assistant", "content": api_message.content, "reasoning": reasoning},
|
||||
]
|
||||
|
||||
last_reasoning = None
|
||||
for msg in reversed(messages):
|
||||
if msg.get("role") == "user":
|
||||
break
|
||||
if msg.get("role") == "assistant" and msg.get("reasoning"):
|
||||
last_reasoning = msg["reasoning"]
|
||||
break
|
||||
|
||||
result = {
|
||||
"final_response": api_message.content,
|
||||
"last_reasoning": last_reasoning,
|
||||
}
|
||||
|
||||
self.assertIn("last_reasoning", result)
|
||||
self.assertIn("Python list methods", result["last_reasoning"])
|
||||
|
||||
def test_no_reasoning_model_pipeline(self):
|
||||
from run_agent import AIAgent
|
||||
|
||||
api_message = SimpleNamespace(content="Paris.", tool_calls=None)
|
||||
reasoning = AIAgent._extract_reasoning(None, api_message)
|
||||
self.assertIsNone(reasoning)
|
||||
|
||||
result = {"final_response": api_message.content, "last_reasoning": reasoning}
|
||||
self.assertIsNone(result["last_reasoning"])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Duplicate reasoning box prevention (Bug fix: 3 boxes for 1 reasoning)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestReasoningDeltasFiredFlag(unittest.TestCase):
|
||||
"""_build_assistant_message should not re-fire reasoning_callback when
|
||||
reasoning was already streamed via _fire_reasoning_delta."""
|
||||
|
||||
def _make_agent(self):
|
||||
from run_agent import AIAgent
|
||||
agent = AIAgent.__new__(AIAgent)
|
||||
agent.reasoning_callback = None
|
||||
agent.stream_delta_callback = None
|
||||
agent._stream_callback = None
|
||||
agent.verbose_logging = False
|
||||
return agent
|
||||
|
||||
def test_fire_reasoning_delta_calls_callback(self):
|
||||
agent = self._make_agent()
|
||||
captured = []
|
||||
agent.reasoning_callback = lambda t: captured.append(t)
|
||||
agent._fire_reasoning_delta("thinking...")
|
||||
self.assertEqual(captured, ["thinking..."])
|
||||
|
||||
def test_build_assistant_message_skips_callback_when_already_streamed(self):
|
||||
"""When streaming already fired reasoning deltas, the post-stream
|
||||
_build_assistant_message should NOT re-fire the callback."""
|
||||
agent = self._make_agent()
|
||||
captured = []
|
||||
agent.reasoning_callback = lambda t: captured.append(t)
|
||||
agent.stream_delta_callback = lambda t: None # streaming is active
|
||||
|
||||
# Simulate streaming having already fired reasoning
|
||||
|
||||
msg = SimpleNamespace(
|
||||
content="I'll merge that.",
|
||||
tool_calls=None,
|
||||
reasoning_content="Let me merge the PR.",
|
||||
reasoning=None,
|
||||
reasoning_details=None,
|
||||
)
|
||||
agent._build_assistant_message(msg, "stop")
|
||||
|
||||
# Callback should NOT have been fired again
|
||||
self.assertEqual(captured, [])
|
||||
|
||||
def test_build_assistant_message_skips_callback_when_streaming_active(self):
|
||||
"""When streaming is active, callback should NEVER fire from
|
||||
_build_assistant_message — reasoning was already displayed during the
|
||||
stream (either via reasoning_content deltas or content tag extraction).
|
||||
Any missed reasoning is caught by the CLI post-response fallback."""
|
||||
agent = self._make_agent()
|
||||
captured = []
|
||||
agent.reasoning_callback = lambda t: captured.append(t)
|
||||
agent.stream_delta_callback = lambda t: None # streaming active
|
||||
|
||||
# Reasoning came through content tags, not reasoning_content deltas.
|
||||
# Callback should not fire since streaming is active.
|
||||
|
||||
msg = SimpleNamespace(
|
||||
content="I'll merge that.",
|
||||
tool_calls=None,
|
||||
reasoning_content="Let me merge the PR.",
|
||||
reasoning=None,
|
||||
reasoning_details=None,
|
||||
)
|
||||
agent._build_assistant_message(msg, "stop")
|
||||
|
||||
# Callback should NOT fire — streaming is active
|
||||
self.assertEqual(captured, [])
|
||||
|
||||
def test_build_assistant_message_fires_callback_without_streaming(self):
|
||||
"""When no streaming is active, callback always fires for structured
|
||||
reasoning."""
|
||||
agent = self._make_agent()
|
||||
captured = []
|
||||
agent.reasoning_callback = lambda t: captured.append(t)
|
||||
# No streaming
|
||||
agent.stream_delta_callback = None
|
||||
|
||||
msg = SimpleNamespace(
|
||||
content="I'll merge that.",
|
||||
tool_calls=None,
|
||||
reasoning_content="Let me merge the PR.",
|
||||
reasoning=None,
|
||||
reasoning_details=None,
|
||||
)
|
||||
agent._build_assistant_message(msg, "stop")
|
||||
|
||||
self.assertEqual(captured, ["Let me merge the PR."])
|
||||
|
||||
|
||||
class TestReasoningShownThisTurnFlag(unittest.TestCase):
|
||||
"""Post-response reasoning display should be suppressed when reasoning
|
||||
was already shown during streaming in a tool-calling loop."""
|
||||
|
||||
def _make_cli(self):
|
||||
from cli import HermesCLI
|
||||
cli = HermesCLI.__new__(HermesCLI)
|
||||
cli.show_reasoning = True
|
||||
cli.streaming_enabled = True
|
||||
cli._stream_box_opened = False
|
||||
cli._reasoning_box_opened = False
|
||||
cli._reasoning_stream_started = False
|
||||
cli._reasoning_shown_this_turn = False
|
||||
cli._reasoning_buf = ""
|
||||
cli._stream_buf = ""
|
||||
cli._stream_started = False
|
||||
cli._stream_text_ansi = ""
|
||||
cli._stream_prefilt = ""
|
||||
cli._in_reasoning_block = False
|
||||
cli._reasoning_preview_buf = ""
|
||||
return cli
|
||||
|
||||
@patch("cli._cprint")
|
||||
def test_streaming_reasoning_sets_turn_flag(self, mock_cprint):
|
||||
cli = self._make_cli()
|
||||
self.assertFalse(cli._reasoning_shown_this_turn)
|
||||
cli._stream_reasoning_delta("Thinking about it...")
|
||||
self.assertTrue(cli._reasoning_shown_this_turn)
|
||||
|
||||
@patch("cli._cprint")
|
||||
def test_turn_flag_survives_reset_stream_state(self, mock_cprint):
|
||||
"""_reasoning_shown_this_turn must NOT be cleared by
|
||||
_reset_stream_state (called at intermediate turn boundaries)."""
|
||||
cli = self._make_cli()
|
||||
cli._stream_reasoning_delta("Thinking...")
|
||||
self.assertTrue(cli._reasoning_shown_this_turn)
|
||||
|
||||
# Simulate intermediate turn boundary (tool call)
|
||||
cli._reset_stream_state()
|
||||
|
||||
# Flag must persist
|
||||
self.assertTrue(cli._reasoning_shown_this_turn)
|
||||
|
||||
@patch("cli._cprint")
|
||||
def test_turn_flag_cleared_before_new_turn(self, mock_cprint):
|
||||
"""The turn flag should be reset at the start of a new user turn.
|
||||
This happens outside _reset_stream_state, at the call site."""
|
||||
cli = self._make_cli()
|
||||
cli._reasoning_shown_this_turn = True
|
||||
|
||||
# Simulate new user turn setup
|
||||
cli._reset_stream_state()
|
||||
cli._reasoning_shown_this_turn = False # done by process_input
|
||||
|
||||
self.assertFalse(cli._reasoning_shown_this_turn)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,730 @@
|
||||
"""Tests for session resume history display — _display_resumed_history() and
|
||||
_preload_resumed_session().
|
||||
|
||||
Verifies that resuming a session shows a compact recap of the previous
|
||||
conversation with correct formatting, truncation, and config behavior.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
from io import StringIO
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import cli as cli_mod
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||
|
||||
|
||||
def _make_cli(config_overrides=None, env_overrides=None, **kwargs):
|
||||
"""Create a HermesCLI instance with minimal mocking."""
|
||||
import cli as _cli_mod
|
||||
from cli import HermesCLI
|
||||
|
||||
_clean_config = {
|
||||
"model": {
|
||||
"default": "anthropic/claude-opus-4.6",
|
||||
"base_url": "https://openrouter.ai/api/v1",
|
||||
"provider": "auto",
|
||||
},
|
||||
"display": {"compact": False, "tool_progress": "all", "resume_display": "full"},
|
||||
"agent": {},
|
||||
"terminal": {"env_type": "local"},
|
||||
}
|
||||
if config_overrides:
|
||||
for k, v in config_overrides.items():
|
||||
if isinstance(v, dict) and k in _clean_config and isinstance(_clean_config[k], dict):
|
||||
_clean_config[k].update(v)
|
||||
else:
|
||||
_clean_config[k] = v
|
||||
|
||||
clean_env = {"LLM_MODEL": "", "HERMES_MAX_ITERATIONS": ""}
|
||||
if env_overrides:
|
||||
clean_env.update(env_overrides)
|
||||
with (
|
||||
patch("cli.get_tool_definitions", return_value=[]),
|
||||
patch.dict("os.environ", clean_env, clear=False),
|
||||
patch.dict(_cli_mod.__dict__, {"CLI_CONFIG": _clean_config}),
|
||||
):
|
||||
return HermesCLI(**kwargs)
|
||||
|
||||
|
||||
# ── Sample conversation histories for tests ──────────────────────────
|
||||
|
||||
|
||||
def _simple_history():
|
||||
"""Two-turn conversation: user → assistant → user → assistant."""
|
||||
return [
|
||||
{"role": "system", "content": "You are a helpful assistant."},
|
||||
{"role": "user", "content": "What is Python?"},
|
||||
{"role": "assistant", "content": "Python is a high-level programming language."},
|
||||
{"role": "user", "content": "How do I install it?"},
|
||||
{"role": "assistant", "content": "You can install Python from python.org."},
|
||||
]
|
||||
|
||||
|
||||
def _tool_call_history():
|
||||
"""Conversation with tool calls and tool results."""
|
||||
return [
|
||||
{"role": "system", "content": "system prompt"},
|
||||
{"role": "user", "content": "Search for Python tutorials"},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": None,
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": "call_1",
|
||||
"type": "function",
|
||||
"function": {"name": "web_search", "arguments": '{"query":"python tutorials"}'},
|
||||
},
|
||||
{
|
||||
"id": "call_2",
|
||||
"type": "function",
|
||||
"function": {"name": "web_extract", "arguments": '{"urls":["https://example.com"]}'},
|
||||
},
|
||||
],
|
||||
},
|
||||
{"role": "tool", "tool_call_id": "call_1", "content": "Found 5 results..."},
|
||||
{"role": "tool", "tool_call_id": "call_2", "content": "Page content..."},
|
||||
{"role": "assistant", "content": "Here are some great Python tutorials I found."},
|
||||
]
|
||||
|
||||
|
||||
def _large_history(n_exchanges=15):
|
||||
"""Build a history with many exchanges to test truncation."""
|
||||
msgs = [{"role": "system", "content": "system prompt"}]
|
||||
for i in range(n_exchanges):
|
||||
msgs.append({"role": "user", "content": f"Question #{i + 1}: What is item {i + 1}?"})
|
||||
msgs.append({"role": "assistant", "content": f"Answer #{i + 1}: Item {i + 1} is great."})
|
||||
return msgs
|
||||
|
||||
|
||||
def _multimodal_history():
|
||||
"""Conversation with multimodal (image) content."""
|
||||
return [
|
||||
{"role": "system", "content": "system prompt"},
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": "What's in this image?"},
|
||||
{"type": "image_url", "image_url": {"url": "https://example.com/cat.jpg"}},
|
||||
],
|
||||
},
|
||||
{"role": "assistant", "content": "I see a cat in the image."},
|
||||
]
|
||||
|
||||
|
||||
# ── Tests for _display_resumed_history ───────────────────────────────
|
||||
|
||||
|
||||
class TestDisplayResumedHistory:
|
||||
"""_display_resumed_history() renders a Rich panel with conversation recap."""
|
||||
|
||||
def _capture_display(self, cli_obj):
|
||||
"""Run _display_resumed_history and capture the Rich console output."""
|
||||
buf = StringIO()
|
||||
cli_obj.console.file = buf
|
||||
cli_obj._display_resumed_history()
|
||||
return buf.getvalue()
|
||||
|
||||
def test_simple_history_shows_user_and_assistant(self):
|
||||
cli = _make_cli()
|
||||
cli.conversation_history = _simple_history()
|
||||
output = self._capture_display(cli)
|
||||
|
||||
assert "You:" in output
|
||||
assert "Hermes:" in output
|
||||
assert "What is Python?" in output
|
||||
assert "Python is a high-level programming language." in output
|
||||
assert "How do I install it?" in output
|
||||
|
||||
def test_system_messages_hidden(self):
|
||||
cli = _make_cli()
|
||||
cli.conversation_history = _simple_history()
|
||||
output = self._capture_display(cli)
|
||||
|
||||
assert "You are a helpful assistant" not in output
|
||||
|
||||
def test_tool_messages_hidden(self):
|
||||
cli = _make_cli()
|
||||
cli.conversation_history = _tool_call_history()
|
||||
output = self._capture_display(cli)
|
||||
|
||||
# Tool result content should NOT appear
|
||||
assert "Found 5 results" not in output
|
||||
assert "Page content" not in output
|
||||
|
||||
def test_tool_calls_shown_as_summary(self):
|
||||
# Disable tool-only skip so the summary line is rendered for this fixture.
|
||||
cli = _make_cli(config_overrides={"display": {"resume_skip_tool_only": False}})
|
||||
cli.conversation_history = _tool_call_history()
|
||||
import cli as _cli_mod
|
||||
# CLI_CONFIG is read at call-time inside _display_resumed_history, so
|
||||
# apply the override for the duration of the capture, not just at init.
|
||||
with patch.dict(_cli_mod.__dict__, {"CLI_CONFIG": {
|
||||
"display": {"resume_skip_tool_only": False, "resume_display": "full"}
|
||||
}}):
|
||||
output = self._capture_display(cli)
|
||||
|
||||
assert "2 tool calls" in output
|
||||
assert "web_search" in output
|
||||
assert "web_extract" in output
|
||||
|
||||
def test_tool_only_message_skipped_by_default(self):
|
||||
"""Assistant messages with only tool_calls (no text) are skipped when
|
||||
resume_skip_tool_only=True (the default). The summary line is hidden.
|
||||
"""
|
||||
cli = _make_cli()
|
||||
cli.conversation_history = _tool_call_history()
|
||||
output = self._capture_display(cli)
|
||||
|
||||
# The tool-only assistant entry should be skipped
|
||||
assert "2 tool calls" not in output
|
||||
# The final text reply should still appear
|
||||
assert "Here are some great Python tutorials" in output
|
||||
|
||||
def test_long_user_message_truncated(self):
|
||||
cli = _make_cli()
|
||||
long_text = "A" * 500
|
||||
cli.conversation_history = [
|
||||
{"role": "user", "content": long_text},
|
||||
{"role": "assistant", "content": "OK."},
|
||||
]
|
||||
output = self._capture_display(cli)
|
||||
|
||||
# Should have truncation indicator and NOT contain the full 500 chars
|
||||
assert "..." in output
|
||||
assert "A" * 500 not in output
|
||||
# The 300-char truncated text is present but may be line-wrapped by
|
||||
# Rich's panel renderer, so check the total A count in the output
|
||||
a_count = output.count("A")
|
||||
assert 200 <= a_count <= 310 # roughly 300 chars (±panel padding)
|
||||
|
||||
def test_long_assistant_message_truncated(self):
|
||||
"""Non-last assistant messages are still truncated."""
|
||||
cli = _make_cli()
|
||||
long_text = "B" * 400
|
||||
cli.conversation_history = [
|
||||
{"role": "user", "content": "Tell me a lot."},
|
||||
{"role": "assistant", "content": long_text},
|
||||
{"role": "user", "content": "And more?"},
|
||||
{"role": "assistant", "content": "Short final reply."},
|
||||
]
|
||||
output = self._capture_display(cli)
|
||||
|
||||
# The non-last assistant message should be truncated
|
||||
assert "B" * 400 not in output
|
||||
# The last assistant message shown in full
|
||||
assert "Short final reply." in output
|
||||
|
||||
def test_multiline_assistant_truncated(self):
|
||||
"""Non-last multiline assistant messages are truncated to 3 lines."""
|
||||
cli = _make_cli()
|
||||
multi = "\n".join([f"Line {i}" for i in range(20)])
|
||||
cli.conversation_history = [
|
||||
{"role": "user", "content": "Show me lines."},
|
||||
{"role": "assistant", "content": multi},
|
||||
{"role": "user", "content": "What else?"},
|
||||
{"role": "assistant", "content": "Done."},
|
||||
]
|
||||
output = self._capture_display(cli)
|
||||
|
||||
# First 3 lines of non-last assistant should be there
|
||||
assert "Line 0" in output
|
||||
assert "Line 1" in output
|
||||
assert "Line 2" in output
|
||||
# Line 19 should NOT be in the truncated message
|
||||
assert "Line 19" not in output
|
||||
|
||||
def test_last_assistant_response_shown_in_full(self):
|
||||
"""The last assistant response is shown un-truncated so the user
|
||||
knows where they left off without wasting tokens re-asking."""
|
||||
cli = _make_cli()
|
||||
long_text = "X" * 500
|
||||
cli.conversation_history = [
|
||||
{"role": "user", "content": "Tell me everything."},
|
||||
{"role": "assistant", "content": long_text},
|
||||
]
|
||||
output = self._capture_display(cli)
|
||||
|
||||
# Full 500-char text should be present (may be line-wrapped by Rich)
|
||||
x_count = output.count("X")
|
||||
assert x_count >= 490 # allow small Rich formatting variance
|
||||
|
||||
def test_last_assistant_multiline_shown_in_full(self):
|
||||
"""The last assistant response shows all lines, not just 3."""
|
||||
cli = _make_cli()
|
||||
multi = "\n".join([f"Line {i}" for i in range(20)])
|
||||
cli.conversation_history = [
|
||||
{"role": "user", "content": "Show me everything."},
|
||||
{"role": "assistant", "content": multi},
|
||||
]
|
||||
output = self._capture_display(cli)
|
||||
|
||||
# All 20 lines should be present since it's the last response
|
||||
assert "Line 0" in output
|
||||
assert "Line 10" in output
|
||||
assert "Line 19" in output
|
||||
|
||||
def test_large_history_shows_truncation_indicator(self):
|
||||
cli = _make_cli()
|
||||
cli.conversation_history = _large_history(n_exchanges=15)
|
||||
output = self._capture_display(cli)
|
||||
|
||||
# Should show "earlier messages" indicator
|
||||
assert "earlier messages" in output
|
||||
# Last question should still be visible
|
||||
assert "Question #15" in output
|
||||
|
||||
def test_multimodal_content_handled(self):
|
||||
cli = _make_cli()
|
||||
cli.conversation_history = _multimodal_history()
|
||||
output = self._capture_display(cli)
|
||||
|
||||
assert "What's in this image?" in output
|
||||
assert "[image]" in output
|
||||
|
||||
def test_empty_history_no_output(self):
|
||||
cli = _make_cli()
|
||||
cli.conversation_history = []
|
||||
output = self._capture_display(cli)
|
||||
|
||||
assert output.strip() == ""
|
||||
|
||||
def test_minimal_config_suppresses_display(self):
|
||||
cli = _make_cli(config_overrides={"display": {"resume_display": "minimal"}})
|
||||
# resume_display is captured as an instance variable during __init__
|
||||
assert cli.resume_display == "minimal"
|
||||
cli.conversation_history = _simple_history()
|
||||
output = self._capture_display(cli)
|
||||
|
||||
assert output.strip() == ""
|
||||
|
||||
def test_panel_has_title(self):
|
||||
cli = _make_cli()
|
||||
cli.conversation_history = _simple_history()
|
||||
output = self._capture_display(cli)
|
||||
|
||||
assert "Previous Conversation" in output
|
||||
|
||||
def test_panel_is_stored_as_resize_aware_history_entry(self):
|
||||
cli = _make_cli()
|
||||
cli.conversation_history = _simple_history()
|
||||
cli_mod._configure_output_history(True, 10)
|
||||
cli_mod._clear_output_history()
|
||||
|
||||
try:
|
||||
output = self._capture_display(cli)
|
||||
|
||||
assert "Previous Conversation" in output
|
||||
assert len(cli_mod._OUTPUT_HISTORY) == 1
|
||||
assert callable(cli_mod._OUTPUT_HISTORY[0])
|
||||
finally:
|
||||
cli_mod._configure_output_history(True, 200)
|
||||
|
||||
def test_assistant_with_no_content_no_tools_skipped(self):
|
||||
"""Assistant messages with no visible output (e.g. pure reasoning)
|
||||
are skipped in the recap."""
|
||||
cli = _make_cli()
|
||||
cli.conversation_history = [
|
||||
{"role": "user", "content": "Hello"},
|
||||
{"role": "assistant", "content": None},
|
||||
]
|
||||
output = self._capture_display(cli)
|
||||
|
||||
# The assistant entry should be skipped, only the user message shown
|
||||
assert "You:" in output
|
||||
assert "Hermes:" not in output
|
||||
|
||||
def test_only_system_messages_no_output(self):
|
||||
cli = _make_cli()
|
||||
cli.conversation_history = [
|
||||
{"role": "system", "content": "You are helpful."},
|
||||
]
|
||||
output = self._capture_display(cli)
|
||||
|
||||
assert output.strip() == ""
|
||||
|
||||
def test_reasoning_scratchpad_stripped(self):
|
||||
"""<REASONING_SCRATCHPAD> blocks should be stripped from display."""
|
||||
cli = _make_cli()
|
||||
cli.conversation_history = [
|
||||
{"role": "user", "content": "Think about this"},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": (
|
||||
"<REASONING_SCRATCHPAD>\nLet me think step by step.\n"
|
||||
"</REASONING_SCRATCHPAD>\n\nThe answer is 42."
|
||||
),
|
||||
},
|
||||
]
|
||||
output = self._capture_display(cli)
|
||||
|
||||
assert "REASONING_SCRATCHPAD" not in output
|
||||
assert "Let me think step by step" not in output
|
||||
assert "The answer is 42" in output
|
||||
|
||||
def test_pure_reasoning_message_skipped(self):
|
||||
"""Assistant messages that are only reasoning should be skipped."""
|
||||
cli = _make_cli()
|
||||
cli.conversation_history = [
|
||||
{"role": "user", "content": "Hello"},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "<REASONING_SCRATCHPAD>\nJust thinking...\n</REASONING_SCRATCHPAD>",
|
||||
},
|
||||
{"role": "assistant", "content": "Hi there!"},
|
||||
]
|
||||
output = self._capture_display(cli)
|
||||
|
||||
assert "Just thinking" not in output
|
||||
assert "Hi there!" in output
|
||||
|
||||
def test_think_tags_stripped(self):
|
||||
"""<think>...</think> blocks should be stripped from display (#11316)."""
|
||||
cli = _make_cli()
|
||||
cli.conversation_history = [
|
||||
{"role": "user", "content": "Solve this"},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "<think>\nI need to reason carefully here.\n</think>\n\nThe answer is 7.",
|
||||
},
|
||||
]
|
||||
output = self._capture_display(cli)
|
||||
|
||||
assert "<think>" not in output
|
||||
assert "</think>" not in output
|
||||
assert "I need to reason carefully here" not in output
|
||||
assert "The answer is 7" in output
|
||||
|
||||
def test_thinking_tags_stripped(self):
|
||||
"""<thinking>...</thinking> blocks should be stripped from display."""
|
||||
cli = _make_cli()
|
||||
cli.conversation_history = [
|
||||
{"role": "user", "content": "What is 2+2?"},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "<thinking>\nLet me compute: 2 + 2 = 4\n</thinking>\n\nThe answer is 4.",
|
||||
},
|
||||
]
|
||||
output = self._capture_display(cli)
|
||||
|
||||
assert "<thinking>" not in output
|
||||
assert "Let me compute" not in output
|
||||
assert "The answer is 4" in output
|
||||
|
||||
def test_reasoning_tags_stripped(self):
|
||||
"""<reasoning>...</reasoning> blocks should be stripped from display."""
|
||||
cli = _make_cli()
|
||||
cli.conversation_history = [
|
||||
{"role": "user", "content": "Explain gravity"},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": (
|
||||
"<reasoning>\nGravity is a fundamental force...\n</reasoning>\n\n"
|
||||
"Gravity pulls objects together."
|
||||
),
|
||||
},
|
||||
]
|
||||
output = self._capture_display(cli)
|
||||
|
||||
assert "<reasoning>" not in output
|
||||
assert "fundamental force" not in output
|
||||
assert "Gravity pulls objects together" in output
|
||||
|
||||
def test_thought_tags_stripped(self):
|
||||
"""<thought>...</thought> blocks (Gemma 4) should be stripped."""
|
||||
cli = _make_cli()
|
||||
cli.conversation_history = [
|
||||
{"role": "user", "content": "Say hello"},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "<thought>\nInternal thought here.\n</thought>\n\nHello!",
|
||||
},
|
||||
]
|
||||
output = self._capture_display(cli)
|
||||
|
||||
assert "<thought>" not in output
|
||||
assert "Internal thought here" not in output
|
||||
assert "Hello!" in output
|
||||
|
||||
def test_unclosed_think_tag_stripped(self):
|
||||
"""Unclosed <think> (truncated generation) should not leak reasoning."""
|
||||
cli = _make_cli()
|
||||
cli.conversation_history = [
|
||||
{"role": "user", "content": "Truncated response"},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "Some text before.\n<think>\nUnfinished reasoning...",
|
||||
},
|
||||
]
|
||||
output = self._capture_display(cli)
|
||||
|
||||
assert "<think>" not in output
|
||||
assert "Unfinished reasoning" not in output
|
||||
assert "Some text before" in output
|
||||
|
||||
def test_multiple_reasoning_blocks_all_stripped(self):
|
||||
"""Multiple interleaved reasoning blocks are all stripped."""
|
||||
cli = _make_cli()
|
||||
cli.conversation_history = [
|
||||
{"role": "user", "content": "Complex question"},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": (
|
||||
"<think>\nFirst thought.\n</think>\n"
|
||||
"Partial text.\n"
|
||||
"<reasoning>\nSecond thought.\n</reasoning>\n"
|
||||
"Final answer."
|
||||
),
|
||||
},
|
||||
]
|
||||
output = self._capture_display(cli)
|
||||
|
||||
assert "First thought" not in output
|
||||
assert "Second thought" not in output
|
||||
assert "Partial text" in output
|
||||
assert "Final answer" in output
|
||||
|
||||
def test_orphan_closing_think_tag_stripped(self):
|
||||
"""A stray </think> with no matching open should not render to user."""
|
||||
cli = _make_cli()
|
||||
cli.conversation_history = [
|
||||
{"role": "user", "content": "Broken output"},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "some leftover reasoning</think>Visible answer.",
|
||||
},
|
||||
]
|
||||
output = self._capture_display(cli)
|
||||
|
||||
assert "</think>" not in output
|
||||
assert "Visible answer" in output
|
||||
|
||||
def test_assistant_with_text_and_tool_calls(self):
|
||||
"""When an assistant message has both text content AND tool_calls."""
|
||||
cli = _make_cli()
|
||||
cli.conversation_history = [
|
||||
{"role": "user", "content": "Do something complex"},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "Let me search for that.",
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": "call_1",
|
||||
"type": "function",
|
||||
"function": {"name": "terminal", "arguments": '{"command":"ls"}'},
|
||||
}
|
||||
],
|
||||
},
|
||||
]
|
||||
output = self._capture_display(cli)
|
||||
|
||||
assert "Let me search for that." in output
|
||||
assert "1 tool call" in output
|
||||
assert "terminal" in output
|
||||
|
||||
|
||||
# ── Tests for _preload_resumed_session ──────────────────────────────
|
||||
|
||||
|
||||
class TestPreloadResumedSession:
|
||||
"""_preload_resumed_session() loads session from DB early."""
|
||||
|
||||
def test_returns_false_when_not_resumed(self):
|
||||
cli = _make_cli()
|
||||
assert cli._preload_resumed_session() is False
|
||||
|
||||
def test_returns_false_when_no_session_db(self):
|
||||
cli = _make_cli(resume="test_session_id")
|
||||
cli._session_db = None
|
||||
assert cli._preload_resumed_session() is False
|
||||
|
||||
def test_returns_false_when_session_not_found(self):
|
||||
cli = _make_cli(resume="nonexistent_session")
|
||||
mock_db = MagicMock()
|
||||
mock_db.get_session.return_value = None
|
||||
cli._session_db = mock_db
|
||||
|
||||
buf = StringIO()
|
||||
cli.console.file = buf
|
||||
result = cli._preload_resumed_session()
|
||||
|
||||
assert result is False
|
||||
output = buf.getvalue()
|
||||
assert "Session not found" in output
|
||||
|
||||
def test_returns_false_when_session_has_no_messages(self):
|
||||
cli = _make_cli(resume="empty_session")
|
||||
mock_db = MagicMock()
|
||||
mock_db.get_session.return_value = {"id": "empty_session", "title": None}
|
||||
mock_db.get_messages_as_conversation.return_value = []
|
||||
cli._session_db = mock_db
|
||||
|
||||
buf = StringIO()
|
||||
cli.console.file = buf
|
||||
result = cli._preload_resumed_session()
|
||||
|
||||
assert result is False
|
||||
output = buf.getvalue()
|
||||
assert "no messages" in output
|
||||
|
||||
def test_loads_session_successfully(self):
|
||||
cli = _make_cli(resume="good_session")
|
||||
messages = _simple_history()
|
||||
mock_db = MagicMock()
|
||||
mock_db.get_session.return_value = {"id": "good_session", "title": "Test Session"}
|
||||
mock_db.get_messages_as_conversation.return_value = messages
|
||||
cli._session_db = mock_db
|
||||
|
||||
buf = StringIO()
|
||||
cli.console.file = buf
|
||||
result = cli._preload_resumed_session()
|
||||
|
||||
assert result is True
|
||||
assert cli.conversation_history == messages
|
||||
output = buf.getvalue()
|
||||
assert "Resumed session" in output
|
||||
assert "good_session" in output
|
||||
assert "Test Session" in output
|
||||
assert "2 user messages" in output
|
||||
|
||||
def test_reopens_session_in_db(self):
|
||||
cli = _make_cli(resume="reopen_session")
|
||||
messages = [{"role": "user", "content": "hi"}]
|
||||
mock_db = MagicMock()
|
||||
mock_db.get_session.return_value = {"id": "reopen_session", "title": None}
|
||||
mock_db.get_messages_as_conversation.return_value = messages
|
||||
mock_conn = MagicMock()
|
||||
mock_db._conn = mock_conn
|
||||
cli._session_db = mock_db
|
||||
|
||||
buf = StringIO()
|
||||
cli.console.file = buf
|
||||
cli._preload_resumed_session()
|
||||
|
||||
# Should have executed UPDATE to clear ended_at
|
||||
mock_conn.execute.assert_called_once()
|
||||
call_args = mock_conn.execute.call_args
|
||||
assert "ended_at = NULL" in call_args[0][0]
|
||||
mock_conn.commit.assert_called_once()
|
||||
|
||||
def test_singular_user_message_grammar(self):
|
||||
"""1 user message should say 'message' not 'messages'."""
|
||||
cli = _make_cli(resume="one_msg_session")
|
||||
messages = [
|
||||
{"role": "user", "content": "hello"},
|
||||
{"role": "assistant", "content": "hi"},
|
||||
]
|
||||
mock_db = MagicMock()
|
||||
mock_db.get_session.return_value = {"id": "one_msg_session", "title": None}
|
||||
mock_db.get_messages_as_conversation.return_value = messages
|
||||
mock_db._conn = MagicMock()
|
||||
cli._session_db = mock_db
|
||||
|
||||
buf = StringIO()
|
||||
cli.console.file = buf
|
||||
cli._preload_resumed_session()
|
||||
|
||||
output = buf.getvalue()
|
||||
assert "1 user message," in output
|
||||
assert "1 user messages" not in output
|
||||
|
||||
|
||||
# ── Tests for _handle_resume_command recap display ───────────────────
|
||||
|
||||
|
||||
class TestHandleResumeCommandRecap:
|
||||
"""In-session /resume should show the same recap panel as startup resume."""
|
||||
|
||||
def test_resume_command_displays_recap_when_messages_restored(self):
|
||||
cli = _make_cli()
|
||||
cli.session_id = "current_session"
|
||||
messages = _simple_history()
|
||||
|
||||
mock_db = MagicMock()
|
||||
mock_db.get_session.return_value = {"id": "target_session", "title": "Test Session"}
|
||||
mock_db.get_messages_as_conversation.return_value = messages
|
||||
# resolve_resume_session_id passes the id through when no compression chain.
|
||||
mock_db.resolve_resume_session_id.return_value = "target_session"
|
||||
cli._session_db = mock_db
|
||||
|
||||
with (
|
||||
patch("hermes_cli.main._resolve_session_by_name_or_id", return_value="target_session"),
|
||||
patch.object(cli, "_display_resumed_history") as display_mock,
|
||||
):
|
||||
cli._handle_resume_command("/resume test session")
|
||||
|
||||
assert cli.session_id == "target_session"
|
||||
assert cli.conversation_history == messages
|
||||
mock_db.end_session.assert_called_once_with("current_session", "resumed_other")
|
||||
mock_db.reopen_session.assert_called_once_with("target_session")
|
||||
display_mock.assert_called_once_with()
|
||||
|
||||
def test_resume_command_skips_recap_when_session_has_no_messages(self):
|
||||
cli = _make_cli()
|
||||
cli.session_id = "current_session"
|
||||
|
||||
mock_db = MagicMock()
|
||||
mock_db.get_session.return_value = {"id": "target_session", "title": None}
|
||||
mock_db.get_messages_as_conversation.return_value = []
|
||||
mock_db.resolve_resume_session_id.return_value = "target_session"
|
||||
cli._session_db = mock_db
|
||||
|
||||
with (
|
||||
patch("hermes_cli.main._resolve_session_by_name_or_id", return_value="target_session"),
|
||||
patch.object(cli, "_display_resumed_history") as display_mock,
|
||||
):
|
||||
cli._handle_resume_command("/resume target_session")
|
||||
|
||||
display_mock.assert_not_called()
|
||||
|
||||
|
||||
# ── Integration: _init_agent skips when preloaded ────────────────────
|
||||
|
||||
|
||||
class TestInitAgentSkipsPreloaded:
|
||||
"""_init_agent() should skip DB load when history is already populated."""
|
||||
|
||||
def test_init_agent_skips_db_when_preloaded(self):
|
||||
"""If conversation_history is already set, _init_agent should not
|
||||
reload from the DB."""
|
||||
cli = _make_cli(resume="preloaded_session")
|
||||
cli.conversation_history = _simple_history()
|
||||
|
||||
mock_db = MagicMock()
|
||||
cli._session_db = mock_db
|
||||
|
||||
# _init_agent will fail at credential resolution (no real API key),
|
||||
# but the session-loading block should be skipped entirely
|
||||
with patch.object(cli, "_ensure_runtime_credentials", return_value=False):
|
||||
cli._init_agent()
|
||||
|
||||
# get_messages_as_conversation should NOT have been called
|
||||
mock_db.get_messages_as_conversation.assert_not_called()
|
||||
|
||||
|
||||
# ── Config default tests ─────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestResumeDisplayConfig:
|
||||
"""resume_display config option defaults and behavior."""
|
||||
|
||||
def test_default_config_has_resume_display(self):
|
||||
"""DEFAULT_CONFIG in hermes_cli/config.py includes resume_display."""
|
||||
from hermes_cli.config import DEFAULT_CONFIG
|
||||
display = DEFAULT_CONFIG.get("display", {})
|
||||
assert "resume_display" in display
|
||||
assert display["resume_display"] == "full"
|
||||
|
||||
def test_cli_defaults_have_resume_display(self):
|
||||
"""cli.py load_cli_config defaults include resume_display."""
|
||||
from cli import load_cli_config
|
||||
|
||||
with (
|
||||
patch("pathlib.Path.exists", return_value=False),
|
||||
patch.dict("os.environ", {"LLM_MODEL": ""}, clear=False),
|
||||
):
|
||||
config = load_cli_config()
|
||||
|
||||
display = config.get("display", {})
|
||||
assert display.get("resume_display") == "full"
|
||||
@@ -0,0 +1,120 @@
|
||||
"""Tests for /resume status lines going to stderr in quiet mode (#11793).
|
||||
|
||||
The fix in cli._init_agent routes three messages to stderr when
|
||||
``tool_progress_mode == "off"`` (set by ``hermes chat --quiet``):
|
||||
|
||||
* "Session not found: ..."
|
||||
* "↻ Resumed session ... (N user messages, M total messages)"
|
||||
* "Session ... found but has no messages. Starting fresh."
|
||||
|
||||
Interactive mode (tool_progress_mode == "full") still uses ChatConsole.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
|
||||
from cli import HermesCLI
|
||||
|
||||
|
||||
def _make_cli(quiet=False, session_id="20260524_111111_xyz", db=None):
|
||||
"""Build a minimal HermesCLI bound to only what _init_agent needs for
|
||||
the resume code path: _resumed, _session_db, conversation_history,
|
||||
session_id, and tool_progress_mode."""
|
||||
cli = HermesCLI.__new__(HermesCLI)
|
||||
cli.session_id = session_id
|
||||
cli._resumed = True
|
||||
cli.conversation_history = []
|
||||
cli._session_db = db
|
||||
cli.tool_progress_mode = "off" if quiet else "full"
|
||||
cli.session_start = datetime.now()
|
||||
cli.agent = None
|
||||
# We need _init_agent to reach the resume block (line ~4757) but not
|
||||
# proceed into actual AIAgent construction. _ensure_runtime_credentials
|
||||
# must return True (False returns early at line 4743). _install_tool_callbacks,
|
||||
# _ensure_tirith_security are stubbed; the resume block will either return
|
||||
# False (session-not-found) or reach the eventual AIAgent() call which
|
||||
# we'll let raise — we only check stdout/stderr printed BEFORE that.
|
||||
cli._install_tool_callbacks = lambda: None
|
||||
cli._ensure_tirith_security = lambda: None
|
||||
cli._ensure_runtime_credentials = lambda: True
|
||||
return cli
|
||||
|
||||
|
||||
class TestResumeQuietStderr:
|
||||
def test_session_not_found_goes_to_stderr_in_quiet_mode(self, capsys):
|
||||
db = MagicMock()
|
||||
db.get_session.return_value = None
|
||||
cli = _make_cli(quiet=True, db=db)
|
||||
|
||||
with patch("cli._prepare_deferred_agent_startup"):
|
||||
result = cli._init_agent()
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert result is False
|
||||
# stdout must stay clean
|
||||
assert "Session not found" not in captured.out
|
||||
# the resume status goes to stderr
|
||||
assert "Session not found" in captured.err
|
||||
assert "hermes sessions list" in captured.err
|
||||
|
||||
def test_session_not_found_goes_to_stdout_in_full_mode(self, capsys):
|
||||
db = MagicMock()
|
||||
db.get_session.return_value = None
|
||||
cli = _make_cli(quiet=False, db=db)
|
||||
|
||||
with patch("cli._prepare_deferred_agent_startup"):
|
||||
result = cli._init_agent()
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert result is False
|
||||
# Interactive mode keeps the existing _cprint path → stdout.
|
||||
assert "Session not found" in captured.out
|
||||
|
||||
def test_resumed_banner_goes_to_stderr_in_quiet_mode(self, capsys):
|
||||
db = MagicMock()
|
||||
db.get_session.return_value = {"id": "20260524_111111_xyz", "title": "demo"}
|
||||
db.resolve_resume_session_id.return_value = "20260524_111111_xyz"
|
||||
db.get_messages_as_conversation.return_value = [
|
||||
{"role": "user", "content": "hi"},
|
||||
{"role": "assistant", "content": "hey"},
|
||||
]
|
||||
db._conn = MagicMock() # for the reopen execute() call
|
||||
|
||||
cli = _make_cli(quiet=True, db=db)
|
||||
# Stop _init_agent right after the resume banner: prevent it from
|
||||
# constructing a real AIAgent (the next code path).
|
||||
with patch("cli._prepare_deferred_agent_startup"):
|
||||
try:
|
||||
cli._init_agent()
|
||||
except Exception:
|
||||
# The post-resume agent-init machinery may fail in this
|
||||
# stubbed context (no API key, no real config) — we only
|
||||
# care about the printed banner that comes earlier.
|
||||
pass
|
||||
|
||||
captured = capsys.readouterr()
|
||||
# Banner on stderr — stdout stays clean for automation.
|
||||
assert "↻ Resumed session" not in captured.out
|
||||
assert "↻ Resumed session" in captured.err
|
||||
assert "20260524_111111_xyz" in captured.err
|
||||
assert "demo" in captured.err
|
||||
|
||||
def test_no_messages_goes_to_stderr_in_quiet_mode(self, capsys):
|
||||
db = MagicMock()
|
||||
db.get_session.return_value = {"id": "20260524_111111_xyz"}
|
||||
db.resolve_resume_session_id.return_value = "20260524_111111_xyz"
|
||||
db.get_messages_as_conversation.return_value = []
|
||||
db._conn = MagicMock()
|
||||
|
||||
cli = _make_cli(quiet=True, db=db)
|
||||
with patch("cli._prepare_deferred_agent_startup"):
|
||||
try:
|
||||
cli._init_agent()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "has no messages" not in captured.out
|
||||
assert "has no messages" in captured.err
|
||||
assert "Starting fresh" in captured.err
|
||||
@@ -0,0 +1,101 @@
|
||||
"""Tests for /save — the conversation snapshot slash command.
|
||||
|
||||
Regression: the old implementation wrote ``hermes_conversation_<ts>.json``
|
||||
to the current working directory (CWD). Users who ran /save expected the
|
||||
file to be discoverable via ``hermes sessions browse``, but CWD-resident
|
||||
snapshots are not indexed in the state DB and are generally invisible.
|
||||
The fix writes snapshots under ``~/.hermes/sessions/saved/`` and prints
|
||||
the absolute path plus the resume hint for the live session.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def hermes_home(tmp_path, monkeypatch):
|
||||
home = tmp_path / ".hermes"
|
||||
home.mkdir()
|
||||
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
||||
monkeypatch.setenv("HERMES_HOME", str(home))
|
||||
# Clear any cached hermes_home computation
|
||||
import hermes_constants
|
||||
if hasattr(hermes_constants, "_hermes_home_cache"):
|
||||
hermes_constants._hermes_home_cache = None
|
||||
return home
|
||||
|
||||
|
||||
def _make_stub_cli(history):
|
||||
"""Build a minimal object exposing just what save_conversation uses."""
|
||||
return SimpleNamespace(
|
||||
conversation_history=history,
|
||||
model="test-model",
|
||||
session_id="20260101_120000_abc123",
|
||||
session_start=datetime(2026, 1, 1, 12, 0, 0),
|
||||
)
|
||||
|
||||
|
||||
def test_save_conversation_writes_under_hermes_home(hermes_home, tmp_path, monkeypatch, capsys):
|
||||
"""Snapshot must land under ~/.hermes/sessions/saved/, not CWD."""
|
||||
# Change CWD to a different directory to prove the file does NOT go there.
|
||||
work = tmp_path / "somewhere-else"
|
||||
work.mkdir()
|
||||
monkeypatch.chdir(work)
|
||||
|
||||
# Import fresh to pick up the HERMES_HOME fixture
|
||||
for mod in [m for m in sys.modules if m.startswith("cli") or m == "hermes_constants"]:
|
||||
sys.modules.pop(mod, None)
|
||||
|
||||
import cli # noqa: F401 (module under test)
|
||||
|
||||
stub = _make_stub_cli([
|
||||
{"role": "user", "content": "hi"},
|
||||
{"role": "assistant", "content": "hello"},
|
||||
])
|
||||
|
||||
# Call the unbound method against our stub.
|
||||
cli.HermesCLI.save_conversation(stub)
|
||||
|
||||
# File must NOT be in CWD
|
||||
cwd_leak = list(work.glob("hermes_conversation_*.json"))
|
||||
assert not cwd_leak, f"snapshot leaked to CWD: {cwd_leak}"
|
||||
|
||||
# File MUST be under ~/.hermes/sessions/saved/
|
||||
saved_dir = hermes_home / "sessions" / "saved"
|
||||
assert saved_dir.is_dir(), "expected saved/ subdirectory to be created"
|
||||
files = list(saved_dir.glob("hermes_conversation_*.json"))
|
||||
assert len(files) == 1, files
|
||||
|
||||
payload = json.loads(files[0].read_text())
|
||||
assert payload["model"] == "test-model"
|
||||
assert payload["session_id"] == "20260101_120000_abc123"
|
||||
assert payload["messages"] == [
|
||||
{"role": "user", "content": "hi"},
|
||||
{"role": "assistant", "content": "hello"},
|
||||
]
|
||||
|
||||
# User-facing message must include the absolute path AND the resume hint.
|
||||
out = capsys.readouterr().out
|
||||
assert str(files[0]) in out, out
|
||||
assert "hermes --resume 20260101_120000_abc123" in out, out
|
||||
|
||||
|
||||
def test_save_conversation_empty_history_does_nothing(hermes_home, capsys):
|
||||
for mod in [m for m in sys.modules if m.startswith("cli") or m == "hermes_constants"]:
|
||||
sys.modules.pop(mod, None)
|
||||
import cli
|
||||
|
||||
stub = _make_stub_cli([])
|
||||
cli.HermesCLI.save_conversation(stub)
|
||||
|
||||
saved_dir = hermes_home / "sessions" / "saved"
|
||||
assert not saved_dir.exists() or not list(saved_dir.iterdir())
|
||||
out = capsys.readouterr().out
|
||||
assert "No conversation to save" in out
|
||||
@@ -0,0 +1,103 @@
|
||||
from unittest.mock import MagicMock, patch
|
||||
from types import SimpleNamespace
|
||||
from hermes_cli.plugins import VALID_HOOKS, PluginManager
|
||||
from cli import HermesCLI
|
||||
|
||||
|
||||
def test_session_hooks_in_valid_hooks():
|
||||
"""Verify on_session_finalize and on_session_reset are registered as valid hooks."""
|
||||
assert "on_session_finalize" in VALID_HOOKS
|
||||
assert "on_session_reset" in VALID_HOOKS
|
||||
|
||||
|
||||
@patch("hermes_cli.plugins.invoke_hook")
|
||||
def test_session_finalize_on_reset(mock_invoke_hook):
|
||||
"""Verify on_session_finalize fires when /new or /reset is used."""
|
||||
cli = HermesCLI()
|
||||
cli.agent = MagicMock()
|
||||
cli.agent.session_id = "test-session-id"
|
||||
|
||||
# Simulate /new command which triggers on_session_finalize for the old session
|
||||
cli.new_session(silent=True)
|
||||
|
||||
# Check if on_session_finalize was called for the old session
|
||||
assert any(
|
||||
c.args == ("on_session_finalize",)
|
||||
and c.kwargs["session_id"] == "test-session-id"
|
||||
and c.kwargs["platform"] == "cli"
|
||||
for c in mock_invoke_hook.call_args_list
|
||||
)
|
||||
# Check if on_session_reset was called for the new session
|
||||
assert any(
|
||||
c.args == ("on_session_reset",)
|
||||
and c.kwargs["session_id"] == cli.session_id
|
||||
and c.kwargs["platform"] == "cli"
|
||||
for c in mock_invoke_hook.call_args_list
|
||||
)
|
||||
|
||||
|
||||
@patch("hermes_cli.plugins.invoke_hook")
|
||||
def test_session_finalize_on_cleanup(mock_invoke_hook):
|
||||
"""Verify on_session_finalize fires during CLI exit cleanup."""
|
||||
import cli as cli_mod
|
||||
|
||||
mock_agent = MagicMock()
|
||||
mock_agent.session_id = "cleanup-session-id"
|
||||
cli_mod._active_agent_ref = mock_agent
|
||||
cli_mod._cleanup_done = False
|
||||
|
||||
cli_mod._run_cleanup()
|
||||
|
||||
assert any(
|
||||
c.args == ("on_session_finalize",)
|
||||
and c.kwargs["session_id"] == "cleanup-session-id"
|
||||
and c.kwargs["platform"] == "cli"
|
||||
and c.kwargs["reason"] == "shutdown"
|
||||
for c in mock_invoke_hook.call_args_list
|
||||
)
|
||||
|
||||
|
||||
@patch("hermes_cli.plugins.invoke_hook")
|
||||
def test_interrupted_session_end_helper_emits_observer_shape(mock_invoke_hook):
|
||||
"""Verify quiet single-query interruption emits a correlated session end."""
|
||||
import cli as cli_mod
|
||||
|
||||
mock_agent = MagicMock()
|
||||
mock_agent.session_id = "agent-session-id"
|
||||
mock_agent.model = "test-model"
|
||||
mock_agent.platform = "cli"
|
||||
mock_agent._current_task_id = "task-1"
|
||||
mock_agent._current_turn_id = "turn-1"
|
||||
mock_agent._current_api_request_id = "api-1"
|
||||
cli = SimpleNamespace(agent=mock_agent, session_id="cli-session-id")
|
||||
|
||||
cli_mod._emit_interrupted_session_end(cli, reason="keyboard_interrupt")
|
||||
|
||||
mock_agent.interrupt.assert_called_once_with("keyboard interrupt")
|
||||
assert cli.session_id == "agent-session-id"
|
||||
mock_invoke_hook.assert_called_once()
|
||||
call = mock_invoke_hook.call_args
|
||||
assert call.args == ("on_session_end",)
|
||||
assert call.kwargs["session_id"] == "agent-session-id"
|
||||
assert call.kwargs["task_id"] == "task-1"
|
||||
assert call.kwargs["turn_id"] == "turn-1"
|
||||
assert call.kwargs["api_request_id"] == "api-1"
|
||||
assert call.kwargs["completed"] is False
|
||||
assert call.kwargs["interrupted"] is True
|
||||
assert call.kwargs["reason"] == "keyboard_interrupt"
|
||||
|
||||
|
||||
@patch("hermes_cli.plugins.invoke_hook")
|
||||
def test_hook_errors_are_caught(mock_invoke_hook):
|
||||
"""Verify hook exceptions are caught and don't crash the agent."""
|
||||
mgr = PluginManager()
|
||||
|
||||
# Register a hook that raises
|
||||
def bad_callback(**kwargs):
|
||||
raise Exception("Hook failed")
|
||||
|
||||
mgr._hooks["on_session_finalize"] = [bad_callback]
|
||||
|
||||
# This should not raise
|
||||
results = mgr.invoke_hook("on_session_finalize", session_id="test", platform="cli")
|
||||
assert results == []
|
||||
@@ -0,0 +1,270 @@
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
import cli
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def reset_single_query_finalize_state(monkeypatch):
|
||||
monkeypatch.setattr(cli, "_single_query_finalize_attempted_session_ids", set())
|
||||
monkeypatch.setattr(cli, "_cleanup_done", False)
|
||||
|
||||
|
||||
def test_finalize_single_query_runs_cleanup_without_reemitting_finalize_before_release(monkeypatch):
|
||||
calls = []
|
||||
fake_cli = SimpleNamespace(_release_active_session=lambda: calls.append(("release", {})))
|
||||
|
||||
def cleanup(**kwargs):
|
||||
calls.append(("cleanup", kwargs))
|
||||
|
||||
monkeypatch.setattr(
|
||||
cli,
|
||||
"_notify_single_query_session_finalize",
|
||||
lambda _cli: calls.append(("finalize", {})),
|
||||
)
|
||||
monkeypatch.setattr(cli, "_run_cleanup", cleanup)
|
||||
|
||||
cli._finalize_single_query(fake_cli)
|
||||
|
||||
assert calls == [
|
||||
("finalize", {}),
|
||||
("cleanup", {"notify_session_finalize": False}),
|
||||
("release", {}),
|
||||
]
|
||||
|
||||
|
||||
def test_finalize_single_query_releases_session_when_cleanup_fails(monkeypatch):
|
||||
calls = []
|
||||
fake_cli = SimpleNamespace(_release_active_session=lambda: calls.append("release"))
|
||||
|
||||
def cleanup(**kwargs):
|
||||
calls.append("cleanup")
|
||||
raise RuntimeError("cleanup failed")
|
||||
|
||||
monkeypatch.setattr(
|
||||
cli,
|
||||
"_notify_single_query_session_finalize",
|
||||
lambda _cli: calls.append("finalize"),
|
||||
)
|
||||
monkeypatch.setattr(cli, "_run_cleanup", cleanup)
|
||||
|
||||
with pytest.raises(RuntimeError, match="cleanup failed"):
|
||||
cli._finalize_single_query(fake_cli)
|
||||
|
||||
assert calls == ["finalize", "cleanup", "release"]
|
||||
|
||||
|
||||
def test_finalize_single_query_runs_cleanup_when_finalize_hook_fails(monkeypatch):
|
||||
calls = []
|
||||
fake_agent = SimpleNamespace(session_id="agent-session", platform="cli")
|
||||
fake_cli = SimpleNamespace(
|
||||
agent=fake_agent,
|
||||
session_id="cli-session",
|
||||
_release_active_session=lambda: calls.append("release"),
|
||||
)
|
||||
|
||||
def invoke_hook(name, **kwargs):
|
||||
calls.append("finalize")
|
||||
raise RuntimeError("hook failed")
|
||||
|
||||
monkeypatch.setattr("hermes_cli.plugins.invoke_hook", invoke_hook)
|
||||
monkeypatch.setattr(cli, "_run_cleanup", lambda **kwargs: calls.append("cleanup"))
|
||||
|
||||
cli._finalize_single_query(fake_cli)
|
||||
|
||||
assert calls == ["finalize", "cleanup", "release"]
|
||||
|
||||
|
||||
def test_finalize_single_query_signal_window_does_not_reemit_during_atexit(monkeypatch):
|
||||
calls = []
|
||||
fake_agent = SimpleNamespace(session_id="agent-session", platform="cli")
|
||||
fake_cli = SimpleNamespace(
|
||||
agent=fake_agent,
|
||||
session_id="cli-session",
|
||||
_release_active_session=lambda: calls.append(("release", {})),
|
||||
)
|
||||
|
||||
def invoke_hook(name, **kwargs):
|
||||
calls.append((name, kwargs))
|
||||
|
||||
def interrupted_cleanup(**_kwargs):
|
||||
raise KeyboardInterrupt()
|
||||
|
||||
expected_finalize = (
|
||||
"on_session_finalize",
|
||||
{
|
||||
"session_id": "agent-session",
|
||||
"platform": "cli",
|
||||
"reason": "shutdown",
|
||||
},
|
||||
)
|
||||
|
||||
original_run_cleanup = cli._run_cleanup
|
||||
monkeypatch.setattr("hermes_cli.plugins.invoke_hook", invoke_hook)
|
||||
monkeypatch.setattr(cli, "_run_cleanup", interrupted_cleanup)
|
||||
|
||||
with pytest.raises(KeyboardInterrupt):
|
||||
cli._finalize_single_query(fake_cli)
|
||||
|
||||
assert calls == [expected_finalize, ("release", {})]
|
||||
|
||||
# Simulate later atexit cleanup after the interrupted one-shot path. The
|
||||
# active agent may already be unavailable by then.
|
||||
monkeypatch.setattr(cli, "_run_cleanup", original_run_cleanup)
|
||||
monkeypatch.setattr(cli, "_active_agent_ref", None)
|
||||
monkeypatch.setattr(cli, "_reset_terminal_input_modes_on_exit", lambda: None)
|
||||
monkeypatch.setattr(cli, "_cleanup_all_terminals", lambda: None)
|
||||
monkeypatch.setattr(cli, "_cleanup_all_browsers", lambda: None)
|
||||
monkeypatch.setattr("tools.mcp_tool.shutdown_mcp_servers", lambda: None)
|
||||
monkeypatch.setattr("agent.auxiliary_client.shutdown_cached_clients", lambda: None)
|
||||
|
||||
cli._run_cleanup()
|
||||
|
||||
assert calls == [expected_finalize, ("release", {})]
|
||||
|
||||
|
||||
def test_notify_single_query_session_finalize_uses_agent_session(monkeypatch):
|
||||
calls = []
|
||||
fake_agent = SimpleNamespace(session_id="agent-session", platform="cli")
|
||||
fake_cli = SimpleNamespace(agent=fake_agent, session_id="cli-session")
|
||||
|
||||
def invoke_hook(name, **kwargs):
|
||||
calls.append((name, kwargs))
|
||||
|
||||
monkeypatch.setattr("hermes_cli.plugins.invoke_hook", invoke_hook)
|
||||
|
||||
cli._notify_single_query_session_finalize(fake_cli)
|
||||
|
||||
assert calls == [
|
||||
(
|
||||
"on_session_finalize",
|
||||
{
|
||||
"session_id": "agent-session",
|
||||
"platform": "cli",
|
||||
"reason": "shutdown",
|
||||
},
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
def test_human_single_query_main_finalizes_after_query(monkeypatch):
|
||||
calls = []
|
||||
|
||||
import cli as cli_mod
|
||||
|
||||
class _Console:
|
||||
def print(self, *_args, **_kwargs):
|
||||
calls.append("query-label")
|
||||
|
||||
class FakeCLI:
|
||||
def __init__(self, **_kwargs):
|
||||
self.console = _Console()
|
||||
self.session_id = "single-query-session"
|
||||
self.agent = SimpleNamespace(
|
||||
session_id="single-query-session",
|
||||
platform="cli",
|
||||
)
|
||||
|
||||
def _claim_active_session(self, surface, *, stderr=False):
|
||||
calls.append(("claim", surface, stderr))
|
||||
return True
|
||||
|
||||
def _show_security_advisories(self):
|
||||
calls.append("advisories")
|
||||
|
||||
def chat(self, query, images=None):
|
||||
calls.append(("chat", query, images))
|
||||
return "done"
|
||||
|
||||
def _print_exit_summary(self, clear_screen=True):
|
||||
calls.append("summary")
|
||||
|
||||
monkeypatch.setattr(cli_mod, "HermesCLI", FakeCLI)
|
||||
monkeypatch.setattr(cli_mod.atexit, "register", lambda *_args, **_kwargs: None)
|
||||
monkeypatch.setattr(
|
||||
cli_mod,
|
||||
"_finalize_single_query",
|
||||
lambda fake_cli: calls.append(("finalize", fake_cli.session_id)),
|
||||
)
|
||||
|
||||
cli_mod.main(query="hello", quiet=False, toolsets="terminal")
|
||||
|
||||
assert calls == [
|
||||
("claim", "cli", False),
|
||||
"query-label",
|
||||
"advisories",
|
||||
("chat", "hello", None),
|
||||
"summary",
|
||||
("finalize", "single-query-session"),
|
||||
]
|
||||
|
||||
|
||||
def test_quiet_single_query_main_finalizes_while_preserving_exit_code(monkeypatch):
|
||||
calls = []
|
||||
|
||||
import cli as cli_mod
|
||||
|
||||
def run_conversation(*, user_message, conversation_history):
|
||||
calls.append(("run", user_message, conversation_history))
|
||||
return {
|
||||
"final_response": "",
|
||||
"error": "provider failed",
|
||||
"failed": True,
|
||||
}
|
||||
|
||||
class FakeCLI:
|
||||
def __init__(self, **_kwargs):
|
||||
self.provider = "test-provider"
|
||||
self.model = "test-model"
|
||||
self.session_id = "quiet-session"
|
||||
self.conversation_history = []
|
||||
self._active_agent_route_signature = "same-route"
|
||||
self.agent = SimpleNamespace(
|
||||
session_id="quiet-session",
|
||||
platform="cli",
|
||||
quiet_mode=False,
|
||||
suppress_status_output=False,
|
||||
stream_delta_callback=object(),
|
||||
tool_gen_callback=object(),
|
||||
run_conversation=run_conversation,
|
||||
)
|
||||
|
||||
def _claim_active_session(self, surface, *, stderr=False):
|
||||
calls.append(("claim", surface, stderr))
|
||||
return True
|
||||
|
||||
def _ensure_runtime_credentials(self):
|
||||
calls.append("credentials")
|
||||
return True
|
||||
|
||||
def _resolve_turn_agent_config(self, effective_query):
|
||||
calls.append(("resolve", effective_query))
|
||||
return {
|
||||
"signature": "same-route",
|
||||
"model": None,
|
||||
"runtime": None,
|
||||
"request_overrides": None,
|
||||
}
|
||||
|
||||
def _init_agent(self, **kwargs):
|
||||
calls.append(("init", kwargs))
|
||||
return True
|
||||
|
||||
monkeypatch.delenv("HERMES_KANBAN_TASK", raising=False)
|
||||
monkeypatch.delenv("HERMES_KANBAN_GOAL_MODE", raising=False)
|
||||
monkeypatch.setattr(cli_mod, "HermesCLI", FakeCLI)
|
||||
monkeypatch.setattr(cli_mod.atexit, "register", lambda *_args, **_kwargs: None)
|
||||
monkeypatch.setattr(
|
||||
cli_mod,
|
||||
"_finalize_single_query",
|
||||
lambda fake_cli: calls.append(("finalize", fake_cli.session_id)),
|
||||
)
|
||||
|
||||
with pytest.raises(SystemExit) as exc_info:
|
||||
cli_mod.main(query="hello", quiet=True, toolsets="terminal")
|
||||
|
||||
assert exc_info.value.code == 1
|
||||
assert ("claim", "cli", True) in calls
|
||||
assert ("run", "hello", []) in calls
|
||||
assert calls[-1] == ("finalize", "quiet-session")
|
||||
@@ -0,0 +1,113 @@
|
||||
"""Tests for the KeyboardInterrupt guard around slash command dispatch.
|
||||
|
||||
A Ctrl+C during a slow slash command (e.g. /skills browse on a large
|
||||
skill tree, or /sessions list against a multi-GB SQLite DB) used to
|
||||
unwind to the outer prompt_toolkit loop and kill the entire session.
|
||||
The fix wraps `self.process_command(user_input)` in a try/except
|
||||
KeyboardInterrupt so the command aborts but the session survives.
|
||||
|
||||
These tests verify the contract without spinning up the full
|
||||
prompt_toolkit input loop. We exercise the same try/except by calling
|
||||
through a thin wrapper that mirrors the real dispatch shape.
|
||||
"""
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
from cli import HermesCLI
|
||||
|
||||
|
||||
def _make_cli():
|
||||
cli = HermesCLI.__new__(HermesCLI)
|
||||
cli._should_exit = False
|
||||
cli.conversation_history = []
|
||||
cli.agent = None
|
||||
cli._session_db = None
|
||||
return cli
|
||||
|
||||
|
||||
def _dispatch(cli, user_input: str, process_command_side_effect=None):
|
||||
"""Mirror the production dispatch shape from cli.py around line 14236.
|
||||
|
||||
Real call site:
|
||||
if not _file_drop and isinstance(user_input, str) and _looks_like_slash_command(user_input):
|
||||
_cprint(f"\\n⚙️ {user_input}")
|
||||
try:
|
||||
if not self.process_command(user_input):
|
||||
self._should_exit = True
|
||||
if app.is_running:
|
||||
app.exit()
|
||||
except KeyboardInterrupt:
|
||||
_cprint("\\n[dim]Command interrupted.[/dim]")
|
||||
continue
|
||||
"""
|
||||
if process_command_side_effect is not None:
|
||||
with patch.object(cli, "process_command", side_effect=process_command_side_effect) as mock_pc:
|
||||
try:
|
||||
if not cli.process_command(user_input):
|
||||
cli._should_exit = True
|
||||
except KeyboardInterrupt:
|
||||
# Mirror production: swallow, do NOT raise.
|
||||
pass
|
||||
return mock_pc
|
||||
|
||||
|
||||
class TestSlashCommandKeyboardInterrupt:
|
||||
def test_keyboardinterrupt_in_slash_command_does_not_set_exit(self):
|
||||
"""Ctrl+C in the middle of /skills browse must NOT set _should_exit.
|
||||
|
||||
Before the fix: KeyboardInterrupt unwinds past the dispatch,
|
||||
the outer event loop catches it, session dies.
|
||||
After the fix: KeyboardInterrupt is caught locally, _should_exit
|
||||
stays False, the prompt loop continues.
|
||||
"""
|
||||
cli = _make_cli()
|
||||
|
||||
def raises_keyboard_interrupt(_cmd):
|
||||
raise KeyboardInterrupt("user pressed Ctrl+C during slow command")
|
||||
|
||||
_dispatch(cli, "/skills browse", process_command_side_effect=raises_keyboard_interrupt)
|
||||
|
||||
assert cli._should_exit is False, (
|
||||
"KeyboardInterrupt during slash command must not flag exit"
|
||||
)
|
||||
|
||||
def test_normal_slash_command_returns_truthy_keeps_session_alive(self):
|
||||
"""A successful slash command (returns truthy) must NOT set _should_exit."""
|
||||
cli = _make_cli()
|
||||
|
||||
_dispatch(cli, "/help", process_command_side_effect=[True])
|
||||
|
||||
assert cli._should_exit is False
|
||||
|
||||
def test_slash_command_returning_false_sets_exit(self):
|
||||
"""The legitimate exit signal — process_command() returning False —
|
||||
still sets _should_exit. This is the path /exit / /quit use."""
|
||||
cli = _make_cli()
|
||||
|
||||
_dispatch(cli, "/exit", process_command_side_effect=[False])
|
||||
|
||||
assert cli._should_exit is True
|
||||
|
||||
def test_other_exceptions_propagate(self):
|
||||
"""Only KeyboardInterrupt is caught locally. Other exceptions must
|
||||
propagate so they show up in logs and the global handler can deal
|
||||
with them — silently swallowing all exceptions would mask bugs."""
|
||||
cli = _make_cli()
|
||||
|
||||
class CustomError(Exception):
|
||||
pass
|
||||
|
||||
def raises_custom(_cmd):
|
||||
raise CustomError("real bug")
|
||||
|
||||
try:
|
||||
with patch.object(cli, "process_command", side_effect=raises_custom):
|
||||
try:
|
||||
if not cli.process_command("/something"):
|
||||
cli._should_exit = True
|
||||
except KeyboardInterrupt:
|
||||
pass # would NOT catch CustomError
|
||||
except CustomError:
|
||||
return # expected — non-KBI exceptions propagate
|
||||
|
||||
raise AssertionError("CustomError should have propagated")
|
||||
@@ -0,0 +1,404 @@
|
||||
"""Regression tests for #30768, #32383, and #33961.
|
||||
|
||||
``_prompt_text_input_modal`` answers destructive-slash confirmations through a
|
||||
queue-based modal driven by prompt_toolkit key bindings. When invoked from the
|
||||
``process_loop`` daemon thread it sets the modal up on the app's event loop via
|
||||
``call_soon_threadsafe``, so it is safe on every platform — including native
|
||||
Windows (#33961), where the earlier ``sys.platform == "win32"`` → raw ``input()``
|
||||
fallback deadlocked the daemon thread against prompt_toolkit's stdin ownership.
|
||||
|
||||
These tests verify:
|
||||
1. Daemon-thread confirm uses the modal via the app loop on Linux AND native
|
||||
Windows (#33961) — never the raw stdin fallback, never a hang.
|
||||
2. Main-thread confirm with a running app uses the modal.
|
||||
3. The raw stdin fallback is kept ONLY for the safe cases: no running app, and
|
||||
(on win32, off-thread) a scheduling failure degrades to a clean cancel.
|
||||
4. Empty choices returns None.
|
||||
"""
|
||||
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def _make_cli():
|
||||
"""Minimal HermesCLI shell exposing the prompt/modal helpers."""
|
||||
import cli as cli_mod
|
||||
|
||||
obj = object.__new__(cli_mod.HermesCLI)
|
||||
obj._app = MagicMock()
|
||||
obj._app.loop = MagicMock()
|
||||
obj._status_bar_visible = True
|
||||
obj._last_invalidate = 0.0
|
||||
obj._modal_input_snapshot = None
|
||||
obj._slash_confirm_state = None
|
||||
obj._slash_confirm_deadline = 0
|
||||
return obj
|
||||
|
||||
|
||||
_SAMPLE_CHOICES = [
|
||||
("once", "Approve Once", "proceed this time only"),
|
||||
("always", "Always Approve", "proceed and silence this prompt permanently"),
|
||||
("cancel", "Cancel", "keep current conversation"),
|
||||
]
|
||||
|
||||
|
||||
def _answer_modal_when_open(cli, response, stop=None):
|
||||
"""Push ``response`` onto the modal's response_queue once it opens.
|
||||
|
||||
Gives up after ~2s, or early when ``stop`` is set (the modal will never open,
|
||||
e.g. a scheduling failure) so degraded-path tests don't wait the full budget.
|
||||
"""
|
||||
for _ in range(100):
|
||||
if stop is not None and stop.is_set():
|
||||
return
|
||||
state = cli._slash_confirm_state
|
||||
if state and "response_queue" in state:
|
||||
state["response_queue"].put(response)
|
||||
return
|
||||
time.sleep(0.02)
|
||||
|
||||
|
||||
def _run_on_daemon(call, cli, *, platform, response, schedule=None):
|
||||
"""Invoke ``call`` on a daemon thread — as the process_loop does — answering
|
||||
the modal with ``response`` once it opens.
|
||||
|
||||
Returns ``{result, stdin_called, capture, restore}``. ``schedule`` overrides
|
||||
the ``call_soon_threadsafe`` side effect (default: run the callback inline);
|
||||
pass a raiser to simulate a scheduling failure. Fails if the worker hangs,
|
||||
which is the deadlock canary for #33961.
|
||||
"""
|
||||
outcome = {"capture": [], "restore": [], "result": None, "stdin_called": False}
|
||||
done = threading.Event()
|
||||
|
||||
def _worker():
|
||||
try:
|
||||
with patch.object(sys, "platform", platform), \
|
||||
patch.object(cli._app.loop, "call_soon_threadsafe", side_effect=schedule or (lambda cb: cb())), \
|
||||
patch.object(cli, "_prompt_text_input") as mock_stdin, \
|
||||
patch.object(cli, "_invalidate"), \
|
||||
patch.object(cli, "_capture_modal_input_snapshot", side_effect=lambda: outcome["capture"].append(1)), \
|
||||
patch.object(cli, "_restore_modal_input_snapshot", side_effect=lambda: outcome["restore"].append(1)):
|
||||
outcome["result"] = call()
|
||||
outcome["stdin_called"] = mock_stdin.called
|
||||
finally:
|
||||
done.set()
|
||||
|
||||
worker = threading.Thread(target=_worker, daemon=True)
|
||||
answerer = threading.Thread(target=_answer_modal_when_open, args=(cli, response, done), daemon=True)
|
||||
answerer.start()
|
||||
worker.start()
|
||||
worker.join(timeout=2.0)
|
||||
answerer.join(timeout=2.0)
|
||||
assert not worker.is_alive(), "daemon thread hung — modal deadlocked"
|
||||
return outcome
|
||||
|
||||
|
||||
class TestModal:
|
||||
"""Behaviour of _prompt_text_input_modal across platforms and threads."""
|
||||
|
||||
@pytest.mark.parametrize("platform", ["linux", "win32"])
|
||||
def test_daemon_thread_uses_modal_via_app_loop(self, platform):
|
||||
"""Off the process_loop daemon thread, the confirm uses the modal via
|
||||
call_soon_threadsafe on every platform — including native Windows, where
|
||||
the old win32 early-return deadlocked on raw input() (#33961)."""
|
||||
cli = _make_cli()
|
||||
outcome = _run_on_daemon(
|
||||
lambda: cli._prompt_text_input_modal(
|
||||
title="⚠️ /reset",
|
||||
detail="This starts a fresh session.",
|
||||
choices=_SAMPLE_CHOICES,
|
||||
timeout=5,
|
||||
),
|
||||
cli,
|
||||
platform=platform,
|
||||
response="once",
|
||||
)
|
||||
assert outcome["stdin_called"] is False, "must use the modal, not raw input()"
|
||||
assert outcome["result"] == "once"
|
||||
assert outcome["capture"] == [1]
|
||||
assert outcome["restore"] == [1]
|
||||
assert cli._slash_confirm_state is None
|
||||
|
||||
def test_main_thread_with_app_uses_modal(self):
|
||||
"""On the main thread with a running app, the queue-based modal is used."""
|
||||
cli = _make_cli()
|
||||
with patch.object(sys, "platform", "darwin"), \
|
||||
patch.object(cli, "_capture_modal_input_snapshot"), \
|
||||
patch.object(cli, "_restore_modal_input_snapshot"), \
|
||||
patch.object(cli, "_invalidate"), \
|
||||
patch.object(cli, "_prompt_text_input") as mock_stdin:
|
||||
answerer = threading.Thread(target=_answer_modal_when_open, args=(cli, "once"), daemon=True)
|
||||
answerer.start()
|
||||
result = cli._prompt_text_input_modal(
|
||||
title="⚠️ /new",
|
||||
detail="This starts a fresh session.",
|
||||
choices=_SAMPLE_CHOICES,
|
||||
timeout=5,
|
||||
)
|
||||
answerer.join(timeout=2.0)
|
||||
|
||||
mock_stdin.assert_not_called()
|
||||
assert result == "once"
|
||||
|
||||
def test_no_app_falls_back_to_stdin(self):
|
||||
"""Without a running app (oneshot / non-interactive), use the stdin prompt."""
|
||||
cli = _make_cli()
|
||||
cli._app = None
|
||||
|
||||
with patch.object(cli, "_prompt_text_input", return_value="3") as mock_stdin:
|
||||
result = cli._prompt_text_input_modal(
|
||||
title="⚠️ /clear",
|
||||
detail="This clears the screen.",
|
||||
choices=_SAMPLE_CHOICES,
|
||||
)
|
||||
|
||||
mock_stdin.assert_called_once_with("Choice [1/2/3]: ")
|
||||
assert result == "3"
|
||||
|
||||
def test_windows_no_app_falls_back_to_stdin(self):
|
||||
"""win32 without a running app keeps stdin — the only case where the raw
|
||||
prompt is safe on Windows, since no app owns the console to deadlock."""
|
||||
cli = _make_cli()
|
||||
cli._app = None
|
||||
|
||||
with patch.object(sys, "platform", "win32"), \
|
||||
patch.object(cli, "_prompt_text_input", return_value="1") as mock_stdin:
|
||||
result = cli._prompt_text_input_modal(
|
||||
title="⚠️ /new — destroys conversation state",
|
||||
detail="This starts a fresh session.",
|
||||
choices=_SAMPLE_CHOICES,
|
||||
)
|
||||
|
||||
mock_stdin.assert_called_once_with("Choice [1/2/3]: ")
|
||||
assert result == "1"
|
||||
|
||||
def test_windows_scheduling_failure_clean_cancels(self):
|
||||
"""win32 off the main thread: if marshaling onto the app loop fails, cancel
|
||||
cleanly (None) rather than fall to raw input() (which deadlocks on native
|
||||
Windows) or hang. Asserts the _stdin_fallback guard (#33961)."""
|
||||
cli = _make_cli()
|
||||
|
||||
def _raise(_cb):
|
||||
raise RuntimeError("loop closed")
|
||||
|
||||
outcome = _run_on_daemon(
|
||||
lambda: cli._prompt_text_input_modal(
|
||||
title="⚠️ /reset",
|
||||
detail="This starts a fresh session.",
|
||||
choices=_SAMPLE_CHOICES,
|
||||
timeout=5,
|
||||
),
|
||||
cli,
|
||||
platform="win32",
|
||||
response="once",
|
||||
schedule=_raise,
|
||||
)
|
||||
assert outcome["stdin_called"] is False, "win32 off-thread must NOT call raw input()"
|
||||
assert outcome["result"] is None
|
||||
assert cli._slash_confirm_state is None
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"platform, expect_stdin, expect_result",
|
||||
[("win32", False, None), ("linux", True, "1")],
|
||||
)
|
||||
def test_daemon_thread_no_app_loop_uses_fallback(self, platform, expect_stdin, expect_result):
|
||||
"""Off the daemon thread with no resolvable app loop (``self._app.loop``
|
||||
is None / raises), the modal can never be scheduled, so the method short-
|
||||
circuits at the app_loop-is-None site (cli.py ~7260) — a distinct path
|
||||
from a call_soon_threadsafe failure. win32 clean-cancels (None) instead of
|
||||
deadlocking on raw input(); other platforms keep the stdin prompt."""
|
||||
cli = _make_cli()
|
||||
cli._app.loop = None # forces app_loop is None, off the main thread
|
||||
|
||||
outcome = {"result": None, "stdin_called": False}
|
||||
done = threading.Event()
|
||||
|
||||
def _worker():
|
||||
try:
|
||||
with patch.object(sys, "platform", platform), \
|
||||
patch.object(cli, "_prompt_text_input", return_value="1") as mock_stdin, \
|
||||
patch.object(cli, "_invalidate"):
|
||||
outcome["result"] = cli._prompt_text_input_modal(
|
||||
title="⚠️ /reset",
|
||||
detail="This starts a fresh session.",
|
||||
choices=_SAMPLE_CHOICES,
|
||||
timeout=5,
|
||||
)
|
||||
outcome["stdin_called"] = mock_stdin.called
|
||||
finally:
|
||||
done.set()
|
||||
|
||||
worker = threading.Thread(target=_worker, daemon=True)
|
||||
worker.start()
|
||||
worker.join(timeout=2.0)
|
||||
assert not worker.is_alive(), "daemon thread hung — modal deadlocked"
|
||||
assert outcome["stdin_called"] is expect_stdin
|
||||
assert outcome["result"] == expect_result
|
||||
assert cli._slash_confirm_state is None
|
||||
|
||||
def test_empty_choices_returns_none(self):
|
||||
"""Empty choices returns None without prompting."""
|
||||
cli = _make_cli()
|
||||
|
||||
with patch.object(cli, "_prompt_text_input") as mock_stdin:
|
||||
result = cli._prompt_text_input_modal(title="Test", detail="Test", choices=[])
|
||||
|
||||
mock_stdin.assert_not_called()
|
||||
assert result is None
|
||||
|
||||
|
||||
class TestConfirmDestructiveSlashWindows:
|
||||
"""End-to-end _confirm_destructive_slash on the native-Windows daemon thread."""
|
||||
|
||||
def _make_interactive_cli(self):
|
||||
cli = _make_cli()
|
||||
cli.model = "test-model"
|
||||
cli._agent_running = False
|
||||
cli._spinner_text = ""
|
||||
cli._should_exit = False
|
||||
cli._command_running = False
|
||||
cli.session_id = "test-session"
|
||||
cli._pending_tool_info = {}
|
||||
cli._tool_start_time = 0.0
|
||||
cli._last_scrollback_tool = ""
|
||||
return cli
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"response, expected",
|
||||
[("once", "once"), ("cancel", None)],
|
||||
)
|
||||
def test_confirm_destructive_slash_uses_modal_on_windows(self, response, expected):
|
||||
"""On native Windows, the bare /new confirm drives the modal (not stdin)
|
||||
and returns the chosen outcome — the bug #33961 froze this path."""
|
||||
cli = self._make_interactive_cli()
|
||||
with patch("cli.load_cli_config", return_value={"approvals": {"destructive_slash_confirm": True}}):
|
||||
outcome = _run_on_daemon(
|
||||
lambda: cli._confirm_destructive_slash(
|
||||
"new",
|
||||
"This starts a fresh session.\nThe current conversation history will be discarded.",
|
||||
),
|
||||
cli,
|
||||
platform="win32",
|
||||
response=response,
|
||||
)
|
||||
|
||||
assert outcome["stdin_called"] is False
|
||||
assert outcome["result"] == expected
|
||||
|
||||
|
||||
class TestNativeWindowsNoRawInputDeadlock:
|
||||
"""Anti-regression guard exercising the REAL ``_prompt_text_input``.
|
||||
|
||||
Every other test here mocks ``_prompt_text_input`` away, so they only
|
||||
assert *routing* (modal vs. stdin) — they cannot observe the actual hang
|
||||
that #33961 was. The historical regression was precisely that
|
||||
``_prompt_text_input_modal`` delegated to the *real* ``_prompt_text_input``
|
||||
on native Windows, which on a non-main thread runs a bare ``input()`` that
|
||||
blocks forever against prompt_toolkit's stdin ownership.
|
||||
|
||||
These tests let the real ``_prompt_text_input`` run with a blocking
|
||||
``input()`` and assert the worker thread never hangs. They fail on the
|
||||
pre-#33961 code (win32 → ``_prompt_text_input`` → off-main ``input()``)
|
||||
and pass once the modal path / clean-cancel fallback is in place.
|
||||
"""
|
||||
|
||||
def test_win32_daemon_thread_never_blocks_on_real_input(self):
|
||||
"""A blocking input() must NOT hang the daemon thread on win32.
|
||||
|
||||
Drives the genuine helper chain (no mock of ``_prompt_text_input``)
|
||||
with ``builtins.input`` patched to block forever. The confirm must
|
||||
resolve via the app-loop modal (answered on a background thread, as
|
||||
the real key bindings would) and never sit in ``input()``. On the
|
||||
pre-#33961 code the win32 early-return routed to the real
|
||||
``_prompt_text_input`` → off-main ``input()`` → permanent hang.
|
||||
"""
|
||||
cli = _make_cli()
|
||||
cli._app.loop.call_soon_threadsafe = lambda cb: cb()
|
||||
|
||||
def _blocking_input(prompt=""): # stands in for "no line ever arrives"
|
||||
time.sleep(30)
|
||||
return "1"
|
||||
|
||||
outcome = {}
|
||||
done = threading.Event()
|
||||
|
||||
def _worker():
|
||||
try:
|
||||
with patch.object(sys, "platform", "win32"), \
|
||||
patch("builtins.input", side_effect=_blocking_input), \
|
||||
patch.object(cli, "_capture_modal_input_snapshot"), \
|
||||
patch.object(cli, "_restore_modal_input_snapshot"), \
|
||||
patch.object(cli, "_invalidate"):
|
||||
outcome["result"] = cli._prompt_text_input_modal(
|
||||
title="/new",
|
||||
detail="destroys conversation state",
|
||||
choices=_SAMPLE_CHOICES,
|
||||
timeout=3,
|
||||
)
|
||||
finally:
|
||||
done.set()
|
||||
|
||||
worker = threading.Thread(target=_worker, daemon=True)
|
||||
answerer = threading.Thread(
|
||||
target=_answer_modal_when_open, args=(cli, "cancel", done), daemon=True
|
||||
)
|
||||
answerer.start()
|
||||
worker.start()
|
||||
worker.join(timeout=5.0)
|
||||
answerer.join(timeout=5.0)
|
||||
assert not worker.is_alive(), (
|
||||
"daemon thread hung in real input() — native-Windows confirm "
|
||||
"deadlock regressed (#33961)"
|
||||
)
|
||||
# cancel → None; the point is it RETURNED rather than blocking forever.
|
||||
assert outcome.get("result") in (None, "cancel")
|
||||
|
||||
def test_win32_scheduling_failure_cleanly_cancels_no_input(self):
|
||||
"""If the modal can't be marshaled onto the app loop on native Windows
|
||||
(scheduling failure) the off-main-thread path must cancel cleanly —
|
||||
NOT fall through to a blocking raw ``input()``.
|
||||
|
||||
This is the degraded branch the pre-#33961 code handled with
|
||||
``return self._prompt_text_input(...)`` (which deadlocks); the fix
|
||||
returns ``None`` instead.
|
||||
"""
|
||||
cli = _make_cli()
|
||||
|
||||
def _raise(cb): # call_soon_threadsafe scheduling failure
|
||||
raise RuntimeError("event loop closed")
|
||||
|
||||
cli._app.loop.call_soon_threadsafe = _raise
|
||||
|
||||
input_called = {"n": 0}
|
||||
|
||||
def _tracking_input(prompt=""):
|
||||
input_called["n"] += 1
|
||||
time.sleep(30)
|
||||
return "1"
|
||||
|
||||
outcome = {}
|
||||
|
||||
def _worker():
|
||||
with patch.object(sys, "platform", "win32"), \
|
||||
patch("builtins.input", side_effect=_tracking_input), \
|
||||
patch.object(cli, "_invalidate"):
|
||||
outcome["result"] = cli._prompt_text_input_modal(
|
||||
title="/new",
|
||||
detail="destroys conversation state",
|
||||
choices=_SAMPLE_CHOICES,
|
||||
timeout=3,
|
||||
)
|
||||
|
||||
worker = threading.Thread(target=_worker, daemon=True)
|
||||
worker.start()
|
||||
worker.join(timeout=5.0)
|
||||
assert not worker.is_alive(), (
|
||||
"daemon thread hung — win32 scheduling-failure fallback used raw "
|
||||
"input() instead of cleanly cancelling (#33961)"
|
||||
)
|
||||
assert input_called["n"] == 0, "win32 off-thread fallback must not call input()"
|
||||
assert outcome.get("result") is None
|
||||
@@ -0,0 +1,116 @@
|
||||
"""Regression guard for issue #34569 — inline /steer (and /model) submit
|
||||
must repaint the input area after clearing the buffer.
|
||||
|
||||
Mechanism of the bug
|
||||
--------------------
|
||||
``handle_enter`` dispatches ``/steer`` (and ``/model``) inline on the UI
|
||||
thread while the agent is running. Those branches called
|
||||
``buffer.reset(append_to_history=True)`` but — unlike every *other*
|
||||
early-return branch in the handler — did NOT call ``event.app.invalidate()``.
|
||||
Because ``process_command()`` prints through ``patch_stdout`` (which scrolls
|
||||
output above the prompt and never triggers a prompt_toolkit redraw), the
|
||||
just-cleared input area could keep showing the submitted ``/steer <text>``
|
||||
until some unrelated redraw fired. The user saw their submitted text as if
|
||||
it were unsent and could accidentally re-submit it.
|
||||
|
||||
This test pins the contract structurally: inside ``handle_enter``, any
|
||||
inline-command early-return that resets the buffer must be followed by an
|
||||
``event.app.invalidate()`` before its ``return``. It is an *invariant*
|
||||
(every reset-then-return repaints), not a snapshot of current source.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def _load_handle_enter_node() -> ast.FunctionDef:
|
||||
"""Extract the ``handle_enter`` nested function node from cli.py."""
|
||||
cli_path = Path(__file__).resolve().parents[2] / "cli.py"
|
||||
tree = ast.parse(cli_path.read_text(encoding="utf-8"))
|
||||
|
||||
target = None
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.FunctionDef) and node.name == "handle_enter":
|
||||
target = node
|
||||
break
|
||||
assert target is not None, "handle_enter closure not found in cli.py"
|
||||
return target
|
||||
|
||||
|
||||
def _is_buffer_reset(node: ast.stmt) -> bool:
|
||||
"""True if the statement is ``...current_buffer.reset(...)``."""
|
||||
if not isinstance(node, ast.Expr):
|
||||
return False
|
||||
call = node.value
|
||||
if not isinstance(call, ast.Call):
|
||||
return False
|
||||
func = call.func
|
||||
return isinstance(func, ast.Attribute) and func.attr == "reset"
|
||||
|
||||
|
||||
def _is_invalidate(node: ast.stmt) -> bool:
|
||||
"""True if the statement is ``event.app.invalidate()``."""
|
||||
if not isinstance(node, ast.Expr):
|
||||
return False
|
||||
call = node.value
|
||||
if not isinstance(call, ast.Call):
|
||||
return False
|
||||
func = call.func
|
||||
return isinstance(func, ast.Attribute) and func.attr == "invalidate"
|
||||
|
||||
|
||||
def _collect_reset_blocks(func: ast.FunctionDef) -> list[list[ast.stmt]]:
|
||||
"""Find every statement sequence (a block body/orelse/finalbody) within
|
||||
``handle_enter`` that contains a ``buffer.reset()`` call."""
|
||||
blocks: list[list[ast.stmt]] = []
|
||||
for node in ast.walk(func):
|
||||
for attr in ("body", "orelse", "finalbody"):
|
||||
seq = getattr(node, attr, None)
|
||||
if not isinstance(seq, list):
|
||||
continue
|
||||
if any(isinstance(s, ast.stmt) and _is_buffer_reset(s) for s in seq):
|
||||
blocks.append(seq)
|
||||
return blocks
|
||||
|
||||
|
||||
def test_inline_command_reset_branches_invalidate():
|
||||
"""Every handle_enter branch that resets the buffer and then returns must
|
||||
invalidate the app first (issue #34569)."""
|
||||
func = _load_handle_enter_node()
|
||||
reset_blocks = _collect_reset_blocks(func)
|
||||
|
||||
assert reset_blocks, "expected to find buffer.reset() calls in handle_enter"
|
||||
|
||||
offenders = []
|
||||
for seq in reset_blocks:
|
||||
for i, stmt in enumerate(seq):
|
||||
if not _is_buffer_reset(stmt):
|
||||
continue
|
||||
# Find the next return after this reset in the same block.
|
||||
ret_idx = None
|
||||
for j in range(i + 1, len(seq)):
|
||||
if isinstance(seq[j], ast.Return):
|
||||
ret_idx = j
|
||||
break
|
||||
if ret_idx is None:
|
||||
# reset not directly followed by a return in this block
|
||||
# (e.g. the fall-through reset at the end of the handler) —
|
||||
# the next user input naturally repaints, so skip.
|
||||
continue
|
||||
between = seq[i + 1 : ret_idx]
|
||||
if not any(_is_invalidate(s) for s in between):
|
||||
offenders.append(ast.dump(stmt))
|
||||
|
||||
assert not offenders, (
|
||||
"handle_enter has reset-then-return branch(es) that never call "
|
||||
"event.app.invalidate() — the input area can keep showing the "
|
||||
"submitted text (issue #34569). Offending reset stmts:\n"
|
||||
+ "\n".join(offenders)
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
test_inline_command_reset_branches_invalidate()
|
||||
print("ok")
|
||||
@@ -0,0 +1,152 @@
|
||||
"""Tests for _stream_delta's handling of <think> tags in prose vs real reasoning blocks."""
|
||||
import sys
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", ".."))
|
||||
|
||||
|
||||
|
||||
def _make_cli_stub():
|
||||
"""Create a minimal HermesCLI-like object with stream state."""
|
||||
from cli import HermesCLI
|
||||
|
||||
cli = HermesCLI.__new__(HermesCLI)
|
||||
cli.show_reasoning = False
|
||||
cli._stream_buf = ""
|
||||
cli._stream_started = False
|
||||
cli._stream_box_opened = False
|
||||
cli._stream_prefilt = ""
|
||||
cli._in_reasoning_block = False
|
||||
cli._reasoning_stream_started = False
|
||||
cli._reasoning_box_opened = False
|
||||
cli._reasoning_buf = ""
|
||||
cli._reasoning_preview_buf = ""
|
||||
cli._deferred_content = ""
|
||||
cli._stream_text_ansi = ""
|
||||
cli._stream_needs_break = False
|
||||
cli._emitted = []
|
||||
|
||||
# Mock _emit_stream_text to capture output
|
||||
def mock_emit(text):
|
||||
cli._emitted.append(text)
|
||||
cli._emit_stream_text = mock_emit
|
||||
|
||||
# Mock _stream_reasoning_delta
|
||||
cli._reasoning_emitted = []
|
||||
def mock_reasoning(text):
|
||||
cli._reasoning_emitted.append(text)
|
||||
cli._stream_reasoning_delta = mock_reasoning
|
||||
|
||||
return cli
|
||||
|
||||
|
||||
class TestThinkTagInProse:
|
||||
"""<think> mentioned in prose should NOT trigger reasoning suppression."""
|
||||
|
||||
def test_think_tag_mid_sentence(self):
|
||||
"""'(/think not producing <think> tags)' should pass through."""
|
||||
cli = _make_cli_stub()
|
||||
tokens = [
|
||||
" 1. Fix reasoning mode in eval ",
|
||||
"(/think not producing ",
|
||||
"<think>",
|
||||
" tags — ~2% gap)",
|
||||
"\n 2. Launch production",
|
||||
]
|
||||
for t in tokens:
|
||||
cli._stream_delta(t)
|
||||
assert not cli._in_reasoning_block, "<think> in prose should not enter reasoning block"
|
||||
full = "".join(cli._emitted)
|
||||
assert "<think>" in full, "The literal <think> tag should be in the emitted text"
|
||||
assert "Launch production" in full
|
||||
|
||||
def test_think_tag_after_text_on_same_line(self):
|
||||
"""'some text <think>' should NOT trigger reasoning."""
|
||||
cli = _make_cli_stub()
|
||||
cli._stream_delta("Here is the <think> tag explanation")
|
||||
assert not cli._in_reasoning_block
|
||||
full = "".join(cli._emitted)
|
||||
assert "<think>" in full
|
||||
|
||||
def test_think_tag_in_backticks(self):
|
||||
"""'`<think>`' should NOT trigger reasoning."""
|
||||
cli = _make_cli_stub()
|
||||
cli._stream_delta("Use the `<think>` tag for reasoning")
|
||||
assert not cli._in_reasoning_block
|
||||
|
||||
|
||||
class TestRealReasoningBlock:
|
||||
"""Real <think> tags at block boundaries should still be caught."""
|
||||
|
||||
def test_think_at_start_of_stream(self):
|
||||
"""'<think>reasoning</think>answer' should suppress reasoning."""
|
||||
cli = _make_cli_stub()
|
||||
cli._stream_delta("<think>")
|
||||
assert cli._in_reasoning_block
|
||||
cli._stream_delta("I need to analyze this")
|
||||
cli._stream_delta("</think>")
|
||||
assert not cli._in_reasoning_block
|
||||
cli._stream_delta("Here is my answer")
|
||||
full = "".join(cli._emitted)
|
||||
assert "Here is my answer" in full
|
||||
assert "I need to analyze" not in full # reasoning was suppressed
|
||||
|
||||
def test_think_after_newline(self):
|
||||
"""'text\\n<think>' should trigger reasoning block."""
|
||||
cli = _make_cli_stub()
|
||||
cli._stream_delta("Some preamble\n<think>")
|
||||
assert cli._in_reasoning_block
|
||||
full = "".join(cli._emitted)
|
||||
assert "Some preamble" in full
|
||||
|
||||
def test_think_after_newline_with_whitespace(self):
|
||||
"""'text\\n <think>' should trigger reasoning block."""
|
||||
cli = _make_cli_stub()
|
||||
cli._stream_delta("Some preamble\n <think>")
|
||||
assert cli._in_reasoning_block
|
||||
|
||||
def test_think_with_only_whitespace_before(self):
|
||||
"""' <think>' (whitespace only prefix) should trigger."""
|
||||
cli = _make_cli_stub()
|
||||
cli._stream_delta(" <think>")
|
||||
assert cli._in_reasoning_block
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"tag",
|
||||
["THINK", "Think", "ThInK", "THOUGHT", "REASONING", "Thinking"],
|
||||
)
|
||||
def test_reasoning_tags_are_case_insensitive(self, tag):
|
||||
cli = _make_cli_stub()
|
||||
cli._stream_delta(f"<{tag}>hidden reasoning</{tag}>Visible answer")
|
||||
assert not cli._in_reasoning_block
|
||||
full = "".join(cli._emitted)
|
||||
assert full == "Visible answer"
|
||||
assert "hidden reasoning" not in full
|
||||
|
||||
|
||||
class TestFlushRecovery:
|
||||
"""_flush_stream should recover content from false-positive reasoning blocks."""
|
||||
|
||||
def test_flush_recovers_buffered_content(self):
|
||||
"""If somehow in reasoning block at flush, content is recovered."""
|
||||
cli = _make_cli_stub()
|
||||
# Manually set up a false-positive state
|
||||
cli._in_reasoning_block = True
|
||||
cli._stream_prefilt = " tags — ~2% gap)\n 2. Launch production"
|
||||
cli._stream_box_opened = True
|
||||
|
||||
# Mock _close_reasoning_box and box closing
|
||||
cli._close_reasoning_box = lambda: None
|
||||
|
||||
# Call flush
|
||||
from unittest.mock import patch
|
||||
import shutil
|
||||
with patch.object(shutil, "get_terminal_size", return_value=os.terminal_size((80, 24))):
|
||||
with patch("cli._cprint"):
|
||||
cli._flush_stream()
|
||||
|
||||
assert not cli._in_reasoning_block
|
||||
full = "".join(cli._emitted)
|
||||
assert "Launch production" in full
|
||||
@@ -0,0 +1,94 @@
|
||||
"""Streaming display force-flush: long partial lines must paint before the
|
||||
first newline arrives (TTFT-perception fix, July 2026).
|
||||
|
||||
Previously ``_emit_stream_text`` only emitted on ``"\\n"``, so a response
|
||||
opening with a long paragraph stayed invisible until the model produced a
|
||||
newline — seconds of blank box on slow models. Now partial lines are
|
||||
force-flushed at terminal width (mirroring the reasoning box's 80-char rule).
|
||||
"""
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", ".."))
|
||||
|
||||
|
||||
def _strip_ansi(s: str) -> str:
|
||||
return re.sub(r"\x1b\[[0-9;]*m", "", s)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def cli_stub(monkeypatch):
|
||||
from cli import HermesCLI
|
||||
import cli as climod
|
||||
|
||||
cli = HermesCLI.__new__(HermesCLI)
|
||||
cli.show_reasoning = False
|
||||
cli.final_response_markdown = "raw"
|
||||
cli.show_timestamps = False
|
||||
cli._reset_stream_state()
|
||||
|
||||
emitted = []
|
||||
monkeypatch.setattr(climod, "_cprint", lambda s: emitted.append(s))
|
||||
# Deterministic width regardless of the test runner's terminal
|
||||
monkeypatch.setattr(climod, "_terminal_width_for_streaming", lambda: 74)
|
||||
return cli, emitted
|
||||
|
||||
|
||||
class TestPartialLineForceFlush:
|
||||
def test_long_paragraph_paints_before_first_newline(self, cli_stub):
|
||||
cli, emitted = cli_stub
|
||||
text = (
|
||||
"This is a long opening paragraph that would previously sit "
|
||||
"invisible in the buffer until the model finally produced a "
|
||||
"newline character, which on a slow model could take seconds. "
|
||||
) * 3
|
||||
for i in range(0, len(text), 12):
|
||||
cli._stream_delta(text[i : i + 12])
|
||||
# Box header + several wrapped lines painted with NO newline seen yet
|
||||
assert len(emitted) > 3
|
||||
|
||||
def test_no_content_lost_across_wraps(self, cli_stub):
|
||||
cli, emitted = cli_stub
|
||||
words = [f"word{i}" for i in range(120)]
|
||||
text = " ".join(words)
|
||||
for i in range(0, len(text), 7):
|
||||
cli._stream_delta(text[i : i + 7])
|
||||
cli._flush_stream()
|
||||
plain = " ".join(_strip_ansi("\n".join(emitted)).split())
|
||||
for w in words:
|
||||
assert w in plain, f"lost {w} at a wrap boundary"
|
||||
|
||||
def test_short_partial_stays_buffered(self, cli_stub):
|
||||
cli, emitted = cli_stub
|
||||
cli._stream_delta("short line, no newline")
|
||||
# Under wrap width: the box header may open, but the text itself
|
||||
# stays buffered until a newline or the width threshold.
|
||||
plain = _strip_ansi("\n".join(emitted))
|
||||
assert "short line" not in plain
|
||||
assert cli._stream_buf == "short line, no newline"
|
||||
|
||||
def test_table_rows_not_force_flushed(self, cli_stub):
|
||||
cli, emitted = cli_stub
|
||||
# A long partial table row must stay buffered for block realignment
|
||||
row = "| " + " | ".join(f"cell{i}" for i in range(20)) + " |"
|
||||
cli._stream_delta(row) # no newline
|
||||
plain = _strip_ansi("\n".join(emitted))
|
||||
assert "cell19" not in plain
|
||||
|
||||
def test_newline_lines_still_emit_normally(self, cli_stub):
|
||||
cli, emitted = cli_stub
|
||||
cli._stream_delta("line one\nline two\n")
|
||||
plain = _strip_ansi("\n".join(emitted))
|
||||
assert "line one" in plain
|
||||
assert "line two" in plain
|
||||
|
||||
def test_unbreakable_run_hard_wraps(self, cli_stub):
|
||||
cli, emitted = cli_stub
|
||||
blob = "x" * 300 # no spaces
|
||||
cli._stream_delta(blob)
|
||||
cli._flush_stream()
|
||||
plain = _strip_ansi("\n".join(emitted))
|
||||
assert plain.count("x") == 300
|
||||
@@ -0,0 +1,335 @@
|
||||
"""Tests for surrogate character sanitization in user input.
|
||||
|
||||
Surrogates (U+D800..U+DFFF) are invalid in UTF-8 and crash json.dumps()
|
||||
inside the OpenAI SDK. They can appear via clipboard paste from rich-text
|
||||
editors like Google Docs, OR from byte-level reasoning models (xiaomi/mimo,
|
||||
kimi, glm) emitting lone halves in reasoning output.
|
||||
"""
|
||||
import json
|
||||
import pytest
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from run_agent import (
|
||||
_sanitize_surrogates,
|
||||
_sanitize_messages_surrogates,
|
||||
_sanitize_structure_surrogates,
|
||||
)
|
||||
|
||||
|
||||
class TestSanitizeSurrogates:
|
||||
"""Test the _sanitize_surrogates() helper."""
|
||||
|
||||
def test_normal_text_unchanged(self):
|
||||
text = "Hello, this is normal text with unicode: café ñ 日本語 🎉"
|
||||
assert _sanitize_surrogates(text) == text
|
||||
|
||||
def test_empty_string(self):
|
||||
assert _sanitize_surrogates("") == ""
|
||||
|
||||
def test_single_surrogate_replaced(self):
|
||||
result = _sanitize_surrogates("Hello \udce2 world")
|
||||
assert result == "Hello \ufffd world"
|
||||
|
||||
def test_multiple_surrogates_replaced(self):
|
||||
result = _sanitize_surrogates("a\ud800b\udc00c\udfff")
|
||||
assert result == "a\ufffdb\ufffdc\ufffd"
|
||||
|
||||
def test_all_surrogate_range(self):
|
||||
"""Verify the regex catches the full surrogate range."""
|
||||
for cp in [0xD800, 0xD900, 0xDA00, 0xDB00, 0xDC00, 0xDD00, 0xDE00, 0xDF00, 0xDFFF]:
|
||||
text = f"test{chr(cp)}end"
|
||||
result = _sanitize_surrogates(text)
|
||||
assert '\ufffd' in result, f"Surrogate U+{cp:04X} not caught"
|
||||
|
||||
def test_result_is_json_serializable(self):
|
||||
"""Sanitized text must survive json.dumps + utf-8 encoding."""
|
||||
dirty = "data \udce2\udcb0 from clipboard"
|
||||
clean = _sanitize_surrogates(dirty)
|
||||
serialized = json.dumps({"content": clean}, ensure_ascii=False)
|
||||
# Must not raise UnicodeEncodeError
|
||||
serialized.encode("utf-8")
|
||||
|
||||
def test_original_surrogates_fail_encoding(self):
|
||||
"""Confirm the original bug: surrogates crash utf-8 encoding."""
|
||||
dirty = "data \udce2 from clipboard"
|
||||
serialized = json.dumps({"content": dirty}, ensure_ascii=False)
|
||||
with pytest.raises(UnicodeEncodeError):
|
||||
serialized.encode("utf-8")
|
||||
|
||||
|
||||
class TestSanitizeMessagesSurrogates:
|
||||
"""Test the _sanitize_messages_surrogates() helper for message lists."""
|
||||
|
||||
def test_clean_messages_returns_false(self):
|
||||
msgs = [
|
||||
{"role": "user", "content": "all clean"},
|
||||
{"role": "assistant", "content": "me too"},
|
||||
]
|
||||
assert _sanitize_messages_surrogates(msgs) is False
|
||||
|
||||
def test_dirty_string_content_sanitized(self):
|
||||
msgs = [
|
||||
{"role": "user", "content": "text with \udce2 surrogate"},
|
||||
]
|
||||
assert _sanitize_messages_surrogates(msgs) is True
|
||||
assert "\ufffd" in msgs[0]["content"]
|
||||
assert "\udce2" not in msgs[0]["content"]
|
||||
|
||||
def test_dirty_multimodal_content_sanitized(self):
|
||||
msgs = [
|
||||
{"role": "user", "content": [
|
||||
{"type": "text", "text": "multimodal \udce2 content"},
|
||||
{"type": "image_url", "image_url": {"url": "http://example.com"}},
|
||||
]},
|
||||
]
|
||||
assert _sanitize_messages_surrogates(msgs) is True
|
||||
assert "\ufffd" in msgs[0]["content"][0]["text"]
|
||||
assert "\udce2" not in msgs[0]["content"][0]["text"]
|
||||
|
||||
def test_mixed_clean_and_dirty(self):
|
||||
msgs = [
|
||||
{"role": "user", "content": "clean text"},
|
||||
{"role": "user", "content": "dirty \udce2 text"},
|
||||
{"role": "assistant", "content": "clean response"},
|
||||
]
|
||||
assert _sanitize_messages_surrogates(msgs) is True
|
||||
assert msgs[0]["content"] == "clean text"
|
||||
assert "\ufffd" in msgs[1]["content"]
|
||||
assert msgs[2]["content"] == "clean response"
|
||||
|
||||
def test_non_dict_items_skipped(self):
|
||||
msgs = ["not a dict", {"role": "user", "content": "ok"}]
|
||||
assert _sanitize_messages_surrogates(msgs) is False
|
||||
|
||||
def test_tool_messages_sanitized(self):
|
||||
"""Tool results could also contain surrogates from file reads etc."""
|
||||
msgs = [
|
||||
{"role": "tool", "content": "result with \udce2 data", "tool_call_id": "x"},
|
||||
]
|
||||
assert _sanitize_messages_surrogates(msgs) is True
|
||||
assert "\ufffd" in msgs[0]["content"]
|
||||
|
||||
|
||||
class TestReasoningFieldSurrogates:
|
||||
"""Surrogates in reasoning fields (byte-level reasoning models).
|
||||
|
||||
xiaomi/mimo, kimi, glm and similar byte-level tokenizers can emit lone
|
||||
surrogates in reasoning output. These fields are carried through to the
|
||||
API as `reasoning_content` on assistant messages, and must be sanitized
|
||||
or json.dumps() crashes with 'utf-8' codec can't encode surrogates.
|
||||
"""
|
||||
|
||||
def test_reasoning_field_sanitized(self):
|
||||
msgs = [
|
||||
{"role": "assistant", "content": "ok", "reasoning": "thought \udce2 here"},
|
||||
]
|
||||
assert _sanitize_messages_surrogates(msgs) is True
|
||||
assert "\udce2" not in msgs[0]["reasoning"]
|
||||
assert "\ufffd" in msgs[0]["reasoning"]
|
||||
|
||||
def test_reasoning_content_field_sanitized(self):
|
||||
"""api_messages carry `reasoning_content` built from `reasoning`."""
|
||||
msgs = [
|
||||
{"role": "assistant", "content": "ok", "reasoning_content": "thought \udce2 here"},
|
||||
]
|
||||
assert _sanitize_messages_surrogates(msgs) is True
|
||||
assert "\udce2" not in msgs[0]["reasoning_content"]
|
||||
assert "\ufffd" in msgs[0]["reasoning_content"]
|
||||
|
||||
def test_reasoning_details_nested_sanitized(self):
|
||||
"""reasoning_details is a list of dicts with nested string fields."""
|
||||
msgs = [
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "ok",
|
||||
"reasoning_details": [
|
||||
{"type": "reasoning.summary", "summary": "summary \udce2 text"},
|
||||
{"type": "reasoning.text", "text": "chain \udc00 of thought"},
|
||||
],
|
||||
},
|
||||
]
|
||||
assert _sanitize_messages_surrogates(msgs) is True
|
||||
assert "\udce2" not in msgs[0]["reasoning_details"][0]["summary"]
|
||||
assert "\ufffd" in msgs[0]["reasoning_details"][0]["summary"]
|
||||
assert "\udc00" not in msgs[0]["reasoning_details"][1]["text"]
|
||||
assert "\ufffd" in msgs[0]["reasoning_details"][1]["text"]
|
||||
|
||||
def test_deeply_nested_reasoning_sanitized(self):
|
||||
"""Nested dicts / lists inside extra fields are recursed into."""
|
||||
msgs = [
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "ok",
|
||||
"reasoning_details": [
|
||||
{
|
||||
"type": "reasoning.encrypted",
|
||||
"content": {
|
||||
"encrypted_content": "opaque",
|
||||
"text_parts": ["part1", "part2 \udce2 part"],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
assert _sanitize_messages_surrogates(msgs) is True
|
||||
assert (
|
||||
msgs[0]["reasoning_details"][0]["content"]["text_parts"][1]
|
||||
== "part2 \ufffd part"
|
||||
)
|
||||
|
||||
def test_reasoning_end_to_end_json_serialization(self):
|
||||
"""After sanitization, the full message dict must serialize clean."""
|
||||
msgs = [
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "answer",
|
||||
"reasoning_content": "reasoning with \udce2 surrogate",
|
||||
"reasoning_details": [
|
||||
{"summary": "nested \udcb0 surrogate"},
|
||||
],
|
||||
},
|
||||
]
|
||||
_sanitize_messages_surrogates(msgs)
|
||||
# Must round-trip through json + utf-8 encoding without error
|
||||
payload = json.dumps(msgs, ensure_ascii=False).encode("utf-8")
|
||||
assert b"\\" not in payload[:0] # sanity — just ensure we got bytes
|
||||
assert len(payload) > 0
|
||||
|
||||
def test_no_surrogates_returns_false(self):
|
||||
"""Clean reasoning fields don't trigger a modification."""
|
||||
msgs = [
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "ok",
|
||||
"reasoning": "clean thought",
|
||||
"reasoning_content": "also clean",
|
||||
"reasoning_details": [{"summary": "clean summary"}],
|
||||
},
|
||||
]
|
||||
assert _sanitize_messages_surrogates(msgs) is False
|
||||
|
||||
|
||||
class TestSanitizeStructureSurrogates:
|
||||
"""Test the _sanitize_structure_surrogates() helper for nested payloads."""
|
||||
|
||||
def test_empty_payload(self):
|
||||
assert _sanitize_structure_surrogates({}) is False
|
||||
assert _sanitize_structure_surrogates([]) is False
|
||||
|
||||
def test_flat_dict(self):
|
||||
payload = {"a": "clean", "b": "dirty \udce2 text"}
|
||||
assert _sanitize_structure_surrogates(payload) is True
|
||||
assert payload["a"] == "clean"
|
||||
assert "\ufffd" in payload["b"]
|
||||
|
||||
def test_flat_list(self):
|
||||
payload = ["clean", "dirty \udce2"]
|
||||
assert _sanitize_structure_surrogates(payload) is True
|
||||
assert payload[0] == "clean"
|
||||
assert "\ufffd" in payload[1]
|
||||
|
||||
def test_nested_dict_in_list(self):
|
||||
payload = [{"x": "dirty \udce2"}, {"x": "clean"}]
|
||||
assert _sanitize_structure_surrogates(payload) is True
|
||||
assert "\ufffd" in payload[0]["x"]
|
||||
assert payload[1]["x"] == "clean"
|
||||
|
||||
def test_deeply_nested(self):
|
||||
payload = {
|
||||
"level1": {
|
||||
"level2": [
|
||||
{"level3": "deep \udce2 surrogate"},
|
||||
],
|
||||
},
|
||||
}
|
||||
assert _sanitize_structure_surrogates(payload) is True
|
||||
assert "\ufffd" in payload["level1"]["level2"][0]["level3"]
|
||||
|
||||
def test_clean_payload_returns_false(self):
|
||||
payload = {"a": "clean", "b": [{"c": "also clean"}]}
|
||||
assert _sanitize_structure_surrogates(payload) is False
|
||||
|
||||
def test_non_string_values_ignored(self):
|
||||
payload = {"int": 42, "list": [1, 2, 3], "dict": {"none": None}, "bool": True}
|
||||
assert _sanitize_structure_surrogates(payload) is False
|
||||
# Non-string values survive unchanged
|
||||
assert payload["int"] == 42
|
||||
assert payload["list"] == [1, 2, 3]
|
||||
|
||||
|
||||
class TestApiMessagesSurrogateRecovery:
|
||||
"""Integration: verify the recovery block sanitizes api_messages.
|
||||
|
||||
The bug this guards against: a surrogate in `reasoning_content` on
|
||||
api_messages (transformed from `reasoning` during build) crashes the
|
||||
OpenAI SDK's json.dumps(), and the recovery block previously only
|
||||
sanitized the canonical `messages` list — not `api_messages` — so the
|
||||
next retry would send the same broken payload and fail 3 times.
|
||||
"""
|
||||
|
||||
def test_api_messages_reasoning_content_sanitized(self):
|
||||
"""The extended sanitizer catches reasoning_content in api_messages."""
|
||||
api_messages = [
|
||||
{"role": "system", "content": "sys"},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "response",
|
||||
"reasoning_content": "thought \udce2 trail",
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": "call_1",
|
||||
"function": {"name": "tool", "arguments": "{}"},
|
||||
}
|
||||
],
|
||||
},
|
||||
{"role": "tool", "content": "result", "tool_call_id": "call_1"},
|
||||
]
|
||||
assert _sanitize_messages_surrogates(api_messages) is True
|
||||
assert "\udce2" not in api_messages[1]["reasoning_content"]
|
||||
# Full payload must now serialize clean
|
||||
json.dumps(api_messages, ensure_ascii=False).encode("utf-8")
|
||||
|
||||
|
||||
class TestRunConversationSurrogateSanitization:
|
||||
"""Integration: verify run_conversation sanitizes user_message."""
|
||||
|
||||
@patch("run_agent.AIAgent._build_system_prompt")
|
||||
@patch("run_agent.AIAgent._interruptible_streaming_api_call")
|
||||
@patch("run_agent.AIAgent._interruptible_api_call")
|
||||
def test_user_message_surrogates_sanitized(self, mock_api, mock_stream, mock_sys):
|
||||
"""Surrogates in user_message are stripped before API call."""
|
||||
from run_agent import AIAgent
|
||||
|
||||
mock_sys.return_value = "system prompt"
|
||||
|
||||
# Mock streaming to return a simple response
|
||||
mock_choice = MagicMock()
|
||||
mock_choice.message.content = "response"
|
||||
mock_choice.message.tool_calls = None
|
||||
mock_choice.message.refusal = None
|
||||
mock_choice.finish_reason = "stop"
|
||||
mock_choice.message.reasoning_content = None
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.choices = [mock_choice]
|
||||
mock_response.usage = MagicMock(prompt_tokens=10, completion_tokens=5, total_tokens=15)
|
||||
mock_response.model = "test-model"
|
||||
mock_response.id = "test-id"
|
||||
|
||||
mock_stream.return_value = mock_response
|
||||
mock_api.return_value = mock_response
|
||||
|
||||
agent = AIAgent(model="test/model", api_key="test-key", base_url="http://localhost:1234/v1", quiet_mode=True, skip_memory=True, skip_context_files=True)
|
||||
agent.client = MagicMock()
|
||||
|
||||
# Pass a message with surrogates
|
||||
result = agent.run_conversation(
|
||||
user_message="test \udce2 message",
|
||||
conversation_history=[],
|
||||
)
|
||||
|
||||
# The message stored in history should have surrogates replaced
|
||||
for msg in result.get("messages", []):
|
||||
if msg.get("role") == "user":
|
||||
assert "\udce2" not in msg["content"], "Surrogate leaked into stored message"
|
||||
assert "\ufffd" in msg["content"], "Replacement char not in stored message"
|
||||
@@ -0,0 +1,112 @@
|
||||
"""Regression tests for #33271: terminal recovery after interrupt.
|
||||
|
||||
When the user interrupts a running agent turn by typing a new message,
|
||||
prompt_toolkit may have an in-flight ``CSI 6n`` cursor-position query whose
|
||||
reply (``ESC[<row>;<col>R``) arrives on stdin after the input parser has torn
|
||||
down. The reply then leaks as literal text (``^[[19;1R``) and the VT100 parser
|
||||
can stall, accepting no further keystrokes — the terminal appears frozen.
|
||||
|
||||
The recovery path lives in ``HermesCLI._recover_terminal_after_interrupt()``,
|
||||
which is invoked from ``process_loop``'s ``finally`` block only when
|
||||
``self._last_turn_interrupted`` is set. It must:
|
||||
1. Drain stray escape bytes from the OS input buffer (``flush_stdin``).
|
||||
2. Force a clean prompt_toolkit renderer redraw (``_force_full_redraw``).
|
||||
|
||||
These tests exercise the real method (not a re-implementation of its logic),
|
||||
and assert that the finally block actually wires it in behind the interrupt
|
||||
guard.
|
||||
"""
|
||||
|
||||
import inspect
|
||||
import re
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
import cli as cli_mod
|
||||
from cli import HermesCLI
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def bare_cli():
|
||||
"""A HermesCLI with no __init__ — we only exercise the recovery helper."""
|
||||
return object.__new__(HermesCLI)
|
||||
|
||||
|
||||
class TestRecoverTerminalAfterInterrupt:
|
||||
"""Directly exercise HermesCLI._recover_terminal_after_interrupt()."""
|
||||
|
||||
def test_drains_stdin_then_redraws(self, bare_cli):
|
||||
"""Happy path: flush_stdin runs, then a full redraw is forced."""
|
||||
bare_cli._force_full_redraw = MagicMock()
|
||||
with patch("hermes_cli.curses_ui.flush_stdin") as mock_flush:
|
||||
bare_cli._recover_terminal_after_interrupt()
|
||||
|
||||
mock_flush.assert_called_once()
|
||||
bare_cli._force_full_redraw.assert_called_once()
|
||||
|
||||
def test_redraw_still_runs_when_flush_fails(self, bare_cli):
|
||||
"""A flush_stdin failure (no TTY, non-POSIX) must not skip the redraw.
|
||||
|
||||
The two recovery steps are independent — losing the stdin drain must
|
||||
never leave the renderer un-repainted.
|
||||
"""
|
||||
bare_cli._force_full_redraw = MagicMock()
|
||||
with patch(
|
||||
"hermes_cli.curses_ui.flush_stdin", side_effect=OSError("no tty")
|
||||
):
|
||||
bare_cli._recover_terminal_after_interrupt() # must not raise
|
||||
|
||||
bare_cli._force_full_redraw.assert_called_once()
|
||||
|
||||
def test_flush_runs_before_redraw(self, bare_cli):
|
||||
"""Order matters: drain stray bytes first so they don't arrive mid-redraw."""
|
||||
events = []
|
||||
bare_cli._force_full_redraw = MagicMock(
|
||||
side_effect=lambda: events.append("redraw")
|
||||
)
|
||||
with patch(
|
||||
"hermes_cli.curses_ui.flush_stdin",
|
||||
side_effect=lambda: events.append("flush"),
|
||||
):
|
||||
bare_cli._recover_terminal_after_interrupt()
|
||||
|
||||
assert events == ["flush", "redraw"]
|
||||
|
||||
def test_flush_stdin_is_tty_gated(self):
|
||||
"""The real flush_stdin is a no-op on non-TTY stdin (piped/redirected).
|
||||
|
||||
Under pytest stdin is not a TTY, so this must return cleanly without
|
||||
touching termios.
|
||||
"""
|
||||
from hermes_cli.curses_ui import flush_stdin
|
||||
|
||||
flush_stdin() # must not raise in a non-TTY test environment
|
||||
|
||||
|
||||
class TestFinallyBlockWiring:
|
||||
"""The recovery helper is only useful if process_loop actually calls it.
|
||||
|
||||
These guard against the helper silently becoming dead code (the fix being
|
||||
present but never invoked), which a unit test of the helper alone can't
|
||||
catch.
|
||||
"""
|
||||
|
||||
def test_recovery_is_invoked_behind_interrupt_guard(self):
|
||||
src = inspect.getsource(HermesCLI.run)
|
||||
# The recovery call must be gated on _last_turn_interrupted so it only
|
||||
# fires after an actual interrupt, not on every normal turn.
|
||||
guard = re.search(
|
||||
r"if self\._last_turn_interrupted:\s*\n\s*"
|
||||
r"self\._recover_terminal_after_interrupt\(\)",
|
||||
src,
|
||||
)
|
||||
assert guard, (
|
||||
"process_loop's finally block must call "
|
||||
"_recover_terminal_after_interrupt() guarded by "
|
||||
"self._last_turn_interrupted"
|
||||
)
|
||||
|
||||
def test_recovery_helper_exists(self):
|
||||
assert hasattr(HermesCLI, "_recover_terminal_after_interrupt")
|
||||
assert callable(HermesCLI._recover_terminal_after_interrupt)
|
||||
@@ -0,0 +1,295 @@
|
||||
"""Tests for stacked tool progress scrollback lines in the CLI TUI.
|
||||
|
||||
When tool_progress_mode is "all" or "new", _on_tool_progress should print
|
||||
persistent lines to scrollback on tool.completed, restoring the stacked
|
||||
tool history that was lost when the TUI switched to a single-line spinner.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import importlib
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||
|
||||
# Module-level reference to the cli module (set by _make_cli on first call)
|
||||
_cli_mod = None
|
||||
_UNSET = object()
|
||||
|
||||
|
||||
def _make_cli(tool_progress="all", verbose=_UNSET):
|
||||
"""Create a HermesCLI instance with minimal mocking."""
|
||||
global _cli_mod
|
||||
_clean_config = {
|
||||
"model": {
|
||||
"default": "anthropic/claude-opus-4.6",
|
||||
"base_url": "https://openrouter.ai/api/v1",
|
||||
"provider": "auto",
|
||||
},
|
||||
"display": {"compact": False, "tool_progress": tool_progress},
|
||||
"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 mod
|
||||
mod = importlib.reload(mod)
|
||||
_cli_mod = mod
|
||||
with patch.object(mod, "get_tool_definitions", return_value=[]), \
|
||||
patch.dict(mod.__dict__, {"CLI_CONFIG": _clean_config}):
|
||||
if verbose is _UNSET:
|
||||
return mod.HermesCLI()
|
||||
return mod.HermesCLI(verbose=verbose)
|
||||
|
||||
|
||||
class TestToolProgressScrollback:
|
||||
"""Stacked scrollback lines for 'all' and 'new' modes."""
|
||||
|
||||
def test_all_mode_prints_scrollback_on_completed(self):
|
||||
"""In 'all' mode, tool.completed prints a stacked line."""
|
||||
cli = _make_cli(tool_progress="all")
|
||||
# Simulate tool.started
|
||||
cli._on_tool_progress("tool.started", "terminal", "git log", {"command": "git log"})
|
||||
# Simulate tool.completed
|
||||
with patch.object(_cli_mod, "_cprint") as mock_print:
|
||||
cli._on_tool_progress("tool.completed", "terminal", None, None, duration=1.5, is_error=False)
|
||||
|
||||
mock_print.assert_called_once()
|
||||
line = mock_print.call_args[0][0]
|
||||
# Should contain tool info (the cute message format has "git log" for terminal)
|
||||
assert "git log" in line or "$" in line
|
||||
|
||||
def test_all_mode_prints_every_call(self):
|
||||
"""In 'all' mode, consecutive calls to the same tool each get a line."""
|
||||
cli = _make_cli(tool_progress="all")
|
||||
with patch.object(_cli_mod, "_cprint") as mock_print:
|
||||
# First call
|
||||
cli._on_tool_progress("tool.started", "read_file", "cli.py", {"path": "cli.py"})
|
||||
cli._on_tool_progress("tool.completed", "read_file", None, None, duration=0.1, is_error=False)
|
||||
# Second call (same tool)
|
||||
cli._on_tool_progress("tool.started", "read_file", "run_agent.py", {"path": "run_agent.py"})
|
||||
cli._on_tool_progress("tool.completed", "read_file", None, None, duration=0.2, is_error=False)
|
||||
|
||||
assert mock_print.call_count == 2
|
||||
|
||||
def test_new_mode_skips_consecutive_repeats(self):
|
||||
"""In 'new' mode, consecutive calls to the same tool only print once."""
|
||||
cli = _make_cli(tool_progress="new")
|
||||
with patch.object(_cli_mod, "_cprint") as mock_print:
|
||||
cli._on_tool_progress("tool.started", "read_file", "cli.py", {"path": "cli.py"})
|
||||
cli._on_tool_progress("tool.completed", "read_file", None, None, duration=0.1, is_error=False)
|
||||
cli._on_tool_progress("tool.started", "read_file", "run_agent.py", {"path": "run_agent.py"})
|
||||
cli._on_tool_progress("tool.completed", "read_file", None, None, duration=0.2, is_error=False)
|
||||
|
||||
assert mock_print.call_count == 1 # Only the first read_file
|
||||
|
||||
def test_new_mode_prints_when_tool_changes(self):
|
||||
"""In 'new' mode, a different tool name triggers a new line."""
|
||||
cli = _make_cli(tool_progress="new")
|
||||
with patch.object(_cli_mod, "_cprint") as mock_print:
|
||||
cli._on_tool_progress("tool.started", "read_file", "cli.py", {"path": "cli.py"})
|
||||
cli._on_tool_progress("tool.completed", "read_file", None, None, duration=0.1, is_error=False)
|
||||
cli._on_tool_progress("tool.started", "search_files", "pattern", {"pattern": "test"})
|
||||
cli._on_tool_progress("tool.completed", "search_files", None, None, duration=0.3, is_error=False)
|
||||
cli._on_tool_progress("tool.started", "read_file", "run_agent.py", {"path": "run_agent.py"})
|
||||
cli._on_tool_progress("tool.completed", "read_file", None, None, duration=0.2, is_error=False)
|
||||
|
||||
# read_file, search_files, read_file (3rd prints because search_files broke the streak)
|
||||
assert mock_print.call_count == 3
|
||||
|
||||
def test_off_mode_no_scrollback(self):
|
||||
"""In 'off' mode, no stacked lines are printed."""
|
||||
cli = _make_cli(tool_progress="off")
|
||||
with patch.object(_cli_mod, "_cprint") as mock_print:
|
||||
cli._on_tool_progress("tool.started", "terminal", "ls", {"command": "ls"})
|
||||
cli._on_tool_progress("tool.completed", "terminal", None, None, duration=0.5, is_error=False)
|
||||
|
||||
mock_print.assert_not_called()
|
||||
|
||||
def test_error_suffix_on_failed_tool(self):
|
||||
"""When a failed tool's result is forwarded, the stacked line surfaces
|
||||
the specific error (e.g. ``[exit 1]`` or ``[File not found: x]``)
|
||||
instead of the legacy generic ``[error]`` suffix."""
|
||||
import json
|
||||
cli = _make_cli(tool_progress="all")
|
||||
cli._on_tool_progress("tool.started", "terminal", "false", {"command": "false"})
|
||||
with patch.object(_cli_mod, "_cprint") as mock_print:
|
||||
cli._on_tool_progress(
|
||||
"tool.completed", "terminal", None, None,
|
||||
duration=0.5, is_error=True,
|
||||
result=json.dumps({"output": "", "exit_code": 1}),
|
||||
)
|
||||
|
||||
line = mock_print.call_args[0][0]
|
||||
assert "[exit 1]" in line
|
||||
|
||||
def test_spinner_still_updates_on_started(self):
|
||||
"""tool.started still updates the spinner text for live display."""
|
||||
cli = _make_cli(tool_progress="all")
|
||||
cli._on_tool_progress("tool.started", "terminal", "git status", {"command": "git status"})
|
||||
assert "git status" in cli._spinner_text
|
||||
|
||||
def test_spinner_timer_clears_on_completed(self):
|
||||
"""tool.completed still clears the tool timer."""
|
||||
cli = _make_cli(tool_progress="all")
|
||||
cli._on_tool_progress("tool.started", "terminal", "git status", {"command": "git status"})
|
||||
assert cli._tool_start_time > 0
|
||||
with patch.object(_cli_mod, "_cprint"):
|
||||
cli._on_tool_progress("tool.completed", "terminal", None, None, duration=0.5, is_error=False)
|
||||
assert cli._tool_start_time == 0.0
|
||||
|
||||
def test_concurrent_tools_produce_stacked_lines(self):
|
||||
"""Multiple tool.started followed by multiple tool.completed all produce lines."""
|
||||
cli = _make_cli(tool_progress="all")
|
||||
with patch.object(_cli_mod, "_cprint") as mock_print:
|
||||
# All start first (concurrent pattern)
|
||||
cli._on_tool_progress("tool.started", "web_search", "query 1", {"query": "test 1"})
|
||||
cli._on_tool_progress("tool.started", "web_search", "query 2", {"query": "test 2"})
|
||||
# All complete
|
||||
cli._on_tool_progress("tool.completed", "web_search", None, None, duration=1.0, is_error=False)
|
||||
cli._on_tool_progress("tool.completed", "web_search", None, None, duration=1.5, is_error=False)
|
||||
|
||||
assert mock_print.call_count == 2
|
||||
|
||||
def test_verbose_mode_commits_scrollback_line(self):
|
||||
"""In 'verbose' mode, tool.completed commits a persistent scrollback line.
|
||||
|
||||
Regression: 'verbose' used to be omitted from the scrollback gate on
|
||||
the premise that run_agent renders verbose output. That premise is
|
||||
false in the interactive CLI — run_agent's verbose prints are gated on
|
||||
``not quiet_mode`` and the interactive CLI runs quiet_mode=True. So a
|
||||
non-streaming model call (MoA aggregator, copilot-acp) under 'verbose'
|
||||
rendered each tool only into the self-overwriting spinner, building no
|
||||
scrollable history. 'verbose' is strictly more than 'all', so it must
|
||||
commit at least the same line.
|
||||
"""
|
||||
cli = _make_cli(tool_progress="verbose")
|
||||
with patch.object(_cli_mod, "_cprint") as mock_print:
|
||||
cli._on_tool_progress("tool.started", "terminal", "ls", {"command": "ls"})
|
||||
cli._on_tool_progress("tool.completed", "terminal", None, None, duration=0.5, is_error=False)
|
||||
|
||||
mock_print.assert_called_once()
|
||||
|
||||
def test_verbose_mode_commits_every_call(self):
|
||||
"""In 'verbose' mode, consecutive same-tool calls each commit a line.
|
||||
|
||||
Mirrors 'all' (no consecutive-repeat suppression — that is 'new'-only),
|
||||
so a multi-step turn builds a full scrollable tool history.
|
||||
"""
|
||||
cli = _make_cli(tool_progress="verbose")
|
||||
with patch.object(_cli_mod, "_cprint") as mock_print:
|
||||
cli._on_tool_progress("tool.started", "terminal", "echo one", {"command": "echo one"})
|
||||
cli._on_tool_progress("tool.completed", "terminal", None, None, duration=0.1, is_error=False)
|
||||
cli._on_tool_progress("tool.started", "terminal", "echo two", {"command": "echo two"})
|
||||
cli._on_tool_progress("tool.completed", "terminal", None, None, duration=0.1, is_error=False)
|
||||
|
||||
assert mock_print.call_count == 2
|
||||
|
||||
def test_verbose_mode_config_does_not_enable_global_debug_logging(self):
|
||||
"""display.tool_progress=verbose controls TOOL-CALL DISPLAY ONLY.
|
||||
|
||||
It must NOT auto-flip self.verbose, which controls root-logger DEBUG
|
||||
level for the entire process (every module spews to console). PR
|
||||
#6a1aa420e had coupled them, causing all debug logs to flood the
|
||||
terminal whenever a user picked tool_progress: verbose for richer
|
||||
per-tool rendering.
|
||||
"""
|
||||
cli = _make_cli(tool_progress="verbose")
|
||||
|
||||
assert cli.tool_progress_mode == "verbose"
|
||||
assert cli.verbose is False
|
||||
|
||||
def test_explicit_verbose_argument_wins_over_config(self):
|
||||
"""Explicit verbose=True from the CLI flag still enables DEBUG logging
|
||||
regardless of tool_progress_mode."""
|
||||
cli = _make_cli(tool_progress="off", verbose=True)
|
||||
|
||||
assert cli.tool_progress_mode == "off"
|
||||
assert cli.verbose is True
|
||||
|
||||
def test_explicit_non_verbose_argument_keeps_debug_logging_off(self):
|
||||
"""Explicit verbose=False overrides any default to enable DEBUG."""
|
||||
cli = _make_cli(tool_progress="verbose", verbose=False)
|
||||
|
||||
assert cli.tool_progress_mode == "verbose"
|
||||
assert cli.verbose is False
|
||||
|
||||
def test_pending_info_stores_on_started(self):
|
||||
"""tool.started stores args for later use by tool.completed."""
|
||||
cli = _make_cli(tool_progress="all")
|
||||
cli._on_tool_progress("tool.started", "terminal", "ls", {"command": "ls"})
|
||||
assert "terminal" in cli._pending_tool_info
|
||||
assert len(cli._pending_tool_info["terminal"]) == 1
|
||||
assert cli._pending_tool_info["terminal"][0] == {"command": "ls"}
|
||||
|
||||
def test_pending_info_consumed_on_completed(self):
|
||||
"""tool.completed consumes stored args (FIFO for concurrent)."""
|
||||
cli = _make_cli(tool_progress="all")
|
||||
cli._on_tool_progress("tool.started", "terminal", "ls", {"command": "ls"})
|
||||
cli._on_tool_progress("tool.started", "terminal", "pwd", {"command": "pwd"})
|
||||
assert len(cli._pending_tool_info["terminal"]) == 2
|
||||
with patch.object(_cli_mod, "_cprint"):
|
||||
cli._on_tool_progress("tool.completed", "terminal", None, None, duration=0.1, is_error=False)
|
||||
# First entry consumed, second remains
|
||||
assert len(cli._pending_tool_info.get("terminal", [])) == 1
|
||||
assert cli._pending_tool_info["terminal"][0] == {"command": "pwd"}
|
||||
|
||||
|
||||
class TestMoAReferenceBlocks:
|
||||
"""moa.reference renders a labelled thinking-style block; moa.aggregating
|
||||
updates the spinner. Both are display-only and must commit regardless of
|
||||
tool_progress_mode (MoA is non-streaming)."""
|
||||
|
||||
def test_reference_event_prints_labelled_block(self):
|
||||
cli = _make_cli(tool_progress="all")
|
||||
with patch.object(_cli_mod, "_cprint") as mock_print:
|
||||
cli._on_tool_progress(
|
||||
"moa.reference",
|
||||
"openrouter:openai/gpt-5.5",
|
||||
"Paris is the capital.",
|
||||
None,
|
||||
moa_index=1,
|
||||
moa_count=2,
|
||||
)
|
||||
printed = " ".join(str(c.args[0]) for c in mock_print.call_args_list)
|
||||
# Header names the source model + index/count; body carries the text.
|
||||
assert "openrouter:openai/gpt-5.5" in printed
|
||||
assert "Reference 1/2" in printed
|
||||
assert "Paris is the capital." in printed
|
||||
|
||||
def test_reference_event_prints_even_when_progress_off(self):
|
||||
"""Reference blocks are the MoA process view, not tool progress — they
|
||||
must show even with tool_progress: off."""
|
||||
cli = _make_cli(tool_progress="off")
|
||||
with patch.object(_cli_mod, "_cprint") as mock_print:
|
||||
cli._on_tool_progress(
|
||||
"moa.reference", "openrouter:anthropic/claude-opus-4.8", "Four.", None,
|
||||
moa_index=2, moa_count=2,
|
||||
)
|
||||
assert mock_print.called
|
||||
|
||||
def test_aggregating_event_updates_spinner_only(self):
|
||||
cli = _make_cli(tool_progress="all")
|
||||
with patch.object(_cli_mod, "_cprint") as mock_print:
|
||||
cli._on_tool_progress("moa.aggregating", "openrouter:anthropic/claude-opus-4.8", None, None)
|
||||
assert "aggregating" in cli._spinner_text
|
||||
# aggregating is a spinner-only transition; no committed scrollback line.
|
||||
mock_print.assert_not_called()
|
||||
@@ -0,0 +1,212 @@
|
||||
"""Regression tests for GitHub #36823 — the TUI must reset terminal input
|
||||
modes on exit so focus-reporting / mouse-tracking escape sequences don't leak
|
||||
into the next shell session sharing the tab.
|
||||
|
||||
prompt_toolkit restores these on a clean teardown, but Ctrl+C, SIGTERM/SIGHUP
|
||||
and crashes can bypass its unwind. ``_run_cleanup`` (the once-only cleanup that
|
||||
runs on every catchable exit path, including ``atexit``) now emits the disable
|
||||
sequence as its first step via ``_reset_terminal_input_modes_on_exit`` — gated
|
||||
on ``_tui_input_modes_active`` so non-TUI one-shot CLI runs (which share
|
||||
``_run_cleanup`` via ``atexit``) don't emit codes for modes they never set.
|
||||
"""
|
||||
|
||||
import unittest
|
||||
from unittest.mock import mock_open, patch
|
||||
|
||||
|
||||
def _import_cli():
|
||||
import hermes_cli.config as config_mod
|
||||
|
||||
if not hasattr(config_mod, "save_env_value_secure"):
|
||||
config_mod.save_env_value_secure = lambda key, value: {
|
||||
"success": True,
|
||||
"stored_as": key,
|
||||
"validated": False,
|
||||
}
|
||||
|
||||
import cli as cli_mod
|
||||
|
||||
return cli_mod
|
||||
|
||||
|
||||
class _FakeStream:
|
||||
def __init__(self, isatty: bool = True):
|
||||
self._isatty = isatty
|
||||
self.written: list[str] = []
|
||||
self.flushed = 0
|
||||
|
||||
def isatty(self) -> bool:
|
||||
return self._isatty
|
||||
|
||||
def write(self, s: str) -> int:
|
||||
self.written.append(s)
|
||||
return len(s)
|
||||
|
||||
def flush(self) -> None:
|
||||
self.flushed += 1
|
||||
|
||||
|
||||
class TestResetTerminalInputModes(unittest.TestCase):
|
||||
def test_emits_reset_seq_on_tty_when_tui_ran(self):
|
||||
cli_mod = _import_cli()
|
||||
fake = _FakeStream(isatty=True)
|
||||
with (
|
||||
patch.object(cli_mod, "_tui_input_modes_active", True),
|
||||
patch.object(cli_mod.sys, "stdout", fake),
|
||||
):
|
||||
cli_mod._reset_terminal_input_modes_on_exit()
|
||||
|
||||
written = "".join(fake.written)
|
||||
self.assertEqual(written, cli_mod._TERMINAL_INPUT_MODE_RESET_SEQ)
|
||||
self.assertGreaterEqual(fake.flushed, 1)
|
||||
# The focus-reporting disable is the specific leak the issue reports.
|
||||
self.assertIn("\x1b[?1004l", written)
|
||||
|
||||
def test_noop_when_tui_never_ran(self):
|
||||
"""Non-TUI one-shot CLI runs share _run_cleanup via atexit — they must
|
||||
not emit terminal escape codes they never needed (review finding #1)."""
|
||||
cli_mod = _import_cli()
|
||||
fake = _FakeStream(isatty=True)
|
||||
with (
|
||||
patch.object(cli_mod, "_tui_input_modes_active", False),
|
||||
patch.object(cli_mod.sys, "stdout", fake),
|
||||
# Guard: must not touch the real /dev/tty either.
|
||||
patch("builtins.open", mock_open()) as m_open,
|
||||
):
|
||||
cli_mod._reset_terminal_input_modes_on_exit()
|
||||
|
||||
self.assertEqual(fake.written, [])
|
||||
m_open.assert_not_called()
|
||||
|
||||
def test_noop_when_not_a_tty_and_no_dev_tty(self):
|
||||
"""stdout redirected and /dev/tty unavailable → nothing written, no raise."""
|
||||
cli_mod = _import_cli()
|
||||
fake = _FakeStream(isatty=False)
|
||||
with (
|
||||
patch.object(cli_mod, "_tui_input_modes_active", True),
|
||||
patch.object(cli_mod.sys, "stdout", fake),
|
||||
patch("builtins.open", side_effect=OSError("no /dev/tty")),
|
||||
):
|
||||
cli_mod._reset_terminal_input_modes_on_exit()
|
||||
|
||||
self.assertEqual(fake.written, [], "must not pollute the redirected stream")
|
||||
|
||||
def test_falls_back_to_dev_tty_when_stdout_redirected(self):
|
||||
"""When stdout isn't the terminal, reset via /dev/tty (issue's own
|
||||
suggestion) so a TUI that drove /dev/tty still gets cleaned up."""
|
||||
cli_mod = _import_cli()
|
||||
fake = _FakeStream(isatty=False)
|
||||
m_open = mock_open()
|
||||
with (
|
||||
patch.object(cli_mod, "_tui_input_modes_active", True),
|
||||
patch.object(cli_mod.sys, "stdout", fake),
|
||||
patch("builtins.open", m_open),
|
||||
):
|
||||
cli_mod._reset_terminal_input_modes_on_exit()
|
||||
|
||||
self.assertEqual(fake.written, [])
|
||||
m_open.assert_called_once_with("/dev/tty", "w", encoding="ascii")
|
||||
m_open().write.assert_called_once_with(cli_mod._TERMINAL_INPUT_MODE_RESET_SEQ)
|
||||
|
||||
def test_swallows_stdout_errors(self):
|
||||
cli_mod = _import_cli()
|
||||
|
||||
class _Boom:
|
||||
def isatty(self):
|
||||
raise OSError("stdout closed")
|
||||
|
||||
with (
|
||||
patch.object(cli_mod, "_tui_input_modes_active", True),
|
||||
patch.object(cli_mod.sys, "stdout", _Boom()),
|
||||
patch("builtins.open", side_effect=OSError("no /dev/tty")),
|
||||
):
|
||||
# Cleanup runs at process teardown — it must never raise.
|
||||
cli_mod._reset_terminal_input_modes_on_exit()
|
||||
|
||||
def test_mark_tui_input_modes_active_sets_flag(self):
|
||||
cli_mod = _import_cli()
|
||||
original = cli_mod._tui_input_modes_active
|
||||
cli_mod._tui_input_modes_active = False
|
||||
try:
|
||||
cli_mod._mark_tui_input_modes_active()
|
||||
self.assertTrue(cli_mod._tui_input_modes_active)
|
||||
finally:
|
||||
cli_mod._tui_input_modes_active = original
|
||||
|
||||
def test_flag_cleared_after_reset(self):
|
||||
"""Once the modes are disabled they are no longer active — the flag must
|
||||
flip back so a re-armed cleanup doesn't re-emit the sequence."""
|
||||
cli_mod = _import_cli()
|
||||
fake = _FakeStream(isatty=True)
|
||||
original = cli_mod._tui_input_modes_active
|
||||
cli_mod._tui_input_modes_active = True
|
||||
try:
|
||||
with patch.object(cli_mod.sys, "stdout", fake):
|
||||
cli_mod._reset_terminal_input_modes_on_exit()
|
||||
self.assertIn("\x1b[?1004l", "".join(fake.written))
|
||||
self.assertFalse(
|
||||
cli_mod._tui_input_modes_active, "flag must clear after reset"
|
||||
)
|
||||
finally:
|
||||
cli_mod._tui_input_modes_active = original
|
||||
|
||||
|
||||
class TestRunCleanupWiring(unittest.TestCase):
|
||||
"""_run_cleanup must call the reset, as its first step, on every invocation
|
||||
— even if a later cleanup step raises."""
|
||||
|
||||
def _run_cleanup_isolated(self, cli_mod, **extra_patches):
|
||||
"""Invoke _run_cleanup with heavy/real teardown steps stubbed out so the
|
||||
test is hermetic (review finding #5)."""
|
||||
original_done = cli_mod._cleanup_done
|
||||
cli_mod._cleanup_done = False
|
||||
patches = {
|
||||
"_cleanup_all_terminals": lambda: None,
|
||||
"_cleanup_all_browsers": lambda: None,
|
||||
}
|
||||
try:
|
||||
with (
|
||||
patch.object(
|
||||
cli_mod, "_reset_terminal_input_modes_on_exit"
|
||||
) as mock_reset,
|
||||
patch.object(
|
||||
cli_mod, "_cleanup_all_terminals", patches["_cleanup_all_terminals"]
|
||||
),
|
||||
patch.object(
|
||||
cli_mod, "_cleanup_all_browsers", patches["_cleanup_all_browsers"]
|
||||
),
|
||||
patch("tools.mcp_tool.shutdown_mcp_servers", lambda *a, **k: None),
|
||||
patch(
|
||||
"agent.auxiliary_client.shutdown_cached_clients",
|
||||
lambda *a, **k: None,
|
||||
),
|
||||
patch("hermes_cli.plugins.invoke_hook", lambda *a, **k: None),
|
||||
):
|
||||
if extra_patches.get("terminals_raise"):
|
||||
with patch.object(
|
||||
cli_mod,
|
||||
"_cleanup_all_terminals",
|
||||
side_effect=RuntimeError("boom"),
|
||||
):
|
||||
cli_mod._run_cleanup()
|
||||
else:
|
||||
cli_mod._run_cleanup()
|
||||
return mock_reset
|
||||
finally:
|
||||
cli_mod._cleanup_done = original_done
|
||||
|
||||
def test_run_cleanup_calls_reset(self):
|
||||
cli_mod = _import_cli()
|
||||
mock_reset = self._run_cleanup_isolated(cli_mod)
|
||||
mock_reset.assert_called_once()
|
||||
|
||||
def test_reset_runs_even_when_a_cleanup_step_raises(self):
|
||||
"""The reset is the first step, so a failing teardown step can't skip
|
||||
it — covering the Ctrl+C / crash paths the issue is about."""
|
||||
cli_mod = _import_cli()
|
||||
mock_reset = self._run_cleanup_isolated(cli_mod, terminals_raise=True)
|
||||
mock_reset.assert_called_once()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,150 @@
|
||||
"""Tests for the /update slash command in the classic CLI and TUI launcher.
|
||||
|
||||
Verifies that ``HermesCLI._handle_update_command`` correctly:
|
||||
- Refuses to run under a managed install (Homebrew, Docker, etc.)
|
||||
- Sets ``_pending_relaunch`` and returns ``True`` on confirmation
|
||||
- Cancels cleanly on a "no"-shaped answer or unrecognized input
|
||||
- Cancels cleanly when ``_prompt_text_input_modal`` returns None (timeout /
|
||||
modal dismissed)
|
||||
|
||||
Also verifies that ``hermes_cli.main._launch_tui`` correctly handles exit
|
||||
code 42 (the TUI's signal to trigger an update) by calling
|
||||
``relaunch(["update"], preserve_inherited=False)`` from the Python wrapper
|
||||
side. The companion Vitest (``ui-tui/src/__tests__/createSlashHandler.test.ts``)
|
||||
covers the TypeScript slash-handler that *emits* code 42; this file covers
|
||||
the Python wrapper branch that *acts on* it.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from cli import HermesCLI
|
||||
|
||||
|
||||
def _bound(fn, instance):
|
||||
"""Bind an unbound method to a stand-in instance."""
|
||||
return fn.__get__(instance, type(instance))
|
||||
|
||||
|
||||
def _make_self(modal_response):
|
||||
"""Build a minimal stand-in 'self' for ``_handle_update_command``.
|
||||
|
||||
Uses the same SimpleNamespace pattern as ``test_destructive_slash_confirm``
|
||||
so we don't need a full ``HermesCLI`` construction.
|
||||
``_prompt_text_input_modal`` is stubbed to return *modal_response*
|
||||
directly so tests can drive the entire confirmation branch without
|
||||
touching stdin or prompt_toolkit internals.
|
||||
"""
|
||||
self_ = SimpleNamespace(
|
||||
_app=None,
|
||||
_pending_relaunch=None,
|
||||
_prompt_text_input_modal=lambda **_kw: modal_response,
|
||||
)
|
||||
self_._normalize_slash_confirm_choice = _bound(
|
||||
HermesCLI._normalize_slash_confirm_choice, self_
|
||||
)
|
||||
return self_
|
||||
|
||||
|
||||
def _call(self_):
|
||||
"""Invoke the real ``_handle_update_command`` on the stub."""
|
||||
return HermesCLI._handle_update_command(self_)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Managed-install guard
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_managed_install_refuses_and_does_not_set_pending_relaunch(capsys):
|
||||
"""Under a managed install (brew/docker), /update prints a hint and
|
||||
returns without setting ``_pending_relaunch``."""
|
||||
self_ = SimpleNamespace(
|
||||
_app=None,
|
||||
_pending_relaunch=None,
|
||||
# Use pytest.fail so any unexpected modal invocation surfaces as a failure.
|
||||
_prompt_text_input_modal=lambda **_kw: pytest.fail("Modal should not be called"),
|
||||
)
|
||||
self_._normalize_slash_confirm_choice = _bound(
|
||||
HermesCLI._normalize_slash_confirm_choice, self_
|
||||
)
|
||||
with (
|
||||
patch("hermes_cli.config.is_managed", return_value=True),
|
||||
patch(
|
||||
"hermes_cli.config.format_managed_message",
|
||||
return_value="Use `brew upgrade hermes-agent` to update.",
|
||||
),
|
||||
):
|
||||
result = _call(self_)
|
||||
|
||||
out = capsys.readouterr().out
|
||||
assert "brew upgrade hermes-agent" in out
|
||||
assert self_._pending_relaunch is None
|
||||
assert not result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Confirmation proceeds only on recognised affirmative responses
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize("answer", ["y", "Y", "yes", "YES", "1", "ok"])
|
||||
def test_affirmative_answer_sets_pending_relaunch_and_returns_true(answer, capsys):
|
||||
"""Recognised affirmative answers ("y", "yes", "1", "ok") set
|
||||
``_pending_relaunch = ["update"]`` and return ``True`` so the caller
|
||||
(process_command) can trigger the main-thread app-exit path."""
|
||||
self_ = _make_self(modal_response=answer)
|
||||
with patch("hermes_cli.config.is_managed", return_value=False):
|
||||
result = _call(self_)
|
||||
|
||||
assert self_._pending_relaunch == ["update"]
|
||||
assert result is True
|
||||
assert "Launching update" in capsys.readouterr().out
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Cancellation paths — _pending_relaunch must stay None
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize("answer", ["n", "N", "no", "NO", " no "])
|
||||
def test_negative_answer_cancels(answer, capsys):
|
||||
"""Any "no"-shaped answer cancels without setting ``_pending_relaunch``."""
|
||||
self_ = _make_self(modal_response=answer)
|
||||
with patch("hermes_cli.config.is_managed", return_value=False):
|
||||
result = _call(self_)
|
||||
|
||||
assert self_._pending_relaunch is None
|
||||
assert not result
|
||||
assert "Launching update" not in capsys.readouterr().out
|
||||
|
||||
|
||||
def test_none_response_cancels(capsys):
|
||||
"""``None`` from the modal (timeout or dismiss) cancels cleanly."""
|
||||
self_ = _make_self(modal_response=None)
|
||||
with patch("hermes_cli.config.is_managed", return_value=False):
|
||||
result = _call(self_)
|
||||
|
||||
assert self_._pending_relaunch is None
|
||||
assert not result
|
||||
|
||||
|
||||
@pytest.mark.parametrize("answer", ["nope", "cancel", "sure", "2", "3", "abort", ""])
|
||||
def test_unrecognized_or_cancel_input_cancels(answer, capsys):
|
||||
"""Unrecognised input and explicit "cancel" do not proceed.
|
||||
|
||||
Previously the implementation treated any non-"n/no" answer as approval,
|
||||
which meant typos like "nope" or "cancel" would launch the update.
|
||||
Now only confirmed affirmative aliases ("y", "yes", "1", "ok") proceed;
|
||||
everything else (including empty string, "cancel", typos) cancels.
|
||||
"""
|
||||
self_ = _make_self(modal_response=answer)
|
||||
with patch("hermes_cli.config.is_managed", return_value=False):
|
||||
result = _call(self_)
|
||||
|
||||
assert self_._pending_relaunch is None
|
||||
assert not result
|
||||
@@ -0,0 +1,28 @@
|
||||
"""Tests for the /version slash command."""
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
from cli import HermesCLI
|
||||
from hermes_cli.commands import GATEWAY_KNOWN_COMMANDS, resolve_command
|
||||
|
||||
|
||||
def test_version_command_is_registered():
|
||||
cmd = resolve_command("version")
|
||||
assert cmd is not None
|
||||
assert cmd.name == "version"
|
||||
assert cmd.category == "Info"
|
||||
assert resolve_command("v") is cmd
|
||||
|
||||
|
||||
def test_version_is_gateway_known():
|
||||
assert "version" in GATEWAY_KNOWN_COMMANDS
|
||||
assert "v" in GATEWAY_KNOWN_COMMANDS
|
||||
|
||||
|
||||
def test_process_command_version_prints_version_info():
|
||||
cli_obj = HermesCLI.__new__(HermesCLI)
|
||||
|
||||
with patch("hermes_cli.main._print_version_info") as mock_print:
|
||||
assert cli_obj.process_command("/version") is True
|
||||
|
||||
mock_print.assert_called_once_with(check_updates=True)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,130 @@
|
||||
"""Security-focused integration tests for CLI worktree setup."""
|
||||
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def git_repo(tmp_path):
|
||||
"""Create a temporary git repo for testing real cli._setup_worktree behavior."""
|
||||
repo = tmp_path / "test-repo"
|
||||
repo.mkdir()
|
||||
subprocess.run(["git", "init"], cwd=repo, check=True, capture_output=True)
|
||||
subprocess.run(["git", "config", "user.email", "test@test.com"], cwd=repo, check=True, capture_output=True)
|
||||
subprocess.run(["git", "config", "user.name", "Test"], cwd=repo, check=True, capture_output=True)
|
||||
(repo / "README.md").write_text("# Test Repo\n")
|
||||
subprocess.run(["git", "add", "."], cwd=repo, check=True, capture_output=True)
|
||||
subprocess.run(["git", "commit", "-m", "Initial commit"], cwd=repo, check=True, capture_output=True)
|
||||
return repo
|
||||
|
||||
|
||||
def _force_remove_worktree(info: dict | None) -> None:
|
||||
if not info:
|
||||
return
|
||||
subprocess.run(
|
||||
["git", "worktree", "remove", info["path"], "--force"],
|
||||
cwd=info["repo_root"],
|
||||
capture_output=True,
|
||||
check=False,
|
||||
)
|
||||
subprocess.run(
|
||||
["git", "branch", "-D", info["branch"]],
|
||||
cwd=info["repo_root"],
|
||||
capture_output=True,
|
||||
check=False,
|
||||
)
|
||||
|
||||
|
||||
class TestWorktreeIncludeSecurity:
|
||||
def test_rejects_parent_directory_file_traversal(self, git_repo):
|
||||
import cli as cli_mod
|
||||
|
||||
outside_file = git_repo.parent / "sensitive.txt"
|
||||
outside_file.write_text("SENSITIVE DATA")
|
||||
(git_repo / ".worktreeinclude").write_text("../sensitive.txt\n")
|
||||
|
||||
info = None
|
||||
try:
|
||||
info = cli_mod._setup_worktree(str(git_repo))
|
||||
assert info is not None
|
||||
|
||||
wt_path = Path(info["path"])
|
||||
assert not (wt_path.parent / "sensitive.txt").exists()
|
||||
assert not (wt_path / "../sensitive.txt").resolve().exists()
|
||||
finally:
|
||||
_force_remove_worktree(info)
|
||||
|
||||
def test_rejects_parent_directory_directory_traversal(self, git_repo):
|
||||
import cli as cli_mod
|
||||
|
||||
outside_dir = git_repo.parent / "outside-dir"
|
||||
outside_dir.mkdir()
|
||||
(outside_dir / "secret.txt").write_text("SENSITIVE DIR DATA")
|
||||
(git_repo / ".worktreeinclude").write_text("../outside-dir\n")
|
||||
|
||||
info = None
|
||||
try:
|
||||
info = cli_mod._setup_worktree(str(git_repo))
|
||||
assert info is not None
|
||||
|
||||
wt_path = Path(info["path"])
|
||||
escaped_dir = wt_path.parent / "outside-dir"
|
||||
assert not escaped_dir.exists()
|
||||
assert not escaped_dir.is_symlink()
|
||||
finally:
|
||||
_force_remove_worktree(info)
|
||||
|
||||
def test_rejects_symlink_that_resolves_outside_repo(self, git_repo):
|
||||
import cli as cli_mod
|
||||
|
||||
outside_file = git_repo.parent / "linked-secret.txt"
|
||||
outside_file.write_text("LINKED SECRET")
|
||||
(git_repo / "leak.txt").symlink_to(outside_file)
|
||||
(git_repo / ".worktreeinclude").write_text("leak.txt\n")
|
||||
|
||||
info = None
|
||||
try:
|
||||
info = cli_mod._setup_worktree(str(git_repo))
|
||||
assert info is not None
|
||||
|
||||
assert not (Path(info["path"]) / "leak.txt").exists()
|
||||
finally:
|
||||
_force_remove_worktree(info)
|
||||
|
||||
def test_allows_valid_file_include(self, git_repo):
|
||||
import cli as cli_mod
|
||||
|
||||
(git_repo / ".env").write_text("SECRET=***\n")
|
||||
(git_repo / ".worktreeinclude").write_text(".env\n")
|
||||
|
||||
info = None
|
||||
try:
|
||||
info = cli_mod._setup_worktree(str(git_repo))
|
||||
assert info is not None
|
||||
|
||||
copied = Path(info["path"]) / ".env"
|
||||
assert copied.exists()
|
||||
assert copied.read_text() == "SECRET=***\n"
|
||||
finally:
|
||||
_force_remove_worktree(info)
|
||||
|
||||
def test_allows_valid_directory_include(self, git_repo):
|
||||
import cli as cli_mod
|
||||
|
||||
assets_dir = git_repo / ".venv" / "lib"
|
||||
assets_dir.mkdir(parents=True)
|
||||
(assets_dir / "marker.txt").write_text("venv marker")
|
||||
(git_repo / ".worktreeinclude").write_text(".venv\n")
|
||||
|
||||
info = None
|
||||
try:
|
||||
info = cli_mod._setup_worktree(str(git_repo))
|
||||
assert info is not None
|
||||
|
||||
linked_dir = Path(info["path"]) / ".venv"
|
||||
assert linked_dir.is_symlink()
|
||||
assert (linked_dir / "lib" / "marker.txt").read_text() == "venv marker"
|
||||
finally:
|
||||
_force_remove_worktree(info)
|
||||
@@ -0,0 +1,124 @@
|
||||
"""Tests for worktree base-ref resolution — branch from the fresh remote tip.
|
||||
|
||||
A worktree created off the standalone clone's local ``HEAD`` roots the new
|
||||
branch on a stale base when that clone lags the remote. ``_resolve_worktree_base``
|
||||
fetches and branches from the remote tip instead so the worktree starts current.
|
||||
|
||||
These tests exercise the REAL ``cli._resolve_worktree_base`` /
|
||||
``cli._setup_worktree`` against a real local "remote" repo (so ``git fetch``
|
||||
works offline in the hermetic sandbox), proving the worktree includes commits
|
||||
that exist on the remote but not on the stale local HEAD.
|
||||
"""
|
||||
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
import cli
|
||||
|
||||
|
||||
def _run(args, cwd):
|
||||
return subprocess.run(args, cwd=cwd, capture_output=True, text=True, timeout=30)
|
||||
|
||||
|
||||
def _commit(repo, name, msg):
|
||||
(Path(repo) / name).write_text(msg + "\n")
|
||||
_run(["git", "add", "."], repo)
|
||||
_run(["git", "commit", "-m", msg], repo)
|
||||
|
||||
|
||||
def _head(repo):
|
||||
return _run(["git", "rev-parse", "HEAD"], repo).stdout.strip()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def remote_and_clone(tmp_path):
|
||||
"""A bare 'remote' + a clone that is intentionally BEHIND the remote.
|
||||
|
||||
Returns (clone_path, remote_head_sha, stale_local_head_sha).
|
||||
"""
|
||||
remote = tmp_path / "remote.git"
|
||||
seed = tmp_path / "seed"
|
||||
seed.mkdir()
|
||||
_run(["git", "init"], seed)
|
||||
_run(["git", "config", "user.email", "t@t.com"], seed)
|
||||
_run(["git", "config", "user.name", "T"], seed)
|
||||
# Pin the seed repo's branch name so push + remote default are 'main'.
|
||||
_run(["git", "checkout", "-b", "main"], seed)
|
||||
_commit(seed, "README.md", "base commit")
|
||||
_run(["git", "init", "--bare", str(remote)], tmp_path)
|
||||
_run(["git", "remote", "add", "origin", str(remote)], seed)
|
||||
_run(["git", "push", "origin", "main"], seed)
|
||||
# Set the bare remote's default branch so a clone gets origin/HEAD ->
|
||||
# origin/main and a tracking branch (mirrors a real GitHub remote).
|
||||
_run(["git", "symbolic-ref", "HEAD", "refs/heads/main"], remote)
|
||||
|
||||
# Clone it (this clone tracks origin/main).
|
||||
clone = tmp_path / "clone"
|
||||
_run(["git", "clone", str(remote), str(clone)], tmp_path)
|
||||
_run(["git", "config", "user.email", "t@t.com"], clone)
|
||||
_run(["git", "config", "user.name", "T"], clone)
|
||||
stale_local_head = _head(clone)
|
||||
|
||||
# Advance the REMOTE past the clone (simulating other merges landing on
|
||||
# main while this clone sat stale).
|
||||
_commit(seed, "feature.txt", "remote-only commit")
|
||||
_run(["git", "push", "origin", "main"], seed)
|
||||
remote_head = _head(seed)
|
||||
|
||||
assert remote_head != stale_local_head
|
||||
return clone, remote_head, stale_local_head
|
||||
|
||||
|
||||
class TestResolveWorktreeBase:
|
||||
def test_resolves_to_fetched_upstream(self, remote_and_clone):
|
||||
clone, remote_head, stale_local_head = remote_and_clone
|
||||
base_ref, label = cli._resolve_worktree_base(str(clone))
|
||||
# Should resolve to the upstream tracking ref and have fetched it.
|
||||
assert base_ref == "origin/main"
|
||||
assert "fetched" in label
|
||||
# The fetched ref now points at the remote tip, not the stale local HEAD.
|
||||
resolved = _run(["git", "rev-parse", base_ref], clone).stdout.strip()
|
||||
assert resolved == remote_head
|
||||
assert resolved != stale_local_head
|
||||
|
||||
def test_falls_back_to_head_without_remote(self, tmp_path):
|
||||
repo = tmp_path / "no-remote"
|
||||
repo.mkdir()
|
||||
_run(["git", "init"], repo)
|
||||
_run(["git", "config", "user.email", "t@t.com"], repo)
|
||||
_run(["git", "config", "user.name", "T"], repo)
|
||||
_commit(repo, "README.md", "only commit")
|
||||
base_ref, label = cli._resolve_worktree_base(str(repo))
|
||||
assert base_ref == "HEAD"
|
||||
assert "HEAD" in label
|
||||
|
||||
|
||||
class TestSetupWorktreeSyncBase:
|
||||
def test_sync_true_branches_from_remote_tip(self, remote_and_clone, monkeypatch):
|
||||
clone, remote_head, stale_local_head = remote_and_clone
|
||||
info = cli._setup_worktree(str(clone), sync_base=True)
|
||||
assert info is not None
|
||||
# The new worktree's HEAD must be the REMOTE tip, not the stale local one.
|
||||
wt_head = _head(info["path"])
|
||||
assert wt_head == remote_head, "worktree should start from the fetched remote tip"
|
||||
assert wt_head != stale_local_head
|
||||
# And it must contain the remote-only file.
|
||||
assert (Path(info["path"]) / "feature.txt").exists()
|
||||
|
||||
def test_sync_false_branches_from_local_head(self, remote_and_clone):
|
||||
clone, remote_head, stale_local_head = remote_and_clone
|
||||
info = cli._setup_worktree(str(clone), sync_base=False)
|
||||
assert info is not None
|
||||
# Opted out -> branch from the stale local HEAD (old behavior).
|
||||
wt_head = _head(info["path"])
|
||||
assert wt_head == stale_local_head
|
||||
assert not (Path(info["path"]) / "feature.txt").exists()
|
||||
|
||||
def test_default_is_sync_true(self, remote_and_clone):
|
||||
"""The default path (no sync_base arg) branches from the remote tip."""
|
||||
clone, remote_head, _ = remote_and_clone
|
||||
info = cli._setup_worktree(str(clone))
|
||||
assert info is not None
|
||||
assert _head(info["path"]) == remote_head
|
||||
Reference in New Issue
Block a user