Initial import of NousResearch/hermes-agent
Deploy Site / deploy-vercel (push) Has been cancelled
Deploy Site / deploy-docs (push) Has been cancelled
Docker / shell lint / Lint Dockerfile (hadolint) (push) Has been cancelled
Docker / shell lint / Lint docker/ shell scripts (shellcheck) (push) Has been cancelled
Docker Build and Publish / build-amd64 (push) Has been cancelled
Docker Build and Publish / build-arm64 (push) Has been cancelled
Lint (ruff + ty) / ruff + ty diff (push) Has been cancelled
Lint (ruff + ty) / ruff enforcement (blocking) (push) Has been cancelled
Lint (ruff + ty) / Windows footguns (blocking) (push) Has been cancelled
Nix Lockfile Fix / auto-fix-main (push) Has been cancelled
Nix Lockfile Fix / fix (push) Has been cancelled
Nix / nix (macos-latest) (push) Has been cancelled
Nix / nix (ubuntu-latest) (push) Has been cancelled
OSV-Scanner / Scan lockfiles (push) Has been cancelled
Build Skills Index / build-index (push) Has been cancelled
Tests / test (1) (push) Has been cancelled
Tests / test (2) (push) Has been cancelled
Tests / test (3) (push) Has been cancelled
Tests / test (4) (push) Has been cancelled
Tests / test (5) (push) Has been cancelled
Tests / test (6) (push) Has been cancelled
Tests / e2e (push) Has been cancelled
uv.lock check / uv lock --check (push) Has been cancelled
Docker Build and Publish / merge (push) Has been cancelled
Build Skills Index / trigger-deploy (push) Has been cancelled
Tests / save-durations (push) Has been cancelled
Deploy Site / deploy-vercel (push) Has been cancelled
Deploy Site / deploy-docs (push) Has been cancelled
Docker / shell lint / Lint Dockerfile (hadolint) (push) Has been cancelled
Docker / shell lint / Lint docker/ shell scripts (shellcheck) (push) Has been cancelled
Docker Build and Publish / build-amd64 (push) Has been cancelled
Docker Build and Publish / build-arm64 (push) Has been cancelled
Lint (ruff + ty) / ruff + ty diff (push) Has been cancelled
Lint (ruff + ty) / ruff enforcement (blocking) (push) Has been cancelled
Lint (ruff + ty) / Windows footguns (blocking) (push) Has been cancelled
Nix Lockfile Fix / auto-fix-main (push) Has been cancelled
Nix Lockfile Fix / fix (push) Has been cancelled
Nix / nix (macos-latest) (push) Has been cancelled
Nix / nix (ubuntu-latest) (push) Has been cancelled
OSV-Scanner / Scan lockfiles (push) Has been cancelled
Build Skills Index / build-index (push) Has been cancelled
Tests / test (1) (push) Has been cancelled
Tests / test (2) (push) Has been cancelled
Tests / test (3) (push) Has been cancelled
Tests / test (4) (push) Has been cancelled
Tests / test (5) (push) Has been cancelled
Tests / test (6) (push) Has been cancelled
Tests / e2e (push) Has been cancelled
uv.lock check / uv lock --check (push) Has been cancelled
Docker Build and Publish / merge (push) Has been cancelled
Build Skills Index / trigger-deploy (push) Has been cancelled
Tests / save-durations (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,242 @@
|
||||
"""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"
|
||||
@@ -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,424 @@
|
||||
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-keyring.gpg" not in rendered
|
||||
|
||||
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")
|
||||
|
||||
deadline = time.time() + 2
|
||||
while cli._background_tasks and time.time() < deadline:
|
||||
time.sleep(0.01)
|
||||
|
||||
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
|
||||
|
||||
|
||||
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
|
||||
@@ -0,0 +1,191 @@
|
||||
"""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
|
||||
@@ -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,253 @@
|
||||
"""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 (
|
||||
get_chrome_debug_candidates,
|
||||
is_browser_debug_ready,
|
||||
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("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("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("subprocess.Popen", side_effect=fake_popen):
|
||||
assert HermesCLI._try_launch_chrome_debug(9222, "Linux") is True
|
||||
|
||||
assert attempts == [brave, chrome]
|
||||
|
||||
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("cli.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,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,193 @@
|
||||
"""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_preserves_scrollback_and_resets_renderer(self, bare_cli, monkeypatch):
|
||||
"""Resize recovery must NOT erase screen or scrollback.
|
||||
|
||||
The startup banner lives in normal terminal scrollback (printed
|
||||
before prompt_toolkit owns the chrome). Clearing scrollback on
|
||||
SIGWINCH removes it and ``_replay_output_history`` cannot
|
||||
reconstruct it. The fix is to only reset the renderer cache and
|
||||
let ``original_on_resize`` recalculate layout.
|
||||
|
||||
Additionally, ``_status_bar_suppressed_after_resize`` must be set
|
||||
so the input rules and status bar hide until the next user input,
|
||||
preventing duplicated-bar artifacts on column shrink (#19280).
|
||||
"""
|
||||
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 the attribute the way __init__ would.
|
||||
bare_cli._status_bar_suppressed_after_resize = False
|
||||
bare_cli._recover_after_resize(app, original_on_resize)
|
||||
|
||||
assert events == [
|
||||
"renderer_reset",
|
||||
"invalidate",
|
||||
"original_resize",
|
||||
]
|
||||
# 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_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),
|
||||
):
|
||||
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),
|
||||
):
|
||||
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,649 @@
|
||||
"""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
|
||||
|
||||
|
||||
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_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
|
||||
|
||||
|
||||
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,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,308 @@
|
||||
"""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_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,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,704 @@
|
||||
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 on the same xdist worker, 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_offers_tool_gateway_prompt_when_unconfigured(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": "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")
|
||||
|
||||
out = capsys.readouterr().out
|
||||
# Tool Gateway prompt should be shown (input() raises OSError in pytest
|
||||
# which is caught, so the prompt text appears but nothing is applied)
|
||||
assert "Tool Gateway" in out
|
||||
|
||||
|
||||
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,223 @@
|
||||
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_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
|
||||
@@ -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,111 @@
|
||||
"""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()
|
||||
@@ -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,655 @@
|
||||
import time
|
||||
from datetime import datetime, timedelta
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
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_input_height_counts_wide_characters_using_cell_width(self):
|
||||
cli_obj = _make_cli()
|
||||
|
||||
class _Doc:
|
||||
lines = ["你" * 10]
|
||||
|
||||
class _Buffer:
|
||||
document = _Doc()
|
||||
|
||||
input_area = SimpleNamespace(buffer=_Buffer())
|
||||
|
||||
def _input_height():
|
||||
try:
|
||||
from prompt_toolkit.application import get_app
|
||||
from prompt_toolkit.utils import get_cwidth
|
||||
|
||||
doc = input_area.buffer.document
|
||||
prompt_width = max(2, get_cwidth(cli_obj._get_tui_prompt_text()))
|
||||
try:
|
||||
available_width = get_app().output.get_size().columns - prompt_width
|
||||
except Exception:
|
||||
import shutil
|
||||
available_width = shutil.get_terminal_size((80, 24)).columns - prompt_width
|
||||
if available_width < 10:
|
||||
available_width = 40
|
||||
visual_lines = 0
|
||||
for line in doc.lines:
|
||||
line_width = get_cwidth(line)
|
||||
if line_width <= 0:
|
||||
visual_lines += 1
|
||||
else:
|
||||
visual_lines += max(1, -(-line_width // available_width))
|
||||
return min(max(visual_lines, 1), 8)
|
||||
except Exception:
|
||||
return 1
|
||||
|
||||
mock_app = MagicMock()
|
||||
mock_app.output.get_size.return_value = MagicMock(columns=14)
|
||||
with patch.object(HermesCLI, "_get_tui_prompt_text", return_value="❯ "), \
|
||||
patch("prompt_toolkit.application.get_app", return_value=mock_app):
|
||||
assert _input_height() == 2
|
||||
|
||||
def test_input_height_uses_prompt_toolkit_width_over_shutil(self):
|
||||
cli_obj = _make_cli()
|
||||
|
||||
class _Doc:
|
||||
lines = ["你" * 10]
|
||||
|
||||
class _Buffer:
|
||||
document = _Doc()
|
||||
|
||||
input_area = SimpleNamespace(buffer=_Buffer())
|
||||
|
||||
def _input_height():
|
||||
try:
|
||||
from prompt_toolkit.application import get_app
|
||||
from prompt_toolkit.utils import get_cwidth
|
||||
|
||||
doc = input_area.buffer.document
|
||||
prompt_width = max(2, get_cwidth(cli_obj._get_tui_prompt_text()))
|
||||
try:
|
||||
available_width = get_app().output.get_size().columns - prompt_width
|
||||
except Exception:
|
||||
import shutil
|
||||
available_width = shutil.get_terminal_size((80, 24)).columns - prompt_width
|
||||
if available_width < 10:
|
||||
available_width = 40
|
||||
visual_lines = 0
|
||||
for line in doc.lines:
|
||||
line_width = get_cwidth(line)
|
||||
if line_width <= 0:
|
||||
visual_lines += 1
|
||||
else:
|
||||
visual_lines += max(1, -(-line_width // available_width))
|
||||
return min(max(visual_lines, 1), 8)
|
||||
except Exception:
|
||||
return 1
|
||||
|
||||
mock_app = MagicMock()
|
||||
mock_app.output.get_size.return_value = MagicMock(columns=14)
|
||||
with patch.object(HermesCLI, "_get_tui_prompt_text", return_value="❯ "), \
|
||||
patch("prompt_toolkit.application.get_app", return_value=mock_app), \
|
||||
patch("shutil.get_terminal_size") as mock_shutil:
|
||||
assert _input_height() == 2
|
||||
mock_shutil.assert_not_called()
|
||||
|
||||
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 until the user
|
||||
submits the next input, at which point the flag is cleared.
|
||||
"""
|
||||
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_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"
|
||||
|
||||
# <60s path
|
||||
cli_obj._tool_start_time = time.monotonic() - 9.2
|
||||
short = cli_obj._render_spinner_text()
|
||||
|
||||
# >=60s path
|
||||
cli_obj._tool_start_time = time.monotonic() - 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_includes_estimated_cost(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
|
||||
|
||||
assert "Model:" in output
|
||||
assert "Cost status:" in output
|
||||
assert "Cost source:" in output
|
||||
assert "Total cost:" in output
|
||||
assert "$" in output
|
||||
assert "0.064" in output
|
||||
assert "Session duration:" in output
|
||||
assert "Compressions:" in output
|
||||
|
||||
def test_show_usage_marks_unknown_pricing(self, capsys):
|
||||
cli_obj = _attach_agent(
|
||||
_make_cli(model="local/my-custom-model"),
|
||||
prompt_tokens=1_000,
|
||||
completion_tokens=500,
|
||||
total_tokens=1_500,
|
||||
api_calls=1,
|
||||
context_tokens=1_000,
|
||||
context_length=32_000,
|
||||
)
|
||||
cli_obj.verbose = False
|
||||
|
||||
cli_obj._show_usage()
|
||||
output = capsys.readouterr().out
|
||||
|
||||
assert "Total cost:" in output
|
||||
assert "n/a" in output
|
||||
assert "Pricing unknown for local/my-custom-model" in output
|
||||
|
||||
def test_zero_priced_provider_models_stay_unknown(self, capsys):
|
||||
cli_obj = _attach_agent(
|
||||
_make_cli(model="glm-5"),
|
||||
prompt_tokens=1_000,
|
||||
completion_tokens=500,
|
||||
total_tokens=1_500,
|
||||
api_calls=1,
|
||||
context_tokens=1_000,
|
||||
context_length=32_000,
|
||||
)
|
||||
cli_obj.verbose = False
|
||||
|
||||
cli_obj._show_usage()
|
||||
output = capsys.readouterr().out
|
||||
|
||||
assert "Total cost:" in output
|
||||
assert "n/a" in output
|
||||
assert "Pricing unknown for glm-5" 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
|
||||
@@ -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,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,481 @@
|
||||
"""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):
|
||||
"""Per Anthropic docs, fast mode is currently Opus 4.6 only.
|
||||
|
||||
Sending speed=fast to Opus 4.7, Sonnet, or Haiku returns HTTP 400.
|
||||
Pre-fix this test asserted all Claude variants supported fast mode,
|
||||
which mirrored the bug rather than the API contract.
|
||||
"""
|
||||
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, Sonnet, Haiku
|
||||
unsupported = [
|
||||
"claude-opus-4-7",
|
||||
"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 fast mode — Anthropic restricts "
|
||||
f"speed=fast to Opus 4.6"
|
||||
)
|
||||
|
||||
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):
|
||||
"""Anthropic restricts fast mode to Opus 4.6 — others must be 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.
|
||||
"""
|
||||
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("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 and other Claude models don't support fast mode (API 400s).
|
||||
|
||||
Per Anthropic docs, fast mode is currently Opus 4.6 only.
|
||||
"""
|
||||
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-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):
|
||||
"""Fast mode is currently Opus 4.6 only — other Claude variants must be 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 per Anthropic API contract — would 400 if we sent speed=fast
|
||||
assert _is_anthropic_fast_model("claude-opus-4-7") 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 support fast mode — /fast must be hidden."""
|
||||
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,21 @@
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
|
||||
def test_gquota_uses_chat_console_when_tui_is_live():
|
||||
from agent.google_oauth import GoogleOAuthError
|
||||
from cli import HermesCLI
|
||||
|
||||
cli = HermesCLI.__new__(HermesCLI)
|
||||
cli.console = MagicMock()
|
||||
cli._app = object()
|
||||
|
||||
live_console = MagicMock()
|
||||
|
||||
with patch("cli.ChatConsole", return_value=live_console), \
|
||||
patch("agent.google_oauth.get_valid_access_token", side_effect=GoogleOAuthError("No Google OAuth credentials found")), \
|
||||
patch("agent.google_oauth.load_credentials", return_value=None), \
|
||||
patch("agent.google_code_assist.retrieve_user_quota"):
|
||||
cli._handle_gquota_command("/gquota")
|
||||
|
||||
assert live_console.print.call_count == 2
|
||||
cli.console.print.assert_not_called()
|
||||
@@ -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,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,101 @@
|
||||
"""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_falls_back_to_direct_input(self):
|
||||
"""On a daemon thread, skip run_in_terminal and call input() directly.
|
||||
|
||||
This preserves the fallback for any prompt that still runs off the main
|
||||
UI thread: run_in_terminal's coroutine would otherwise be orphaned.
|
||||
"""
|
||||
cli = _make_cli()
|
||||
captured = {}
|
||||
|
||||
def fake_input(prompt):
|
||||
captured["prompt"] = prompt
|
||||
return "1"
|
||||
|
||||
result_holder = {}
|
||||
|
||||
def run_on_daemon():
|
||||
with patch("prompt_toolkit.application.run_in_terminal") as mock_rit, \
|
||||
patch("builtins.input", side_effect=fake_input):
|
||||
result_holder["value"] = cli._prompt_text_input("Choice [1/2/3]: ")
|
||||
result_holder["rit_called"] = mock_rit.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 — input() was not driven"
|
||||
|
||||
# run_in_terminal was bypassed entirely on the background thread.
|
||||
assert result_holder["rit_called"] is False
|
||||
# input() was invoked with the prompt and its return value was captured.
|
||||
assert captured.get("prompt") == "Choice [1/2/3]: "
|
||||
assert result_holder["value"] == "1"
|
||||
|
||||
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,248 @@
|
||||
"""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 (the module snapshots env at import time, so other
|
||||
# tests in the same xdist worker can flip the flag).
|
||||
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,770 @@
|
||||
"""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", "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("ultra"))
|
||||
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)
|
||||
self.assertFalse(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,62 @@
|
||||
from unittest.mock import MagicMock, patch
|
||||
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
|
||||
mock_invoke_hook.assert_any_call(
|
||||
"on_session_finalize", session_id="test-session-id", platform="cli"
|
||||
)
|
||||
# Check if on_session_reset was called for the new session
|
||||
mock_invoke_hook.assert_any_call(
|
||||
"on_session_reset", session_id=cli.session_id, platform="cli"
|
||||
)
|
||||
|
||||
|
||||
@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()
|
||||
|
||||
mock_invoke_hook.assert_any_call(
|
||||
"on_session_finalize", session_id="cleanup-session-id", platform="cli"
|
||||
)
|
||||
|
||||
|
||||
@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,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,291 @@
|
||||
"""Regression tests for issue #30768 and #32383.
|
||||
|
||||
``_prompt_text_input_modal`` uses a queue-based modal that relies on
|
||||
prompt_toolkit key bindings receiving keyboard events. On Windows the
|
||||
prompt_toolkit input channel can deadlock when the modal is entered from
|
||||
the ``process_loop`` daemon thread. The fix falls back to the simpler
|
||||
``_prompt_text_input`` (stdin-based) prompt on Windows.
|
||||
|
||||
These tests verify:
|
||||
1. Windows detection triggers the stdin fallback
|
||||
2. Non-Windows daemon threads still use the modal via the app loop
|
||||
3. macOS/Linux main-thread path still uses the modal (no regression)
|
||||
4. No-app path still uses the stdin fallback (existing behavior)
|
||||
5. Empty choices returns None (existing behavior)
|
||||
"""
|
||||
|
||||
import queue
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
|
||||
|
||||
def _make_cli():
|
||||
"""Minimal HermesCLI shell exposing 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 used across tests
|
||||
# ---------------------------------------------------------------------------
|
||||
_SAMPLE_CHOICES = [
|
||||
("once", "Approve Once", "proceed this time only"),
|
||||
("always", "Always Approve", "proceed and silence this prompt permanently"),
|
||||
("cancel", "Cancel", "keep current conversation"),
|
||||
]
|
||||
|
||||
|
||||
class TestModalWindowsFallback:
|
||||
"""Windows dead-lock regression tests for _prompt_text_input_modal."""
|
||||
|
||||
def test_windows_falls_back_to_stdin(self):
|
||||
"""On Windows, _prompt_text_input_modal should use _prompt_text_input."""
|
||||
cli = _make_cli()
|
||||
|
||||
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,
|
||||
)
|
||||
|
||||
# The stdin-based fallback was used, not the modal queue path.
|
||||
mock_stdin.assert_called_once_with("Choice [1/2/3]: ")
|
||||
assert result == "1"
|
||||
|
||||
def test_non_main_thread_uses_modal_via_app_loop(self):
|
||||
"""Off the main thread on Linux, keep the modal path via app-loop setup."""
|
||||
cli = _make_cli()
|
||||
result_holder = {}
|
||||
setup_calls = []
|
||||
teardown_calls = []
|
||||
|
||||
def _call_soon_threadsafe(callback):
|
||||
callback()
|
||||
|
||||
def run_on_daemon():
|
||||
with patch.object(sys, "platform", "linux"), \
|
||||
patch.object(cli._app.loop, "call_soon_threadsafe", side_effect=_call_soon_threadsafe), \
|
||||
patch.object(cli, "_prompt_text_input") as mock_stdin, \
|
||||
patch.object(cli, "_capture_modal_input_snapshot", side_effect=lambda: setup_calls.append("capture")), \
|
||||
patch.object(cli, "_restore_modal_input_snapshot", side_effect=lambda: teardown_calls.append("restore")):
|
||||
result_holder["result"] = cli._prompt_text_input_modal(
|
||||
title="⚠️ /reset",
|
||||
detail="This starts a fresh session.",
|
||||
choices=_SAMPLE_CHOICES,
|
||||
timeout=5,
|
||||
)
|
||||
result_holder["stdin_called"] = mock_stdin.called
|
||||
|
||||
def _submit_after_delay():
|
||||
time.sleep(0.2)
|
||||
state = cli._slash_confirm_state
|
||||
if state and "response_queue" in state:
|
||||
state["response_queue"].put("once")
|
||||
|
||||
submitter = threading.Thread(target=_submit_after_delay, daemon=True)
|
||||
t = threading.Thread(target=run_on_daemon, daemon=True)
|
||||
submitter.start()
|
||||
t.start()
|
||||
t.join(timeout=2.0)
|
||||
submitter.join(timeout=2.0)
|
||||
assert not t.is_alive(), "daemon thread hung — modal deadlocked"
|
||||
assert result_holder["stdin_called"] is False
|
||||
assert result_holder["result"] == "once"
|
||||
assert setup_calls == ["capture"]
|
||||
assert teardown_calls == ["restore"]
|
||||
|
||||
def test_main_thread_non_windows_uses_modal(self):
|
||||
"""On macOS/Linux main thread, the queue-based modal is still used."""
|
||||
cli = _make_cli()
|
||||
|
||||
# We need to simulate the modal receiving a response. We'll patch
|
||||
# the response_queue to immediately return a value.
|
||||
with patch.object(sys, "platform", "darwin"), \
|
||||
patch.object(cli, "_capture_modal_input_snapshot"), \
|
||||
patch.object(cli, "_restore_modal_input_snapshot"), \
|
||||
patch.object(cli, "_invalidate"):
|
||||
# Start the modal in a way that it will receive a response
|
||||
# immediately via the queue.
|
||||
original_queue = queue.Queue
|
||||
original_time = time.monotonic
|
||||
|
||||
def _fake_modal_flow(*args, **kwargs):
|
||||
"""Simulate the modal flow: set state, put response, return."""
|
||||
# We'll directly test that the modal path is entered by
|
||||
# checking that _slash_confirm_state was set.
|
||||
pass
|
||||
|
||||
# Since we can't easily mock the internal queue, let's test
|
||||
# that the modal path is entered by checking that
|
||||
# _prompt_text_input was NOT called.
|
||||
with patch.object(cli, "_prompt_text_input") as mock_stdin:
|
||||
# Set up a response that will be put into the queue
|
||||
# after the modal starts waiting.
|
||||
def _submit_after_delay():
|
||||
time.sleep(0.2)
|
||||
state = cli._slash_confirm_state
|
||||
if state and "response_queue" in state:
|
||||
state["response_queue"].put("once")
|
||||
|
||||
submitter = threading.Thread(target=_submit_after_delay, daemon=True)
|
||||
submitter.start()
|
||||
|
||||
result = cli._prompt_text_input_modal(
|
||||
title="⚠️ /new",
|
||||
detail="This starts a fresh session.",
|
||||
choices=_SAMPLE_CHOICES,
|
||||
timeout=5,
|
||||
)
|
||||
|
||||
submitter.join(timeout=2.0)
|
||||
|
||||
# The stdin fallback should NOT have been called.
|
||||
mock_stdin.assert_not_called()
|
||||
# The result should be "once" from the simulated modal response.
|
||||
assert result == "once"
|
||||
|
||||
def test_no_app_falls_back_to_stdin(self):
|
||||
"""Without a prompt_toolkit app, always use stdin fallback."""
|
||||
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_empty_choices_returns_none(self):
|
||||
"""Empty choices list should return 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
|
||||
|
||||
def test_windows_fallback_does_not_set_modal_state(self):
|
||||
"""Verify Windows fallback doesn't leave _slash_confirm_state set."""
|
||||
cli = _make_cli()
|
||||
|
||||
with patch.object(sys, "platform", "win32"), \
|
||||
patch.object(cli, "_prompt_text_input", return_value="1"):
|
||||
cli._prompt_text_input_modal(
|
||||
title="⚠️ /reset",
|
||||
detail="This starts a fresh session.",
|
||||
choices=_SAMPLE_CHOICES,
|
||||
)
|
||||
|
||||
assert cli._slash_confirm_state is None
|
||||
|
||||
def test_non_main_thread_modal_clears_state(self):
|
||||
"""Verify daemon-thread modal teardown does not leave state behind."""
|
||||
cli = _make_cli()
|
||||
errors = []
|
||||
|
||||
def _call_soon_threadsafe(callback):
|
||||
callback()
|
||||
|
||||
def run_on_daemon():
|
||||
try:
|
||||
with patch.object(sys, "platform", "linux"), \
|
||||
patch.object(cli._app.loop, "call_soon_threadsafe", side_effect=_call_soon_threadsafe):
|
||||
def _submit_after_delay():
|
||||
time.sleep(0.2)
|
||||
state = cli._slash_confirm_state
|
||||
if state and "response_queue" in state:
|
||||
state["response_queue"].put("cancel")
|
||||
|
||||
submitter = threading.Thread(target=_submit_after_delay, daemon=True)
|
||||
submitter.start()
|
||||
cli._prompt_text_input_modal(
|
||||
title="⚠️ /new",
|
||||
detail="This starts a fresh session.",
|
||||
choices=_SAMPLE_CHOICES,
|
||||
timeout=5,
|
||||
)
|
||||
submitter.join(timeout=2.0)
|
||||
if cli._slash_confirm_state is not None:
|
||||
errors.append("_slash_confirm_state should be None")
|
||||
except Exception as exc:
|
||||
errors.append(str(exc))
|
||||
|
||||
t = threading.Thread(target=run_on_daemon, daemon=True)
|
||||
t.start()
|
||||
t.join(timeout=2.0)
|
||||
assert not errors, f"unexpected errors: {errors}"
|
||||
assert cli._slash_confirm_state is None
|
||||
|
||||
|
||||
class TestConfirmDestructiveSlashWindows:
|
||||
"""Integration-level tests for _confirm_destructive_slash on Windows."""
|
||||
|
||||
def test_confirm_destructive_slash_bypasses_modal_on_windows(self):
|
||||
"""_confirm_destructive_slash should work on Windows via stdin fallback."""
|
||||
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 = ""
|
||||
|
||||
with patch.object(sys, "platform", "win32"), \
|
||||
patch.object(cli, "_prompt_text_input", return_value="1"), \
|
||||
patch("cli.load_cli_config", return_value={"approvals": {"destructive_slash_confirm": True}}):
|
||||
result = cli._confirm_destructive_slash(
|
||||
"new",
|
||||
"This starts a fresh session.\nThe current conversation history will be discarded.",
|
||||
)
|
||||
|
||||
assert result == "once"
|
||||
|
||||
def test_confirm_destructive_slash_cancelled_on_windows(self):
|
||||
"""Cancellation via stdin fallback works on Windows."""
|
||||
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 = ""
|
||||
|
||||
with patch.object(sys, "platform", "win32"), \
|
||||
patch.object(cli, "_prompt_text_input", return_value="3"), \
|
||||
patch("cli.load_cli_config", return_value={"approvals": {"destructive_slash_confirm": True}}):
|
||||
result = cli._confirm_destructive_slash(
|
||||
"reset",
|
||||
"This starts a fresh session.\nThe current conversation history will be discarded.",
|
||||
)
|
||||
|
||||
# Choice "3" normalizes to "cancel", which returns None.
|
||||
assert 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,137 @@
|
||||
"""Tests for _stream_delta's handling of <think> tags in prose vs real reasoning blocks."""
|
||||
import sys
|
||||
import os
|
||||
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
|
||||
|
||||
|
||||
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,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,228 @@
|
||||
"""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_no_duplicate_scrollback(self):
|
||||
"""In 'verbose' mode, scrollback lines are NOT printed (run_agent handles verbose output)."""
|
||||
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_not_called()
|
||||
|
||||
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"}
|
||||
@@ -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
|
||||
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)
|
||||
Reference in New Issue
Block a user