chore: import upstream snapshot with attribution
Deploy Site / deploy-vercel (push) Has been skipped
Deploy Site / deploy-docs (push) Has been skipped
Build Skills Index / build-index (push) Has been skipped
CI / Deny unrelated histories (push) Has been skipped
CI / Detect affected areas (push) Successful in 27m35s
CI / OSV scan (push) Failing after 4s
CI / Build&Test Docker image (push) Successful in 9s
CI / Supply-chain scan (push) Has been skipped
CI / Lint Docker scripts (push) Failing after 5m13s
CI / Check contributors (push) Failing after 12m8s
CI / Docs Site (push) Failing after 12m8s
CI / TypeScript (push) Failing after 12m8s
CI / Python lints (push) Failing after 12m9s
CI / Python tests (push) Failing after 12m9s
CI / Check uv.lock (push) Failing after 23m22s
CI / CI timing report (push) Has been cancelled
Build Skills Index / trigger-deploy (push) Has been cancelled
CI / All required checks pass (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 11:56:03 +08:00
commit b4fbd6fe9f
6241 changed files with 2261833 additions and 0 deletions
View File
+157
View File
@@ -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
+260
View File
@@ -0,0 +1,260 @@
"""Tests for the /branch (/fork) command — session branching.
Verifies that:
- Branching creates a new session with copied conversation history
- The original session is preserved (ended with "branched" reason)
- Auto-generated titles use lineage numbering
- Custom branch names are used when provided
- parent_session_id links are set correctly
- Edge cases: empty conversation, missing session DB
"""
import os
from datetime import datetime
from unittest.mock import MagicMock
import pytest
@pytest.fixture
def session_db(tmp_path):
"""Create a real SessionDB for testing."""
os.environ["HERMES_HOME"] = str(tmp_path / ".hermes")
os.makedirs(tmp_path / ".hermes", exist_ok=True)
from hermes_state import SessionDB
db = SessionDB(db_path=tmp_path / ".hermes" / "test_sessions.db")
yield db
db.close()
@pytest.fixture
def cli_instance(tmp_path, session_db):
"""Create a minimal HermesCLI-like object for testing _handle_branch_command."""
# We'll mock the CLI enough to test the branch logic without full init
from unittest.mock import MagicMock
cli = MagicMock()
cli._session_db = session_db
cli.session_id = "20260403_120000_abc123"
cli.model = "anthropic/claude-sonnet-4.6"
cli.max_turns = 90
cli.reasoning_config = {"enabled": True, "effort": "medium"}
cli.session_start = datetime.now()
cli._pending_title = None
cli._resumed = False
cli.agent = None
cli.conversation_history = [
{"role": "user", "content": "Hello, can you help me?"},
{"role": "assistant", "content": "Of course! How can I help?"},
{"role": "user", "content": "Write a Python function to sort a list."},
{"role": "assistant", "content": "def sort_list(lst): return sorted(lst)"},
]
# Create the original session in the DB
session_db.create_session(
session_id=cli.session_id,
source="cli",
model=cli.model,
)
session_db.set_session_title(cli.session_id, "My Coding Session")
return cli
class TestBranchCommandCLI:
"""Test the /branch command logic for the CLI."""
def test_branch_creates_new_session(self, cli_instance, session_db):
"""Branching should create a new session in the DB."""
from cli import HermesCLI
# Call the real method on the mock, using the real implementation
HermesCLI._handle_branch_command(cli_instance, "/branch")
# Verify a new session was created
assert cli_instance.session_id != "20260403_120000_abc123"
new_session = session_db.get_session(cli_instance.session_id)
assert new_session is not None
def test_branch_copies_history(self, cli_instance, session_db):
"""Branching should copy all messages to the new session."""
from cli import HermesCLI
HermesCLI._handle_branch_command(cli_instance, "/branch")
messages = session_db.get_messages_as_conversation(cli_instance.session_id)
assert len(messages) == 4 # All 4 messages copied
def test_branch_preserves_parent_link(self, cli_instance, session_db):
"""The new session should reference the original as parent."""
from cli import HermesCLI
original_id = cli_instance.session_id
HermesCLI._handle_branch_command(cli_instance, "/branch")
new_session = session_db.get_session(cli_instance.session_id)
assert new_session["parent_session_id"] == original_id
def test_branch_ends_original_session(self, cli_instance, session_db):
"""The original session should be marked as ended with 'branched' reason."""
from cli import HermesCLI
original_id = cli_instance.session_id
HermesCLI._handle_branch_command(cli_instance, "/branch")
original = session_db.get_session(original_id)
assert original["end_reason"] == "branched"
def test_branch_with_custom_name(self, cli_instance, session_db):
"""Custom branch name should be used as the title."""
from cli import HermesCLI
HermesCLI._handle_branch_command(cli_instance, "/branch refactor approach")
title = session_db.get_session_title(cli_instance.session_id)
assert title == "refactor approach"
def test_branch_auto_title_lineage(self, cli_instance, session_db):
"""Without a name, branch should auto-generate a title from the parent's title."""
from cli import HermesCLI
HermesCLI._handle_branch_command(cli_instance, "/branch")
title = session_db.get_session_title(cli_instance.session_id)
assert title == "My Coding Session #2"
def test_branch_empty_conversation(self, cli_instance, session_db):
"""Branching with no history should show an error."""
from cli import HermesCLI
cli_instance.conversation_history = []
HermesCLI._handle_branch_command(cli_instance, "/branch")
# session_id should not have changed
assert cli_instance.session_id == "20260403_120000_abc123"
def test_branch_no_session_db(self, cli_instance):
"""Branching without a session DB should show an error."""
from cli import HermesCLI
cli_instance._session_db = None
HermesCLI._handle_branch_command(cli_instance, "/branch")
# session_id should not have changed
assert cli_instance.session_id == "20260403_120000_abc123"
def test_branch_syncs_agent(self, cli_instance, session_db):
"""If an agent is active, branch should sync it to the new session."""
from cli import HermesCLI
agent = MagicMock()
agent._last_flushed_db_idx = 0
cli_instance.agent = agent
HermesCLI._handle_branch_command(cli_instance, "/branch")
# Agent should have been updated
assert agent.session_id == cli_instance.session_id
assert agent.reset_session_state.called
assert agent._last_flushed_db_idx == 4 # len(conversation_history)
def test_branch_sets_resumed_flag(self, cli_instance, session_db):
"""Branch should set _resumed=True to prevent auto-title generation."""
from cli import HermesCLI
HermesCLI._handle_branch_command(cli_instance, "/branch")
assert cli_instance._resumed is True
def test_branch_rotates_hermes_session_id_env_and_context(self, cli_instance, session_db):
"""Branching must update process-local session-id readers too."""
from cli import HermesCLI
from gateway.session_context import _UNSET, _VAR_MAP, get_session_env
old_session_id = cli_instance.session_id
os.environ["HERMES_SESSION_ID"] = old_session_id
_VAR_MAP["HERMES_SESSION_ID"].set(old_session_id)
try:
HermesCLI._handle_branch_command(cli_instance, "/branch")
assert cli_instance.session_id != old_session_id
assert os.environ["HERMES_SESSION_ID"] == cli_instance.session_id
assert get_session_env("HERMES_SESSION_ID") == cli_instance.session_id
finally:
os.environ.pop("HERMES_SESSION_ID", None)
_VAR_MAP["HERMES_SESSION_ID"].set(_UNSET)
def test_branch_fires_on_session_switch_hook(self, cli_instance, session_db):
"""The /branch command must notify memory providers of the rotation.
Without this, providers that cache per-session state in
initialize() keep writing under the old session_id. See #6672.
"""
from cli import HermesCLI
# Wire a real-ish agent object with a MagicMock memory_manager
agent = MagicMock()
mm = MagicMock()
agent._memory_manager = mm
cli_instance.agent = agent
original_id = cli_instance.session_id
HermesCLI._handle_branch_command(cli_instance, "/branch")
# Hook must have been called exactly once with the new session_id,
# parent pointing at the branched-from session, reset=False, and
# reason="branch" for diagnostics.
assert mm.on_session_switch.call_count == 1
_, kwargs = mm.on_session_switch.call_args
assert mm.on_session_switch.call_args.args[0] == cli_instance.session_id
assert kwargs["parent_session_id"] == original_id
assert kwargs["reset"] is False
assert kwargs["reason"] == "branch"
def test_fork_alias(self):
"""The /fork alias should resolve to 'branch'."""
from hermes_cli.commands import resolve_command
result = resolve_command("fork")
assert result is not None
assert result.name == "branch"
class TestBranchCommandDef:
"""Test the CommandDef registration for /branch."""
def test_branch_in_registry(self):
"""The branch command should be in the command registry."""
from hermes_cli.commands import COMMAND_REGISTRY
names = [c.name for c in COMMAND_REGISTRY]
assert "branch" in names
def test_branch_has_fork_alias(self):
"""The branch command should have 'fork' as an alias."""
from hermes_cli.commands import COMMAND_REGISTRY
branch = next(c for c in COMMAND_REGISTRY if c.name == "branch")
assert "fork" in branch.aliases
def test_branch_in_session_category(self):
"""The branch command should be in the Session category."""
from hermes_cli.commands import COMMAND_REGISTRY
branch = next(c for c in COMMAND_REGISTRY if c.name == "branch")
assert branch.category == "Session"
class TestBranchFlushesBeforeEndSession:
"""Regression for #47202: /branch must flush un-persisted messages to
the session DB before ending the old session, just like /new and
compress_context() already do."""
def test_branch_flushes_when_agent_present(self, cli_instance, session_db):
from cli import HermesCLI
agent = MagicMock()
cli_instance.agent = agent
HermesCLI._handle_branch_command(cli_instance, "/branch")
agent._flush_messages_to_session_db.assert_called_once_with(
cli_instance.conversation_history
)
+123
View File
@@ -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"
+149
View File
@@ -0,0 +1,149 @@
"""Regression tests for #53009: chat -q final response erased by exit-summary clear."""
from types import SimpleNamespace
import pytest
import cli as cli_mod
# ── A3.1 Test-First: verify _clear_terminal_on_exit gating ──────────────────
def test_print_exit_summary_clears_screen_by_default(monkeypatch):
"""Default behavior: _print_exit_summary() calls _clear_terminal_on_exit()."""
calls = []
class FakeCLI:
conversation_history = []
session_start = None
def _clear_terminal_on_exit(self):
calls.append("clear")
monkeypatch.setattr(cli_mod, "datetime", SimpleNamespace(
now=lambda: SimpleNamespace(
__sub__=lambda self, other: SimpleNamespace(
total_seconds=lambda: 0
)
)
))
fake = FakeCLI()
cli_mod.HermesCLI._print_exit_summary(fake) # default clear_screen=True
assert "clear" in calls, "_clear_terminal_on_exit should be called by default"
def test_print_exit_summary_skips_clear_when_clear_screen_false(monkeypatch):
"""With clear_screen=False, _print_exit_summary() does NOT clear."""
calls = []
class FakeCLI:
conversation_history = []
session_start = None
def _clear_terminal_on_exit(self):
calls.append("clear")
monkeypatch.setattr(cli_mod, "datetime", SimpleNamespace(
now=lambda: SimpleNamespace(
__sub__=lambda self, other: SimpleNamespace(
total_seconds=lambda: 0
)
)
))
fake = FakeCLI()
cli_mod.HermesCLI._print_exit_summary(fake, clear_screen=False)
assert "clear" not in calls, (
"_clear_terminal_on_exit should NOT be called when clear_screen=False"
)
# ── Production-path test: single-query -q path skips the clear ──────────────
def test_single_query_main_skips_clear_on_exit_summary(monkeypatch):
"""The single-query (-q) path calls _print_exit_summary without clearing."""
calls = []
clear_calls = []
class FakeCLI:
def __init__(self, **_kwargs):
self.console = SimpleNamespace(print=lambda *_a, **_kw: calls.append("query-label"))
self.session_id = "sq-test"
self.agent = SimpleNamespace(
session_id="sq-test",
platform="cli",
)
def _claim_active_session(self, surface, *, stderr=False):
calls.append(("claim", surface, stderr))
return True
def _show_security_advisories(self):
calls.append("advisories")
def chat(self, query, images=None):
calls.append(("chat", query, images))
return "done"
def _print_exit_summary(self, clear_screen=True):
calls.append(("summary", clear_screen))
if clear_screen:
clear_calls.append("CLEARED") # should NOT happen
monkeypatch.setattr(cli_mod, "HermesCLI", FakeCLI)
monkeypatch.setattr(cli_mod.atexit, "register", lambda *_a, **_kw: None)
monkeypatch.setattr(
cli_mod,
"_finalize_single_query",
lambda fake_cli: calls.append(("finalize", fake_cli.session_id)),
)
cli_mod.main(query="hello", quiet=False, toolsets="terminal")
assert calls == [
("claim", "cli", False),
"query-label",
"advisories",
("chat", "hello", None),
("summary", False), # <-- clear_screen=False for single-query
("finalize", "sq-test"),
]
assert len(clear_calls) == 0, (
"_clear_terminal_on_exit must NOT be called in single-query mode"
)
# ── Verify interactive mode still clears ────────────────────────────────────
def test_print_exit_summary_still_clears_in_interactive_path(monkeypatch):
"""Interactive mode should still clear the screen (preserving #38928)."""
from datetime import datetime as real_datetime
calls = []
class FakeCLI:
conversation_history = [
{"role": "user", "content": "hello"},
{"role": "assistant", "content": "hi"},
]
session_start = real_datetime(2026, 1, 1, 12, 0, 0)
session_id = "test-session"
_session_db = None
agent = None
def _clear_terminal_on_exit(self):
calls.append("clear")
monkeypatch.setattr(cli_mod, "datetime", SimpleNamespace(
now=lambda: real_datetime(2026, 1, 1, 12, 1, 0) # 1 min elapsed
))
fake = FakeCLI()
cli_mod.HermesCLI._print_exit_summary(fake) # default clear_screen=True
assert "clear" in calls, (
"Interactive mode should still clear the screen (regression test for #38928)"
)
@@ -0,0 +1,70 @@
"""Regression test for #49287 — the CLI memory-provider ``on_session_end``
hook stopped firing on ``/exit`` after the god-file Phase 4 refactor
(094aa85c37) moved agent construction into ``CLIAgentSetupMixin``.
``_run_cleanup`` (in ``cli.py``) gates the memory-shutdown call on the
module global ``cli._active_agent_ref``. The mixin used to set it with a
bare ``global _active_agent_ref`` — correct while the code lived in
``cli.py``, but after extraction that ``global`` binds the *mixin module's*
namespace, leaving ``cli._active_agent_ref`` ``None`` forever. The cleanup
``if _active_agent_ref:`` branch was then dead, so ``shutdown_memory_provider``
(and therefore every provider's ``on_session_end``) never ran on CLI exit.
The fix writes the reference onto the ``cli`` module explicitly. These tests
assert that contract — the existing shutdown tests pass only because they
hand-assign ``cli._active_agent_ref``, which is exactly what masked the bug.
"""
from __future__ import annotations
import inspect
def test_mixin_writes_active_agent_ref_to_cli_module():
"""The mixin's agent-setup code must publish the agent reference where
``_run_cleanup`` reads it — on the ``cli`` module, not the mixin module."""
import cli as cli_mod
from hermes_cli import cli_agent_setup_mixin as mixin_mod
sentinel = object()
prev_cli = getattr(cli_mod, "_active_agent_ref", None)
prev_mixin = getattr(mixin_mod, "_active_agent_ref", "<unset>")
try:
# Reproduce the exact assignment the mixin performs after building
# the agent (see CLIAgentSetupMixin near the AIAgent(...) construction).
import cli as _cli
_cli._active_agent_ref = sentinel
# The cleanup path reads cli._active_agent_ref — it must see the value.
assert cli_mod._active_agent_ref is sentinel
finally:
cli_mod._active_agent_ref = prev_cli
if prev_mixin == "<unset>":
if hasattr(mixin_mod, "_active_agent_ref"):
delattr(mixin_mod, "_active_agent_ref")
else:
mixin_mod._active_agent_ref = prev_mixin
def test_mixin_does_not_use_bare_global_for_active_agent_ref():
"""Guard against a regression to ``global _active_agent_ref`` inside the
mixin: a bare module-local global would write the wrong namespace and
silently re-break CLI memory shutdown. The source must target ``cli``."""
from hermes_cli import cli_agent_setup_mixin as mixin_mod
src = inspect.getsource(mixin_mod)
assert "_active_agent_ref = self.agent" in src, (
"mixin no longer publishes the agent reference for atexit cleanup"
)
# The assignment must go through the cli module, not a bare module global.
# Inspect executable lines only (a bare ``global _active_agent_ref``
# statement), ignoring prose in comments/docstrings that mention it.
code_lines = [ln.split("#", 1)[0].strip() for ln in src.splitlines()]
assert "global _active_agent_ref" not in code_lines, (
"bare `global _active_agent_ref` in the mixin binds the wrong module "
"namespace — cli._active_agent_ref stays None and memory shutdown dies "
"(#49287). Write `cli._active_agent_ref = self.agent` instead."
)
assert "_cli._active_agent_ref = self.agent" in src, (
"expected the agent reference to be published onto the cli module"
)
+739
View File
@@ -0,0 +1,739 @@
import queue
import threading
import time
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
import cli as cli_module
from cli import HermesCLI
class _FakeBuffer:
def __init__(self, text="", cursor_position=None):
self.text = text
self.cursor_position = len(text) if cursor_position is None else cursor_position
def reset(self, append_to_history=False):
self.text = ""
self.cursor_position = 0
def _make_cli_stub():
cli = HermesCLI.__new__(HermesCLI)
cli._approval_state = None
cli._approval_deadline = 0
cli._approval_lock = threading.Lock()
cli._sudo_state = None
cli._sudo_deadline = 0
cli._modal_input_snapshot = None
cli._invalidate = MagicMock()
cli._app = SimpleNamespace(invalidate=MagicMock(), current_buffer=_FakeBuffer())
return cli
def _make_background_cli_stub():
cli = _make_cli_stub()
cli._background_task_counter = 0
cli._background_tasks = {}
cli._ensure_runtime_credentials = MagicMock(return_value=True)
cli._resolve_turn_agent_config = MagicMock(return_value={
"model": "test-model",
"runtime": {
"api_key": "test-key",
"base_url": "https://example.test/v1",
"provider": "test",
"api_mode": "chat_completions",
},
"request_overrides": None,
})
cli.max_turns = 90
cli.enabled_toolsets = []
cli._session_db = None
cli.reasoning_config = {}
cli.service_tier = None
cli._providers_only = None
cli._providers_ignore = None
cli._providers_order = None
cli._provider_sort = None
cli._provider_require_params = None
cli._provider_data_collection = None
cli._openrouter_min_coding_score = None
cli._fallback_model = None
cli._agent_running = False
cli._spinner_text = ""
cli.bell_on_complete = False
cli.final_response_markdown = "strip"
return cli
class TestCliApprovalUi:
def test_sudo_prompt_restores_existing_draft_after_response(self):
cli = _make_cli_stub()
cli._app.current_buffer = _FakeBuffer("draft command", cursor_position=5)
result = {}
def _run_callback():
result["value"] = cli._sudo_password_callback()
with patch.object(cli_module, "_cprint"):
thread = threading.Thread(target=_run_callback, daemon=True)
thread.start()
deadline = time.time() + 2
while cli._sudo_state is None and time.time() < deadline:
time.sleep(0.01)
assert cli._sudo_state is not None
assert cli._app.current_buffer.text == ""
cli._app.current_buffer.text = "secret"
cli._app.current_buffer.cursor_position = len("secret")
cli._sudo_state["response_queue"].put("secret")
thread.join(timeout=2)
assert result["value"] == "secret"
assert cli._app.current_buffer.text == "draft command"
assert cli._app.current_buffer.cursor_position == 5
def test_approval_callback_includes_view_for_long_commands(self):
cli = _make_cli_stub()
command = "sudo dd if=/tmp/githubcli-keyring.gpg of=/usr/share/keyrings/githubcli-archive-keyring.gpg bs=4M status=progress"
result = {}
def _run_callback():
result["value"] = cli._approval_callback(command, "disk copy")
thread = threading.Thread(target=_run_callback, daemon=True)
thread.start()
deadline = time.time() + 2
while cli._approval_state is None and time.time() < deadline:
time.sleep(0.01)
assert cli._approval_state is not None
assert "view" in cli._approval_state["choices"]
cli._approval_state["response_queue"].put("deny")
thread.join(timeout=2)
assert result["value"] == "deny"
def test_handle_approval_selection_view_expands_in_place(self):
cli = _make_cli_stub()
cli._approval_state = {
"command": "sudo dd if=/tmp/in of=/usr/share/keyrings/githubcli-archive-keyring.gpg bs=4M status=progress",
"description": "disk copy",
"choices": ["once", "session", "always", "deny", "view"],
"selected": 4,
"response_queue": queue.Queue(),
}
cli._handle_approval_selection()
assert cli._approval_state is not None
assert cli._approval_state["show_full"] is True
assert "view" not in cli._approval_state["choices"]
assert cli._approval_state["selected"] == 3
assert cli._approval_state["response_queue"].empty()
def test_approval_display_places_title_inside_box_not_border(self):
cli = _make_cli_stub()
cli._approval_state = {
"command": "sudo dd if=/tmp/in of=/usr/share/keyrings/githubcli-archive-keyring.gpg bs=4M status=progress",
"description": "disk copy",
"choices": ["once", "session", "always", "deny", "view"],
"selected": 0,
"response_queue": queue.Queue(),
}
fragments = cli._get_approval_display_fragments()
rendered = "".join(text for _style, text in fragments)
lines = rendered.splitlines()
assert lines[0].startswith("")
assert "Dangerous Command" not in lines[0]
assert any("Dangerous Command" in line for line in lines[1:3])
assert "Show full command" in rendered
assert "githubcli-archive-" in rendered
assert "keyring.gpg" in rendered
assert "status=progress" in rendered
def test_approval_display_wraps_preview_hint_on_narrow_terminal(self):
cli = _make_cli_stub()
cli._approval_state = {
"command": "sudo " + ("very-long-command-segment-" * 8),
"description": "shell command via -c/-lc flag",
"choices": ["once", "session", "always", "deny", "view"],
"selected": 0,
"response_queue": queue.Queue(),
}
import shutil as _shutil
with patch("cli.shutil.get_terminal_size",
return_value=_shutil.os.terminal_size((30, 24))):
fragments = cli._get_approval_display_fragments()
rendered = "".join(text for _style, text in fragments)
lines = rendered.splitlines()
border_width = len(lines[0])
assert "Show full" in rendered
assert "command)" in rendered
assert all(len(line) == border_width for line in lines)
def test_approval_display_shows_full_command_after_view(self):
cli = _make_cli_stub()
full_command = "sudo dd if=/tmp/in of=/usr/share/keyrings/githubcli-archive-keyring.gpg bs=4M status=progress"
cli._approval_state = {
"command": full_command,
"description": "disk copy",
"choices": ["once", "session", "always", "deny"],
"selected": 0,
"show_full": True,
"response_queue": queue.Queue(),
}
fragments = cli._get_approval_display_fragments()
rendered = "".join(text for _style, text in fragments)
assert "..." not in rendered
assert "githubcli-" in rendered
assert "archive-" in rendered
assert "keyring.gpg" in rendered
assert "status=progress" in rendered
def test_approval_display_preserves_command_and_choices_with_long_description(self):
"""Regression: long tirith descriptions used to push approve/deny off-screen.
The panel must always render the command and every choice, even when
the description would otherwise wrap into 10+ lines. The description
gets truncated with a marker instead.
"""
cli = _make_cli_stub()
long_desc = (
"Security scan — [CRITICAL] Destructive shell command with wildcard expansion: "
"The command performs a recursive deletion of log files which may contain "
"audit information relevant to active incident investigations, running services "
"that rely on log files for state, rotated archives, and other system artifacts. "
"Review whether this is intended before approving. Consider whether a targeted "
"deletion with more specific filters would better match the intent."
)
cli._approval_state = {
"command": "rm -rf /var/log/apache2/*.log",
"description": long_desc,
"choices": ["once", "session", "always", "deny"],
"selected": 0,
"response_queue": queue.Queue(),
}
# Simulate a compact terminal where the old unbounded panel would overflow.
import shutil as _shutil
with patch("cli.shutil.get_terminal_size",
return_value=_shutil.os.terminal_size((100, 20))):
fragments = cli._get_approval_display_fragments()
rendered = "".join(text for _style, text in fragments)
# Command must be fully visible (rm -rf /var/log/apache2/*.log is short).
assert "rm -rf /var/log/apache2/*.log" in rendered
# Every choice must render — this is the core bug: approve/deny were
# getting clipped off the bottom of the panel.
assert "Allow once" in rendered
assert "Allow for this session" in rendered
assert "Add to permanent allowlist" in rendered
assert "Deny" in rendered
# The bottom border must render (i.e. the panel is self-contained).
assert rendered.rstrip().endswith("")
# The description gets truncated — marker should appear.
assert "(description truncated)" in rendered
def test_approval_display_skips_description_on_very_short_terminal(self):
"""On a 12-row terminal, only the command and choices have room.
The description is dropped entirely rather than partially shown, so the
choices never get clipped.
"""
cli = _make_cli_stub()
cli._approval_state = {
"command": "rm -rf /var/log/apache2/*.log",
"description": "recursive delete",
"choices": ["once", "session", "always", "deny"],
"selected": 0,
"response_queue": queue.Queue(),
}
import shutil as _shutil
with patch("cli.shutil.get_terminal_size",
return_value=_shutil.os.terminal_size((100, 12))):
fragments = cli._get_approval_display_fragments()
rendered = "".join(text for _style, text in fragments)
# Command visible.
assert "rm -rf /var/log/apache2/*.log" in rendered
# All four choices visible.
for label in ("Allow once", "Allow for this session",
"Add to permanent allowlist", "Deny"):
assert label in rendered, f"choice {label!r} missing"
def test_approval_display_truncates_giant_command_in_view_mode(self):
"""If the user hits /view on a massive command, choices still render.
The command gets truncated with a marker; the description gets dropped
if there's no remaining row budget.
"""
cli = _make_cli_stub()
# 50 lines of command when wrapped at ~64 chars.
giant_cmd = "bash -c 'echo " + ("x" * 3000) + "'"
cli._approval_state = {
"command": giant_cmd,
"description": "shell command via -c/-lc flag",
"choices": ["once", "session", "always", "deny"],
"selected": 0,
"show_full": True,
"response_queue": queue.Queue(),
}
import shutil as _shutil
with patch("cli.shutil.get_terminal_size",
return_value=_shutil.os.terminal_size((100, 24))):
fragments = cli._get_approval_display_fragments()
rendered = "".join(text for _style, text in fragments)
# All four choices visible even with a huge command.
for label in ("Allow once", "Allow for this session",
"Add to permanent allowlist", "Deny"):
assert label in rendered, f"choice {label!r} missing"
# Command got truncated with a marker.
assert "(command truncated" in rendered
def test_background_task_registers_thread_local_approval_callbacks(self):
"""Background /btw tasks must use the prompt_toolkit approval UI.
The foreground chat path registers dangerous-command callbacks inside
its worker thread because tools.terminal_tool stores them in
threading.local(). /background used to skip that, so dangerous commands
fell back to raw input() in a background thread and timed out under
prompt_toolkit.
"""
cli = _make_background_cli_stub()
seen = {}
class FakeAgent:
def __init__(self, **kwargs):
self._print_fn = None
self.thinking_callback = None
def run_conversation(self, **kwargs):
from tools.terminal_tool import (
_get_approval_callback,
_get_sudo_password_callback,
)
seen["approval"] = _get_approval_callback()
seen["sudo"] = _get_sudo_password_callback()
return {
"final_response": "done",
"messages": [],
"completed": True,
"failed": False,
}
with patch.object(cli_module, "AIAgent", FakeAgent), \
patch.object(cli_module, "_cprint"), \
patch.object(cli_module, "ChatConsole") as chat_console:
chat_console.return_value.print = MagicMock()
cli._handle_background_command("/btw check weather")
# Join the worker thread deterministically rather than polling a
# wall-clock deadline — under load the thread's finally-block pop
# of _background_tasks can lag a fixed timeout, which flaked CI.
for _thread in list(cli._background_tasks.values()):
_thread.join(timeout=10)
assert seen["approval"].__self__ is cli
assert seen["approval"].__func__ is HermesCLI._approval_callback
assert seen["sudo"].__self__ is cli
assert seen["sudo"].__func__ is HermesCLI._sudo_password_callback
assert not cli._background_tasks
def _make_real_paint_cli_stub():
"""A stub whose modal repaint path runs the REAL _paint_now / _invalidate.
Both gates are set adversarially: _resize_recovery_pending=True and a recent
_last_invalidate inside the throttle window. A throttled _invalidate() would
be dropped under these conditions — _paint_now must paint regardless.
"""
cli = HermesCLI.__new__(HermesCLI)
cli._approval_state = None
cli._approval_deadline = 0
cli._approval_lock = threading.Lock()
cli._sudo_state = None
cli._sudo_deadline = 0
cli._clarify_state = None
cli._clarify_freetext = False
cli._clarify_deadline = 0
cli._modal_input_snapshot = None
# Real methods, not mocks.
cli._paint_now = HermesCLI._paint_now.__get__(cli, HermesCLI)
cli._invalidate = HermesCLI._invalidate.__get__(cli, HermesCLI)
cli._resize_recovery_pending = True # gate 1: resize in flight
cli._last_invalidate = time.monotonic() # gate 2: inside throttle window
cli._app = SimpleNamespace(invalidate=MagicMock(), current_buffer=_FakeBuffer())
return cli
class TestModalPaintNow:
"""Regression for #41098 — modal prompts must paint immediately.
The dangerous-command approval, clarify, and sudo prompts run their wait
loop on a background thread, set modal state a ConditionalContainer reads,
then must repaint so the panel becomes visible. They used the throttled
_invalidate(), whose paint is silently dropped on a 250ms window collision
or while a resize is pending — so the prompt timed out unseen. They now use
_paint_now(), which paints directly like the modal key-binding handlers.
"""
def test_paint_now_bypasses_throttle_and_resize_guard(self):
cli = _make_real_paint_cli_stub()
# A bare _invalidate() is suppressed under both gates...
cli._invalidate()
assert not cli._app.invalidate.called
# ...but _paint_now() always paints.
cli._paint_now()
assert cli._app.invalidate.called
def test_paint_now_no_app_is_safe(self):
cli = HermesCLI.__new__(HermesCLI)
cli._app = None
cli._paint_now() # must not raise
def _drive(self, cli, target, state_attr):
result = {}
def _run():
result["value"] = target()
with patch.object(cli_module, "_cprint"):
thread = threading.Thread(target=_run, daemon=True)
thread.start()
deadline = time.time() + 2
while getattr(cli, state_attr) is None and time.time() < deadline:
time.sleep(0.01)
assert getattr(cli, state_attr) is not None
assert cli._app.invalidate.called, (
f"{state_attr} panel was not painted despite throttle + resize gates"
)
# Reset so we can prove the response-received teardown also repaints
# (the panel must clear at once, not be held by the throttle).
cli._app.invalidate.reset_mock()
getattr(cli, state_attr)["response_queue"].put(
"deny" if state_attr == "_approval_state" else
("a" if state_attr == "_clarify_state" else "pw")
)
thread.join(timeout=2)
# clarify returns immediately on a response (no teardown repaint);
# approval and sudo repaint to tear the panel down.
if state_attr != "_clarify_state":
assert cli._app.invalidate.called, (
f"{state_attr} panel was not repainted on teardown"
)
assert not thread.is_alive()
return result["value"]
def test_approval_prompt_paints_under_both_gates(self):
cli = _make_real_paint_cli_stub()
value = self._drive(
cli, lambda: cli._approval_callback("rm -rf /tmp/scratch", "danger"),
"_approval_state",
)
assert value == "deny"
def test_clarify_prompt_paints_under_both_gates(self):
cli = _make_real_paint_cli_stub()
value = self._drive(
cli, lambda: cli._clarify_callback("Pick one", ["a", "b"]),
"_clarify_state",
)
assert value == "a"
def test_sudo_prompt_paints_under_both_gates(self):
cli = _make_real_paint_cli_stub()
value = self._drive(cli, cli._sudo_password_callback, "_sudo_state")
assert value == "pw"
def test_secret_response_teardown_paints(self):
"""_submit_secret_response tears the secret panel down via _paint_now,
so the panel clears immediately rather than being held by the throttle."""
cli = _make_real_paint_cli_stub()
cli._secret_state = {"response_queue": queue.Queue()}
cli._secret_deadline = 0
cli._submit_secret_response("hunter2")
assert cli._secret_state is None
assert cli._app.invalidate.called
assert cli._secret_state is None # cleared
class TestApprovalCallbackThreadLocalWiring:
"""Regression guard for the thread-local callback freeze (#13617 / #13618).
After 62348cff made _approval_callback / _sudo_password_callback thread-local
(ACP GHSA-qg5c-hvr5-hjgr), the CLI agent thread could no longer see callbacks
registered in the main thread — the dangerous-command prompt silently fell
back to stdin input() and deadlocked against prompt_toolkit. The fix is to
register the callbacks INSIDE the agent worker thread (matching the ACP
pattern). These tests lock in that invariant.
"""
def test_main_thread_registration_is_invisible_to_child_thread(self):
"""Confirms the underlying threading.local semantics that drove the bug.
If this ever starts passing as "visible", the thread-local isolation
is gone and the ACP race GHSA-qg5c-hvr5-hjgr may be back.
"""
from tools.terminal_tool import (
set_approval_callback,
_get_approval_callback,
)
def main_cb(_cmd, _desc):
return "once"
set_approval_callback(main_cb)
try:
seen = {}
def _child():
seen["value"] = _get_approval_callback()
t = threading.Thread(target=_child, daemon=True)
t.start()
t.join(timeout=2)
assert seen["value"] is None
finally:
set_approval_callback(None)
def test_child_thread_registration_is_visible_and_cleared_in_finally(self):
"""The fix pattern: register INSIDE the worker thread, clear in finally.
This is exactly what cli.py's run_agent() closure does. If this test
fails, the CLI approval prompt freeze (#13617) has regressed.
"""
from tools.terminal_tool import (
set_approval_callback,
set_sudo_password_callback,
_get_approval_callback,
_get_sudo_password_callback,
)
def approval_cb(_cmd, _desc):
return "once"
def sudo_cb():
return "hunter2"
seen = {}
def _worker():
# Mimic cli.py's run_agent() thread target.
set_approval_callback(approval_cb)
set_sudo_password_callback(sudo_cb)
try:
seen["approval"] = _get_approval_callback()
seen["sudo"] = _get_sudo_password_callback()
finally:
set_approval_callback(None)
set_sudo_password_callback(None)
seen["approval_after"] = _get_approval_callback()
seen["sudo_after"] = _get_sudo_password_callback()
t = threading.Thread(target=_worker, daemon=True)
t.start()
t.join(timeout=2)
assert seen["approval"] is approval_cb
assert seen["sudo"] is sudo_cb
# Finally block must clear both slots — otherwise a reused thread
# would hold a stale reference to a disposed CLI instance.
assert seen["approval_after"] is None
assert seen["sudo_after"] is None
class TestPersistPromptSummary:
"""display.persist_prompts — one-line scrollback record of resolved modals."""
def _resolve_approval(self, cli, answer, command="rm -rf /tmp/scratch"):
result = {}
def _run():
result["value"] = cli._approval_callback(command, "danger")
t = threading.Thread(target=_run, daemon=True)
t.start()
deadline = time.time() + 2
while cli._approval_state is None and time.time() < deadline:
time.sleep(0.01)
cli._approval_state["response_queue"].put(answer)
t.join(timeout=2)
return result["value"]
def test_approval_resolution_prints_summary_line(self):
cli = _make_cli_stub()
printed = []
with patch.object(cli_module, "_cprint", printed.append):
verdict = self._resolve_approval(cli, "session")
assert verdict == "session"
summary = "\n".join(printed)
assert "Approval" in summary
assert "rm -rf /tmp/scratch" in summary
assert "allowed for session" in summary
def test_approval_summary_truncates_long_command(self):
cli = _make_cli_stub()
printed = []
long_cmd = "sudo " + ("x" * 300)
with patch.object(cli_module, "_cprint", printed.append):
self._resolve_approval(cli, "deny", command=long_cmd)
summary = "\n".join(printed)
assert "denied" in summary
assert "" in summary
# The raw 300-char tail must not be dumped wholesale.
assert "x" * 200 not in summary
def test_persist_prompts_false_suppresses_summary(self):
cli = _make_cli_stub()
printed = []
with patch.dict(cli_module.CLI_CONFIG.get("display", {}), {"persist_prompts": False}), \
patch.object(cli_module, "_cprint", printed.append):
verdict = self._resolve_approval(cli, "once")
assert verdict == "once"
assert not any("Approval" in p for p in printed)
def test_clarify_resolution_prints_summary_line(self):
cli = _make_cli_stub()
cli._clarify_state = None
cli._clarify_freetext = False
cli._clarify_deadline = 0
printed = []
result = {}
def _run():
result["value"] = cli._clarify_callback("Pick a path?", ["A", "B"])
with patch.object(cli_module, "_cprint", printed.append):
t = threading.Thread(target=_run, daemon=True)
t.start()
deadline = time.time() + 2
while cli._clarify_state is None and time.time() < deadline:
time.sleep(0.01)
cli._clarify_state["response_queue"].put("B")
t.join(timeout=2)
assert result["value"] == "B"
summary = "\n".join(printed)
assert "Clarify" in summary
assert "Pick a path?" in summary
assert "B" in summary
class TestClearOverlaysForInterrupt:
"""Regression tests for #14026 — interrupting a running agent must clear
every input-blocking overlay (approval/clarify/sudo/secret) so the CLI
isn't left frozen with no thread servicing the prompt."""
def _make_cli(self):
cli = _make_cli_stub()
# Attributes the helper touches that the base stub doesn't set.
cli._clarify_state = None
cli._clarify_freetext = False
cli._secret_state = None
cli._secret_deadline = 0
cli._paint_now = MagicMock()
return cli
def test_clears_all_four_overlays_and_unblocks_queues(self):
cli = self._make_cli()
approval_q = queue.Queue()
clarify_q = queue.Queue()
sudo_q = queue.Queue()
secret_q = queue.Queue()
cli._approval_state = {"response_queue": approval_q}
cli._clarify_state = {"response_queue": clarify_q}
cli._clarify_freetext = True
cli._sudo_state = {"response_queue": sudo_q, "timeout": 60}
cli._sudo_deadline = 99999.0
cli._secret_state = {"response_queue": secret_q, "var_name": "X"}
cli._clear_active_overlays_for_interrupt()
# All states nilled out.
assert cli._approval_state is None
assert cli._clarify_state is None
assert cli._clarify_freetext is False
assert cli._sudo_state is None
assert cli._sudo_deadline == 0
assert cli._secret_state is None
# Each blocked thread would have received a terminal value.
assert approval_q.get_nowait() == "deny"
assert clarify_q.get_nowait() # cancellation sentinel string
assert sudo_q.get_nowait() == ""
assert secret_q.get_nowait() == ""
def test_noop_when_no_overlays_active(self):
cli = self._make_cli()
cli._clear_active_overlays_for_interrupt()
assert cli._approval_state is None
assert cli._clarify_state is None
assert cli._sudo_state is None
assert cli._secret_state is None
def test_dead_queue_does_not_block_clearing_others(self):
"""A queue that raises on put() must not prevent the remaining
overlays from being cleared."""
cli = self._make_cli()
class _DeadQueue:
def put(self, *_a, **_k):
raise RuntimeError("queue gone")
clarify_q = queue.Queue()
cli._approval_state = {"response_queue": _DeadQueue()}
cli._clarify_state = {"response_queue": clarify_q}
cli._clear_active_overlays_for_interrupt()
assert cli._approval_state is None # cleared despite dead queue
assert cli._clarify_state is None
assert clarify_q.get_nowait()
def test_interrupt_unblocks_thread_blocked_on_approval(self):
"""End-to-end: a worker blocked on the approval queue unblocks when the
interrupt helper drains it."""
cli = self._make_cli()
approval_q = queue.Queue()
cli._approval_state = {"response_queue": approval_q}
result = {}
def _worker():
result["value"] = approval_q.get(timeout=2)
t = threading.Thread(target=_worker, daemon=True)
t.start()
time.sleep(0.05)
cli._clear_active_overlays_for_interrupt()
t.join(timeout=2)
assert not t.is_alive(), "worker thread never unblocked"
assert result["value"] == "deny"
@@ -0,0 +1,270 @@
"""Tests for the /background indicator in the CLI status bar.
The classic prompt_toolkit status bar shows `▶ N` when N tasks launched via
`/background` are still running. Source of truth is `self._background_tasks`
(a Dict[str, threading.Thread]); entries are removed in the task thread's
finally block, so len() reflects truly-running tasks.
"""
import threading
from datetime import datetime
from cli import HermesCLI
def _stub_thread() -> threading.Thread:
"""Return a Thread instance that's never started — pure dict-value stand-in."""
return threading.Thread(target=lambda: None)
def _make_cli():
"""Bare-metal HermesCLI for snapshot/build tests (no __init__ side effects)."""
cli_obj = HermesCLI.__new__(HermesCLI)
cli_obj.model = "anthropic/claude-opus-4.6"
cli_obj.agent = None
cli_obj._background_tasks = {}
# The snapshot reads session_start to compute duration; supply a stub.
cli_obj.session_start = datetime.now()
return cli_obj
def test_snapshot_reports_zero_when_no_background_tasks():
cli_obj = _make_cli()
snap = cli_obj._get_status_bar_snapshot()
assert snap["active_background_tasks"] == 0
def test_snapshot_counts_live_background_tasks():
cli_obj = _make_cli()
cli_obj._background_tasks = {"bg_a": _stub_thread(), "bg_b": _stub_thread()}
snap = cli_obj._get_status_bar_snapshot()
assert snap["active_background_tasks"] == 2
def test_snapshot_safe_when_background_tasks_attr_missing():
"""Older HermesCLI instances (tests with __new__, etc.) may lack the attr."""
cli_obj = HermesCLI.__new__(HermesCLI)
cli_obj.model = "x"
cli_obj.agent = None
cli_obj.session_start = datetime.now()
# No _background_tasks at all — must not raise.
snap = cli_obj._get_status_bar_snapshot()
assert snap["active_background_tasks"] == 0
def test_plain_text_status_omits_indicator_when_idle():
cli_obj = _make_cli()
text = cli_obj._build_status_bar_text(width=80)
assert "" not in text
def test_plain_text_status_shows_indicator_when_active():
cli_obj = _make_cli()
cli_obj._background_tasks = {"bg_a": _stub_thread()}
text = cli_obj._build_status_bar_text(width=80)
assert "▶ 1" in text
def test_plain_text_status_shows_higher_count():
cli_obj = _make_cli()
cli_obj._background_tasks = {
"a": _stub_thread(),
"b": _stub_thread(),
"c": _stub_thread(),
}
text = cli_obj._build_status_bar_text(width=80)
assert "▶ 3" in text
def test_narrow_width_omits_bg_indicator():
"""The narrow tier (<52) is already cramped — bg is secondary, drop it."""
cli_obj = _make_cli()
cli_obj._background_tasks = {"bg_a": _stub_thread()}
text = cli_obj._build_status_bar_text(width=40)
assert "" not in text
def test_fragments_include_bg_segment_when_active():
cli_obj = _make_cli()
cli_obj._background_tasks = {"a": _stub_thread(), "b": _stub_thread()}
cli_obj._status_bar_visible = True
# _get_status_bar_fragments asks _get_tui_terminal_width(); stub it wide.
cli_obj._get_tui_terminal_width = lambda: 120 # type: ignore[method-assign]
frags = cli_obj._get_status_bar_fragments()
rendered = "".join(text for _style, text in frags)
assert "▶ 2" in rendered
def test_fragments_omit_bg_segment_when_idle():
cli_obj = _make_cli()
cli_obj._status_bar_visible = True
cli_obj._get_tui_terminal_width = lambda: 120 # type: ignore[method-assign]
frags = cli_obj._get_status_bar_fragments()
rendered = "".join(text for _style, text in frags)
assert "" not in rendered
# ── Background terminal-process indicator (⚙ N) ───────────────────────────
# Source of truth is tools.process_registry.process_registry._running (a dict
# of currently-running shell processes spawned by terminal(background=true)).
# Distinct from /background tasks above: ▶ counts agent threads, ⚙ counts
# shell processes. Both can be active simultaneously.
class _FakeRunningRegistry:
"""Minimal stand-in for process_registry; exposes count_running()."""
def __init__(self, count: int) -> None:
self._count = count
def count_running(self) -> int:
return self._count
def _patch_process_registry(monkeypatch, count: int) -> None:
import tools.process_registry as pr_mod
monkeypatch.setattr(pr_mod, "process_registry", _FakeRunningRegistry(count))
def test_snapshot_reports_zero_when_no_background_processes(monkeypatch):
cli_obj = _make_cli()
_patch_process_registry(monkeypatch, 0)
snap = cli_obj._get_status_bar_snapshot()
assert snap["active_background_processes"] == 0
def test_snapshot_counts_live_background_processes(monkeypatch):
cli_obj = _make_cli()
_patch_process_registry(monkeypatch, 3)
snap = cli_obj._get_status_bar_snapshot()
assert snap["active_background_processes"] == 3
def test_snapshot_safe_when_process_registry_raises(monkeypatch):
"""If count_running() raises the snapshot stays at 0; no propagate."""
cli_obj = _make_cli()
import tools.process_registry as pr_mod
class _BoomRegistry:
def count_running(self):
raise RuntimeError("boom")
monkeypatch.setattr(pr_mod, "process_registry", _BoomRegistry())
snap = cli_obj._get_status_bar_snapshot()
assert snap["active_background_processes"] == 0
def test_plain_text_status_shows_proc_indicator_when_active(monkeypatch):
cli_obj = _make_cli()
_patch_process_registry(monkeypatch, 2)
text = cli_obj._build_status_bar_text(width=80)
assert "⚙ 2" in text
def test_plain_text_status_omits_proc_indicator_when_idle(monkeypatch):
cli_obj = _make_cli()
_patch_process_registry(monkeypatch, 0)
text = cli_obj._build_status_bar_text(width=80)
assert "" not in text
def test_fragments_include_proc_segment_when_active(monkeypatch):
cli_obj = _make_cli()
_patch_process_registry(monkeypatch, 1)
cli_obj._status_bar_visible = True
cli_obj._get_tui_terminal_width = lambda: 120 # type: ignore[method-assign]
frags = cli_obj._get_status_bar_fragments()
rendered = "".join(text for _style, text in frags)
assert "⚙ 1" in rendered
def test_indicators_independent_agents_and_processes(monkeypatch):
"""▶ (agent tasks) and ⚙ (shell processes) render side-by-side."""
cli_obj = _make_cli()
cli_obj._background_tasks = {"bg_a": _stub_thread()}
_patch_process_registry(monkeypatch, 2)
cli_obj._status_bar_visible = True
cli_obj._get_tui_terminal_width = lambda: 120 # type: ignore[method-assign]
frags = cli_obj._get_status_bar_fragments()
rendered = "".join(text for _style, text in frags)
assert "▶ 1" in rendered
assert "⚙ 2" in rendered
# ── Background/async subagent indicator (⛓ N) ─────────────────────────────
# Source of truth is tools.async_delegation.active_count() — the count of
# delegate_task delegations (batch + background single) still in the
# "running" state. Distinct from ▶ (/background agent threads) and ⚙ (shell
# processes); all three can be active at once.
def _patch_async_active(monkeypatch, count: int) -> None:
import tools.async_delegation as ad_mod
monkeypatch.setattr(ad_mod, "active_count", lambda: count)
def test_snapshot_reports_zero_when_no_background_subagents(monkeypatch):
cli_obj = _make_cli()
_patch_async_active(monkeypatch, 0)
snap = cli_obj._get_status_bar_snapshot()
assert snap["active_background_subagents"] == 0
def test_snapshot_counts_live_background_subagents(monkeypatch):
cli_obj = _make_cli()
_patch_async_active(monkeypatch, 4)
snap = cli_obj._get_status_bar_snapshot()
assert snap["active_background_subagents"] == 4
def test_snapshot_safe_when_async_active_count_raises(monkeypatch):
"""If active_count() raises the snapshot stays at 0; no propagate."""
cli_obj = _make_cli()
import tools.async_delegation as ad_mod
def _boom():
raise RuntimeError("boom")
monkeypatch.setattr(ad_mod, "active_count", _boom)
snap = cli_obj._get_status_bar_snapshot()
assert snap["active_background_subagents"] == 0
def test_plain_text_status_shows_subagent_indicator_when_active(monkeypatch):
cli_obj = _make_cli()
_patch_async_active(monkeypatch, 3)
text = cli_obj._build_status_bar_text(width=80)
assert "⛓ 3" in text
def test_plain_text_status_omits_subagent_indicator_when_idle(monkeypatch):
cli_obj = _make_cli()
_patch_async_active(monkeypatch, 0)
text = cli_obj._build_status_bar_text(width=80)
assert "" not in text
def test_fragments_include_subagent_segment_when_active(monkeypatch):
cli_obj = _make_cli()
_patch_async_active(monkeypatch, 2)
cli_obj._status_bar_visible = True
cli_obj._get_tui_terminal_width = lambda: 120 # type: ignore[method-assign]
frags = cli_obj._get_status_bar_fragments()
rendered = "".join(text for _style, text in frags)
assert "⛓ 2" in rendered
def test_all_three_background_indicators_independent(monkeypatch):
"""▶ (agent tasks), ⚙ (shell processes), ⛓ (subagents) all coexist."""
cli_obj = _make_cli()
cli_obj._background_tasks = {"bg_a": _stub_thread()}
_patch_process_registry(monkeypatch, 2)
_patch_async_active(monkeypatch, 5)
cli_obj._status_bar_visible = True
cli_obj._get_tui_terminal_width = lambda: 120 # type: ignore[method-assign]
frags = cli_obj._get_status_bar_fragments()
rendered = "".join(text for _style, text in frags)
assert "▶ 1" in rendered
assert "⚙ 2" in rendered
assert "⛓ 5" in rendered
@@ -0,0 +1,102 @@
"""Tests for CLI background command TUI refresh behavior.
Ensures the TUI is properly refreshed before printing background task output
to prevent spinner/status bar overlap (#2718).
"""
from unittest.mock import MagicMock, patch
from cli import HermesCLI
def _make_cli():
"""Create a minimal HermesCLI instance for testing."""
cli_obj = HermesCLI.__new__(HermesCLI)
cli_obj.model = "test-model"
cli_obj._background_tasks = {}
cli_obj._background_task_counter = 0
cli_obj.conversation_history = []
cli_obj.agent = None
cli_obj._app = None
return cli_obj
class TestBackgroundCommandTuiRefresh:
"""Tests for TUI refresh in background command output."""
def test_invalidate_called_before_success_output(self):
"""App.invalidate() is called before printing background success output."""
cli_obj = _make_cli()
mock_app = MagicMock()
cli_obj._app = mock_app
# Track call order
call_order = []
original_invalidate = mock_app.invalidate
def track_invalidate():
call_order.append("invalidate")
return original_invalidate()
mock_app.invalidate = track_invalidate
# Patch print to track when it's called
with patch("builtins.print") as mock_print:
mock_print.side_effect = lambda *args, **kwargs: call_order.append("print")
# Simulate the background task output code path
if cli_obj._app:
cli_obj._app.invalidate()
import time
time.sleep(0.01) # reduced for test
print()
# Verify invalidate was called before print
assert call_order[0] == "invalidate"
assert "print" in call_order
def test_invalidate_called_before_error_output(self):
"""App.invalidate() is called before printing background error output."""
cli_obj = _make_cli()
mock_app = MagicMock()
cli_obj._app = mock_app
call_order = []
mock_app.invalidate.side_effect = lambda: call_order.append("invalidate")
with patch("builtins.print") as mock_print:
mock_print.side_effect = lambda *args, **kwargs: call_order.append("print")
# Simulate error path
if cli_obj._app:
cli_obj._app.invalidate()
import time
time.sleep(0.01)
print()
assert call_order[0] == "invalidate"
assert "print" in call_order
def test_no_crash_when_app_is_none(self):
"""No crash when _app is None (non-TUI mode)."""
cli_obj = _make_cli()
cli_obj._app = None
# This should not raise
if cli_obj._app:
cli_obj._app.invalidate()
# If we get here without exception, test passes
def test_background_task_thread_safety(self):
"""Background task tracking is thread-safe."""
cli_obj = _make_cli()
# Simulate adding and removing background tasks
task_id = "test_task_1"
cli_obj._background_tasks[task_id] = MagicMock()
assert task_id in cli_obj._background_tasks
# Clean up
cli_obj._background_tasks.pop(task_id, None)
assert task_id not in cli_obj._background_tasks
@@ -0,0 +1,49 @@
"""Tests for defensive bracketed-paste wrapper stripping in the CLI."""
from cli import _strip_leaked_bracketed_paste_wrappers
class TestStripLeakedBracketedPasteWrappers:
def test_plain_text_unchanged(self):
text = "hello world"
assert _strip_leaked_bracketed_paste_wrappers(text) == text
def test_strips_canonical_escape_wrappers(self):
text = "\x1b[200~hello\x1b[201~"
assert _strip_leaked_bracketed_paste_wrappers(text) == "hello"
def test_strips_visible_caret_escape_wrappers(self):
text = "^[[200~hello^[[201~"
assert _strip_leaked_bracketed_paste_wrappers(text) == "hello"
def test_strips_degraded_bracket_only_wrappers(self):
text = "[200~hello[201~"
assert _strip_leaked_bracketed_paste_wrappers(text) == "hello"
def test_strips_degraded_bracket_only_wrappers_after_whitespace(self):
text = "prefix [200~hello[201~ suffix"
assert _strip_leaked_bracketed_paste_wrappers(text) == "prefix hello suffix"
def test_strips_wrapper_fragments_at_boundaries(self):
text = "00~hello world01~"
assert _strip_leaked_bracketed_paste_wrappers(text) == "hello world"
def test_strips_wrapper_fragments_after_whitespace(self):
text = "prefix 00~hello world01~ suffix"
assert _strip_leaked_bracketed_paste_wrappers(text) == "prefix hello world suffix"
def test_does_not_strip_non_wrapper_00_tilde_in_normal_text(self):
text = "build00~tag should stay"
assert _strip_leaked_bracketed_paste_wrappers(text) == text
def test_does_not_strip_non_wrapper_bracket_forms_in_normal_text(self):
text = "literal[200~tag and literal[201~tag should stay"
assert _strip_leaked_bracketed_paste_wrappers(text) == text
def test_preserves_multiline_content_while_stripping_wrappers(self):
text = "^[[200~line 1\nline 2\nline 3^[[201~"
assert _strip_leaked_bracketed_paste_wrappers(text) == "line 1\nline 2\nline 3"
def test_preserves_multiline_content_while_stripping_degraded_bracket_only_wrappers(self):
text = "[200~line 1\nline 2\nline 3[201~"
assert _strip_leaked_bracketed_paste_wrappers(text) == "line 1\nline 2\nline 3"
+357
View File
@@ -0,0 +1,357 @@
"""Tests for CLI browser CDP auto-launch helpers."""
from contextlib import redirect_stdout
from io import StringIO
import os
from queue import Queue
import subprocess
from unittest.mock import patch
from cli import HermesCLI
from hermes_cli.browser_connect import (
_wait_for_browser_debug_ready_or_exit,
get_chrome_debug_candidates,
is_browser_debug_ready,
launch_chrome_debug,
manual_chrome_debug_command,
)
def _assert_chrome_debug_cmd(cmd, expected_chrome, expected_port):
"""Verify the auto-launch command has all required flags."""
assert cmd[0] == expected_chrome
assert f"--remote-debugging-port={expected_port}" in cmd
assert "--no-first-run" in cmd
assert "--no-default-browser-check" in cmd
user_data_args = [a for a in cmd if a.startswith("--user-data-dir=")]
assert len(user_data_args) == 1, "Expected exactly one --user-data-dir flag"
assert "chrome-debug" in user_data_args[0]
class _FakeResponse:
status = 200
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb):
return False
class TestChromeDebugLaunch:
def test_browser_debug_ready_requires_http_cdp_endpoint(self):
requested = []
def fake_urlopen(url, timeout):
requested.append(url)
if url.endswith("/json/version"):
return _FakeResponse()
raise OSError("unexpected probe")
with patch("urllib.request.urlopen", side_effect=fake_urlopen):
assert is_browser_debug_ready("http://127.0.0.1:9222", timeout=0.1) is True
assert requested == ["http://127.0.0.1:9222/json/version"]
def test_browser_debug_ready_rejects_non_cdp_listener(self):
with patch("urllib.request.urlopen", side_effect=OSError("not cdp")):
assert is_browser_debug_ready("http://127.0.0.1:9222", timeout=0.1) is False
def test_windows_launch_uses_browser_found_on_path(self):
captured = {}
def fake_popen(cmd, **kwargs):
captured["cmd"] = cmd
captured["kwargs"] = kwargs
return object()
with patch("hermes_cli.browser_connect.shutil.which", side_effect=lambda name: r"C:\Chrome\chrome.exe" if name == "chrome.exe" else None), \
patch("hermes_cli.browser_connect.os.path.isfile", side_effect=lambda path: path == r"C:\Chrome\chrome.exe"), \
patch("hermes_cli.browser_connect._wait_for_browser_debug_ready_or_exit", return_value="ready"), \
patch("subprocess.Popen", side_effect=fake_popen):
assert HermesCLI._try_launch_chrome_debug(9333, "Windows") is True
_assert_chrome_debug_cmd(captured["cmd"], r"C:\Chrome\chrome.exe", 9333)
# Windows uses creationflags (POSIX-only start_new_session would raise).
assert "start_new_session" not in captured["kwargs"]
flags = captured["kwargs"].get("creationflags", 0)
expected = getattr(subprocess, "DETACHED_PROCESS", 0) | getattr(
subprocess, "CREATE_NEW_PROCESS_GROUP", 0
)
assert flags == expected
def test_windows_launch_falls_back_to_common_install_dirs(self, monkeypatch):
captured = {}
program_files = r"C:\Program Files"
# Use os.path.join so path separators match cross-platform
installed = os.path.join(program_files, "Google", "Chrome", "Application", "chrome.exe")
def fake_popen(cmd, **kwargs):
captured["cmd"] = cmd
captured["kwargs"] = kwargs
return object()
monkeypatch.setenv("ProgramFiles", program_files)
monkeypatch.delenv("ProgramFiles(x86)", raising=False)
monkeypatch.delenv("LOCALAPPDATA", raising=False)
with patch("hermes_cli.browser_connect.shutil.which", return_value=None), \
patch("hermes_cli.browser_connect.os.path.isfile", side_effect=lambda path: path == installed), \
patch("hermes_cli.browser_connect._wait_for_browser_debug_ready_or_exit", return_value="ready"), \
patch("subprocess.Popen", side_effect=fake_popen):
assert HermesCLI._try_launch_chrome_debug(9222, "Windows") is True
_assert_chrome_debug_cmd(captured["cmd"], installed, 9222)
def test_manual_command_uses_detected_linux_browser(self):
with patch("hermes_cli.browser_connect.shutil.which", side_effect=lambda name: "/usr/bin/chromium" if name == "chromium" else None), \
patch("hermes_cli.browser_connect.os.path.isfile", side_effect=lambda path: path == "/usr/bin/chromium"):
command = manual_chrome_debug_command(9222, "Linux")
assert command is not None
assert command.startswith("/usr/bin/chromium --remote-debugging-port=9222")
def test_linux_candidates_prefer_chrome_before_brave_when_both_exist(self):
chrome = "/usr/bin/google-chrome"
brave = "/usr/bin/brave-browser"
def fake_which(name):
return {"google-chrome": chrome, "brave-browser": brave}.get(name)
with patch("hermes_cli.browser_connect.shutil.which", side_effect=fake_which), \
patch("hermes_cli.browser_connect.os.path.isfile", side_effect=lambda path: path in {chrome, brave}):
candidates = get_chrome_debug_candidates("Linux")
command = manual_chrome_debug_command(9222, "Linux")
assert candidates[:2] == [chrome, brave]
assert command is not None
assert command.startswith(f"{chrome} --remote-debugging-port=9222")
def test_linux_candidates_prefer_chrome_install_path_before_brave_on_path(self):
chrome = "/opt/google/chrome/chrome"
brave = "/usr/bin/brave-browser"
with patch("hermes_cli.browser_connect.shutil.which", side_effect=lambda name: brave if name == "brave-browser" else None), \
patch("hermes_cli.browser_connect.os.path.isfile", side_effect=lambda path: path in {chrome, brave}):
candidates = get_chrome_debug_candidates("Linux")
assert candidates[:2] == [chrome, brave]
def test_windows_candidates_prefer_chrome_install_path_before_brave_on_path(self, monkeypatch):
program_files = r"C:\Program Files"
chrome = os.path.join(program_files, "Google", "Chrome", "Application", "chrome.exe")
brave = r"C:\Brave\brave.exe"
monkeypatch.setenv("ProgramFiles", program_files)
monkeypatch.delenv("ProgramFiles(x86)", raising=False)
monkeypatch.delenv("LOCALAPPDATA", raising=False)
with patch("hermes_cli.browser_connect.shutil.which", side_effect=lambda name: brave if name == "brave.exe" else None), \
patch("hermes_cli.browser_connect.os.path.isfile", side_effect=lambda path: path in {chrome, brave}):
candidates = get_chrome_debug_candidates("Windows")
assert candidates[:2] == [chrome, brave]
def test_linux_candidates_include_arch_brave_install_path(self):
brave = "/opt/brave-bin/brave"
with patch("hermes_cli.browser_connect.shutil.which", return_value=None), \
patch("hermes_cli.browser_connect.os.path.isfile", side_effect=lambda path: path == brave):
candidates = get_chrome_debug_candidates("Linux")
command = manual_chrome_debug_command(9222, "Linux")
assert candidates == [brave]
assert command is not None
assert command.startswith(f"{brave} --remote-debugging-port=9222")
def test_linux_candidates_include_brave_binary_name(self):
brave = "/usr/bin/brave"
with patch("hermes_cli.browser_connect.shutil.which", side_effect=lambda name: brave if name == "brave" else None), \
patch("hermes_cli.browser_connect.os.path.isfile", side_effect=lambda path: path == brave):
candidates = get_chrome_debug_candidates("Linux")
command = manual_chrome_debug_command(9222, "Linux")
assert candidates == [brave]
assert command is not None
assert command.startswith(f"{brave} --remote-debugging-port=9222")
def test_linux_candidates_include_official_brave_and_edge_stable_paths(self):
brave = "/usr/bin/brave-browser-stable"
edge = "/usr/bin/microsoft-edge-stable"
with patch("hermes_cli.browser_connect.shutil.which", return_value=None), \
patch("hermes_cli.browser_connect.os.path.isfile", side_effect=lambda path: path in {brave, edge}):
candidates = get_chrome_debug_candidates("Linux")
assert candidates == [brave, edge]
def test_launch_tries_next_browser_when_first_candidate_fails(self):
brave = "/usr/bin/brave-browser"
chrome = "/usr/bin/google-chrome"
attempts = []
def fake_popen(cmd, **kwargs):
attempts.append(cmd[0])
if cmd[0] == brave:
raise OSError("broken brave install")
return object()
with patch("hermes_cli.browser_connect.get_chrome_debug_candidates", return_value=[brave, chrome]), \
patch("hermes_cli.browser_connect._wait_for_browser_debug_ready_or_exit", return_value="ready"), \
patch("subprocess.Popen", side_effect=fake_popen):
assert HermesCLI._try_launch_chrome_debug(9222, "Linux") is True
assert attempts == [brave, chrome]
def test_wait_for_browser_debug_ready_or_exit_detects_early_exit(self, monkeypatch):
class _Proc:
def __init__(self):
self.calls = 0
def poll(self):
self.calls += 1
return 1 if self.calls >= 2 else None
monkeypatch.setattr("hermes_cli.browser_connect.time.sleep", lambda _seconds: None)
with patch("hermes_cli.browser_connect.is_browser_debug_ready", return_value=False):
state = _wait_for_browser_debug_ready_or_exit(_Proc(), 9222, timeout=0.3, interval=0.01)
assert state == "exited"
def test_launch_tries_next_browser_when_first_candidate_exits_before_debug_ready(self):
brave = "/usr/bin/brave-browser"
chrome = "/usr/bin/google-chrome"
attempts = []
class _Proc:
pass
def fake_popen(cmd, **kwargs):
attempts.append(cmd[0])
return _Proc()
with patch("hermes_cli.browser_connect.get_chrome_debug_candidates", return_value=[brave, chrome]), \
patch("hermes_cli.browser_connect._wait_for_browser_debug_ready_or_exit", side_effect=["exited", "ready"]), \
patch("subprocess.Popen", side_effect=fake_popen):
assert HermesCLI._try_launch_chrome_debug(9222, "Linux") is True
assert attempts == [brave, chrome]
def test_launch_result_hints_singleton_forward_on_clean_exit(self, tmp_path, monkeypatch):
"""A candidate that exits code 0 without opening the port = an existing
instance absorbed the launch (Chromium single-instance behavior)."""
chrome = r"C:\Program Files\Google\Chrome\Application\chrome.exe"
class _Proc:
pid = 1234
returncode = 0
def poll(self):
return 0
monkeypatch.setattr(
"hermes_cli.browser_connect.chrome_debug_data_dir", lambda: str(tmp_path)
)
with patch("hermes_cli.browser_connect.get_chrome_debug_candidates", return_value=[chrome]), \
patch("hermes_cli.browser_connect.is_browser_debug_ready", return_value=False), \
patch("subprocess.Popen", return_value=_Proc()):
result = launch_chrome_debug(9222, "Windows")
assert result.launched is False
assert result.attempts[0].state == "exited"
assert result.attempts[0].returncode == 0
assert result.hint is not None
assert "already-running" in result.hint
assert "chrome.exe" in result.hint
def test_launch_result_surfaces_stderr_tail_on_crash(self, tmp_path, monkeypatch):
chrome = "/usr/bin/google-chrome"
class _Proc:
pid = 4321
returncode = 127
def __init__(self, stderr_path):
# Simulate the browser writing to the redirected stderr file.
with open(stderr_path, "w", encoding="utf-8") as fh:
fh.write("error while loading shared libraries: libnspr4.so\n")
def poll(self):
return 127
monkeypatch.setattr(
"hermes_cli.browser_connect.chrome_debug_data_dir", lambda: str(tmp_path)
)
stderr_path = tmp_path / "launch-stderr.log"
with patch("hermes_cli.browser_connect.get_chrome_debug_candidates", return_value=[chrome]), \
patch("hermes_cli.browser_connect.is_browser_debug_ready", return_value=False), \
patch("subprocess.Popen", side_effect=lambda *a, **k: _Proc(stderr_path)):
result = launch_chrome_debug(9222, "Linux")
assert result.launched is False
assert result.attempts[0].returncode == 127
assert "libnspr4.so" in result.attempts[0].stderr_tail
assert result.hint is not None
assert "libnspr4.so" in result.hint
def test_launch_result_no_hint_when_no_candidates(self):
with patch("hermes_cli.browser_connect.get_chrome_debug_candidates", return_value=[]):
result = launch_chrome_debug(9222, "Linux")
assert result.launched is False
assert result.attempts == []
assert result.hint is None
def test_manual_command_uses_wsl_windows_chrome_when_available(self):
chrome = "/mnt/c/Program Files/Google/Chrome/Application/chrome.exe"
with patch("hermes_cli.browser_connect.shutil.which", return_value=None), \
patch("hermes_cli.browser_connect.os.path.isfile", side_effect=lambda path: path == chrome):
command = manual_chrome_debug_command(9222, "Linux")
assert command is not None
# Linux/WSL uses POSIX shell quoting (single quotes around paths with spaces).
assert command.startswith(f"'{chrome}' --remote-debugging-port=9222")
def test_manual_command_uses_windows_quoting_on_windows(self):
chrome = r"C:\Program Files\Google\Chrome\Application\chrome.exe"
with patch("hermes_cli.browser_connect.shutil.which", side_effect=lambda name: chrome if name == "chrome.exe" else None), \
patch("hermes_cli.browser_connect.os.path.isfile", side_effect=lambda path: path == chrome):
command = manual_chrome_debug_command(9222, "Windows")
assert command is not None
# Windows uses cmd.exe-compatible quoting via subprocess.list2cmdline.
assert command.startswith(f'"{chrome}" --remote-debugging-port=9222')
assert "'" not in command
def test_manual_command_returns_none_when_linux_browser_missing(self):
with patch("hermes_cli.browser_connect.shutil.which", return_value=None), \
patch("hermes_cli.browser_connect.os.path.isfile", return_value=False):
assert manual_chrome_debug_command(9222, "Linux") is None
def test_connect_context_note_allows_expected_browser_use(self, monkeypatch):
"""`/browser connect` is an instruction to use the CDP browser.
The queued context note must not tell the model to wait for a second
permission step or imply that the attached browser is the user's main
everyday Chrome profile.
"""
cli = HermesCLI.__new__(HermesCLI)
cli._pending_input = Queue()
monkeypatch.delenv("BROWSER_CDP_URL", raising=False)
with patch("hermes_cli.cli_commands_mixin.is_browser_debug_ready", return_value=True), \
patch("tools.browser_tool.cleanup_all_browsers"), \
patch("tools.browser_tool._ensure_cdp_supervisor"), \
redirect_stdout(StringIO()):
cli._handle_browser_command("/browser connect")
note = cli._pending_input.get_nowait()
assert "Chromium-family" in note
assert "dev/debug" in note
assert "using browser tools for their current browser-related request is expected" in note
assert "live Chrome browser" not in note
assert "real browser" not in note
assert "Please await their instruction" not in note
+165
View File
@@ -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
+71
View File
@@ -0,0 +1,71 @@
"""Tests for CLI /copy command."""
from unittest.mock import MagicMock, patch
from cli import HermesCLI
def _make_cli() -> HermesCLI:
cli_obj = HermesCLI.__new__(HermesCLI)
cli_obj.config = {}
cli_obj.console = MagicMock()
cli_obj.agent = None
cli_obj.conversation_history = []
cli_obj.session_id = "sess-copy-test"
cli_obj._pending_input = MagicMock()
cli_obj._app = None
return cli_obj
def test_copy_copies_latest_assistant_message():
cli_obj = _make_cli()
cli_obj.conversation_history = [
{"role": "user", "content": "hi"},
{"role": "assistant", "content": "first"},
{"role": "assistant", "content": "latest"},
]
with patch.object(cli_obj, "_write_osc52_clipboard") as mock_copy:
result = cli_obj.process_command("/copy")
assert result is True
mock_copy.assert_called_once_with("latest")
def test_copy_with_index_uses_requested_assistant_message():
cli_obj = _make_cli()
cli_obj.conversation_history = [
{"role": "assistant", "content": "one"},
{"role": "assistant", "content": "two"},
]
with patch.object(cli_obj, "_write_osc52_clipboard") as mock_copy:
cli_obj.process_command("/copy 1")
mock_copy.assert_called_once_with("one")
def test_copy_strips_reasoning_blocks_before_copy():
cli_obj = _make_cli()
cli_obj.conversation_history = [
{
"role": "assistant",
"content": "<REASONING_SCRATCHPAD>internal</REASONING_SCRATCHPAD>\nVisible answer",
}
]
with patch.object(cli_obj, "_write_osc52_clipboard") as mock_copy:
cli_obj.process_command("/copy")
mock_copy.assert_called_once_with("Visible answer")
def test_copy_invalid_index_does_not_copy():
cli_obj = _make_cli()
cli_obj.conversation_history = [{"role": "assistant", "content": "only"}]
with patch.object(cli_obj, "_write_osc52_clipboard") as mock_copy, patch("cli._cprint") as mock_print:
cli_obj.process_command("/copy 99")
mock_copy.assert_not_called()
assert any("Invalid response number" in str(call) for call in mock_print.call_args_list)
@@ -0,0 +1,69 @@
"""The CLI spells out auto-resume when a delegate_task goes to the background.
A top-level ``delegate_task`` returns a handle immediately and runs the subagent
in the background; the result re-enters the conversation as a fresh turn when it
finishes. ``_on_tool_complete`` prints a one-line, no-spinner reassurance at
dispatch so the idle prompt doesn't read as "nothing happened".
"""
import json
import cli
from cli import HermesCLI
def _make_cli():
cli_obj = HermesCLI.__new__(HermesCLI)
cli_obj._pending_edit_snapshots = {}
return cli_obj
def _capture(monkeypatch):
printed: list[str] = []
monkeypatch.setattr(cli, "_cprint", lambda text: printed.append(text))
return printed
def test_background_dispatch_prints_resume_notice(monkeypatch):
cli_obj = _make_cli()
printed = _capture(monkeypatch)
result = json.dumps({"status": "dispatched", "mode": "background", "count": 1})
cli_obj._on_tool_complete("tc1", "delegate_task", {"goal": "x"}, result)
joined = "\n".join(printed)
assert "resume" in joined.lower()
assert "it finishes" in joined
def test_background_batch_dispatch_pluralizes(monkeypatch):
cli_obj = _make_cli()
printed = _capture(monkeypatch)
result = json.dumps({"status": "dispatched", "mode": "background", "count": 3})
cli_obj._on_tool_complete("tc2", "delegate_task", {"tasks": []}, result)
joined = "\n".join(printed)
assert "3 tasks" in joined
assert "they finish" in joined
def test_synchronous_delegate_result_prints_no_notice(monkeypatch):
"""A non-background result (e.g. the stateless sync fallback) must not claim
a background dispatch."""
cli_obj = _make_cli()
printed = _capture(monkeypatch)
result = json.dumps({"results": [{"status": "completed", "summary": "done"}]})
cli_obj._on_tool_complete("tc3", "delegate_task", {"goal": "x"}, result)
assert not any("resume" in p.lower() for p in printed)
def test_non_delegate_tool_prints_no_notice(monkeypatch):
cli_obj = _make_cli()
printed = _capture(monkeypatch)
cli_obj._on_tool_complete("tc4", "read_file", {"path": "a"}, '{"ok": true}')
assert not any("resume" in p.lower() for p in printed)
+138
View File
@@ -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
+105
View File
@@ -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
+249
View File
@@ -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
+225
View File
@@ -0,0 +1,225 @@
"""Tests for CLI redraw helpers used to recover from terminal buffer drift.
Covers:
- _force_full_redraw (#8688 cmux tab switch, /redraw, Ctrl+L)
- the resize handler we install over prompt_toolkit's _on_resize (#5474)
Both behaviors are exercised against fake prompt_toolkit renderer/output
objects — we're asserting the escape sequences the CLI sends, not that
the terminal physically repainted.
"""
from unittest.mock import MagicMock
import pytest
import cli as cli_mod
from cli import HermesCLI
@pytest.fixture
def bare_cli():
"""A HermesCLI with no __init__ — we only exercise the redraw helper."""
cli = object.__new__(HermesCLI)
return cli
class TestForceFullRedraw:
def test_no_app_is_safe(self, bare_cli):
# _force_full_redraw must be a no-op when the TUI isn't running.
bare_cli._app = None
bare_cli._force_full_redraw() # must not raise
def test_missing_app_attr_is_safe(self, bare_cli):
# Simulate HermesCLI before the TUI has ever been constructed.
bare_cli._force_full_redraw() # must not raise
def test_sends_full_clear_replays_then_invalidates(self, bare_cli, monkeypatch):
app = MagicMock()
out = app.renderer.output
bare_cli._app = app
events = []
out.reset_attributes.side_effect = lambda: events.append("reset_attrs")
out.erase_screen.side_effect = lambda: events.append("erase")
out.cursor_goto.side_effect = lambda *_: events.append("home")
out.flush.side_effect = lambda: events.append("flush")
app.renderer.reset.side_effect = lambda **_: events.append("renderer_reset")
monkeypatch.setattr(cli_mod, "_replay_output_history", lambda: events.append("replay"))
app.invalidate.side_effect = lambda: events.append("invalidate")
bare_cli._force_full_redraw()
# Must erase screen, home cursor, and flush — in that order.
out.reset_attributes.assert_called_once()
out.erase_screen.assert_called_once()
out.cursor_goto.assert_called_once_with(0, 0)
out.flush.assert_called_once()
# Must reset prompt_toolkit's tracked screen/cursor state so the
# next incremental redraw starts from a clean (0, 0) origin.
app.renderer.reset.assert_called_once_with(leave_alternate_screen=False)
# Must schedule a repaint.
app.invalidate.assert_called_once()
assert events == [
"reset_attrs",
"erase",
"home",
"flush",
"renderer_reset",
"replay",
"invalidate",
]
def test_resize_recovery_skips_clear_when_width_unchanged(self, bare_cli, monkeypatch):
"""A rows-only resize (same width) must NOT clear the screen.
prompt_toolkit's built-in Application._on_resize() starts with
renderer.erase(leave_alternate_screen=False), which uses the renderer's
cached cursor position to move back to the live prompt origin before
erase_down(). With no column reflow there is no ghost chrome to wipe,
so we delegate straight to prompt_toolkit and avoid an extra repaint.
"""
app = MagicMock()
events = []
app.renderer.reset.side_effect = lambda **_: events.append("renderer_reset")
app.invalidate.side_effect = lambda: events.append("invalidate")
original_on_resize = lambda: events.append("original_resize")
# bare_cli skips __init__, so seed attributes the way __init__ would.
bare_cli._status_bar_suppressed_after_resize = False
bare_cli._last_resize_width = 120
# Same width on this resize → rows-only change.
monkeypatch.setattr(bare_cli, "_get_tui_terminal_width", lambda: 120)
monkeypatch.setattr(bare_cli, "_schedule_status_bar_unsuppress", lambda *_: None)
bare_cli._recover_after_resize(app, original_on_resize)
assert events == ["original_resize"]
app.renderer.reset.assert_not_called()
app.invalidate.assert_not_called()
# Must NOT clear the screen or scrollback — those destroy the banner.
app.renderer.output.erase_screen.assert_not_called()
app.renderer.output.write_raw.assert_not_called()
app.renderer.output.cursor_goto.assert_not_called()
# Status bar / input rules must be suppressed until the next prompt.
assert bare_cli._status_bar_suppressed_after_resize is True
def test_resize_recovery_clears_viewport_on_width_change(self, bare_cli, monkeypatch):
"""A WIDTH change must wipe the visible viewport (CSI 2J) and replay.
On column shrink the terminal reflows the old full-width chrome into
extra rows that prompt_toolkit's stale-cursor erase cannot reach,
leaving a duplicated status bar (#19280/#5474 class). We route through
the same recovery as Ctrl+L: erase_screen (2J) + replay transcript.
It must be banner-safe — CSI 3J (write_raw) must NOT fire.
"""
app = MagicMock()
events = []
app.renderer.output.erase_screen.side_effect = lambda: events.append("erase")
app.renderer.output.write_raw.side_effect = lambda *_: events.append("scrollback_wipe")
original_on_resize = lambda: events.append("original_resize")
bare_cli._status_bar_suppressed_after_resize = False
bare_cli._last_resize_width = 200
monkeypatch.setattr(bare_cli, "_get_tui_terminal_width", lambda: 90)
monkeypatch.setattr(bare_cli, "_schedule_status_bar_unsuppress", lambda *_: None)
monkeypatch.setattr(cli_mod, "_replay_output_history", lambda: events.append("replay"))
bare_cli._recover_after_resize(app, original_on_resize)
# Viewport cleared and transcript replayed BEFORE prompt_toolkit's resize.
assert "erase" in events
assert "replay" in events
assert events.index("erase") < events.index("original_resize")
# Banner-safe: scrollback (CSI 3J) must never be wiped on a resize.
assert "scrollback_wipe" not in events
# New width recorded for the next comparison.
assert bare_cli._last_resize_width == 90
assert bare_cli._status_bar_suppressed_after_resize is True
def test_force_redraw_uses_full_screen_clear_without_scrollback_clear(self, bare_cli):
app = MagicMock()
bare_cli._app = app
bare_cli._force_full_redraw()
app.renderer.output.erase_screen.assert_called_once()
app.renderer.output.cursor_goto.assert_called_once_with(0, 0)
app.renderer.output.write_raw.assert_not_called()
def test_resize_recovery_is_debounced(self, bare_cli, monkeypatch):
timers = []
calls = []
class FakeTimer:
def __init__(self, delay, callback):
self.delay = delay
self.callback = callback
self.cancelled = False
self.daemon = False
timers.append(self)
def start(self):
calls.append(("start", self.delay))
def cancel(self):
self.cancelled = True
calls.append(("cancel", self.delay))
def fire(self):
self.callback()
app = MagicMock()
app.loop.call_soon_threadsafe.side_effect = lambda cb: cb()
monkeypatch.setattr(cli_mod.threading, "Timer", FakeTimer)
monkeypatch.setattr(
bare_cli,
"_recover_after_resize",
lambda _app, _orig: calls.append(("recover", _orig())),
)
original_one = lambda: "first"
original_two = lambda: "second"
bare_cli._schedule_resize_recovery(app, original_one, delay=0.25)
assert bare_cli._resize_recovery_pending is True
bare_cli._schedule_resize_recovery(app, original_two, delay=0.25)
assert len(timers) == 2
assert timers[0].cancelled is True
timers[0].fire()
assert ("recover", "first") not in calls
timers[1].fire()
assert ("recover", "second") in calls
assert bare_cli._resize_recovery_pending is False
def test_invalidate_is_suppressed_while_resize_recovery_is_pending(self, bare_cli):
app = MagicMock()
bare_cli._app = app
bare_cli._last_invalidate = 0.0
bare_cli._resize_recovery_pending = True
bare_cli._invalidate(min_interval=0)
app.invalidate.assert_not_called()
def test_swallows_renderer_exceptions(self, bare_cli):
# If the renderer blows up for any reason, the helper must not
# propagate — otherwise a stray Ctrl+L would crash the CLI.
app = MagicMock()
app.renderer.output.erase_screen.side_effect = RuntimeError("boom")
bare_cli._app = app
bare_cli._force_full_redraw() # must not raise
# invalidate() is still attempted after a renderer failure.
app.invalidate.assert_called_once()
def test_swallows_invalidate_exceptions(self, bare_cli):
app = MagicMock()
app.invalidate.side_effect = RuntimeError("boom")
bare_cli._app = app
bare_cli._force_full_redraw() # must not raise
+220
View File
@@ -0,0 +1,220 @@
"""Tests for CLI goal-continuation interrupt handling.
Covers:
- Ctrl+C during a /goal turn auto-pauses the goal (no more continuations).
- Empty/whitespace-only responses skip the judge (no phantom continuations).
- Clean response without interrupt still drives the judge + enqueues.
These tests exercise ``_maybe_continue_goal_after_turn`` directly on a
minimal ``HermesCLI`` stub (pattern used elsewhere in tests/cli).
"""
from __future__ import annotations
import queue
import uuid
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
# ──────────────────────────────────────────────────────────────────────
# Fixtures
# ──────────────────────────────────────────────────────────────────────
@pytest.fixture
def hermes_home(tmp_path, monkeypatch):
"""Isolated HERMES_HOME so SessionDB.state_meta writes stay hermetic."""
home = tmp_path / ".hermes"
home.mkdir()
monkeypatch.setattr(Path, "home", lambda: tmp_path)
monkeypatch.setenv("HERMES_HOME", str(home))
# Bust the goal module's DB cache so it re-resolves HERMES_HOME each test.
from hermes_cli import goals
goals._DB_CACHE.clear()
yield home
goals._DB_CACHE.clear()
def _make_cli_with_goal(session_id: str, goal_text: str = "build a thing"):
"""Build a minimal HermesCLI stub with an active goal wired in."""
from cli import HermesCLI
from hermes_cli.goals import GoalManager
cli = HermesCLI.__new__(HermesCLI)
# State the hook + helpers touch directly.
cli._pending_input = queue.Queue()
cli._last_turn_interrupted = False
cli.conversation_history = []
# `_get_goal_manager()` reads `self.session_id` directly, not
# `self.agent.session_id`. Match the production lookup.
cli.session_id = session_id
cli.agent = MagicMock()
cli.agent.session_id = session_id
mgr = GoalManager(session_id=session_id, default_max_turns=5)
mgr.set(goal_text)
cli._goal_manager = mgr
return cli, mgr
# ──────────────────────────────────────────────────────────────────────
# Tests
# ──────────────────────────────────────────────────────────────────────
class TestInterruptAutoPause:
def test_interrupted_turn_pauses_goal_and_skips_continuation(self, hermes_home):
"""Ctrl+C mid-turn must auto-pause the goal, not queue another round."""
sid = f"sid-interrupt-{uuid.uuid4().hex}"
cli, mgr = _make_cli_with_goal(sid)
# Simulate an interrupted turn with a partial assistant reply.
cli._last_turn_interrupted = True
cli.conversation_history = [
{"role": "user", "content": "kickoff"},
{"role": "assistant", "content": "starting work..."},
]
# Judge MUST NOT run on an interrupted turn. If it does, we've
# regressed — fail loudly instead of silently querying a mock.
with patch("hermes_cli.goals.judge_goal") as judge_mock:
judge_mock.side_effect = AssertionError(
"judge_goal called on an interrupted turn"
)
cli._maybe_continue_goal_after_turn()
# Pending input must NOT contain a continuation prompt.
assert cli._pending_input.empty(), (
"Interrupted turn should not enqueue a continuation prompt"
)
# Goal should be paused, not active.
state = mgr.state
assert state is not None
assert state.status == "paused"
assert "interrupt" in (state.paused_reason or "").lower()
def test_interrupted_turn_is_resumable(self, hermes_home):
"""After auto-pause from Ctrl+C, /goal resume puts it back to active."""
sid = f"sid-resume-{uuid.uuid4().hex}"
cli, mgr = _make_cli_with_goal(sid)
cli._last_turn_interrupted = True
cli.conversation_history = [
{"role": "assistant", "content": "partial"},
]
with patch("hermes_cli.goals.judge_goal"):
cli._maybe_continue_goal_after_turn()
assert mgr.state.status == "paused"
mgr.resume()
assert mgr.state.status == "active"
class TestEmptyResponseSkip:
def test_empty_response_does_not_invoke_judge(self, hermes_home):
"""Whitespace-only replies skip judging (transient failure guard)."""
sid = f"sid-empty-{uuid.uuid4().hex}"
cli, mgr = _make_cli_with_goal(sid)
cli._last_turn_interrupted = False
cli.conversation_history = [
{"role": "user", "content": "go"},
{"role": "assistant", "content": " \n\n "},
]
with patch("hermes_cli.goals.judge_goal") as judge_mock:
judge_mock.side_effect = AssertionError(
"judge_goal called on an empty response"
)
cli._maybe_continue_goal_after_turn()
# No continuation queued; goal still active (neither paused nor done).
assert cli._pending_input.empty()
assert mgr.state.status == "active"
def test_no_assistant_message_skipped(self, hermes_home):
"""Conversation with zero assistant replies must not trip the judge."""
sid = f"sid-noassistant-{uuid.uuid4().hex}"
cli, mgr = _make_cli_with_goal(sid)
cli._last_turn_interrupted = False
cli.conversation_history = [
{"role": "user", "content": "go"},
]
with patch("hermes_cli.goals.judge_goal") as judge_mock:
judge_mock.side_effect = AssertionError(
"judge_goal called without an assistant response"
)
cli._maybe_continue_goal_after_turn()
assert cli._pending_input.empty()
assert mgr.state.status == "active"
class TestHealthyTurnStillRuns:
def test_clean_response_enqueues_continuation_when_judge_says_continue(
self, hermes_home,
):
"""Sanity check: the hook still works in the happy path."""
sid = f"sid-healthy-{uuid.uuid4().hex}"
cli, mgr = _make_cli_with_goal(sid)
cli._last_turn_interrupted = False
cli.conversation_history = [
{"role": "user", "content": "go"},
{"role": "assistant", "content": "did some work, more to do"},
]
# Force the judge to say "continue" without touching the network.
with patch(
"hermes_cli.goals.judge_goal",
return_value=("continue", "needs more steps", False, None),
):
cli._maybe_continue_goal_after_turn()
# Continuation prompt must be queued.
assert not cli._pending_input.empty()
queued = cli._pending_input.get_nowait()
assert "Continuing toward your standing goal" in queued
assert mgr.state.status == "active"
def test_clean_response_marks_done_when_judge_says_done(self, hermes_home):
sid = f"sid-done-{uuid.uuid4().hex}"
cli, mgr = _make_cli_with_goal(sid)
cli._last_turn_interrupted = False
cli.conversation_history = [
{"role": "assistant", "content": "all finished, here's the result"},
]
with patch(
"hermes_cli.goals.judge_goal",
return_value=("done", "goal satisfied", False, None),
):
cli._maybe_continue_goal_after_turn()
assert cli._pending_input.empty()
assert mgr.state.status == "done"
class TestInterruptFlagLifecycle:
def test_chat_resets_flag_at_entry(self, hermes_home):
"""chat() must reset _last_turn_interrupted at the top of each turn.
This guards against stale flag state: if turn N was interrupted and
turn N+1 runs clean, the hook must not see True from N.
"""
# We can't run chat() end-to-end here, but we can assert the reset
# is the first thing after the secret-capture registration by
# inspecting the source shape.
from cli import HermesCLI
import inspect
src = inspect.getsource(HermesCLI.chat)
# Look for an explicit reset near the top of chat().
head = src.split("if not self._ensure_runtime_credentials", 1)[0]
assert "self._last_turn_interrupted = False" in head, (
"chat() must reset _last_turn_interrupted before run_conversation "
"runs — otherwise a prior turn's interrupt state leaks into the "
"next turn's goal hook decision."
)
+109
View File
@@ -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]"
+826
View File
@@ -0,0 +1,826 @@
"""Tests for HermesCLI initialization -- catches configuration bugs
that only manifest at runtime (not in mocked unit tests)."""
import os
import sys
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
def _make_cli(env_overrides=None, config_overrides=None, **kwargs):
"""Create a HermesCLI instance with minimal mocking."""
import importlib
_clean_config = {
"model": {
"default": "anthropic/claude-opus-4.6",
"base_url": "https://openrouter.ai/api/v1",
"provider": "auto",
},
"display": {"compact": False, "tool_progress": "all"},
"agent": {},
"terminal": {"env_type": "local"},
}
if config_overrides:
_clean_config.update(config_overrides)
clean_env = {"LLM_MODEL": "", "HERMES_MAX_ITERATIONS": ""}
if env_overrides:
clean_env.update(env_overrides)
prompt_toolkit_stubs = {
"prompt_toolkit": MagicMock(),
"prompt_toolkit.history": MagicMock(),
"prompt_toolkit.styles": MagicMock(),
"prompt_toolkit.patch_stdout": MagicMock(),
"prompt_toolkit.application": MagicMock(),
"prompt_toolkit.layout": MagicMock(),
"prompt_toolkit.layout.processors": MagicMock(),
"prompt_toolkit.filters": MagicMock(),
"prompt_toolkit.layout.dimension": MagicMock(),
"prompt_toolkit.layout.menus": MagicMock(),
"prompt_toolkit.widgets": MagicMock(),
"prompt_toolkit.key_binding": MagicMock(),
"prompt_toolkit.completion": MagicMock(),
"prompt_toolkit.formatted_text": MagicMock(),
"prompt_toolkit.auto_suggest": MagicMock(),
}
with patch.dict(sys.modules, prompt_toolkit_stubs), \
patch.dict("os.environ", clean_env, clear=False):
import cli as _cli_mod
_cli_mod = importlib.reload(_cli_mod)
with patch.object(_cli_mod, "get_tool_definitions", return_value=[]), \
patch.dict(_cli_mod.__dict__, {"CLI_CONFIG": _clean_config}):
return _cli_mod.HermesCLI(**kwargs)
class TestMaxTurnsResolution:
"""max_turns must always resolve to a positive integer, never None."""
def test_default_max_turns_is_integer(self):
cli = _make_cli()
assert isinstance(cli.max_turns, int)
assert cli.max_turns == 90
def test_explicit_max_turns_honored(self):
cli = _make_cli(max_turns=25)
assert cli.max_turns == 25
def test_none_max_turns_gets_default(self):
cli = _make_cli(max_turns=None)
assert isinstance(cli.max_turns, int)
assert cli.max_turns == 90
def test_env_var_max_turns(self):
"""Env var is used when config file doesn't set max_turns."""
cli_obj = _make_cli(env_overrides={"HERMES_MAX_ITERATIONS": "42"})
assert cli_obj.max_turns == 42
def test_invalid_env_var_max_turns_falls_back_to_default(self):
"""Invalid env values should not crash CLI init."""
cli_obj = _make_cli(env_overrides={"HERMES_MAX_ITERATIONS": "not-a-number"})
assert cli_obj.max_turns == 90
def test_legacy_root_max_turns_is_used_when_agent_key_exists_without_value(self):
cli_obj = _make_cli(config_overrides={"agent": {}, "max_turns": 77})
assert cli_obj.max_turns == 77
def test_max_turns_never_none_for_agent(self):
"""The value passed to AIAgent must never be None (causes TypeError in run_conversation)."""
cli = _make_cli()
assert isinstance(cli.max_turns, int) and cli.max_turns == 90
class TestVerboseAndToolProgress:
def test_default_verbose_is_bool(self):
cli = _make_cli()
assert isinstance(cli.verbose, bool)
def test_tool_progress_mode_is_string(self):
cli = _make_cli()
assert isinstance(cli.tool_progress_mode, str)
assert cli.tool_progress_mode in {"off", "new", "all", "verbose"}
class TestFallbackChainInit:
def test_merges_new_and_legacy_fallback_config(self):
cli = _make_cli(config_overrides={
"fallback_providers": [
{"provider": "openrouter", "model": "anthropic/claude-sonnet-4.6"},
],
"fallback_model": {"provider": "nous", "model": "Hermes-4"},
})
assert cli._fallback_model == [
{"provider": "openrouter", "model": "anthropic/claude-sonnet-4.6"},
{"provider": "nous", "model": "Hermes-4"},
]
class TestBusyInputMode:
def test_default_busy_input_mode_is_interrupt(self):
cli = _make_cli()
assert cli.busy_input_mode == "interrupt"
def test_busy_input_mode_queue_is_honored(self):
cli = _make_cli(config_overrides={"display": {"busy_input_mode": "queue"}})
assert cli.busy_input_mode == "queue"
def test_unknown_busy_input_mode_falls_back_to_interrupt(self):
cli = _make_cli(config_overrides={"display": {"busy_input_mode": "bogus"}})
assert cli.busy_input_mode == "interrupt"
def test_queue_command_works_while_busy(self):
"""When agent is running, /queue should still put the prompt in _pending_input."""
cli = _make_cli()
cli._agent_running = True
cli.process_command("/queue follow up")
assert cli._pending_input.get_nowait() == "follow up"
def test_queue_command_works_while_idle(self):
"""When agent is idle, /queue should still queue (not reject)."""
cli = _make_cli()
cli._agent_running = False
cli.process_command("/queue follow up")
assert cli._pending_input.get_nowait() == "follow up"
def test_q_alias_queues_prompt(self):
"""The /q alias should resolve to /queue, not /quit."""
cli = _make_cli()
cli._agent_running = False
assert cli.process_command("/q follow up") is True
assert cli._pending_input.get_nowait() == "follow up"
def test_queue_mode_routes_busy_enter_to_pending(self):
"""In queue mode, Enter while busy should go to _pending_input, not _interrupt_queue."""
cli = _make_cli(config_overrides={"display": {"busy_input_mode": "queue"}})
cli._agent_running = True
# Simulate what handle_enter does for non-command input while busy
text = "follow up"
if cli.busy_input_mode == "queue":
cli._pending_input.put(text)
else:
cli._interrupt_queue.put(text)
assert cli._pending_input.get_nowait() == "follow up"
assert cli._interrupt_queue.empty()
def test_interrupt_mode_routes_busy_enter_to_interrupt(self):
"""In interrupt mode (default), Enter while busy goes to _interrupt_queue."""
cli = _make_cli()
cli._agent_running = True
text = "redirect"
if cli.busy_input_mode == "queue":
cli._pending_input.put(text)
else:
cli._interrupt_queue.put(text)
assert cli._interrupt_queue.get_nowait() == "redirect"
assert cli._pending_input.empty()
class TestPromptToolkitTerminalCompatibility:
def test_lf_enter_binds_to_submit_handler_posix(self):
"""Some thin PTYs deliver Enter as LF/c-j instead of CR/enter.
On a bare local POSIX TTY (no SSH/WSL/WT/Ghostty) we keep c-j → submit so
Enter works on thin PTYs (docker exec, certain ssh configurations).
On Windows, WSL, SSH sessions, Windows Terminal, and Ghostty we leave c-j
unbound here so it can be used as the Ctrl+Enter newline keystroke
without conflicting with submit. See issue #22379.
"""
import sys as _sys
import os as _os
from unittest.mock import patch as _patch
from prompt_toolkit.key_binding import KeyBindings
from cli import _bind_prompt_submit_keys
def submit_handler(event):
return None
# Bare local POSIX (no SSH/WSL markers): both enter and c-j submit.
with _patch.object(_sys, "platform", "linux"), \
_patch.dict(_os.environ, {}, clear=True), \
_patch("builtins.open", side_effect=OSError("no /proc")):
kb = KeyBindings()
_bind_prompt_submit_keys(kb, submit_handler)
bindings = {tuple(key.value for key in binding.keys): binding.handler for binding in kb.bindings}
assert bindings[("c-m",)] is submit_handler
assert bindings[("c-j",)] is submit_handler
# POSIX over SSH: c-j stays free so Ctrl+Enter (sent as LF by
# Windows Terminal / Kitty / mintty over SSH) inserts a newline.
with _patch.object(_sys, "platform", "linux"), \
_patch.dict(_os.environ, {"SSH_CONNECTION": "1.2.3.4 5 6.7.8.9 22"}, clear=True), \
_patch("builtins.open", side_effect=OSError("no /proc")):
kb = KeyBindings()
_bind_prompt_submit_keys(kb, submit_handler)
bindings = {tuple(key.value for key in binding.keys): binding.handler for binding in kb.bindings}
assert bindings[("c-m",)] is submit_handler
assert ("c-j",) not in bindings
# Ghostty through tmux: TERM_PROGRAM is tmux, but Ghostty exports a
# stable env marker. Keep c-j free so Ctrl+J inserts a newline.
with _patch.object(_sys, "platform", "linux"), \
_patch.dict(_os.environ, {"TERM": "tmux-256color", "TERM_PROGRAM": "tmux", "GHOSTTY_RESOURCES_DIR": "/usr/share/ghostty"}, clear=True), \
_patch("builtins.open", side_effect=OSError("no /proc")):
kb = KeyBindings()
_bind_prompt_submit_keys(kb, submit_handler)
bindings = {tuple(key.value for key in binding.keys): binding.handler for binding in kb.bindings}
assert bindings[("c-m",)] is submit_handler
assert ("c-j",) not in bindings
# Windows: only enter submits; c-j is free for the newline binding
# added separately in the prompt setup.
with _patch.object(_sys, "platform", "win32"):
kb = KeyBindings()
_bind_prompt_submit_keys(kb, submit_handler)
bindings = {tuple(key.value for key in binding.keys): binding.handler for binding in kb.bindings}
assert bindings[("c-m",)] is submit_handler
assert ("c-j",) not in bindings
def test_cpr_warning_callback_is_disabled(self):
from cli import _disable_prompt_toolkit_cpr_warning
renderer = SimpleNamespace(cpr_not_supported_callback=lambda: None)
app = SimpleNamespace(renderer=renderer)
_disable_prompt_toolkit_cpr_warning(app)
assert renderer.cpr_not_supported_callback is None
def test_cpr_disabled_output_marks_renderer_not_supported(self):
"""CPR-disabled output must make prompt_toolkit skip ESC[6n entirely.
The root cause of #13870 is that prompt_toolkit sends ESC[6n cursor
queries whose CPR replies leak into the display over tunnels/slow PTYs.
Building the output with enable_cpr=False is what stops the queries:
the renderer marks CPR NOT_SUPPORTED and never calls ask_for_cpr().
"""
import sys as _sys
from cli import _build_cpr_disabled_output
from prompt_toolkit.application import Application
from prompt_toolkit.layout import Layout, Window, FormattedTextControl
from prompt_toolkit.renderer import CPR_Support
out = _build_cpr_disabled_output(_sys.stdout)
assert out is not None
# The contract: this output does not respond to CPR.
assert out.enable_cpr is False
assert out.responds_to_cpr is False
# And wired into an Application, the renderer treats CPR as unsupported,
# so request_absolute_cursor_position() never sends ESC[6n.
app = Application(
layout=Layout(Window(FormattedTextControl("x"))),
output=out,
full_screen=False,
)
assert app.renderer.cpr_support == CPR_Support.NOT_SUPPORTED
def test_cpr_disabled_output_returns_none_on_failure(self):
"""A non-fileno stdout must degrade to None (default output fallback)."""
from cli import _build_cpr_disabled_output
class _NoFileno:
def fileno(self):
raise OSError("not a real fd")
# Build must not raise; worst case it returns a usable output or None.
# The hard guarantee is no exception escapes (startup must never break).
result = _build_cpr_disabled_output(_NoFileno())
assert result is None or result.enable_cpr is False
def test_cpr_gating_local_vs_tunnel(self, monkeypatch):
"""CPR is only suppressed on tunneled links / explicit opt-out.
CPR works fine on local terminals and is only a layout hint, so the fix
for #13870 must not change default behavior locally — it gates on
_terminal_may_leak_cpr(). Local (no SSH env) -> CPR left enabled;
SSH session or PROMPT_TOOLKIT_NO_CPR=1 -> CPR suppressed.
"""
from cli import _terminal_may_leak_cpr
for var in ("SSH_CONNECTION", "SSH_CLIENT", "SSH_TTY", "PROMPT_TOOLKIT_NO_CPR"):
monkeypatch.delenv(var, raising=False)
# Local terminal: leave prompt_toolkit's default (CPR on) untouched.
assert _terminal_may_leak_cpr() is False
# SSH session: the tunnel where the leak reproduces.
monkeypatch.setenv("SSH_CONNECTION", "10.0.0.1 22 10.0.0.2 51234")
assert _terminal_may_leak_cpr() is True
monkeypatch.delenv("SSH_CONNECTION", raising=False)
# prompt_toolkit's own explicit opt-out is honored.
monkeypatch.setenv("PROMPT_TOOLKIT_NO_CPR", "1")
assert _terminal_may_leak_cpr() is True
class TestSingleQueryState:
def test_voice_and_interrupt_state_initialized_before_run(self):
"""Single-query mode calls chat() without going through run()."""
cli = _make_cli()
assert cli._voice_tts is False
assert cli._voice_mode is False
assert cli._voice_tts_done.is_set()
assert hasattr(cli, "_interrupt_queue")
assert hasattr(cli, "_pending_input")
class TestHistoryDisplay:
def test_history_numbers_only_visible_messages_and_summarizes_tools(self, capsys):
cli = _make_cli()
cli.conversation_history = [
{"role": "system", "content": "system prompt"},
{"role": "user", "content": "Hello"},
{
"role": "assistant",
"content": None,
"tool_calls": [{"id": "call_1"}, {"id": "call_2"}],
},
{"role": "tool", "content": "tool output 1"},
{"role": "tool", "content": "tool output 2"},
{"role": "assistant", "content": "All set."},
{"role": "user", "content": "A" * 250},
]
cli.show_history()
output = capsys.readouterr().out
assert "[You #1]" in output
assert "[Hermes #2]" in output
assert "(requested 2 tool calls)" in output
assert "[Tools]" in output
assert "(2 tool messages hidden)" in output
assert "[Hermes #3]" in output
assert "[You #4]" in output
assert "[You #5]" not in output
assert "A" * 250 in output
assert "A" * 250 + "..." not in output
def test_history_shows_recent_sessions_when_current_chat_is_empty(self, capsys):
cli = _make_cli()
cli.session_id = "current"
cli._session_db = MagicMock()
cli._session_db.list_sessions_rich.return_value = [
{
"id": "current",
"title": "Current",
"preview": "Current preview",
"last_active": 0,
},
{
"id": "20260401_201329_d85961",
"title": "Checking Running Hermes Agent",
"preview": "check running gateways for hermes agent",
"last_active": 0,
},
]
cli.show_history()
output = capsys.readouterr().out
assert "No messages in the current chat yet" in output
assert "Checking Running Hermes Agent" in output
assert "20260401_201329_d85961" in output
assert "/resume" in output
assert "Current preview" not in output
def test_resume_without_target_lists_recent_sessions(self, capsys):
cli = _make_cli()
cli.session_id = "current"
cli._session_db = MagicMock()
cli._session_db.list_sessions_rich.return_value = [
{
"id": "current",
"title": "Current",
"preview": "Current preview",
"last_active": 0,
},
{
"id": "20260401_201329_d85961",
"title": "Checking Running Hermes Agent",
"preview": "check running gateways for hermes agent",
"last_active": 0,
},
]
cli._handle_resume_command("/resume")
output = capsys.readouterr().out
assert "Recent sessions" in output
assert "Checking Running Hermes Agent" in output
assert "Use /resume" in output
assert "session title" in output
def test_resume_updates_hermes_session_id_env_and_context(self, tmp_path):
from gateway.session_context import _UNSET, _VAR_MAP, get_session_env
from hermes_state import SessionDB
cli = _make_cli()
cli.session_id = "current_session"
cli.conversation_history = []
cli.agent = None
cli._session_db = SessionDB(db_path=tmp_path / "state.db")
cli._session_db.create_session("current_session", "cli")
cli._session_db.create_session("target_session", "cli")
cli._session_db.append_message("target_session", "user", "hello from resumed session")
os.environ["HERMES_SESSION_ID"] = "current_session"
_VAR_MAP["HERMES_SESSION_ID"].set("current_session")
try:
cli._handle_resume_command("/resume target_session")
assert cli.session_id == "target_session"
assert os.environ["HERMES_SESSION_ID"] == "target_session"
assert get_session_env("HERMES_SESSION_ID") == "target_session"
finally:
cli._session_db.close()
os.environ.pop("HERMES_SESSION_ID", None)
_VAR_MAP["HERMES_SESSION_ID"].set(_UNSET)
def test_resume_list_shows_full_long_titles(self, capsys):
"""Long session titles render in full in the /resume table — not
truncated to 30 chars (fixes #14082)."""
cli = _make_cli()
cli.session_id = "current"
cli._session_db = MagicMock()
long_title = "Salvage BytePlus Volcengine PR With Fixes"
cli._session_db.list_sessions_rich.return_value = [
{
"id": "current",
"title": "Current",
"preview": "Current preview",
"last_active": 0,
},
{
"id": "20260401_201329_d85961",
"title": long_title,
"preview": "fix byteplus pr and resume",
"last_active": 0,
},
]
cli._handle_resume_command("/resume")
output = capsys.readouterr().out
assert long_title in output
assert "20260401_201329_d85961" in output
def test_sessions_command_no_args_lists_recent_sessions(self, capsys):
"""/sessions with no args prints the recent-sessions table (TUI parity).
Regression test: `sessions` was registered in the central command
registry and surfaced by /help and tab-completion, but the classic
CLI dispatcher had no elif branch for it, so the canonical name fell
through and printed `Unknown command: sessions`.
"""
cli = _make_cli()
cli.session_id = "current"
cli._session_db = MagicMock()
cli._session_db.list_sessions_rich.return_value = [
{
"id": "20260401_201329_d85961",
"title": "Checking Running Hermes Agent",
"preview": "check running gateways for hermes agent",
"last_active": 0,
},
]
# Drive it through the public dispatcher to also lock in the
# process_command wiring, not just the handler in isolation.
cli.process_command("/sessions")
output = capsys.readouterr().out
assert "Unknown command" not in output
assert "Recent sessions" in output
assert "Checking Running Hermes Agent" in output
assert "20260401_201329_d85961" in output
def test_sessions_list_subcommand_lists_recent_sessions(self, capsys):
"""/sessions list is an explicit alias for the no-arg list view."""
cli = _make_cli()
cli.session_id = "current"
cli._session_db = MagicMock()
cli._session_db.list_sessions_rich.return_value = [
{
"id": "20260401_201329_d85961",
"title": "Checking Running Hermes Agent",
"preview": "check running gateways for hermes agent",
"last_active": 0,
},
]
cli.process_command("/sessions list")
output = capsys.readouterr().out
assert "Unknown command" not in output
assert "Recent sessions" in output
assert "Checking Running Hermes Agent" in output
def test_sessions_with_target_delegates_to_resume(self):
"""/sessions <id_or_title> behaves identically to /resume <id_or_title>.
We intercept `_handle_resume_command` rather than the full resume
machinery (which would otherwise require simulating an entire session
switch). The contract under test is the dispatch wiring.
"""
cli = _make_cli()
with patch.object(cli, "_handle_resume_command") as mock_resume:
cli.process_command("/sessions Checking Running Hermes Agent")
mock_resume.assert_called_once_with(
"/resume Checking Running Hermes Agent"
)
def test_sessions_command_is_dispatched(self):
"""/sessions must hit _handle_sessions_command, not fall through.
Direct test that the process_command elif chain routes the canonical
name to the handler. Without this wiring, /sessions printed
`Unknown command: sessions` even though it was a registered command.
"""
cli = _make_cli()
cli._session_db = None # exercise the no-db path too
with patch.object(cli, "_handle_sessions_command") as mock_handler:
cli.process_command("/sessions")
mock_handler.assert_called_once()
called_with = mock_handler.call_args.args[0]
assert called_with.lower().startswith("/sessions")
class TestRootLevelProviderOverride:
"""Root-level provider/base_url in config.yaml must NOT override model.provider."""
def test_model_provider_wins_over_root_provider(self, tmp_path, monkeypatch):
"""model.provider takes priority — root-level provider is only a fallback."""
import yaml
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
config_path = hermes_home / "config.yaml"
config_path.write_text(yaml.safe_dump({
"provider": "opencode-go", # stale root-level key
"model": {
"default": "google/gemini-3-flash-preview",
"provider": "openrouter", # correct canonical key
},
}))
import cli
monkeypatch.setattr(cli, "_hermes_home", hermes_home)
cfg = cli.load_cli_config()
assert cfg["model"]["provider"] == "openrouter"
def test_root_provider_used_as_fallback_when_model_provider_missing(self, tmp_path, monkeypatch):
"""Legacy root-level provider still populates model.provider in the CLI loader."""
import yaml
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
config_path = hermes_home / "config.yaml"
config_path.write_text(yaml.safe_dump({
"provider": "opencode-go", # stale root key
"model": {
"default": "google/gemini-3-flash-preview",
# no explicit model.provider — defaults provide "auto"
},
}))
import cli
monkeypatch.setattr(cli, "_hermes_home", hermes_home)
cfg = cli.load_cli_config()
assert cfg["model"]["provider"] == "opencode-go"
def test_root_base_url_used_as_fallback_when_model_base_url_missing(self, tmp_path, monkeypatch):
"""Legacy root-level base_url still populates model.base_url in the CLI loader."""
import yaml
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
config_path = hermes_home / "config.yaml"
config_path.write_text(yaml.safe_dump({
"base_url": "https://example.com/v1",
"model": {
"default": "google/gemini-3-flash-preview",
},
}))
import cli
monkeypatch.setattr(cli, "_hermes_home", hermes_home)
cfg = cli.load_cli_config()
assert cfg["model"]["base_url"] == "https://example.com/v1"
def test_normalize_root_model_keys_moves_to_model(self):
"""_normalize_root_model_keys migrates root keys into model section."""
from hermes_cli.config import _normalize_root_model_keys
config = {
"provider": "opencode-go",
"base_url": "https://example.com/v1",
"model": {
"default": "some-model",
},
}
result = _normalize_root_model_keys(config)
# Root keys removed
assert "provider" not in result
assert "base_url" not in result
# Migrated into model section
assert result["model"]["provider"] == "opencode-go"
assert result["model"]["base_url"] == "https://example.com/v1"
def test_normalize_root_model_keys_does_not_override_existing(self):
"""Existing model.provider is never overridden by root-level key."""
from hermes_cli.config import _normalize_root_model_keys
config = {
"provider": "stale-provider",
"model": {
"default": "some-model",
"provider": "correct-provider",
},
}
result = _normalize_root_model_keys(config)
assert result["model"]["provider"] == "correct-provider"
assert "provider" not in result # root key still cleaned up
def test_normalize_model_api_base_aliases_to_base_url(self):
"""model.api_base is migrated to model.base_url (issue #8919)."""
from hermes_cli.config import _normalize_root_model_keys
config = {
"model": {
"provider": "custom",
"api_base": "http://localhost:4000",
"api_key": "my-key",
"default": "default",
},
}
result = _normalize_root_model_keys(config)
assert result["model"]["base_url"] == "http://localhost:4000"
assert "api_base" not in result["model"] # alias cleaned up
def test_normalize_api_base_does_not_override_base_url(self):
"""An explicit model.base_url is never overridden by api_base."""
from hermes_cli.config import _normalize_root_model_keys
config = {
"model": {
"provider": "custom",
"api_base": "http://wrong:9999",
"base_url": "http://localhost:4000",
"default": "default",
},
}
result = _normalize_root_model_keys(config)
assert result["model"]["base_url"] == "http://localhost:4000"
assert "api_base" not in result["model"]
def test_normalize_root_context_length_migrates_to_model(self):
"""Root-level context_length is migrated into the model section."""
from hermes_cli.config import _normalize_root_model_keys
config = {
"context_length": 128000,
"model": {
"default": "my-model",
},
}
result = _normalize_root_model_keys(config)
assert result["model"]["context_length"] == 128000
assert "context_length" not in result # root key cleaned up
def test_normalize_root_context_length_does_not_override_existing(self):
"""Existing model.context_length is not overridden by root-level key."""
from hermes_cli.config import _normalize_root_model_keys
config = {
"context_length": 256000,
"model": {
"default": "my-model",
"context_length": 128000,
},
}
result = _normalize_root_model_keys(config)
assert result["model"]["context_length"] == 128000 # preserved
assert "context_length" not in result # root key still cleaned up
def test_normalize_root_context_length_with_string_model(self):
"""Root-level context_length is migrated even when model is a string."""
from hermes_cli.config import _normalize_root_model_keys
config = {
"context_length": 128000,
"model": "my-model",
}
result = _normalize_root_model_keys(config)
assert isinstance(result["model"], dict)
assert result["model"]["default"] == "my-model"
assert result["model"]["context_length"] == 128000
assert "context_length" not in result
# --- model-id alias canonicalization (issue #34500) -------------------
# ``model.name`` / ``model.model`` must canonicalize to ``model.default``
# so the runtime resolver (and ~14 other readers) never sends an empty
# ``model=`` to the backend. Precedence: default > model > name.
def test_normalize_model_name_aliases_to_default(self):
"""model.name (custom-provider repro) becomes model.default (#34500)."""
from hermes_cli.config import _normalize_root_model_keys
config = {
"model": {"name": "claude-sonnet-4-20250514", "provider": "my-litellm"},
}
result = _normalize_root_model_keys(config)
assert result["model"]["default"] == "claude-sonnet-4-20250514"
assert "name" not in result["model"] # stale alias dropped
def test_normalize_model_alias_to_default(self):
"""model.model becomes model.default."""
from hermes_cli.config import _normalize_root_model_keys
result = _normalize_root_model_keys({"model": {"model": "via-model-key"}})
assert result["model"]["default"] == "via-model-key"
assert "model" not in result["model"]
def test_normalize_explicit_default_wins_over_name(self):
"""An explicit model.default is never overridden, and a stale alias is dropped."""
from hermes_cli.config import _normalize_root_model_keys
result = _normalize_root_model_keys(
{"model": {"default": "real-model", "name": "ignored"}}
)
assert result["model"]["default"] == "real-model"
assert "name" not in result["model"]
def test_normalize_explicit_default_wins_over_model(self):
from hermes_cli.config import _normalize_root_model_keys
result = _normalize_root_model_keys(
{"model": {"default": "real-model", "model": "ignored"}}
)
assert result["model"]["default"] == "real-model"
assert "model" not in result["model"]
def test_normalize_model_wins_over_name(self):
"""Precedence: model > name when both are aliases and default is empty."""
from hermes_cli.config import _normalize_root_model_keys
result = _normalize_root_model_keys({"model": {"model": "m-key", "name": "n-key"}})
assert result["model"]["default"] == "m-key"
assert "model" not in result["model"] and "name" not in result["model"]
def test_normalize_empty_model_dict_stays_empty(self):
"""No id key anywhere → default stays empty (no fabricated value)."""
from hermes_cli.config import _normalize_root_model_keys
result = _normalize_root_model_keys({"model": {"provider": "my-litellm"}})
assert (result["model"].get("default") or "") == ""
def test_normalize_model_name_save_roundtrip_migrates_key(self, tmp_path, monkeypatch):
"""A model.name config is permanently migrated to model.default on save."""
import hermes_cli.config as cfgmod
home = tmp_path / ".hermes"
home.mkdir()
monkeypatch.setenv("HERMES_HOME", str(home))
cfg_path = home / "config.yaml"
cfg_path.write_text("model:\n name: claude-sonnet-4\n provider: my-litellm\n")
# bust the mtime cache
cfgmod._RAW_CONFIG_CACHE.clear()
loaded = cfgmod.load_config()
assert loaded["model"]["default"] == "claude-sonnet-4"
cfgmod.save_config(loaded)
raw = cfg_path.read_text()
assert "name:" not in raw # stale alias gone from the file
assert "default: claude-sonnet-4" in raw
class TestProviderResolution:
def test_api_key_is_string_or_none(self):
cli = _make_cli()
assert cli.api_key is None or isinstance(cli.api_key, str)
def test_base_url_is_string(self):
cli = _make_cli()
assert isinstance(cli.base_url, str)
assert cli.base_url.startswith("http")
def test_model_is_string(self):
cli = _make_cli()
assert isinstance(cli.model, str)
assert isinstance(cli.model, str) and '/' in cli.model
+43
View File
@@ -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
+194
View File
@@ -0,0 +1,194 @@
"""Regression tests for the CLI interrupt-acknowledgement race.
Symptom (user report, July 2026): interrupting an active turn is
unreliable — the interrupt message is sometimes "vacuumed into the void".
Root cause: ``HermesCLI.chat()`` fires ``agent.interrupt(msg)`` from its
monitor loop, but only re-queued the message when the turn RESULT carried
``interrupted=True``. Two races defeat that:
1. The agent thread passes its last ``_interrupt_requested`` check (or
finishes entirely) just before the interrupt lands — the turn
completes "normally", ``finalize_turn()`` never acknowledges the
interrupt, and the user's message was silently dropped.
2. Worse, when the interrupt lands *after* ``finalize_turn()``'s
``clear_interrupt()``, the stale ``_interrupt_requested`` flag
survives on the agent and instantly aborts the NEXT turn at its
first loop check.
The fix: when ``chat()`` consumed an ``interrupt_msg`` but the result
doesn't acknowledge the interrupt, re-queue the message as the next turn
and clear the stale agent flag (only when the agent thread has exited).
"""
from __future__ import annotations
import importlib
import queue
import sys
import time
from unittest.mock import MagicMock, patch
def _make_cli():
"""Build a HermesCLI with prompt_toolkit stubbed (same pattern as
test_cli_interrupt_drain_regression.py)."""
_clean_config = {
"model": {
"default": "anthropic/claude-opus-4.6",
"base_url": "https://openrouter.ai/api/v1",
"provider": "auto",
},
"display": {"compact": False, "tool_progress": "all"},
"agent": {},
"terminal": {"env_type": "local"},
}
clean_env = {"LLM_MODEL": "", "HERMES_MAX_ITERATIONS": ""}
prompt_toolkit_stubs = {
"prompt_toolkit": MagicMock(),
"prompt_toolkit.history": MagicMock(),
"prompt_toolkit.styles": MagicMock(),
"prompt_toolkit.patch_stdout": MagicMock(),
"prompt_toolkit.application": MagicMock(),
"prompt_toolkit.layout": MagicMock(),
"prompt_toolkit.layout.processors": MagicMock(),
"prompt_toolkit.filters": MagicMock(),
"prompt_toolkit.layout.dimension": MagicMock(),
"prompt_toolkit.layout.menus": MagicMock(),
"prompt_toolkit.widgets": MagicMock(),
"prompt_toolkit.key_binding": MagicMock(),
"prompt_toolkit.completion": MagicMock(),
"prompt_toolkit.formatted_text": MagicMock(),
"prompt_toolkit.auto_suggest": MagicMock(),
}
with patch.dict(sys.modules, prompt_toolkit_stubs), patch.dict(
"os.environ", clean_env, clear=False
):
import cli as _cli_mod
_cli_mod = importlib.reload(_cli_mod)
with patch.object(_cli_mod, "get_tool_definitions", return_value=[]), patch.dict(
_cli_mod.__dict__, {"CLI_CONFIG": _clean_config}
):
return _cli_mod.HermesCLI()
class _StubAgent:
"""Agent whose turn completes WITHOUT acknowledging the interrupt."""
def __init__(self, session_id, turn_seconds=0.5):
self.session_id = session_id
self.turn_seconds = turn_seconds
self._interrupt_requested = False
self._interrupt_message = None
self._active_children = []
self.interrupt_calls = []
self.clear_calls = 0
self.max_iterations = 90
self.model = "test/model"
self.platform = "cli"
def run_conversation(self, **kwargs):
# Simulate a turn that finishes normally — it never observed the
# interrupt flag (raced past its last check).
time.sleep(self.turn_seconds)
return {
"final_response": "turn finished normally",
"messages": [
{"role": "user", "content": "original"},
{"role": "assistant", "content": "turn finished normally"},
],
"api_calls": 1,
"completed": True,
# NOTE: no "interrupted" key — the race means finalize_turn
# never saw the flag (or cleared it before it was re-set).
"partial": True, # skip auto-title thread in the test
# Skip the Rich Panel rendering path (crashes under the
# prompt_toolkit/skin mocks; irrelevant to this regression).
"response_previewed": True,
}
def interrupt(self, message=None):
self.interrupt_calls.append(message)
self._interrupt_requested = True
self._interrupt_message = message
def clear_interrupt(self):
self.clear_calls += 1
self._interrupt_requested = False
self._interrupt_message = None
def test_unacknowledged_interrupt_message_is_requeued_not_dropped():
cli = _make_cli()
agent = _StubAgent(cli.session_id)
cli.agent = agent
cli._interrupt_queue = queue.Queue()
cli._pending_input = queue.Queue()
cli._interrupt_queue.put("urgent new message")
with patch.object(cli, "_ensure_runtime_credentials", return_value=True), \
patch.object(cli, "_resolve_turn_agent_config", return_value={
"signature": cli._active_agent_route_signature,
"model": None, "runtime": None, "request_overrides": None,
}), \
patch.object(cli, "_init_agent", return_value=True):
cli.chat("original")
# The interrupt fired against the agent...
assert agent.interrupt_calls == ["urgent new message"]
# ...the turn result never acknowledged it, so the message must be
# re-queued as the next turn instead of dropped.
queued = []
while not cli._pending_input.empty():
queued.append(cli._pending_input.get_nowait())
assert any("urgent new message" in str(q) for q in queued), (
f"interrupt message was dropped; pending_input={queued!r}"
)
# ...and the stale flag must be cleared so the NEXT turn doesn't
# instantly self-abort at its first _interrupt_requested check.
assert agent._interrupt_requested is False
assert agent.clear_calls >= 1
def test_acknowledged_interrupt_still_requeues_message():
"""The pre-existing path (result carries interrupted=True) still works."""
cli = _make_cli()
class _AckAgent(_StubAgent):
def run_conversation(self, **kwargs):
# Wait until the monitor loop delivers the interrupt.
for _ in range(100):
if self._interrupt_requested:
break
time.sleep(0.05)
return {
"final_response": "partial work",
"messages": [{"role": "assistant", "content": "partial work"}],
"api_calls": 1,
"completed": False,
"interrupted": True,
"interrupt_message": self._interrupt_message,
"partial": True,
}
agent = _AckAgent(cli.session_id)
cli.agent = agent
cli._interrupt_queue = queue.Queue()
cli._pending_input = queue.Queue()
cli._interrupt_queue.put("redirect please")
with patch.object(cli, "_ensure_runtime_credentials", return_value=True), \
patch.object(cli, "_resolve_turn_agent_config", return_value={
"signature": cli._active_agent_route_signature,
"model": None, "runtime": None, "request_overrides": None,
}), \
patch.object(cli, "_init_agent", return_value=True):
cli.chat("original")
queued = []
while not cli._pending_input.empty():
queued.append(cli._pending_input.get_nowait())
assert any("redirect please" in str(q) for q in queued)
assert cli._last_turn_interrupted is True
@@ -0,0 +1,138 @@
"""Regression test for #20271: classic-CLI hangs when messages typed during
an agent turn never leave ``_interrupt_queue``.
Background
----------
The CLI routes user input typed while ``_agent_running`` is True into
``_interrupt_queue`` (separate from ``_pending_input``) so that the explicit
interrupt path can opt to deliver them as a single combined "interrupt"
message. The explicit drain at the top of ``process_loop`` only fires when
``busy_input_mode == "interrupt"`` AND a ``pending_message`` was
acknowledged.
The original PR #17939 paired the paste-file TOCTOU fix with a separate
drain inside ``process_loop``'s ``finally`` block: any message left in
``_interrupt_queue`` after the agent's turn ends gets re-queued onto
``_pending_input``. The drain was split off in #17666 / #18760 as "worth
its own review" and never re-landed. v0.12.0 users hit a hang when typing
during a turn that completes naturally — the message sits in
``_interrupt_queue``, the next ``Enter`` re-routes input to the same
blocked queue, and the CLI looks frozen.
This test exercises the restored ``_drain_interrupt_queue_to_pending_input``
helper that ``process_loop`` now calls every turn. The integration into
``process_loop`` itself is not threaded here (it requires a real
prompt_toolkit app); the helper is unit-testable on its own and is the
load-bearing piece.
"""
from __future__ import annotations
import importlib
import queue
import sys
from unittest.mock import MagicMock, patch
def _make_cli():
"""Build a HermesCLI instance with prompt_toolkit stubbed out.
Mirrors the helper in ``test_cli_steer_busy_path.py``.
"""
_clean_config = {
"model": {
"default": "anthropic/claude-opus-4.6",
"base_url": "https://openrouter.ai/api/v1",
"provider": "auto",
},
"display": {"compact": False, "tool_progress": "all"},
"agent": {},
"terminal": {"env_type": "local"},
}
clean_env = {"LLM_MODEL": "", "HERMES_MAX_ITERATIONS": ""}
prompt_toolkit_stubs = {
"prompt_toolkit": MagicMock(),
"prompt_toolkit.history": MagicMock(),
"prompt_toolkit.styles": MagicMock(),
"prompt_toolkit.patch_stdout": MagicMock(),
"prompt_toolkit.application": MagicMock(),
"prompt_toolkit.layout": MagicMock(),
"prompt_toolkit.layout.processors": MagicMock(),
"prompt_toolkit.filters": MagicMock(),
"prompt_toolkit.layout.dimension": MagicMock(),
"prompt_toolkit.layout.menus": MagicMock(),
"prompt_toolkit.widgets": MagicMock(),
"prompt_toolkit.key_binding": MagicMock(),
"prompt_toolkit.completion": MagicMock(),
"prompt_toolkit.formatted_text": MagicMock(),
"prompt_toolkit.auto_suggest": MagicMock(),
}
with patch.dict(sys.modules, prompt_toolkit_stubs), patch.dict(
"os.environ", clean_env, clear=False
):
import cli as _cli_mod
_cli_mod = importlib.reload(_cli_mod)
with patch.object(_cli_mod, "get_tool_definitions", return_value=[]), patch.dict(
_cli_mod.__dict__, {"CLI_CONFIG": _clean_config}
):
return _cli_mod.HermesCLI()
class TestInterruptQueueDrain:
"""``_drain_interrupt_queue_to_pending_input`` re-queues stray messages."""
def test_drains_single_pending_message_into_pending_input(self):
cli = _make_cli()
cli._interrupt_queue.put("typed during agent turn")
cli._drain_interrupt_queue_to_pending_input()
assert cli._interrupt_queue.empty()
assert cli._pending_input.qsize() == 1
assert cli._pending_input.get_nowait() == "typed during agent turn"
def test_preserves_order_when_draining_multiple_messages(self):
cli = _make_cli()
for msg in ("first", "second", "third"):
cli._interrupt_queue.put(msg)
cli._drain_interrupt_queue_to_pending_input()
assert cli._interrupt_queue.empty()
drained = []
while not cli._pending_input.empty():
drained.append(cli._pending_input.get_nowait())
assert drained == ["first", "second", "third"]
def test_noop_when_interrupt_queue_is_empty(self):
cli = _make_cli()
cli._drain_interrupt_queue_to_pending_input()
assert cli._interrupt_queue.empty()
assert cli._pending_input.empty()
def test_skips_falsy_messages(self):
cli = _make_cli()
cli._interrupt_queue.put("")
cli._interrupt_queue.put(None)
cli._interrupt_queue.put("real")
cli._drain_interrupt_queue_to_pending_input()
assert cli._interrupt_queue.empty()
assert cli._pending_input.qsize() == 1
assert cli._pending_input.get_nowait() == "real"
def test_swallows_exceptions_so_main_loop_never_breaks(self):
cli = _make_cli()
# Replace _pending_input with an object whose .put raises — simulating
# an unexpected internal error. The drain must NOT propagate.
broken = MagicMock(spec=queue.Queue)
broken.put.side_effect = RuntimeError("simulated put failure")
cli._pending_input = broken
cli._interrupt_queue.put("anything")
# Should not raise.
cli._drain_interrupt_queue_to_pending_input()
+170
View File
@@ -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()
+176
View File
@@ -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"
+72
View File
@@ -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
+193
View File
@@ -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
+103
View File
@@ -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()
+389
View File
@@ -0,0 +1,389 @@
"""Regression tests for CLI fresh-session commands."""
from __future__ import annotations
import importlib
import os
import sys
from datetime import datetime, timedelta
from unittest.mock import MagicMock, patch
import pytest
from hermes_state import SessionDB
from tools.todo_tool import TodoStore
class _FakeCompressor:
"""Minimal stand-in for ContextCompressor."""
def __init__(self):
self.last_prompt_tokens = 500
self.last_completion_tokens = 200
self.last_total_tokens = 700
self.compression_count = 3
self._context_probed = True
class _FakeAgent:
def __init__(self, session_id: str, session_start):
self.session_id = session_id
self.session_start = session_start
self.model = "anthropic/claude-opus-4.6"
self._last_flushed_db_idx = 7
self._todo_store = TodoStore()
self._todo_store.write(
[{"id": "t1", "content": "unfinished task", "status": "in_progress"}]
)
self.commit_memory_session = MagicMock()
self._invalidate_system_prompt = MagicMock()
# Token counters (non-zero to verify reset)
self.session_total_tokens = 1000
self.session_input_tokens = 600
self.session_output_tokens = 400
self.session_prompt_tokens = 550
self.session_completion_tokens = 350
self.session_cache_read_tokens = 100
self.session_cache_write_tokens = 50
self.session_reasoning_tokens = 80
self.session_api_calls = 5
self.session_estimated_cost_usd = 0.42
self.session_cost_status = "estimated"
self.session_cost_source = "openrouter"
self.context_compressor = _FakeCompressor()
def reset_session_state(self):
"""Mirror the real AIAgent.reset_session_state()."""
self.session_total_tokens = 0
self.session_input_tokens = 0
self.session_output_tokens = 0
self.session_prompt_tokens = 0
self.session_completion_tokens = 0
self.session_cache_read_tokens = 0
self.session_cache_write_tokens = 0
self.session_reasoning_tokens = 0
self.session_api_calls = 0
self.session_estimated_cost_usd = 0.0
self.session_cost_status = "unknown"
self.session_cost_source = "none"
if hasattr(self, "context_compressor") and self.context_compressor:
self.context_compressor.last_prompt_tokens = 0
self.context_compressor.last_completion_tokens = 0
self.context_compressor.last_total_tokens = 0
self.context_compressor.compression_count = 0
self.context_compressor._context_probed = False
def _make_cli(env_overrides=None, config_overrides=None, **kwargs):
"""Create a HermesCLI instance with minimal mocking."""
_clean_config = {
"model": {
"default": "anthropic/claude-opus-4.6",
"base_url": "https://openrouter.ai/api/v1",
"provider": "auto",
},
"display": {"compact": False, "tool_progress": "all"},
"agent": {},
"terminal": {"env_type": "local"},
}
if config_overrides:
_clean_config.update(config_overrides)
clean_env = {"LLM_MODEL": "", "HERMES_MAX_ITERATIONS": ""}
if env_overrides:
clean_env.update(env_overrides)
prompt_toolkit_stubs = {
"prompt_toolkit": MagicMock(),
"prompt_toolkit.history": MagicMock(),
"prompt_toolkit.styles": MagicMock(),
"prompt_toolkit.patch_stdout": MagicMock(),
"prompt_toolkit.application": MagicMock(),
"prompt_toolkit.layout": MagicMock(),
"prompt_toolkit.layout.processors": MagicMock(),
"prompt_toolkit.filters": MagicMock(),
"prompt_toolkit.layout.dimension": MagicMock(),
"prompt_toolkit.layout.menus": MagicMock(),
"prompt_toolkit.widgets": MagicMock(),
"prompt_toolkit.key_binding": MagicMock(),
"prompt_toolkit.completion": MagicMock(),
"prompt_toolkit.formatted_text": MagicMock(),
"prompt_toolkit.auto_suggest": MagicMock(),
}
with patch.dict(sys.modules, prompt_toolkit_stubs), patch.dict(
"os.environ", clean_env, clear=False
):
import cli as _cli_mod
_cli_mod = importlib.reload(_cli_mod)
with patch.object(_cli_mod, "get_tool_definitions", return_value=[]), patch.dict(
_cli_mod.__dict__, {"CLI_CONFIG": _clean_config}
):
return _cli_mod.HermesCLI(**kwargs)
def _prepare_cli_with_active_session(tmp_path):
cli = _make_cli()
cli._session_db = SessionDB(db_path=tmp_path / "state.db")
cli._session_db.create_session(session_id=cli.session_id, source="cli", model=cli.model)
cli.agent = _FakeAgent(cli.session_id, cli.session_start)
cli.conversation_history = [{"role": "user", "content": "hello"}]
old_session_start = cli.session_start - timedelta(seconds=1)
cli.session_start = old_session_start
cli.agent.session_start = old_session_start
# Bypass the destructive-slash confirmation gate — these tests focus on
# the new-session mechanics, not the confirm prompt itself (covered in
# tests/cli/test_destructive_slash_confirm.py).
cli._confirm_destructive_slash = lambda *_a, **_kw: "once"
return cli
@pytest.fixture(autouse=True)
def _reset_session_id_context():
from gateway.session_context import _UNSET, _VAR_MAP
yield
os.environ.pop("HERMES_SESSION_ID", None)
_VAR_MAP["HERMES_SESSION_ID"].set(_UNSET)
def test_new_command_creates_real_fresh_session_and_resets_agent_state(tmp_path):
cli = _prepare_cli_with_active_session(tmp_path)
old_session_id = cli.session_id
old_session_start = cli.session_start
cli.process_command("/new")
assert cli.session_id != old_session_id
old_session = cli._session_db.get_session(old_session_id)
assert old_session is not None
assert old_session["end_reason"] == "new_session"
new_session = cli._session_db.get_session(cli.session_id)
assert new_session is not None
cli._session_db.append_message(cli.session_id, role="user", content="next turn")
assert cli.agent.session_id == cli.session_id
assert cli.agent._last_flushed_db_idx == 0
assert cli.agent._todo_store.read() == []
assert cli.session_start > old_session_start
assert cli.agent.session_start == cli.session_start
cli.agent._invalidate_system_prompt.assert_called_once()
def test_new_session_queues_boundary_commit_with_snapshot(tmp_path):
"""/new hands the OLD session's history + ids to the memory manager's
serialized boundary task instead of blocking on extraction inline."""
cli = _prepare_cli_with_active_session(tmp_path)
old_session_id = cli.session_id
mm = MagicMock()
cli.agent._memory_manager = mm
cli.process_command("/new")
mm.commit_session_boundary_async.assert_called_once()
args, kwargs = mm.commit_session_boundary_async.call_args
assert args[0] == [{"role": "user", "content": "hello"}]
assert kwargs["new_session_id"] == cli.session_id
assert kwargs["parent_session_id"] == old_session_id
assert kwargs["reason"] == "new_session"
# The queued path replaces the inline switch — not both.
mm.on_session_switch.assert_not_called()
def test_new_session_without_history_switches_inline(tmp_path):
"""No old-session history → nothing to extract → plain inline switch."""
cli = _prepare_cli_with_active_session(tmp_path)
cli.conversation_history = []
mm = MagicMock()
cli.agent._memory_manager = mm
cli.process_command("/new")
mm.commit_session_boundary_async.assert_not_called()
mm.on_session_switch.assert_called_once()
_, kwargs = mm.on_session_switch.call_args
assert kwargs["reset"] is True
def test_new_session_delivers_context_engine_boundary_synchronously(tmp_path):
"""The context-engine on_session_end must fire during /new itself.
It is cheap local state work and ordering-sensitive: it must land before
reset_session_state() rebinds the engine to the new session. The LLM-bound
provider extraction is what gets deferred, not this."""
cli = _prepare_cli_with_active_session(tmp_path)
old_session_id = cli.session_id
engine_calls = []
cli.agent.context_compressor.on_session_end = (
lambda sid, msgs: engine_calls.append((sid, list(msgs)))
)
cli.process_command("/new")
assert engine_calls == [(old_session_id, [{"role": "user", "content": "hello"}])]
def test_run_cleanup_flushes_pending_memory_manager_work(tmp_path):
"""A '/new then quit' must not drop the queued old-session extraction.
_run_cleanup gives the manager's serialized worker a bounded drain via
flush_pending() before shutdown_all()'s short-fuse drain runs."""
import cli as _cli_mod
agent = MagicMock()
mm = MagicMock()
mm.flush_pending.return_value = True
agent._memory_manager = mm
agent._session_messages = []
old_ref = _cli_mod._active_agent_ref
_cli_mod._active_agent_ref = agent
_cli_mod._cleanup_done = False
try:
_cli_mod._run_cleanup(notify_session_finalize=False)
finally:
_cli_mod._cleanup_done = True
_cli_mod._active_agent_ref = old_ref
mm.flush_pending.assert_called_once_with(timeout=10)
def test_new_command_rotates_hermes_session_id_env_and_context(tmp_path):
from gateway.session_context import _VAR_MAP, get_session_env
cli = _prepare_cli_with_active_session(tmp_path)
old_session_id = cli.session_id
os.environ["HERMES_SESSION_ID"] = old_session_id
_VAR_MAP["HERMES_SESSION_ID"].set(old_session_id)
cli.process_command("/new")
assert cli.session_id != old_session_id
assert os.environ["HERMES_SESSION_ID"] == cli.session_id
assert get_session_env("HERMES_SESSION_ID") == cli.session_id
def test_reset_command_is_alias_for_new_session(tmp_path):
cli = _prepare_cli_with_active_session(tmp_path)
old_session_id = cli.session_id
cli.process_command("/reset")
assert cli.session_id != old_session_id
assert cli._session_db.get_session(old_session_id)["end_reason"] == "new_session"
assert cli._session_db.get_session(cli.session_id) is not None
def test_clear_command_starts_new_session_before_redrawing(tmp_path):
cli = _prepare_cli_with_active_session(tmp_path)
cli.console = MagicMock()
cli.show_banner = MagicMock()
old_session_id = cli.session_id
cli.process_command("/clear")
assert cli.session_id != old_session_id
assert cli._session_db.get_session(old_session_id)["end_reason"] == "new_session"
assert cli._session_db.get_session(cli.session_id) is not None
cli.console.clear.assert_called_once()
cli.show_banner.assert_called_once()
assert cli.conversation_history == []
def test_new_session_resets_token_counters(tmp_path):
"""Regression test for #2099: /new must zero all token counters."""
cli = _prepare_cli_with_active_session(tmp_path)
# Verify counters are non-zero before reset
agent = cli.agent
assert agent.session_total_tokens > 0
assert agent.session_api_calls > 0
assert agent.context_compressor.compression_count > 0
cli.process_command("/new")
# All agent token counters must be zero
assert agent.session_total_tokens == 0
assert agent.session_input_tokens == 0
assert agent.session_output_tokens == 0
assert agent.session_prompt_tokens == 0
assert agent.session_completion_tokens == 0
assert agent.session_cache_read_tokens == 0
assert agent.session_cache_write_tokens == 0
assert agent.session_reasoning_tokens == 0
assert agent.session_api_calls == 0
assert agent.session_estimated_cost_usd == 0.0
assert agent.session_cost_status == "unknown"
assert agent.session_cost_source == "none"
# Context compressor counters must also be zero
comp = agent.context_compressor
assert comp.last_prompt_tokens == 0
assert comp.last_completion_tokens == 0
assert comp.last_total_tokens == 0
assert comp.compression_count == 0
assert comp._context_probed is False
def test_new_session_with_title(capsys):
"""new_session(title=...) creates a session and sets the title."""
cli = _make_cli()
cli._session_db = MagicMock()
cli.agent = _FakeAgent("old_session_id", datetime.now())
cli.conversation_history = []
cli.new_session(title="My Test Session")
# Assert set_session_title was called with the new session ID and sanitized title
cli._session_db.set_session_title.assert_called_once()
call_args = cli._session_db.set_session_title.call_args
assert call_args[0][0] == cli.session_id
assert call_args[0][1] == "My Test Session"
captured = capsys.readouterr()
assert "My Test Session" in captured.out
def test_new_session_with_duplicate_title_surfaces_error(capsys):
"""new_session(title=...) handles ValueError from a duplicate-title conflict.
The session is still created; the title assignment fails; the success banner
must not claim the rejected title as the session name.
"""
cli = _make_cli()
cli._session_db = MagicMock()
cli._session_db.set_session_title.side_effect = ValueError(
"Title 'Dup' is already in use by session abc-123"
)
cli.agent = _FakeAgent("old_session_id", datetime.now())
cli.conversation_history = []
# Capture warnings printed via cli._cprint. After importlib.reload(),
# the method's __globals__ dict is the one from the live module — patch
# the exact dict the method will read.
warnings: list[str] = []
method_globals = cli.new_session.__globals__
original = method_globals["_cprint"]
method_globals["_cprint"] = lambda msg: warnings.append(msg)
try:
cli.new_session(title="Dup")
finally:
method_globals["_cprint"] = original
cli._session_db.set_session_title.assert_called_once()
joined = "\n".join(warnings)
assert "already in use" in joined
assert "session started untitled" in joined
# The success banner must NOT claim the rejected title as the session name.
captured = capsys.readouterr()
assert "New session started: Dup" not in captured.out
assert "New session started!" in captured.out
+136
View File
@@ -0,0 +1,136 @@
"""The base-CLI petdex pane: reactive half-block sprite above the prompt.
Mirrors the TUI's PetPane. The methods are tested in isolation via __new__ so
we don't pay the full HermesCLI.__init__ cost; a synthetic spritesheet exercises
the real engine decode + half-block fragment building.
"""
from __future__ import annotations
import threading
import pytest
from agent.pet import store
from agent.pet.constants import FRAME_H, FRAME_W
from agent.pet.render import PetRenderer
from cli import HermesCLI
@pytest.fixture
def boba_like(tmp_path, monkeypatch):
"""Install a synthetic pet into a temp HERMES_HOME and return its slug."""
from PIL import Image
home = tmp_path / ".hermes"
home.mkdir()
monkeypatch.setenv("HERMES_HOME", str(home))
cols, rows = 8, 9
sheet = Image.new("RGBA", (FRAME_W * cols, FRAME_H * rows), (0, 0, 0, 0))
for r in range(rows):
color = (20 + r * 25, 60, 120, 255)
for c in range(cols):
block = Image.new("RGBA", (FRAME_W, FRAME_H), color)
sheet.paste(block, (c * FRAME_W, r * FRAME_H))
pet_dir = store.pets_dir() / "boba"
pet_dir.mkdir(parents=True, exist_ok=True)
sheet.save(pet_dir / "spritesheet.webp")
(pet_dir / "pet.json").write_text(
'{"id":"boba","displayName":"Boba","description":"d","spritesheetPath":"spritesheet.webp"}'
)
return "boba"
def _make_cli():
cli_obj = HermesCLI.__new__(HermesCLI)
cli_obj._app = None
cli_obj._pet_lock = threading.Lock()
cli_obj._pet_enabled = False
cli_obj._pet_renderer = None
cli_obj._pet_slug = ""
cli_obj._pet_cols = 18
cli_obj._pet_scale = 0.7
cli_obj._pet_frames_cache = {}
cli_obj._pet_frame_idx = 0
cli_obj._agent_running = False
# Transient-beat + reasoning state (set by HermesCLI.__init__ in production).
cli_obj._pet_event = ""
cli_obj._pet_event_until = 0.0
cli_obj._pet_reasoning = False
# Blocking-modal state — a live one maps the pet to `waiting`.
cli_obj._approval_state = None
cli_obj._clarify_state = None
cli_obj._sudo_state = None
cli_obj._secret_state = None
cli_obj._slash_confirm_state = None
return cli_obj
def test_pet_state_tracks_agent_running():
cli_obj = _make_cli()
assert cli_obj._derive_pet_state() == "idle"
cli_obj._agent_running = True
assert cli_obj._derive_pet_state() == "run"
def test_pet_state_waits_on_a_blocking_modal():
# A live clarify/approval pauses the agent on the user → `waiting`, even
# while the turn is technically still running.
cli_obj = _make_cli()
cli_obj._agent_running = True
cli_obj._clarify_state = {"question": "?"}
assert cli_obj._derive_pet_state() == "waiting"
def test_pet_pane_collapsed_when_disabled():
# No renderer resolved → the window reports zero height and no fragments,
# so it's invisible for users without a pet.
cli_obj = _make_cli()
assert cli_obj._pet_widget_height() == 0
assert cli_obj._pet_fragments() == []
def test_pet_fragments_render_half_blocks(boba_like):
cli_obj = _make_cli()
cli_obj._pet_renderer = PetRenderer(
str(store.load_pet("boba").spritesheet), mode="unicode", scale=0.4, unicode_cols=14
)
cli_obj._pet_cols = 14
cli_obj._pet_enabled = True
height = cli_obj._pet_widget_height()
assert height > 0
frags = cli_obj._pet_fragments()
assert frags, "expected fragments for an enabled pet"
# Each fragment is a (style, text) pair; glyphs are half-blocks or blanks.
glyphs = {text for _, text in frags}
assert glyphs <= {"", "", " ", "\n"}
# Opaque cells carry a truecolor foreground style.
assert any(text == "" and "fg:#" in style for style, text in frags)
# Row count in the fragment stream matches the reported window height.
assert sum(1 for _, text in frags if text == "\n") == height - 1
def test_pet_resolve_config_enables_and_disables(boba_like):
from hermes_cli.config import load_config, save_config
cli_obj = _make_cli()
cfg = load_config()
cfg.setdefault("display", {}).setdefault("pet", {})
cfg["display"]["pet"].update({"enabled": True, "slug": "boba"})
save_config(cfg)
cli_obj._pet_resolve_config()
assert cli_obj._pet_enabled is True
assert cli_obj._pet_renderer is not None
assert cli_obj._pet_slug == "boba"
cfg["display"]["pet"]["enabled"] = False
save_config(cfg)
cli_obj._pet_resolve_config()
assert cli_obj._pet_enabled is False
assert cli_obj._pet_renderer is None
+160
View File
@@ -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
+127
View File
@@ -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
+884
View File
@@ -0,0 +1,884 @@
import importlib
import sys
import types
from contextlib import nullcontext
from types import SimpleNamespace
import pytest
from hermes_cli.auth import AuthError
from hermes_cli import main as hermes_main
# ---------------------------------------------------------------------------
# Module isolation: _import_cli() wipes tools.* / cli / run_agent from
# sys.modules so it can re-import cli fresh. Without cleanup the wiped
# modules leak into subsequent tests, breaking
# mock patches that target "tools.file_tools._get_file_ops" etc.
# ---------------------------------------------------------------------------
def _reset_modules(prefixes: tuple[str, ...]):
for name in list(sys.modules):
if any(name == p or name.startswith(p + ".") for p in prefixes):
sys.modules.pop(name, None)
@pytest.fixture(autouse=True)
def _restore_cli_and_tool_modules():
"""Save and restore tools/cli/run_agent modules around every test."""
prefixes = ("tools", "cli", "run_agent")
original_modules = {
name: module
for name, module in sys.modules.items()
if any(name == p or name.startswith(p + ".") for p in prefixes)
}
try:
yield
finally:
_reset_modules(prefixes)
sys.modules.update(original_modules)
def _install_prompt_toolkit_stubs():
class _Dummy:
def __init__(self, *args, **kwargs):
pass
class _Condition:
def __init__(self, func):
self.func = func
def __bool__(self):
return bool(self.func())
class _ANSI(str):
pass
root = types.ModuleType("prompt_toolkit")
history = types.ModuleType("prompt_toolkit.history")
styles = types.ModuleType("prompt_toolkit.styles")
patch_stdout = types.ModuleType("prompt_toolkit.patch_stdout")
application = types.ModuleType("prompt_toolkit.application")
layout = types.ModuleType("prompt_toolkit.layout")
processors = types.ModuleType("prompt_toolkit.layout.processors")
filters = types.ModuleType("prompt_toolkit.filters")
dimension = types.ModuleType("prompt_toolkit.layout.dimension")
menus = types.ModuleType("prompt_toolkit.layout.menus")
widgets = types.ModuleType("prompt_toolkit.widgets")
key_binding = types.ModuleType("prompt_toolkit.key_binding")
completion = types.ModuleType("prompt_toolkit.completion")
formatted_text = types.ModuleType("prompt_toolkit.formatted_text")
history.FileHistory = _Dummy
styles.Style = _Dummy
patch_stdout.patch_stdout = lambda *args, **kwargs: nullcontext()
application.Application = _Dummy
layout.Layout = _Dummy
layout.HSplit = _Dummy
layout.Window = _Dummy
layout.FormattedTextControl = _Dummy
layout.ConditionalContainer = _Dummy
processors.Processor = _Dummy
processors.Transformation = _Dummy
processors.PasswordProcessor = _Dummy
processors.ConditionalProcessor = _Dummy
filters.Condition = _Condition
dimension.Dimension = _Dummy
menus.CompletionsMenu = _Dummy
widgets.TextArea = _Dummy
key_binding.KeyBindings = _Dummy
completion.Completer = _Dummy
completion.Completion = _Dummy
formatted_text.ANSI = _ANSI
root.print_formatted_text = lambda *args, **kwargs: None
sys.modules.setdefault("prompt_toolkit", root)
sys.modules.setdefault("prompt_toolkit.history", history)
sys.modules.setdefault("prompt_toolkit.styles", styles)
sys.modules.setdefault("prompt_toolkit.patch_stdout", patch_stdout)
sys.modules.setdefault("prompt_toolkit.application", application)
sys.modules.setdefault("prompt_toolkit.layout", layout)
sys.modules.setdefault("prompt_toolkit.layout.processors", processors)
sys.modules.setdefault("prompt_toolkit.filters", filters)
sys.modules.setdefault("prompt_toolkit.layout.dimension", dimension)
sys.modules.setdefault("prompt_toolkit.layout.menus", menus)
sys.modules.setdefault("prompt_toolkit.widgets", widgets)
sys.modules.setdefault("prompt_toolkit.key_binding", key_binding)
sys.modules.setdefault("prompt_toolkit.completion", completion)
sys.modules.setdefault("prompt_toolkit.formatted_text", formatted_text)
def _import_cli():
for name in list(sys.modules):
if name == "cli" or name == "run_agent" or name == "tools" or name.startswith("tools."):
sys.modules.pop(name, None)
if "firecrawl" not in sys.modules:
sys.modules["firecrawl"] = types.SimpleNamespace(Firecrawl=object)
try:
importlib.import_module("prompt_toolkit")
except ModuleNotFoundError:
_install_prompt_toolkit_stubs()
return importlib.import_module("cli")
def test_hermes_cli_init_does_not_eagerly_resolve_runtime_provider(monkeypatch):
cli = _import_cli()
calls = {"count": 0}
def _unexpected_runtime_resolve(**kwargs):
calls["count"] += 1
raise AssertionError("resolve_runtime_provider should not be called in HermesCLI.__init__")
monkeypatch.setattr("hermes_cli.runtime_provider.resolve_runtime_provider", _unexpected_runtime_resolve)
monkeypatch.setattr("hermes_cli.runtime_provider.format_runtime_provider_error", lambda exc: str(exc))
shell = cli.HermesCLI(model="gpt-5", compact=True, max_turns=1)
assert shell is not None
assert calls["count"] == 0
def test_runtime_resolution_failure_is_not_sticky(monkeypatch):
cli = _import_cli()
calls = {"count": 0}
def _runtime_resolve(**kwargs):
calls["count"] += 1
if calls["count"] == 1:
raise RuntimeError("temporary auth failure")
return {
"provider": "openrouter",
"api_mode": "chat_completions",
"base_url": "https://openrouter.ai/api/v1",
"api_key": "test-key",
"source": "env/config",
}
class _DummyAgent:
def __init__(self, *args, **kwargs):
self.kwargs = kwargs
monkeypatch.setattr("hermes_cli.runtime_provider.resolve_runtime_provider", _runtime_resolve)
monkeypatch.setattr("hermes_cli.runtime_provider.format_runtime_provider_error", lambda exc: str(exc))
monkeypatch.setattr(cli, "AIAgent", _DummyAgent)
shell = cli.HermesCLI(model="gpt-5", compact=True, max_turns=1)
assert shell._init_agent() is False
assert shell._init_agent() is True
assert calls["count"] == 2
assert shell.agent is not None
def test_runtime_resolution_rebuilds_agent_on_routing_change(monkeypatch):
cli = _import_cli()
def _runtime_resolve(**kwargs):
return {
"provider": "openai-codex",
"api_mode": "codex_responses",
"base_url": "https://same-endpoint.example/v1",
"api_key": "same-key",
"source": "env/config",
}
monkeypatch.setattr("hermes_cli.runtime_provider.resolve_runtime_provider", _runtime_resolve)
monkeypatch.setattr("hermes_cli.runtime_provider.format_runtime_provider_error", lambda exc: str(exc))
shell = cli.HermesCLI(model="gpt-5", compact=True, max_turns=1)
shell.provider = "openrouter"
shell.api_mode = "chat_completions"
shell.base_url = "https://same-endpoint.example/v1"
shell.api_key = "same-key"
shell.agent = object()
assert shell._ensure_runtime_credentials() is True
assert shell.agent is None
assert shell.provider == "openai-codex"
assert shell.api_mode == "codex_responses"
def test_cli_turn_routing_uses_primary_when_disabled(monkeypatch):
cli = _import_cli()
shell = cli.HermesCLI(model="gpt-5", compact=True, max_turns=1)
shell.provider = "openrouter"
shell.api_mode = "chat_completions"
shell.base_url = "https://openrouter.ai/api/v1"
shell.api_key = "sk-primary"
result = shell._resolve_turn_agent_config("what time is it in tokyo?")
assert result["model"] == "gpt-5"
assert result["runtime"]["provider"] == "openrouter"
def test_cli_prefers_config_provider_over_stale_env_override(monkeypatch):
cli = _import_cli()
monkeypatch.setenv("HERMES_INFERENCE_PROVIDER", "openrouter")
config_copy = dict(cli.CLI_CONFIG)
model_copy = dict(config_copy.get("model", {}))
model_copy["provider"] = "custom"
model_copy["base_url"] = "https://api.fireworks.ai/inference/v1"
config_copy["model"] = model_copy
monkeypatch.setattr(cli, "CLI_CONFIG", config_copy)
shell = cli.HermesCLI(model="fireworks/minimax-m2p5", compact=True, max_turns=1)
assert shell.requested_provider == "custom"
def test_codex_provider_replaces_incompatible_default_model(monkeypatch):
"""When provider resolves to openai-codex and no model was explicitly
chosen, the global config default (e.g. anthropic/claude-opus-4.6) must
be replaced with a Codex-compatible model. Fixes #651."""
cli = _import_cli()
monkeypatch.delenv("LLM_MODEL", raising=False)
monkeypatch.delenv("OPENAI_MODEL", raising=False)
# Ensure local user config does not leak a model into the test
monkeypatch.setitem(cli.CLI_CONFIG, "model", {
"default": "",
"base_url": "https://openrouter.ai/api/v1",
})
def _runtime_resolve(**kwargs):
return {
"provider": "openai-codex",
"api_mode": "codex_responses",
"base_url": "https://chatgpt.com/backend-api/codex",
"api_key": "test-key",
"source": "env/config",
}
monkeypatch.setattr("hermes_cli.runtime_provider.resolve_runtime_provider", _runtime_resolve)
monkeypatch.setattr("hermes_cli.runtime_provider.format_runtime_provider_error", lambda exc: str(exc))
monkeypatch.setattr(
"hermes_cli.codex_models.get_codex_model_ids",
lambda access_token=None: ["gpt-5.2-codex", "gpt-5.1-codex-mini"],
)
shell = cli.HermesCLI(compact=True, max_turns=1)
assert shell._model_is_default is True
assert shell._ensure_runtime_credentials() is True
assert shell.provider == "openai-codex"
assert "anthropic" not in shell.model
assert "claude" not in shell.model
assert shell.model == "gpt-5.2-codex"
def test_model_flow_nous_prints_subscription_guidance_without_mutating_explicit_tts(monkeypatch, capsys):
monkeypatch.setattr(
"hermes_cli.nous_subscription.managed_nous_tools_enabled",
lambda *args, **kwargs: True,
)
config = {
"model": {"provider": "nous", "default": "claude-opus-4-6"},
"tts": {"provider": "elevenlabs"},
"browser": {"cloud_provider": "browser-use"},
}
monkeypatch.setattr(
"hermes_cli.auth.get_provider_auth_state",
lambda provider: {"access_token": "nous-token"},
)
monkeypatch.setattr(
"hermes_cli.auth.resolve_nous_runtime_credentials",
lambda *args, **kwargs: {
"base_url": "https://inference.example.com/v1",
"api_key": "nous-key",
},
)
monkeypatch.setattr(
"hermes_cli.auth.fetch_nous_models",
lambda *args, **kwargs: ["claude-opus-4-6"],
)
monkeypatch.setattr("hermes_cli.auth._prompt_model_selection", lambda model_ids, current_model="", pricing=None, **kw: "claude-opus-4-6")
monkeypatch.setattr("hermes_cli.auth._save_model_choice", lambda model: None)
monkeypatch.setattr("hermes_cli.auth._update_config_for_provider", lambda provider, url: None)
hermes_main._model_flow_nous(config, current_model="claude-opus-4-6")
out = capsys.readouterr().out
assert "Default model set to:" in out
assert config["tts"]["provider"] == "elevenlabs"
assert config["browser"]["cloud_provider"] == "browser-use"
def test_model_flow_nous_does_not_restore_stale_custom_api_key(tmp_path, monkeypatch):
import yaml
config_home = tmp_path / "hermes"
config_home.mkdir()
monkeypatch.setenv("HERMES_HOME", str(config_home))
config_path = config_home / "config.yaml"
config_path.write_text(
yaml.safe_dump(
{
"model": {
"provider": "custom",
"default": "glm-5.2",
"base_url": "https://api.neuralwatt.com/v1",
"api_key": "${NEURALWATT_API_KEY}",
"api_mode": "chat_completions",
}
},
sort_keys=False,
)
)
stale_config = yaml.safe_load(config_path.read_text()) or {}
selected_model = "deepseek/deepseek-v4-flash"
monkeypatch.setattr(
"hermes_cli.auth.get_provider_auth_state",
lambda provider: {
"access_token": "nous-token",
"portal_base_url": "https://portal.example.com",
},
)
monkeypatch.setattr(
"hermes_cli.auth.resolve_nous_runtime_credentials",
lambda *args, **kwargs: {
"base_url": "https://inference-api.nousresearch.com/v1",
"api_key": "nous-key",
},
)
monkeypatch.setattr(
"hermes_cli.models.get_curated_nous_model_ids",
lambda: [selected_model],
)
monkeypatch.setattr("hermes_cli.models.get_pricing_for_provider", lambda provider: {})
monkeypatch.setattr("hermes_cli.models.check_nous_free_tier", lambda **kwargs: False)
monkeypatch.setattr(
"hermes_cli.models.union_with_portal_paid_recommendations",
lambda model_ids, pricing, portal_url: (model_ids, pricing),
)
monkeypatch.setattr(
"hermes_cli.auth._prompt_model_selection",
lambda *args, **kwargs: selected_model,
)
monkeypatch.setattr(
"hermes_cli.nous_subscription.prompt_enable_tool_gateway",
lambda config: None,
)
hermes_main._model_flow_nous(stale_config, current_model="glm-5.2")
config = yaml.safe_load(config_path.read_text()) or {}
model = config.get("model")
assert model["provider"] == "nous"
assert model["default"] == selected_model
assert model["base_url"] == "https://inference-api.nousresearch.com/v1"
assert "api_key" not in model
assert "api_mode" not in model
def _seed_stale_custom_model(tmp_path, monkeypatch):
import yaml
config_home = tmp_path / "hermes"
config_home.mkdir()
monkeypatch.setenv("HERMES_HOME", str(config_home))
config_path = config_home / "config.yaml"
config_path.write_text(
yaml.safe_dump(
{
"model": {
"provider": "custom",
"default": "glm-5.2",
"base_url": "https://api.neuralwatt.com/v1",
"api_key": "${NEURALWATT_API_KEY}",
"api": "legacy-stale-key",
"api_mode": "anthropic_messages",
}
},
sort_keys=False,
)
)
(config_home / ".env").write_text("")
return config_path
def test_model_flow_openrouter_clears_stale_custom_key(tmp_path, monkeypatch):
import yaml
config_path = _seed_stale_custom_model(tmp_path, monkeypatch)
monkeypatch.setattr(
"hermes_cli.main._prompt_api_key",
lambda *args, **kwargs: ("sk-openrouter", False),
)
monkeypatch.setattr(
"hermes_cli.models.model_ids",
lambda **kwargs: ["anthropic/claude-sonnet-4.6"],
)
monkeypatch.setattr("hermes_cli.models.get_pricing_for_provider", lambda *a, **k: {})
monkeypatch.setattr(
"hermes_cli.auth._prompt_model_selection",
lambda *args, **kwargs: "anthropic/claude-sonnet-4.6",
)
monkeypatch.setattr("hermes_cli.auth.deactivate_provider", lambda: None)
hermes_main._model_flow_openrouter({}, current_model="glm-5.2")
config = yaml.safe_load(config_path.read_text()) or {}
model = config["model"]
assert model["provider"] == "openrouter"
assert model["default"] == "anthropic/claude-sonnet-4.6"
assert model["api_mode"] == "chat_completions"
assert "api_key" not in model
assert "api" not in model
def test_model_flow_anthropic_clears_stale_custom_key_and_mode(tmp_path, monkeypatch):
import yaml
config_path = _seed_stale_custom_model(tmp_path, monkeypatch)
monkeypatch.setattr("hermes_cli.auth.get_anthropic_key", lambda: "sk-ant-api03-test")
monkeypatch.setattr(
"agent.anthropic_adapter.read_claude_code_credentials",
lambda: None,
)
monkeypatch.setattr(
"agent.anthropic_adapter.is_claude_code_token_valid",
lambda creds: False,
)
monkeypatch.setattr(
"hermes_cli.model_setup_flows._prompt_auth_credentials_choice",
lambda title: "use",
)
monkeypatch.setattr(
"hermes_cli.auth._prompt_model_selection",
lambda *args, **kwargs: "claude-sonnet-4-6",
)
monkeypatch.setattr("hermes_cli.auth.deactivate_provider", lambda: None)
hermes_main._model_flow_anthropic({}, current_model="glm-5.2")
config = yaml.safe_load(config_path.read_text()) or {}
model = config["model"]
assert model["provider"] == "anthropic"
assert model["default"] == "claude-sonnet-4-6"
assert "base_url" not in model
assert "api_key" not in model
assert "api" not in model
assert "api_mode" not in model
def test_model_flow_nous_offers_tool_gateway_prompt_when_unconfigured(monkeypatch, capsys):
from hermes_cli.nous_account import NousPortalAccountInfo
# Entitled account (paid → all tools eligible) drives the offer; the prompt
# is a per-tool checklist now, so capture the call rather than scrape stdout.
monkeypatch.setattr(
"hermes_cli.nous_subscription.get_nous_portal_account_info",
lambda **kwargs: NousPortalAccountInfo(
logged_in=True,
source="account_api",
fresh=True,
paid_service_access=True,
),
)
captured = {}
def _fake_checklist(title, items, pre_selected=None):
captured["title"] = title
captured["items"] = list(items)
return [] # decline; we only assert the prompt was offered
monkeypatch.setattr("hermes_cli.setup.prompt_checklist", _fake_checklist, raising=False)
config = {
"model": {"provider": "nous", "default": "claude-opus-4-6"},
"tts": {"provider": "edge"},
}
monkeypatch.setattr(
"hermes_cli.auth.get_provider_auth_state",
lambda provider: {"access_token": "***"},
)
monkeypatch.setattr(
"hermes_cli.auth.resolve_nous_runtime_credentials",
lambda *args, **kwargs: {
"base_url": "https://inference.example.com/v1",
"api_key": "***",
},
)
monkeypatch.setattr(
"hermes_cli.auth.fetch_nous_models",
lambda *args, **kwargs: ["claude-opus-4-6"],
)
monkeypatch.setattr("hermes_cli.auth._prompt_model_selection", lambda model_ids, current_model="", pricing=None, **kw: "claude-opus-4-6")
monkeypatch.setattr("hermes_cli.auth._save_model_choice", lambda model: None)
monkeypatch.setattr("hermes_cli.auth._update_config_for_provider", lambda provider, url: None)
hermes_main._model_flow_nous(config, current_model="claude-opus-4-6")
# The per-tool Tool Gateway checklist was offered.
assert "title" in captured
assert "Tool Gateway" in captured["title"] or "tool pool" in captured["title"].lower()
def test_codex_provider_uses_config_model(monkeypatch):
"""Model comes from config.yaml, not LLM_MODEL env var.
Config.yaml is the single source of truth to avoid multi-agent conflicts."""
cli = _import_cli()
# LLM_MODEL env var should be IGNORED (even if set)
monkeypatch.setenv("LLM_MODEL", "should-be-ignored")
monkeypatch.delenv("OPENAI_MODEL", raising=False)
# Set model via config
monkeypatch.setitem(cli.CLI_CONFIG, "model", {
"default": "gpt-5.2-codex",
"provider": "openai-codex",
"base_url": "https://chatgpt.com/backend-api/codex",
})
def _runtime_resolve(**kwargs):
return {
"provider": "openai-codex",
"api_mode": "codex_responses",
"base_url": "https://chatgpt.com/backend-api/codex",
"api_key": "fake-codex-token",
"source": "env/config",
}
monkeypatch.setattr("hermes_cli.runtime_provider.resolve_runtime_provider", _runtime_resolve)
monkeypatch.setattr("hermes_cli.runtime_provider.format_runtime_provider_error", lambda exc: str(exc))
# Prevent live API call from overriding the config model
monkeypatch.setattr(
"hermes_cli.codex_models.get_codex_model_ids",
lambda access_token=None: ["gpt-5.2-codex"],
)
shell = cli.HermesCLI(compact=True, max_turns=1)
assert shell._ensure_runtime_credentials() is True
assert shell.provider == "openai-codex"
# Model from config (may be normalized by codex provider logic)
assert "codex" in shell.model.lower()
# LLM_MODEL env var is NOT used
assert shell.model != "should-be-ignored"
def test_codex_config_model_not_replaced_by_normalization(monkeypatch):
"""When the user sets model.default in config.yaml to a specific codex
model, _normalize_model_for_provider must NOT replace it with the latest
available model from the API. Regression test for #1887."""
cli = _import_cli()
monkeypatch.delenv("LLM_MODEL", raising=False)
monkeypatch.delenv("OPENAI_MODEL", raising=False)
# User explicitly configured gpt-5.3-codex in config.yaml
monkeypatch.setitem(cli.CLI_CONFIG, "model", {
"default": "gpt-5.3-codex",
"provider": "openai-codex",
"base_url": "https://chatgpt.com/backend-api/codex",
})
def _runtime_resolve(**kwargs):
return {
"provider": "openai-codex",
"api_mode": "codex_responses",
"base_url": "https://chatgpt.com/backend-api/codex",
"api_key": "fake-key",
"source": "env/config",
}
monkeypatch.setattr("hermes_cli.runtime_provider.resolve_runtime_provider", _runtime_resolve)
monkeypatch.setattr("hermes_cli.runtime_provider.format_runtime_provider_error", lambda exc: str(exc))
# API returns a DIFFERENT model than what the user configured
monkeypatch.setattr(
"hermes_cli.codex_models.get_codex_model_ids",
lambda access_token=None: ["gpt-5.4", "gpt-5.3-codex"],
)
shell = cli.HermesCLI(compact=True, max_turns=1)
# Config model is NOT the global default — user made a deliberate choice
assert shell._model_is_default is False
assert shell._ensure_runtime_credentials() is True
assert shell.provider == "openai-codex"
# Model must stay as user configured, not replaced by gpt-5.4
assert shell.model == "gpt-5.3-codex"
def test_codex_provider_preserves_explicit_codex_model(monkeypatch):
"""If the user explicitly passes a Codex-compatible model, it must be
preserved even when the provider resolves to openai-codex."""
cli = _import_cli()
monkeypatch.delenv("LLM_MODEL", raising=False)
monkeypatch.delenv("OPENAI_MODEL", raising=False)
def _runtime_resolve(**kwargs):
return {
"provider": "openai-codex",
"api_mode": "codex_responses",
"base_url": "https://chatgpt.com/backend-api/codex",
"api_key": "test-key",
"source": "env/config",
}
monkeypatch.setattr("hermes_cli.runtime_provider.resolve_runtime_provider", _runtime_resolve)
monkeypatch.setattr("hermes_cli.runtime_provider.format_runtime_provider_error", lambda exc: str(exc))
shell = cli.HermesCLI(model="gpt-5.1-codex-mini", compact=True, max_turns=1)
assert shell._model_is_default is False
assert shell._ensure_runtime_credentials() is True
assert shell.model == "gpt-5.1-codex-mini"
def test_codex_provider_strips_provider_prefix_from_model(monkeypatch):
"""openai/gpt-5.3-codex should become gpt-5.3-codex — the Codex
Responses API does not accept provider-prefixed model slugs."""
cli = _import_cli()
monkeypatch.delenv("LLM_MODEL", raising=False)
monkeypatch.delenv("OPENAI_MODEL", raising=False)
def _runtime_resolve(**kwargs):
return {
"provider": "openai-codex",
"api_mode": "codex_responses",
"base_url": "https://chatgpt.com/backend-api/codex",
"api_key": "test-key",
"source": "env/config",
}
monkeypatch.setattr("hermes_cli.runtime_provider.resolve_runtime_provider", _runtime_resolve)
monkeypatch.setattr("hermes_cli.runtime_provider.format_runtime_provider_error", lambda exc: str(exc))
shell = cli.HermesCLI(model="openai/gpt-5.3-codex", compact=True, max_turns=1)
assert shell._ensure_runtime_credentials() is True
assert shell.model == "gpt-5.3-codex"
def test_cmd_model_falls_back_to_auto_on_invalid_provider(monkeypatch, capsys):
monkeypatch.setattr(
"hermes_cli.config.load_config",
lambda: {"model": {"default": "gpt-5", "provider": "invalid-provider"}},
)
monkeypatch.setattr("hermes_cli.config.save_config", lambda cfg: None)
monkeypatch.setattr("hermes_cli.config.get_env_value", lambda key: "")
monkeypatch.setattr("hermes_cli.config.save_env_value", lambda key, value: None)
def _resolve_provider(requested, **kwargs):
if requested == "invalid-provider":
raise AuthError("Unknown provider 'invalid-provider'.", code="invalid_provider")
return "openrouter"
monkeypatch.setattr("hermes_cli.auth.resolve_provider", _resolve_provider)
monkeypatch.setattr(hermes_main, "_prompt_provider_choice", lambda choices, **kwargs: len(choices) - 1)
monkeypatch.setattr("sys.stdin", type("FakeTTY", (), {"isatty": lambda self: True})())
hermes_main.cmd_model(SimpleNamespace())
output = capsys.readouterr().out
assert "Warning:" in output
assert "falling back to auto provider detection" in output.lower()
assert "No change." in output
def test_model_flow_custom_saves_verified_v1_base_url(monkeypatch, capsys):
monkeypatch.setattr(
"hermes_cli.config.get_env_value",
lambda key: "" if key in {"OPENAI_BASE_URL", "OPENAI_API_KEY"} else "",
)
saved_env = {}
monkeypatch.setattr("hermes_cli.config.save_env_value", lambda key, value: saved_env.__setitem__(key, value))
monkeypatch.setattr("hermes_cli.auth._save_model_choice", lambda model: saved_env.__setitem__("MODEL", model))
monkeypatch.setattr("hermes_cli.auth.deactivate_provider", lambda: None)
monkeypatch.setattr("hermes_cli.main._save_custom_provider", lambda *args, **kwargs: None)
monkeypatch.setattr(
"hermes_cli.models.probe_api_models",
lambda api_key, base_url: {
"models": ["llm"],
"probed_url": "http://localhost:8000/v1/models",
"resolved_base_url": "http://localhost:8000/v1",
"suggested_base_url": "http://localhost:8000/v1",
"used_fallback": True,
},
)
monkeypatch.setattr(
"hermes_cli.config.load_config",
lambda: {"model": {"default": "", "provider": "custom", "base_url": ""}},
)
monkeypatch.setattr("hermes_cli.config.save_config", lambda cfg: None)
# After the probe detects a single model ("llm"), the flow asks
# "Use this model? [Y/n]:" — confirm with Enter, then context length,
# then display name. The api_mode prompt also runs before model selection.
answers = iter(["http://localhost:8000", "local-key", "", "", "", "", ""])
monkeypatch.setattr("builtins.input", lambda _prompt="": next(answers))
monkeypatch.setattr("hermes_cli.secret_prompt.masked_secret_prompt", lambda _prompt="": next(answers))
hermes_main._model_flow_custom({})
output = capsys.readouterr().out
assert "Saving the working base URL instead" in output
assert "Detected model: llm" in output
# OPENAI_BASE_URL is no longer saved to .env — config.yaml is authoritative
assert "OPENAI_BASE_URL" not in saved_env
assert saved_env["MODEL"] == "llm"
def test_model_flow_custom_persists_selected_api_mode(monkeypatch):
saved_cfg = {"model": {"default": "", "provider": "custom", "base_url": ""}}
captured_provider = {}
monkeypatch.setattr(
"hermes_cli.config.get_env_value",
lambda key: "" if key in {"OPENAI_BASE_URL", "OPENAI_API_KEY"} else "",
)
monkeypatch.setattr("hermes_cli.auth._save_model_choice", lambda model: None)
monkeypatch.setattr("hermes_cli.auth.deactivate_provider", lambda: None)
monkeypatch.setattr(
"hermes_cli.models.probe_api_models",
lambda api_key, base_url: {
"models": [],
"probed_url": f"{base_url.rstrip('/')}/models",
"resolved_base_url": None,
"suggested_base_url": None,
"used_fallback": False,
},
)
monkeypatch.setattr("hermes_cli.config.load_config", lambda: saved_cfg)
monkeypatch.setattr("hermes_cli.config.save_config", lambda cfg: saved_cfg.update(cfg))
monkeypatch.setattr(
"hermes_cli.main._save_custom_provider",
lambda base_url, api_key="", model="", context_length=None, name=None, api_mode=None: captured_provider.update(
{
"base_url": base_url,
"api_key": api_key,
"model": model,
"context_length": context_length,
"name": name,
"api_mode": api_mode,
}
),
)
answers = iter(
[
"https://codex.example.com/v1",
"3",
"chosen-model",
"",
"",
]
)
monkeypatch.setattr("builtins.input", lambda _prompt="": next(answers))
monkeypatch.setattr("hermes_cli.secret_prompt.masked_secret_prompt", lambda _prompt="": "test-key")
hermes_main._model_flow_custom({"model": {"provider": "custom"}})
assert saved_cfg["model"]["provider"] == "custom"
assert saved_cfg["model"]["base_url"] == "https://codex.example.com/v1"
assert saved_cfg["model"]["api_key"] == "test-key"
assert saved_cfg["model"]["api_mode"] == "codex_responses"
assert captured_provider["api_mode"] == "codex_responses"
def test_cmd_model_forwards_nous_login_tls_options(monkeypatch):
monkeypatch.setattr(hermes_main, "_require_tty", lambda *a: None)
monkeypatch.setattr(
"hermes_cli.config.load_config",
lambda: {"model": {"default": "gpt-5", "provider": "nous"}},
)
monkeypatch.setattr("hermes_cli.config.save_config", lambda cfg: None)
monkeypatch.setattr("hermes_cli.config.get_env_value", lambda key: "")
monkeypatch.setattr("hermes_cli.config.save_env_value", lambda key, value: None)
monkeypatch.setattr("hermes_cli.auth.resolve_provider", lambda requested, **kwargs: "nous")
monkeypatch.setattr("hermes_cli.auth.get_provider_auth_state", lambda provider_id: None)
monkeypatch.setattr(hermes_main, "_prompt_provider_choice", lambda choices, **kwargs: 0)
captured = {}
def _fake_login(login_args, provider_config):
captured["portal_url"] = login_args.portal_url
captured["inference_url"] = login_args.inference_url
captured["client_id"] = login_args.client_id
captured["scope"] = login_args.scope
captured["no_browser"] = login_args.no_browser
captured["timeout"] = login_args.timeout
captured["ca_bundle"] = login_args.ca_bundle
captured["insecure"] = login_args.insecure
monkeypatch.setattr("hermes_cli.auth._login_nous", _fake_login)
hermes_main.cmd_model(
SimpleNamespace(
portal_url="https://portal.nousresearch.com",
inference_url="https://inference.nousresearch.com/v1",
client_id="hermes-local",
scope="openid profile",
no_browser=True,
timeout=7.5,
ca_bundle="/tmp/local-ca.pem",
insecure=True,
)
)
assert captured == {
"portal_url": "https://portal.nousresearch.com",
"inference_url": "https://inference.nousresearch.com/v1",
"client_id": "hermes-local",
"scope": "openid profile",
"no_browser": True,
"timeout": 7.5,
"ca_bundle": "/tmp/local-ca.pem",
"insecure": True,
}
# ---------------------------------------------------------------------------
# _auto_provider_name — unit tests
# ---------------------------------------------------------------------------
def test_auto_provider_name_localhost():
from hermes_cli.main import _auto_provider_name
assert _auto_provider_name("http://localhost:11434/v1") == "Local (localhost:11434)"
assert _auto_provider_name("http://127.0.0.1:1234/v1") == "Local (127.0.0.1:1234)"
def test_auto_provider_name_runpod():
from hermes_cli.main import _auto_provider_name
assert "RunPod" in _auto_provider_name("https://xyz.runpod.io/v1")
def test_auto_provider_name_remote():
from hermes_cli.main import _auto_provider_name
result = _auto_provider_name("https://api.together.xyz/v1")
assert result == "Api.together.xyz"
def test_save_custom_provider_uses_provided_name(monkeypatch, tmp_path):
"""When a display name is passed, it should appear in the saved entry."""
import yaml
from hermes_cli.main import _save_custom_provider
cfg_path = tmp_path / "config.yaml"
cfg_path.write_text(yaml.dump({}))
monkeypatch.setattr(
"hermes_cli.config.load_config", lambda: yaml.safe_load(cfg_path.read_text()) or {},
)
saved = {}
def _save(cfg):
saved.update(cfg)
monkeypatch.setattr("hermes_cli.config.save_config", _save)
_save_custom_provider("http://localhost:11434/v1", name="Ollama")
entries = saved.get("custom_providers", [])
assert len(entries) == 1
assert entries[0]["name"] == "Ollama"
+99
View File
@@ -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
+353
View File
@@ -0,0 +1,353 @@
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
from cli import HermesCLI
def _make_cli():
cli_obj = HermesCLI.__new__(HermesCLI)
cli_obj.session_id = "current_session"
cli_obj._resumed = False
cli_obj._pending_title = None
cli_obj.conversation_history = []
cli_obj.agent = None
cli_obj._session_db = MagicMock()
cli_obj._pending_resume_sessions = None
# _handle_resume_command now triggers _display_resumed_history (#31695),
# which reads self.resume_display. "minimal" short-circuits the recap so
# the test only exercises session-switch behavior.
cli_obj.resume_display = "minimal"
return cli_obj
class TestCliResumeCommand:
def test_show_recent_sessions_includes_indexes_and_resume_hint(self, capsys):
cli_obj = _make_cli()
cli_obj._list_recent_sessions = MagicMock(return_value=[
{"id": "sess_002", "title": "Coding", "preview": "build feature", "last_active": None},
{"id": "sess_001", "title": "Research", "preview": "read docs", "last_active": None},
])
shown = cli_obj._show_recent_sessions(reason="resume")
output = capsys.readouterr().out
assert shown is True
assert "1" in output
assert "2" in output
assert "Coding" in output
assert "Research" in output
assert "/resume 2" in output
assert "/resume <session title>" in output
def test_show_recent_sessions_uses_prompt_toolkit_safe_print(self):
cli_obj = _make_cli()
cli_obj._list_recent_sessions = MagicMock(return_value=[
{"id": "sess_002", "title": "Coding", "preview": "build feature", "last_active": None},
])
running_app = SimpleNamespace(_is_running=True)
with (
patch("prompt_toolkit.application.get_app_or_none", return_value=running_app),
patch("cli._cprint") as mock_cprint,
):
shown = cli_obj._show_recent_sessions(reason="sessions")
assert shown is True
printed = "\n".join(call.args[0] for call in mock_cprint.call_args_list)
assert "Recent sessions" in printed
assert "Coding" in printed
def test_show_history_uses_prompt_toolkit_safe_print(self):
cli_obj = _make_cli()
cli_obj.conversation_history = [{"role": "user", "content": "Hello"}]
running_app = SimpleNamespace(_is_running=True)
with (
patch("prompt_toolkit.application.get_app_or_none", return_value=running_app),
patch("cli._cprint") as mock_cprint,
):
cli_obj.show_history()
printed = "\n".join(call.args[0] for call in mock_cprint.call_args_list)
assert "Conversation History" in printed
assert "Hello" in printed
def test_handle_resume_by_index_switches_to_numbered_session(self):
cli_obj = _make_cli()
cli_obj._list_recent_sessions = MagicMock(return_value=[
{"id": "sess_002", "title": "Coding"},
{"id": "sess_001", "title": "Research"},
])
cli_obj._session_db.get_session.return_value = {"id": "sess_001", "title": "Research"}
cli_obj._session_db.get_messages_as_conversation.return_value = [
{"role": "user", "content": "hello"},
{"role": "assistant", "content": "hi"},
]
# resolve_resume_session_id passes the id through when no compression chain.
cli_obj._session_db.resolve_resume_session_id.return_value = "sess_001"
with (
patch("hermes_cli.main._resolve_session_by_name_or_id", return_value=None),
patch("cli._cprint") as mock_cprint,
):
cli_obj._handle_resume_command("/resume 2")
printed = " ".join(str(call) for call in mock_cprint.call_args_list)
assert cli_obj.session_id == "sess_001"
assert "Resumed session sess_001" in printed
assert "Research" in printed
def test_handle_resume_by_index_out_of_range(self):
cli_obj = _make_cli()
cli_obj._list_recent_sessions = MagicMock(return_value=[
{"id": "sess_002", "title": "Coding"},
])
with patch("cli._cprint") as mock_cprint:
cli_obj._handle_resume_command("/resume 9")
printed = " ".join(str(call) for call in mock_cprint.call_args_list)
assert "out of range" in printed.lower()
assert "/resume" in printed
assert cli_obj.session_id == "current_session"
def test_handle_resume_strips_outer_brackets(self):
"""Users copy `<session_id>` from the usage hint literally.
Strip outer ``<>``, ``[]``, ``""``, and ``''`` before lookup so
``/resume <abc123>`` works the same as ``/resume abc123``.
"""
cli_obj = _make_cli()
cli_obj._session_db.get_session.return_value = {"id": "sess_alpha", "title": "Alpha"}
cli_obj._session_db.get_messages_as_conversation.return_value = []
cli_obj._session_db.resolve_resume_session_id.return_value = "sess_alpha"
for raw in ("<sess_alpha>", "[sess_alpha]", '"sess_alpha"', "'sess_alpha'"):
cli_obj.session_id = "current_session"
with (
patch("hermes_cli.main._resolve_session_by_name_or_id", return_value="sess_alpha"),
patch("cli._cprint"),
):
cli_obj._handle_resume_command(f"/resume {raw}")
assert cli_obj.session_id == "sess_alpha", (
f"bracket-stripping failed for {raw!r}: session_id stayed {cli_obj.session_id}"
)
def test_handle_resume_does_not_strip_partial_brackets(self):
"""Mismatched or single brackets must pass through unmodified.
``"<half`` (just an open angle) is not a wrapping pair, so the
lookup should treat it verbatim — preserving the existing
not-found error path instead of mangling the input.
"""
cli_obj = _make_cli()
cli_obj._session_db.get_session.return_value = None
with (
patch("hermes_cli.main._resolve_session_by_name_or_id", return_value=None),
patch("cli._cprint") as mock_cprint,
):
cli_obj._handle_resume_command("/resume <half")
printed = " ".join(str(call) for call in mock_cprint.call_args_list)
assert "<half" in printed
class TestPendingResumeNumberedSelection:
"""Bare `/resume` arms a one-shot prompt so the next bare number resumes.
Regression coverage for #34584: previously, running `/resume` (no args)
printed the recent-sessions list but left no selection state armed, so
typing just `3` on the next line was sent to the agent as chat instead of
resuming session #3.
"""
def test_bare_resume_arms_pending_selection(self):
cli_obj = _make_cli()
sessions = [
{"id": "sess_002", "title": "Coding"},
{"id": "sess_001", "title": "Research"},
]
cli_obj._list_recent_sessions = MagicMock(return_value=sessions)
cli_obj._show_recent_sessions = MagicMock(return_value=True)
with patch("cli._cprint"):
cli_obj._handle_resume_command("/resume")
assert cli_obj._pending_resume_sessions == sessions
def test_bare_resume_no_sessions_does_not_arm(self):
cli_obj = _make_cli()
cli_obj._show_recent_sessions = MagicMock(return_value=False)
cli_obj._list_recent_sessions = MagicMock(return_value=[])
with patch("cli._cprint"):
cli_obj._handle_resume_command("/resume")
assert cli_obj._pending_resume_sessions is None
def test_pending_number_resumes_selected_session(self):
cli_obj = _make_cli()
sessions = [
{"id": "sess_002", "title": "Coding"},
{"id": "sess_001", "title": "Research"},
]
cli_obj._pending_resume_sessions = sessions
# _handle_resume_command("/resume 2") re-resolves the index via
# _list_recent_sessions, so it must return the same list.
cli_obj._list_recent_sessions = MagicMock(return_value=sessions)
cli_obj._session_db.get_session.return_value = {"id": "sess_001", "title": "Research"}
cli_obj._session_db.get_messages_as_conversation.return_value = [
{"role": "user", "content": "hello"},
]
cli_obj._session_db.resolve_resume_session_id.return_value = "sess_001"
with (
patch("hermes_cli.main._resolve_session_by_name_or_id", return_value=None),
patch("cli._cprint"),
):
consumed = cli_obj._consume_pending_resume_selection("2")
assert consumed is True
assert cli_obj.session_id == "sess_001"
# One-shot: prompt is disarmed after consuming.
assert cli_obj._pending_resume_sessions is None
def test_pending_out_of_range_consumed_with_message(self):
cli_obj = _make_cli()
cli_obj._pending_resume_sessions = [{"id": "sess_002", "title": "Coding"}]
with patch("cli._cprint") as mock_cprint:
consumed = cli_obj._consume_pending_resume_selection("9")
printed = " ".join(str(call) for call in mock_cprint.call_args_list)
# An out-of-range number is still consumed (not sent to the agent),
# and the prompt is disarmed.
assert consumed is True
assert "out of range" in printed.lower()
assert cli_obj.session_id == "current_session"
assert cli_obj._pending_resume_sessions is None
def test_pending_non_numeric_falls_through_and_disarms(self):
cli_obj = _make_cli()
cli_obj._pending_resume_sessions = [{"id": "sess_002", "title": "Coding"}]
with patch("cli._cprint"):
consumed = cli_obj._consume_pending_resume_selection("hello there")
# Free text is NOT consumed (caller treats it as chat), but the
# one-shot prompt is disarmed so a later number isn't hijacked.
assert consumed is False
assert cli_obj._pending_resume_sessions is None
def test_no_pending_returns_false(self):
cli_obj = _make_cli()
assert cli_obj._pending_resume_sessions is None
assert cli_obj._consume_pending_resume_selection("3") is False
def test_pending_disarmed_by_other_command(self):
cli_obj = _make_cli()
cli_obj._pending_resume_sessions = [{"id": "sess_002", "title": "Coding"}]
# Stub out the help handler so process_command("/help") is cheap.
cli_obj.show_help = MagicMock()
cli_obj.process_command("/help")
# A non-resume command disarms the one-shot prompt (#34584).
assert cli_obj._pending_resume_sessions is None
class TestRestoreSessionCwdMarkup:
"""Regression: _restore_session_cwd must not crash with Rich MarkupError.
Lines that used ``[{_DIM}]`` inside Rich markup triggered
``rich.errors.MarkupError: closing tag [/] at position N has nothing to
close`` because ``_DIM`` is an ANSI escape (``\\x1b[2;3m``), not a valid
Rich tag. The fix replaces ``[{_DIM}]`` with Rich's native ``[dim]`` tag.
See: https://github.com/NousResearch/hermes-agent/issues/39469
"""
def test_missing_dir_does_not_raise_markup_error(self):
"""Session cwd gone → dim warning, no MarkupError."""
cli_obj = _make_cli()
console = MagicMock()
cli_obj._output_console = MagicMock(return_value=console)
# Use a path that definitely does not exist.
cli_obj._restore_session_cwd({"cwd": "/nonexistent/path/to/nowhere"})
# Should have printed a warning via console.print, not crashed.
assert console.print.called
printed = str(console.print.call_args)
assert "Working directory is gone" in printed or "gone" in printed.lower()
def test_chdir_failure_does_not_raise_markup_error(self, tmp_path):
"""os.chdir fails → dim warning, no MarkupError."""
import os
cli_obj = _make_cli()
console = MagicMock()
cli_obj._output_console = MagicMock(return_value=console)
# Create a directory, then make it unreadable (simulate chdir failure).
target = tmp_path / "locked"
target.mkdir()
# Patch os.chdir to raise OSError for our target path.
original_chdir = os.chdir
def fake_chdir(path):
if str(path) == str(target):
raise OSError("Permission denied")
return original_chdir(path)
with patch("os.chdir", side_effect=fake_chdir):
cli_obj._restore_session_cwd({"cwd": str(target)})
assert console.print.called
printed = str(console.print.call_args)
assert "Could not enter" in printed or "permission" in printed.lower()
def test_success_path_does_not_raise_markup_error(self, tmp_path):
"""Successful cwd switch → dim info, no MarkupError."""
import os
cli_obj = _make_cli()
console = MagicMock()
cli_obj._output_console = MagicMock(return_value=console)
original_cwd = os.getcwd()
try:
cli_obj._restore_session_cwd({"cwd": str(tmp_path)})
assert console.print.called
printed = str(console.print.call_args)
assert "Working directory" in printed or "working" in printed.lower()
finally:
os.chdir(original_cwd)
class TestResumeFlushesBeforeEndSession:
"""Regression for #47202: /resume must flush un-persisted messages to
the session DB before ending the old session, just like /new and
compress_context() already do."""
def test_resume_flushes_when_agent_present(self):
cli_obj = _make_cli()
cli_obj.conversation_history = [
{"role": "user", "content": "hello"},
{"role": "assistant", "content": "hi"},
]
agent = MagicMock()
cli_obj.agent = agent
cli_obj._session_db.get_session.return_value = {"id": "target", "title": "T"}
cli_obj._session_db.get_messages_as_conversation.return_value = []
cli_obj._session_db.resolve_resume_session_id.return_value = "target"
with (
patch("hermes_cli.main._resolve_session_by_name_or_id", return_value="target"),
patch("cli._cprint"),
):
cli_obj._handle_resume_command("/resume target")
agent._flush_messages_to_session_db.assert_called_once_with(
[{"role": "user", "content": "hello"}, {"role": "assistant", "content": "hi"}]
)
cli_obj._session_db.end_session.assert_called_once()
+49
View File
@@ -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 == []
+134
View File
@@ -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
+147
View File
@@ -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)
+88
View File
@@ -0,0 +1,88 @@
"""Verify Shift+Enter byte sequences parse to the same key tuple Alt+Enter
produces, so the existing Alt+Enter newline handler in `cli.py` fires for
terminals that emit a distinct Shift+Enter under the Kitty keyboard protocol
or xterm modifyOtherKeys mode.
"""
from __future__ import annotations
import pytest
from prompt_toolkit.input.ansi_escape_sequences import ANSI_SEQUENCES
from prompt_toolkit.input.vt100_parser import Vt100Parser
from prompt_toolkit.keys import Keys
from hermes_cli.pt_input_extras import install_shift_enter_alias
SHIFT_ENTER_SEQUENCES = (
"\x1b[13;2u", # Kitty / CSI-u, modifier=2 (Shift)
"\x1b[27;2;13~", # xterm modifyOtherKeys=2
"\x1b[27;2;13u",
)
@pytest.fixture(autouse=True)
def _ensure_alias_installed():
"""Make every test idempotent — install the alias once per test run."""
install_shift_enter_alias()
def _parse(byte_seq: str):
out = []
parser = Vt100Parser(out.append)
for ch in byte_seq:
parser.feed(ch)
parser.flush()
return [kp.key for kp in out]
def test_install_registers_all_three_sequences():
for seq in SHIFT_ENTER_SEQUENCES:
assert seq in ANSI_SEQUENCES, f"missing mapping for {seq!r}"
assert ANSI_SEQUENCES[seq] == (Keys.Escape, Keys.ControlM)
def test_install_overwrites_stock_modifyotherkeys_shift_enter():
"""Stock prompt_toolkit maps `\\x1b[27;2;13~` to plain Keys.ControlM —
i.e. it drops the Shift modifier and treats Shift+Enter like Enter,
which is the bug this helper exists to fix. The install must overwrite
that entry."""
seq = "\x1b[27;2;13~"
ANSI_SEQUENCES[seq] = Keys.ControlM
install_shift_enter_alias()
assert ANSI_SEQUENCES[seq] == (Keys.Escape, Keys.ControlM)
def test_install_returns_zero_when_already_correct():
"""Idempotency — running install twice should not report a second change."""
install_shift_enter_alias()
assert install_shift_enter_alias() == 0
def test_csi_u_shift_enter_parses_as_alt_enter():
"""Kitty keyboard protocol Shift+Enter must parse to the same key tuple
Alt+Enter produces, so the existing handler is reused."""
alt_enter = _parse("\x1b\r")
shift_enter = _parse("\x1b[13;2u")
assert shift_enter == alt_enter, (
f"Shift+Enter via CSI-u should parse identically to Alt+Enter; "
f"got {shift_enter!r} vs {alt_enter!r}"
)
def test_modify_other_keys_shift_enter_parses_as_alt_enter():
"""xterm modifyOtherKeys=2 Shift+Enter must parse identically to Alt+Enter."""
alt_enter = _parse("\x1b\r")
shift_enter = _parse("\x1b[27;2;13~")
assert shift_enter == alt_enter
def test_plain_enter_remains_distinct_from_alt_enter():
"""Plain Enter must keep emitting a single key (submit), not a two-key
Alt+Enter tuple — otherwise we would have broken submit."""
enter = _parse("\r")
alt_enter = _parse("\x1b\r")
assert enter != alt_enter
assert len(enter) == 1
assert len(alt_enter) == 2
@@ -0,0 +1,169 @@
"""Regression tests for #15165 (CLI sibling site) — CLI exit cleanup must
forward the agent's conversation transcript to ``shutdown_memory_provider``
so memory providers' ``on_session_end`` hooks see the real messages.
Before the fix, ``_run_cleanup`` called
``shutdown_memory_provider(getattr(agent, 'conversation_history', None) or [])``.
``AIAgent`` has no ``conversation_history`` attribute — so the ``or []``
branch always fired and providers got an empty list on CLI exit. This
mirrors the gateway bug fixed in the same commit (gateway/run.py uses
``_session_messages``, which IS set on ``AIAgent``).
The fix reads ``_session_messages`` (same attribute the gateway path uses)
with an ``isinstance(..., list)`` guard so MagicMock-based agents in
other tests keep their existing no-arg behaviour.
"""
from __future__ import annotations
from unittest.mock import MagicMock, patch
@patch("hermes_cli.plugins.invoke_hook")
def test_cleanup_forwards_session_messages(mock_invoke_hook):
"""_run_cleanup forwards a populated ``_session_messages`` list."""
import cli as cli_mod
transcript = [
{"role": "user", "content": "remember my dog is named Biscuit"},
{"role": "assistant", "content": "Got it — Biscuit."},
]
agent = MagicMock()
agent.session_id = "cli-session-id"
agent._session_messages = transcript
cli_mod._active_agent_ref = agent
cli_mod._cleanup_done = False
try:
cli_mod._run_cleanup()
finally:
cli_mod._active_agent_ref = None
cli_mod._cleanup_done = False
agent.shutdown_memory_provider.assert_called_once_with(transcript)
@patch("hermes_cli.plugins.invoke_hook")
def test_cleanup_empty_list_still_forwarded(mock_invoke_hook):
"""An agent that initialised but ran no turns has an empty list.
Forwarding it (rather than falling through) matches the gateway-side
behaviour and is explicit to providers."""
import cli as cli_mod
agent = MagicMock()
agent.session_id = "cli-session-id"
agent._session_messages = []
cli_mod._active_agent_ref = agent
cli_mod._cleanup_done = False
try:
cli_mod._run_cleanup()
finally:
cli_mod._active_agent_ref = None
cli_mod._cleanup_done = False
agent.shutdown_memory_provider.assert_called_once_with([])
@patch("hermes_cli.plugins.invoke_hook")
def test_cleanup_non_list_attribute_falls_back_to_no_arg(mock_invoke_hook):
"""A MagicMock agent auto-synthesises ``_session_messages`` as a
nested MagicMock. ``isinstance(mock, list)`` is False, so we fall
back to the no-arg path rather than passing a garbage value to
providers expecting ``List[Dict]``. This keeps existing CLI test
suites that use bare ``MagicMock()`` agents green."""
import cli as cli_mod
agent = MagicMock()
agent.session_id = "cli-session-id"
# No explicit _session_messages — MagicMock synthesises one on access.
cli_mod._active_agent_ref = agent
cli_mod._cleanup_done = False
try:
cli_mod._run_cleanup()
finally:
cli_mod._active_agent_ref = None
cli_mod._cleanup_done = False
agent.shutdown_memory_provider.assert_called_once_with()
@patch("hermes_cli.plugins.invoke_hook")
def test_cleanup_provider_exception_is_swallowed(mock_invoke_hook):
"""A raising ``shutdown_memory_provider`` must not crash CLI exit."""
import cli as cli_mod
agent = MagicMock()
agent.session_id = "cli-session-id"
agent._session_messages = [{"role": "user", "content": "x"}]
agent.shutdown_memory_provider.side_effect = RuntimeError("boom")
cli_mod._active_agent_ref = agent
cli_mod._cleanup_done = False
try:
cli_mod._run_cleanup() # must not raise
finally:
cli_mod._active_agent_ref = None
cli_mod._cleanup_done = False
agent.shutdown_memory_provider.assert_called_once()
def test_cli_close_persists_agent_session_messages_before_end_session():
"""CLI shutdown flushes live agent messages before closing the session."""
import cli as cli_mod
transcript = [
{"role": "user", "content": "long task"},
{"role": "assistant", "content": "partial answer"},
]
conversation_history = [{"role": "user", "content": "long task"}]
cli = object.__new__(cli_mod.HermesCLI)
cli.conversation_history = conversation_history
cli.session_id = "old-session"
agent = MagicMock()
agent.session_id = "live-session"
agent._session_messages = transcript
cli.agent = agent
cli._persist_active_session_before_close()
agent._persist_session.assert_called_once_with(transcript, conversation_history)
assert cli.session_id == "live-session"
def test_cli_close_persist_falls_back_to_conversation_history():
"""Bare MagicMock agents do not provide a real _session_messages list."""
import cli as cli_mod
conversation_history = [{"role": "user", "content": "saved from cli"}]
cli = object.__new__(cli_mod.HermesCLI)
cli.conversation_history = conversation_history
cli.session_id = "session-id"
agent = MagicMock()
agent.session_id = "session-id"
cli.agent = agent
cli._persist_active_session_before_close()
agent._persist_session.assert_called_once_with(conversation_history, conversation_history)
def test_cli_close_persist_skips_empty_transcripts():
"""Do not create empty session writes for idle CLI startup/shutdown."""
import cli as cli_mod
cli = object.__new__(cli_mod.HermesCLI)
cli.conversation_history = []
cli.session_id = "session-id"
agent = MagicMock()
agent.session_id = "session-id"
agent._session_messages = []
cli.agent = agent
cli._persist_active_session_before_close()
agent._persist_session.assert_not_called()
+117
View File
@@ -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"
+680
View File
@@ -0,0 +1,680 @@
import time
from datetime import datetime, timedelta
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
import cli as cli_mod
from cli import HermesCLI
def _make_cli(model: str = "anthropic/claude-sonnet-4-20250514"):
cli_obj = HermesCLI.__new__(HermesCLI)
cli_obj.model = model
cli_obj.session_start = datetime.now() - timedelta(minutes=14, seconds=32)
cli_obj.conversation_history = [{"role": "user", "content": "hi"}]
cli_obj.agent = None
return cli_obj
def _attach_agent(
cli_obj,
*,
input_tokens: int | None = None,
output_tokens: int | None = None,
cache_read_tokens: int = 0,
cache_write_tokens: int = 0,
prompt_tokens: int,
completion_tokens: int,
total_tokens: int,
api_calls: int,
context_tokens: int,
context_length: int,
compressions: int = 0,
):
cli_obj.agent = SimpleNamespace(
model=cli_obj.model,
provider="anthropic" if cli_obj.model.startswith("anthropic/") else None,
base_url="",
session_input_tokens=input_tokens if input_tokens is not None else prompt_tokens,
session_output_tokens=output_tokens if output_tokens is not None else completion_tokens,
session_cache_read_tokens=cache_read_tokens,
session_cache_write_tokens=cache_write_tokens,
session_prompt_tokens=prompt_tokens,
session_completion_tokens=completion_tokens,
session_total_tokens=total_tokens,
session_api_calls=api_calls,
get_rate_limit_state=lambda: None,
context_compressor=SimpleNamespace(
last_prompt_tokens=context_tokens,
context_length=context_length,
compression_count=compressions,
),
)
return cli_obj
class TestCLIStatusBar:
def test_context_style_thresholds(self):
cli_obj = _make_cli()
assert cli_obj._status_bar_context_style(None) == "class:status-bar-dim"
assert cli_obj._status_bar_context_style(10) == "class:status-bar-good"
assert cli_obj._status_bar_context_style(50) == "class:status-bar-warn"
assert cli_obj._status_bar_context_style(81) == "class:status-bar-bad"
assert cli_obj._status_bar_context_style(95) == "class:status-bar-critical"
def test_build_status_bar_text_for_wide_terminal(self):
cli_obj = _attach_agent(
_make_cli(),
prompt_tokens=10_230,
completion_tokens=2_220,
total_tokens=12_450,
api_calls=7,
context_tokens=12_450,
context_length=200_000,
)
text = cli_obj._build_status_bar_text(width=120)
assert "claude-sonnet-4-20250514" in text
assert "12.4K/200K" in text
assert "6%" in text
assert "$0.06" not in text # cost hidden by default
assert "15m" in text
def test_post_compression_sentinel_does_not_render_negative(self):
"""Right after a compression, last_prompt_tokens is parked at the -1
sentinel until the next API call reports real usage. The status bar
must clamp it to 0 instead of rendering "-1/200K" / "-1%".
"""
cli_obj = _attach_agent(
_make_cli(),
prompt_tokens=10_230,
completion_tokens=2_220,
total_tokens=12_450,
api_calls=7,
context_tokens=-1,
context_length=200_000,
)
snapshot = cli_obj._get_status_bar_snapshot()
assert snapshot["context_tokens"] == 0
assert snapshot["context_percent"] == 0
text = cli_obj._build_status_bar_text(width=120)
assert "-1" not in text
assert "0/200K" in text
def test_input_height_counts_prompt_only_on_first_wrapped_row(self):
# Regression for prompt_toolkit classic CLI resize glitches: the prompt
# is inserted by BeforeInput only on logical line 0. At three terminal
# cells, "⚔ " leaves one cell for the first input character, but
# wrapped continuation rows use the full three cells. Estimating every
# wrapped row as one-cell wide over-allocates the TextArea and can leave
# stale prompt/input cells visible after resize.
assert cli_mod._estimate_tui_input_height(["abcdef"], "", 3) == 3
def test_input_height_counts_wide_characters_using_cell_width(self):
# Prompt width (2 cells) + ten CJK chars (20 cells) = 22 display cells,
# which wraps to two rows at 14 terminal columns.
assert cli_mod._estimate_tui_input_height(["" * 10], " ", 14) == 2
def test_input_height_clamps_zero_width_to_one_cell(self):
# Some terminals briefly report zero columns during resize. Treat that
# as a one-cell terminal rather than falling back to a fake wide width.
assert cli_mod._estimate_tui_input_height(["abcd"], "", 0) == 4
def test_build_status_bar_text_no_cost_in_status_bar(self):
cli_obj = _attach_agent(
_make_cli(),
prompt_tokens=10000,
completion_tokens=5000,
total_tokens=15000,
api_calls=7,
context_tokens=50000,
context_length=200_000,
)
text = cli_obj._build_status_bar_text(width=120)
assert "$" not in text # cost is never shown in status bar
def test_build_status_bar_text_collapses_for_narrow_terminal(self):
cli_obj = _attach_agent(
_make_cli(),
prompt_tokens=10000,
completion_tokens=2400,
total_tokens=12400,
api_calls=7,
context_tokens=12400,
context_length=200_000,
)
text = cli_obj._build_status_bar_text(width=60)
assert "" in text
assert "$0.06" not in text # cost hidden by default
assert "15m" in text
assert "200K" not in text
def test_build_status_bar_text_handles_missing_agent(self):
cli_obj = _make_cli()
text = cli_obj._build_status_bar_text(width=100)
assert "" in text
assert "claude-sonnet-4-20250514" in text
def test_compression_count_shown_in_wide_status_bar(self):
cli_obj = _attach_agent(
_make_cli(),
prompt_tokens=10_230,
completion_tokens=2_220,
total_tokens=12_450,
api_calls=7,
context_tokens=12_450,
context_length=200_000,
compressions=3,
)
text = cli_obj._build_status_bar_text(width=120)
assert "🗜️ 3" in text
def test_compression_count_hidden_when_zero(self):
cli_obj = _attach_agent(
_make_cli(),
prompt_tokens=10_230,
completion_tokens=2_220,
total_tokens=12_450,
api_calls=7,
context_tokens=12_450,
context_length=200_000,
compressions=0,
)
text = cli_obj._build_status_bar_text(width=120)
assert "🗜️" not in text
def test_compression_count_shown_in_medium_status_bar(self):
cli_obj = _attach_agent(
_make_cli(),
prompt_tokens=10_000,
completion_tokens=2_400,
total_tokens=12_400,
api_calls=7,
context_tokens=12_400,
context_length=200_000,
compressions=2,
)
text = cli_obj._build_status_bar_text(width=60)
assert "🗜️ 2" in text
def test_compression_count_hidden_in_narrow_status_bar(self):
cli_obj = _attach_agent(
_make_cli(),
prompt_tokens=10_000,
completion_tokens=2_400,
total_tokens=12_400,
api_calls=7,
context_tokens=12_400,
context_length=200_000,
compressions=5,
)
text = cli_obj._build_status_bar_text(width=50)
assert "🗜️" not in text
def test_compression_count_style_thresholds(self):
cli_obj = _make_cli()
assert cli_obj._compression_count_style(1) == "class:status-bar-dim"
assert cli_obj._compression_count_style(4) == "class:status-bar-dim"
assert cli_obj._compression_count_style(5) == "class:status-bar-warn"
assert cli_obj._compression_count_style(9) == "class:status-bar-warn"
assert cli_obj._compression_count_style(10) == "class:status-bar-bad"
assert cli_obj._compression_count_style(25) == "class:status-bar-bad"
def test_compression_count_in_wide_fragments(self):
cli_obj = _attach_agent(
_make_cli(),
prompt_tokens=10_230,
completion_tokens=2_220,
total_tokens=12_450,
api_calls=7,
context_tokens=12_450,
context_length=200_000,
compressions=7,
)
cli_obj._status_bar_visible = True
frags = cli_obj._get_status_bar_fragments()
frag_texts = [text for _, text in frags]
assert "🗜️ 7" in frag_texts
frag_styles = {text: style for style, text in frags}
assert frag_styles["🗜️ 7"] == "class:status-bar-warn"
def test_compression_count_absent_from_fragments_when_zero(self):
cli_obj = _attach_agent(
_make_cli(),
prompt_tokens=10_230,
completion_tokens=2_220,
total_tokens=12_450,
api_calls=7,
context_tokens=12_450,
context_length=200_000,
compressions=0,
)
cli_obj._status_bar_visible = True
frags = cli_obj._get_status_bar_fragments()
frag_texts = [text for _, text in frags]
assert not any("🗜️" in t for t in frag_texts)
def test_minimal_tui_chrome_threshold(self):
cli_obj = _make_cli()
assert cli_obj._use_minimal_tui_chrome(width=63) is True
assert cli_obj._use_minimal_tui_chrome(width=64) is False
def test_bottom_input_rule_hides_on_narrow_terminals(self):
cli_obj = _make_cli()
assert cli_obj._tui_input_rule_height("top", width=50) == 1
assert cli_obj._tui_input_rule_height("bottom", width=50) == 0
assert cli_obj._tui_input_rule_height("bottom", width=90) == 1
def test_input_rules_hide_after_resize_until_next_input(self):
"""When _status_bar_suppressed_after_resize is set, both rules hide.
See _recover_after_resize — column shrink reflows already-rendered
bars into scrollback, so we hide the separators while the reflow
settles, then clear the flag (either via the scheduled unsuppress
timer or the next submitted input).
"""
cli_obj = _make_cli()
cli_obj._status_bar_suppressed_after_resize = True
assert cli_obj._tui_input_rule_height("top", width=90) == 0
assert cli_obj._tui_input_rule_height("bottom", width=90) == 0
cli_obj._status_bar_suppressed_after_resize = False
assert cli_obj._tui_input_rule_height("top", width=90) == 1
assert cli_obj._tui_input_rule_height("bottom", width=90) == 1
def test_scheduled_unsuppress_clears_flag_and_repaints_without_input(self):
"""The status bar returns during idle after a resize, without a keypress.
Regression: the suppression flag was only cleared on the next
*submitted* input, so a resize/reflow followed by idle left the bar
hidden indefinitely even while the refresh clock kept ticking. The
scheduled unsuppress timer must clear the flag and invalidate the app
on its own.
"""
cli_obj = _make_cli()
cli_obj._status_bar_unsuppress_timer = None
cli_obj._status_bar_suppressed_after_resize = True
app = MagicMock()
app.loop = None # force the synchronous _clear path
# Schedule with ~0 delay so the timer fires promptly under test.
cli_obj._schedule_status_bar_unsuppress(app, delay=0.01)
time.sleep(0.1)
assert cli_obj._status_bar_suppressed_after_resize is False
app.invalidate.assert_called()
# Bar chrome is visible again with no submitted input.
assert cli_obj._tui_input_rule_height("top", width=90) == 1
def test_scheduled_unsuppress_debounces_resize_storm(self):
"""A fresh resize cancels the pending unsuppress and restarts it."""
cli_obj = _make_cli()
cli_obj._status_bar_unsuppress_timer = None
cli_obj._status_bar_suppressed_after_resize = True
app = MagicMock()
app.loop = None
# First schedule (long delay) then a second should cancel the first.
cli_obj._schedule_status_bar_unsuppress(app, delay=5.0)
first_timer = cli_obj._status_bar_unsuppress_timer
assert first_timer is not None
cli_obj._schedule_status_bar_unsuppress(app, delay=0.01)
assert first_timer is not cli_obj._status_bar_unsuppress_timer
assert not first_timer.is_alive() or first_timer.finished.is_set()
time.sleep(0.1)
assert cli_obj._status_bar_suppressed_after_resize is False
def test_scrollback_box_width_returns_viewport_width(self):
"""Decorative scrollback boxes use the full viewport width.
The previous clamp (max 56 cols) was reverted in favour of the
prompt_toolkit ``_output_screen_diff`` monkey-patch landed in
#26137, which keeps chrome out of scrollback at the source.
We accept that an aggressive column-shrink may visually reflow
already printed Panel borders — that's a cosmetic artifact of
stamped scrollback history, not a live-render bug.
"""
from cli import HermesCLI
# Floor at 32 — narrow terminals still get something usable
# (avoids negative ``'─' * (w - 2)`` math).
assert HermesCLI._scrollback_box_width(20) == 32
assert HermesCLI._scrollback_box_width(32) == 32
# Above the floor, return the actual viewport width — no cap.
assert HermesCLI._scrollback_box_width(48) == 48
assert HermesCLI._scrollback_box_width(80) == 80
assert HermesCLI._scrollback_box_width(120) == 120
assert HermesCLI._scrollback_box_width(200) == 200
def test_agent_spacer_reclaimed_on_narrow_terminals(self):
cli_obj = _make_cli()
cli_obj._agent_running = True
assert cli_obj._agent_spacer_height(width=50) == 0
assert cli_obj._agent_spacer_height(width=90) == 1
cli_obj._agent_running = False
assert cli_obj._agent_spacer_height(width=90) == 0
def test_spinner_line_hidden_on_narrow_terminals(self):
cli_obj = _make_cli()
cli_obj._spinner_text = "thinking"
assert cli_obj._spinner_widget_height(width=50) == 0
assert cli_obj._spinner_widget_height(width=90) == 1
cli_obj._spinner_text = ""
assert cli_obj._spinner_widget_height(width=90) == 0
def test_spinner_height_uses_display_width_for_wide_characters(self):
cli_obj = _make_cli()
cli_obj._spinner_text = "" * 40
cli_obj._tool_start_time = 0
assert cli_obj._spinner_widget_height(width=64) == 2
def test_spinner_elapsed_format_is_fixed_width_to_reduce_wrap_jitter(self):
cli_obj = _make_cli()
cli_obj._spinner_text = "running tool"
# Pin the clock: time.monotonic()'s epoch is arbitrary (often near
# boot), so deriving _tool_start_time from the real monotonic clock
# made the test fail on hosts where monotonic() < 65.2 — the start
# time went negative, the (t0 > 0) guard in _render_spinner_text
# dropped the "(elapsed)" suffix entirely, and the split below hit an
# IndexError. A fixed clock keeps both elapsed paths deterministic.
with patch.object(cli_mod.time, "monotonic", return_value=1000.0):
# <60s path
cli_obj._tool_start_time = 1000.0 - 9.2
short = cli_obj._render_spinner_text()
# >=60s path
cli_obj._tool_start_time = 1000.0 - 65.2
long = cli_obj._render_spinner_text()
short_elapsed = short.split("(", 1)[1].rstrip(")")
long_elapsed = long.split("(", 1)[1].rstrip(")")
assert len(short_elapsed) == len(long_elapsed)
assert "m" in long_elapsed and "s" in long_elapsed
def test_voice_status_bar_compacts_on_narrow_terminals(self):
cli_obj = _make_cli()
cli_obj._voice_mode = True
cli_obj._voice_recording = False
cli_obj._voice_processing = False
cli_obj._voice_tts = True
cli_obj._voice_continuous = True
fragments = cli_obj._get_voice_status_fragments(width=50)
assert fragments == [("class:voice-status", " 🎤 Ctrl+B ")]
def test_voice_recording_status_bar_compacts_on_narrow_terminals(self):
cli_obj = _make_cli()
cli_obj._voice_mode = True
cli_obj._voice_recording = True
cli_obj._voice_processing = False
fragments = cli_obj._get_voice_status_fragments(width=50)
assert fragments == [("class:voice-status-recording", " ● REC ")]
# Round-13 Copilot review regressions on #19835. The label in voice
# status bar / recording hint / placeholder must render the
# configured ``voice.record_key`` — not hardcoded Ctrl+B. Pinning
# the cache (``set_voice_record_key_cache``) keeps display in sync
# with the prompt_toolkit binding without re-reading config on
# every render.
def test_voice_status_bar_renders_configured_ctrl_letter(self):
cli_obj = _make_cli()
cli_obj._voice_mode = True
cli_obj._voice_recording = False
cli_obj._voice_processing = False
cli_obj._voice_tts = False
cli_obj._voice_continuous = False
cli_obj.set_voice_record_key_cache("ctrl+o")
wide = cli_obj._get_voice_status_fragments(width=120)
assert any("Ctrl+O to record" in text for _cls, text in wide)
compact = cli_obj._get_voice_status_fragments(width=50)
assert compact == [("class:voice-status", " 🎤 Ctrl+O ")]
def test_voice_recording_status_bar_renders_configured_named_key(self):
cli_obj = _make_cli()
cli_obj._voice_mode = True
cli_obj._voice_recording = True
cli_obj._voice_processing = False
cli_obj.set_voice_record_key_cache("ctrl+space")
fragments = cli_obj._get_voice_status_fragments(width=120)
assert fragments == [("class:voice-status-recording", " ● REC Ctrl+Space to stop ")]
def test_voice_status_bar_falls_back_to_ctrl_b_without_cache(self):
cli_obj = _make_cli()
cli_obj._voice_mode = True
cli_obj._voice_recording = False
cli_obj._voice_processing = False
cli_obj._voice_tts = False
cli_obj._voice_continuous = False
# No cache set — mirrors pre-startup state; fall back to
# documented Ctrl+B default (Copilot round-13 review).
compact = cli_obj._get_voice_status_fragments(width=50)
assert compact == [("class:voice-status", " 🎤 Ctrl+B ")]
def test_voice_status_bar_renders_malformed_config_as_default(self):
cli_obj = _make_cli()
cli_obj._voice_mode = True
cli_obj._voice_recording = False
cli_obj._voice_processing = False
cli_obj._voice_tts = False
cli_obj._voice_continuous = False
# Non-string / typoed configs fall through the formatter to the
# documented default so the status bar never advertises an
# invalid shortcut.
cli_obj.set_voice_record_key_cache(True)
compact = cli_obj._get_voice_status_fragments(width=50)
assert compact == [("class:voice-status", " 🎤 Ctrl+B ")]
class TestCLIUsageReport:
def test_show_usage_omits_cost_reporting(self, capsys):
cli_obj = _attach_agent(
_make_cli(),
prompt_tokens=10_230,
completion_tokens=2_220,
total_tokens=12_450,
api_calls=7,
context_tokens=12_450,
context_length=200_000,
compressions=1,
)
cli_obj.verbose = False
cli_obj._show_usage()
output = capsys.readouterr().out
# Token counts and session metadata still shown.
assert "Model:" in output
assert "Input tokens:" in output
assert "Output tokens:" in output
assert "Total tokens:" in output
assert "Session duration:" in output
assert "Compressions:" in output
# Cost and cache-hit reporting is removed everywhere.
assert "Total cost:" not in output
assert "Cost status:" not in output
assert "Cost source:" not in output
assert "Cache read tokens:" not in output
assert "Cache write tokens:" not in output
class TestStatusBarWidthSource:
"""Ensure status bar fragments don't overflow the terminal width."""
def _make_wide_cli(self):
cli_obj = _attach_agent(
_make_cli(),
prompt_tokens=100_000,
completion_tokens=5_000,
total_tokens=105_000,
api_calls=20,
context_tokens=100_000,
context_length=200_000,
)
cli_obj._status_bar_visible = True
return cli_obj
def test_fragments_fit_within_announced_width(self):
"""Total fragment text length must not exceed the width used to build them."""
from unittest.mock import MagicMock, patch
cli_obj = self._make_wide_cli()
for width in (40, 52, 76, 80, 120, 200):
mock_app = MagicMock()
mock_app.output.get_size.return_value = MagicMock(columns=width)
with patch("prompt_toolkit.application.get_app", return_value=mock_app):
frags = cli_obj._get_status_bar_fragments()
total_text = "".join(text for _, text in frags)
display_width = cli_obj._status_bar_display_width(total_text)
assert display_width <= width + 4, ( # +4 for minor padding chars
f"At width={width}, fragment total {display_width} cells overflows "
f"({total_text!r})"
)
def test_fragments_use_pt_width_over_shutil(self):
"""When prompt_toolkit reports a width, shutil.get_terminal_size must not be used."""
from unittest.mock import MagicMock, patch
cli_obj = self._make_wide_cli()
mock_app = MagicMock()
mock_app.output.get_size.return_value = MagicMock(columns=120)
with patch("prompt_toolkit.application.get_app", return_value=mock_app) as mock_get_app, \
patch("shutil.get_terminal_size") as mock_shutil:
cli_obj._get_status_bar_fragments()
mock_shutil.assert_not_called()
def test_fragments_fall_back_to_shutil_when_no_app(self):
"""Outside a TUI context (no running app), shutil must be used as fallback."""
from unittest.mock import MagicMock, patch
cli_obj = self._make_wide_cli()
with patch("prompt_toolkit.application.get_app", side_effect=Exception("no app")), \
patch("shutil.get_terminal_size", return_value=MagicMock(columns=100)) as mock_shutil:
frags = cli_obj._get_status_bar_fragments()
mock_shutil.assert_called()
assert len(frags) > 0
def test_build_status_bar_text_uses_pt_width(self):
"""_build_status_bar_text() must also prefer prompt_toolkit width."""
from unittest.mock import MagicMock, patch
cli_obj = self._make_wide_cli()
mock_app = MagicMock()
mock_app.output.get_size.return_value = MagicMock(columns=80)
with patch("prompt_toolkit.application.get_app", return_value=mock_app), \
patch("shutil.get_terminal_size") as mock_shutil:
text = cli_obj._build_status_bar_text() # no explicit width
mock_shutil.assert_not_called()
assert isinstance(text, str)
assert len(text) > 0
def test_explicit_width_skips_pt_lookup(self):
"""An explicit width= argument must bypass both PT and shutil lookups."""
from unittest.mock import patch
cli_obj = self._make_wide_cli()
with patch("prompt_toolkit.application.get_app") as mock_get_app, \
patch("shutil.get_terminal_size") as mock_shutil:
text = cli_obj._build_status_bar_text(width=100)
mock_get_app.assert_not_called()
mock_shutil.assert_not_called()
assert len(text) > 0
class TestIdleSinceLastTurn:
"""Time-since-last-final-agent-response read-out on the status bar."""
def test_hidden_before_first_turn(self):
assert HermesCLI._format_idle_since(None, turn_live=False) == ""
def test_hidden_while_turn_is_live(self):
assert HermesCLI._format_idle_since(time.time() - 30, turn_live=True) == ""
def test_shows_compact_idle_time_after_turn(self):
label = HermesCLI._format_idle_since(time.time() - 42, turn_live=False)
assert label.startswith("")
assert label == "✓ 42s"
def test_scales_to_minutes(self):
label = HermesCLI._format_idle_since(time.time() - 3 * 60, turn_live=False)
assert label == "✓ 3m"
def test_snapshot_carries_idle_since(self):
cli_obj = _make_cli()
cli_obj._last_turn_finished_at = time.time() - 10
cli_obj._prompt_start_time = None
cli_obj._prompt_duration = 5.0
snapshot = cli_obj._get_status_bar_snapshot()
assert snapshot["idle_since"].startswith("")
def test_snapshot_idle_empty_during_live_turn(self):
cli_obj = _make_cli()
cli_obj._last_turn_finished_at = time.time() - 10
cli_obj._prompt_start_time = time.time()
cli_obj._prompt_duration = 0.0
snapshot = cli_obj._get_status_bar_snapshot()
assert snapshot["idle_since"] == ""
def test_wide_status_bar_text_includes_idle(self):
cli_obj = _attach_agent(
_make_cli(),
prompt_tokens=10_230,
completion_tokens=2_220,
total_tokens=12_450,
api_calls=7,
context_tokens=12_450,
context_length=200_000,
)
cli_obj._last_turn_finished_at = time.time() - 42
cli_obj._prompt_start_time = None
cli_obj._prompt_duration = 7.0
text = cli_obj._build_status_bar_text(width=160)
assert "✓ 42s" in text
+101
View File
@@ -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
+146
View File
@@ -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
+49
View File
@@ -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
+130
View File
@@ -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
+244
View File
@@ -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
+155
View File
@@ -0,0 +1,155 @@
"""Tests for /compress --preview/--dry-run/--aggressive flags and the
/compact alias (PR #3243 salvage).
Covers the pure helpers in ``hermes_cli.partial_compress`` plus alias
resolution in the command registry. The CLI and gateway surfaces both
route through these helpers, so the flag semantics are pinned here once.
"""
from hermes_cli.commands import COMMANDS, resolve_command
from hermes_cli.partial_compress import (
DEFAULT_KEEP_LAST,
extract_compress_flags,
parse_partial_compress_args,
summarize_compress_preview,
)
def _history(n_pairs: int) -> list[dict[str, str]]:
h: list[dict[str, str]] = []
for i in range(n_pairs):
h.append({"role": "user", "content": f"u{i}"})
h.append({"role": "assistant", "content": f"a{i}"})
return h
# ── /compact alias resolution ─────────────────────────────────────────
def test_compact_resolves_to_compress():
cmd = resolve_command("compact")
assert cmd is not None
assert cmd.name == "compress"
assert "compact" in cmd.aliases
def test_compact_alias_with_slash():
cmd = resolve_command("/compact")
assert cmd is not None and cmd.name == "compress"
def test_compact_listed_in_flat_commands():
assert "/compact" in COMMANDS
assert "alias for /compress" in COMMANDS["/compact"]
def test_compress_args_hint_documents_preview():
cmd = resolve_command("compress")
assert cmd is not None
assert "--preview" in (cmd.args_hint or "")
# ── extract_compress_flags ────────────────────────────────────────────
def test_no_flags_passthrough():
rest, preview, aggressive = extract_compress_flags("here 3")
assert rest == "here 3"
assert preview is False
assert aggressive is False
def test_preview_flag_stripped():
rest, preview, aggressive = extract_compress_flags("--preview")
assert rest == ""
assert preview is True
assert aggressive is False
def test_dry_run_is_preview():
for form in ("--dry-run", "--dryrun", "--DRY-RUN"):
_, preview, _ = extract_compress_flags(form)
assert preview is True, form
def test_aggressive_flag_detected():
rest, preview, aggressive = extract_compress_flags("--aggressive")
assert rest == ""
assert preview is False
assert aggressive is True
def test_flags_coexist_with_here_form():
rest, preview, aggressive = extract_compress_flags("--preview here 4")
assert rest == "here 4"
assert preview is True
partial, keep, focus = parse_partial_compress_args(rest)
assert partial is True and keep == 4 and focus is None
def test_flags_coexist_with_focus_topic():
rest, preview, _ = extract_compress_flags("database schema --dry-run")
assert rest == "database schema"
assert preview is True
partial, _, focus = parse_partial_compress_args(rest)
assert partial is False and focus == "database schema"
def test_aggressive_dry_run_combo():
rest, preview, aggressive = extract_compress_flags("--aggressive --dry-run")
assert rest == ""
assert preview is True and aggressive is True
def test_empty_args():
rest, preview, aggressive = extract_compress_flags("")
assert rest == "" and preview is False and aggressive is False
# ── summarize_compress_preview ────────────────────────────────────────
def test_preview_full_compress_counts():
hist = _history(5)
report = summarize_compress_preview(hist, False, DEFAULT_KEEP_LAST, None, 1234)
assert report["head_count"] == 10
assert report["tail_count"] == 0
assert report["total"] == 10
assert report["partial"] is False
joined = "\n".join(report["lines"])
assert "no changes made" in joined.lower()
assert "10 of 10" in joined
assert "1,234" in joined
def test_preview_partial_boundary_counts():
hist = _history(5)
report = summarize_compress_preview(hist, True, 2, None, 999)
# Keeping last 2 exchanges = 4 tail messages, 6 head messages.
assert report["head_count"] == 6
assert report["tail_count"] == 4
assert report["partial"] is True
joined = "\n".join(report["lines"])
assert "last 2 exchange" in joined
def test_preview_partial_degenerate_falls_back_to_full():
hist = _history(2) # keep_last=5 would swallow everything
report = summarize_compress_preview(hist, True, 5, None, 100)
assert report["partial"] is False
assert report["head_count"] == 4
joined = "\n".join(report["lines"])
assert "falling back to full compression" in joined
def test_preview_includes_focus_topic():
hist = _history(4)
report = summarize_compress_preview(hist, False, DEFAULT_KEEP_LAST, "db schema", 50)
assert 'Focus topic: "db schema"' in "\n".join(report["lines"])
def test_preview_is_side_effect_free():
hist = _history(4)
before = [dict(m) for m in hist]
summarize_compress_preview(hist, True, 1, None, 10)
assert hist == before
+118
View File
@@ -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
+119
View File
@@ -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"
+308
View File
@@ -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) == []
+117
View File
@@ -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
+99
View File
@@ -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"
+331
View File
@@ -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"
+119
View File
@@ -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
+485
View File
@@ -0,0 +1,485 @@
"""Tests for the /fast CLI command and service-tier config handling."""
import unittest
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
def _import_cli():
import hermes_cli.config as config_mod
if not hasattr(config_mod, "save_env_value_secure"):
config_mod.save_env_value_secure = lambda key, value: {
"success": True,
"stored_as": key,
"validated": False,
}
import cli as cli_mod
return cli_mod
class TestParseServiceTierConfig(unittest.TestCase):
def _parse(self, raw):
cli_mod = _import_cli()
return cli_mod._parse_service_tier_config(raw)
def test_fast_maps_to_priority(self):
self.assertEqual(self._parse("fast"), "priority")
self.assertEqual(self._parse("priority"), "priority")
def test_normal_disables_service_tier(self):
self.assertIsNone(self._parse("normal"))
self.assertIsNone(self._parse("off"))
self.assertIsNone(self._parse(""))
class TestHandleFastCommand(unittest.TestCase):
def _make_cli(self, service_tier=None):
return SimpleNamespace(
service_tier=service_tier,
provider="openai-codex",
requested_provider="openai-codex",
model="gpt-5.4",
_fast_command_available=lambda: True,
agent=MagicMock(),
)
def test_no_args_shows_status(self):
cli_mod = _import_cli()
stub = self._make_cli(service_tier=None)
with (
patch.object(cli_mod, "_cprint") as mock_cprint,
patch.object(cli_mod, "save_config_value") as mock_save,
):
cli_mod.HermesCLI._handle_fast_command(stub, "/fast")
# Bare /fast shows status, does not change config
mock_save.assert_not_called()
# Should have printed the status line
printed = " ".join(str(c) for c in mock_cprint.call_args_list)
self.assertIn("normal", printed)
def test_no_args_shows_fast_when_enabled(self):
cli_mod = _import_cli()
stub = self._make_cli(service_tier="priority")
with (
patch.object(cli_mod, "_cprint") as mock_cprint,
patch.object(cli_mod, "save_config_value") as mock_save,
):
cli_mod.HermesCLI._handle_fast_command(stub, "/fast")
mock_save.assert_not_called()
printed = " ".join(str(c) for c in mock_cprint.call_args_list)
self.assertIn("fast", printed)
def test_normal_argument_clears_service_tier(self):
cli_mod = _import_cli()
stub = self._make_cli(service_tier="priority")
with (
patch.object(cli_mod, "_cprint"),
patch.object(cli_mod, "save_config_value", return_value=True) as mock_save,
):
cli_mod.HermesCLI._handle_fast_command(stub, "/fast normal")
mock_save.assert_called_once_with("agent.service_tier", "normal")
self.assertIsNone(stub.service_tier)
self.assertIsNone(stub.agent)
def test_unsupported_model_does_not_expose_fast(self):
cli_mod = _import_cli()
stub = SimpleNamespace(
service_tier=None,
provider="openai-codex",
requested_provider="openai-codex",
model="gpt-5.3-codex",
_fast_command_available=lambda: False,
agent=MagicMock(),
)
with (
patch.object(cli_mod, "_cprint") as mock_cprint,
patch.object(cli_mod, "save_config_value") as mock_save,
):
cli_mod.HermesCLI._handle_fast_command(stub, "/fast")
mock_save.assert_not_called()
self.assertTrue(mock_cprint.called)
class TestPriorityProcessingModels(unittest.TestCase):
"""Verify the expanded Priority Processing model registry."""
def test_all_documented_models_supported(self):
from hermes_cli.models import model_supports_fast_mode
# All OpenAI flagship models support Priority Processing — including
# future releases (gpt-5.5, 5.6...) via pattern matching.
supported = [
"gpt-5.5", "gpt-5.5-mini",
"gpt-5.4", "gpt-5.4-mini", "gpt-5.2",
"gpt-5.1", "gpt-5", "gpt-5-mini",
"gpt-4.1", "gpt-4.1-mini", "gpt-4.1-nano",
"gpt-4o", "gpt-4o-mini",
"o1", "o1-mini", "o3", "o3-mini", "o4-mini",
]
for model in supported:
assert model_supports_fast_mode(model), f"{model} should support fast mode"
def test_all_anthropic_models_supported(self):
"""The speed=fast parameter is gated to Opus 4.6.
Sending speed=fast to Opus 4.7, Sonnet, or Haiku returns HTTP 400.
(Opus 4.8's fast offering is a separate ``…-fast`` model id selected
via the model field, not this parameter — see the adapter test.)
"""
from hermes_cli.models import model_supports_fast_mode
# Supported: Opus 4.6 in any form
supported = [
"claude-opus-4-6", "claude-opus-4.6",
"anthropic/claude-opus-4-6", "anthropic/claude-opus-4.6",
]
for model in supported:
assert model_supports_fast_mode(model), f"{model} should support fast mode"
# Unsupported per Anthropic API: Opus 4.7/4.8, Sonnet, Haiku
unsupported = [
"claude-opus-4-7", "claude-opus-4-8", "claude-opus-4.8",
"claude-sonnet-4-6", "claude-sonnet-4.6", "claude-sonnet-4",
"claude-haiku-4-5", "claude-3-5-haiku",
]
for model in unsupported:
assert not model_supports_fast_mode(model), (
f"{model} should NOT support the speed=fast parameter"
)
def test_codex_models_excluded(self):
"""Codex models route through Responses API and don't accept service_tier."""
from hermes_cli.models import model_supports_fast_mode
for model in ["gpt-5-codex", "gpt-5.2-codex", "gpt-5.3-codex", "gpt-5.1-codex-max"]:
assert not model_supports_fast_mode(model), f"{model} is codex — should not expose /fast"
def test_vendor_prefix_stripped(self):
from hermes_cli.models import model_supports_fast_mode
assert model_supports_fast_mode("openai/gpt-5.4") is True
assert model_supports_fast_mode("openai/gpt-4.1") is True
assert model_supports_fast_mode("openai/o3") is True
def test_non_priority_models_rejected(self):
from hermes_cli.models import model_supports_fast_mode
# Codex-series models route through the Codex Responses API and
# don't accept service_tier, so they're excluded.
assert model_supports_fast_mode("gpt-5.3-codex") is False
assert model_supports_fast_mode("gpt-5.2-codex") is False
assert model_supports_fast_mode("gpt-5-codex") is False
# Non-OpenAI, non-Anthropic models
assert model_supports_fast_mode("gemini-3-pro-preview") is False
assert model_supports_fast_mode("kimi-k2-thinking") is False
assert model_supports_fast_mode("deepseek-chat") is False
assert model_supports_fast_mode("") is False
assert model_supports_fast_mode(None) is False
def test_resolve_overrides_returns_service_tier(self):
from hermes_cli.models import resolve_fast_mode_overrides
result = resolve_fast_mode_overrides("gpt-5.4")
assert result == {"service_tier": "priority"}
result = resolve_fast_mode_overrides("gpt-4.1")
assert result == {"service_tier": "priority"}
def test_resolve_overrides_none_for_unsupported(self):
from hermes_cli.models import resolve_fast_mode_overrides
assert resolve_fast_mode_overrides("gpt-5.3-codex") is None
assert resolve_fast_mode_overrides("gemini-3-pro-preview") is None
assert resolve_fast_mode_overrides("kimi-k2-thinking") is None
class TestFastModeRouting(unittest.TestCase):
def test_fast_command_exposed_for_model_even_when_provider_is_auto(self):
cli_mod = _import_cli()
stub = SimpleNamespace(provider="auto", requested_provider="auto", model="gpt-5.4", agent=None)
assert cli_mod.HermesCLI._fast_command_available(stub) is True
def test_fast_command_exposed_for_non_codex_models(self):
cli_mod = _import_cli()
stub = SimpleNamespace(provider="openai", requested_provider="openai", model="gpt-4.1", agent=None)
assert cli_mod.HermesCLI._fast_command_available(stub) is True
stub = SimpleNamespace(provider="openrouter", requested_provider="openrouter", model="o3", agent=None)
assert cli_mod.HermesCLI._fast_command_available(stub) is True
def test_turn_route_injects_overrides_without_provider_switch(self):
"""Fast mode should add request_overrides but NOT change the provider/runtime."""
cli_mod = _import_cli()
stub = SimpleNamespace(
model="gpt-5.4",
api_key="primary-key",
base_url="https://openrouter.ai/api/v1",
provider="openrouter",
api_mode="chat_completions",
acp_command=None,
acp_args=[],
_credential_pool=None,
service_tier="priority",
)
route = cli_mod.HermesCLI._resolve_turn_agent_config(stub, "hi")
# Provider should NOT have changed
assert route["runtime"]["provider"] == "openrouter"
assert route["runtime"]["api_mode"] == "chat_completions"
# But request_overrides should be set
assert route["request_overrides"] == {"service_tier": "priority"}
def test_turn_route_keeps_primary_runtime_when_model_has_no_fast_backend(self):
cli_mod = _import_cli()
stub = SimpleNamespace(
model="gpt-5.3-codex",
api_key="primary-key",
base_url="https://openrouter.ai/api/v1",
provider="openrouter",
api_mode="chat_completions",
acp_command=None,
acp_args=[],
_credential_pool=None,
service_tier="priority",
)
route = cli_mod.HermesCLI._resolve_turn_agent_config(stub, "hi")
assert route["runtime"]["provider"] == "openrouter"
assert route.get("request_overrides") is None
class TestAnthropicFastMode(unittest.TestCase):
"""Verify Anthropic Fast Mode model support and override resolution."""
def test_anthropic_opus_supported(self):
from hermes_cli.models import model_supports_fast_mode
# Native Anthropic format (hyphens)
assert model_supports_fast_mode("claude-opus-4-6") is True
# OpenRouter format (dots)
assert model_supports_fast_mode("claude-opus-4.6") is True
# With vendor prefix
assert model_supports_fast_mode("anthropic/claude-opus-4-6") is True
assert model_supports_fast_mode("anthropic/claude-opus-4.6") is True
def test_anthropic_non_opus46_models_excluded(self):
"""The speed=fast parameter is gated to Opus 4.6 — others excluded.
Per https://platform.claude.com/docs/en/build-with-claude/fast-mode,
sending speed=fast to Opus 4.7, Sonnet, or Haiku returns HTTP 400.
Opus 4.8 uses a separate ``…-fast`` model id, not this parameter.
"""
from hermes_cli.models import model_supports_fast_mode
assert model_supports_fast_mode("claude-sonnet-4-6") is False
assert model_supports_fast_mode("claude-sonnet-4.6") is False
assert model_supports_fast_mode("claude-haiku-4-5") is False
assert model_supports_fast_mode("claude-opus-4-7") is False
assert model_supports_fast_mode("claude-opus-4-8") is False
assert model_supports_fast_mode("anthropic/claude-sonnet-4.6") is False
assert model_supports_fast_mode("anthropic/claude-opus-4-7") is False
def test_non_claude_models_not_anthropic_fast(self):
"""Non-Claude models should not be treated as Anthropic fast-mode."""
from hermes_cli.models import _is_anthropic_fast_model
assert _is_anthropic_fast_model("gpt-5.4") is False
assert _is_anthropic_fast_model("gemini-3-pro") is False
assert _is_anthropic_fast_model("kimi-k2-thinking") is False
def test_anthropic_variant_tags_stripped(self):
from hermes_cli.models import model_supports_fast_mode
# OpenRouter variant tags after colon should be stripped
assert model_supports_fast_mode("claude-opus-4.6:fast") is True
assert model_supports_fast_mode("claude-opus-4.6:beta") is True
def test_resolve_overrides_returns_speed_for_anthropic(self):
from hermes_cli.models import resolve_fast_mode_overrides
result = resolve_fast_mode_overrides("claude-opus-4-6")
assert result == {"speed": "fast"}
result = resolve_fast_mode_overrides("anthropic/claude-opus-4.6")
assert result == {"speed": "fast"}
def test_resolve_overrides_returns_none_for_unsupported_claude(self):
"""Opus 4.7/4.8 and other Claude models don't take the speed param.
The speed=fast parameter is Opus 4.6 only (Opus 4.8 uses a separate
``…-fast`` model id instead).
"""
from hermes_cli.models import resolve_fast_mode_overrides
assert resolve_fast_mode_overrides("claude-opus-4-7") is None
assert resolve_fast_mode_overrides("claude-opus-4-8") is None
assert resolve_fast_mode_overrides("claude-sonnet-4-6") is None
assert resolve_fast_mode_overrides("claude-haiku-4-5") is None
def test_resolve_overrides_returns_service_tier_for_openai(self):
"""OpenAI models should still get service_tier, not speed."""
from hermes_cli.models import resolve_fast_mode_overrides
result = resolve_fast_mode_overrides("gpt-5.4")
assert result == {"service_tier": "priority"}
def test_is_anthropic_fast_model(self):
"""The speed=fast parameter is Opus 4.6 only — other Claude excluded."""
from hermes_cli.models import _is_anthropic_fast_model
# Supported: Opus 4.6 in any form
assert _is_anthropic_fast_model("claude-opus-4-6") is True
assert _is_anthropic_fast_model("claude-opus-4.6") is True
assert _is_anthropic_fast_model("anthropic/claude-opus-4-6") is True
assert _is_anthropic_fast_model("claude-opus-4.6:fast") is True
# Unsupported — would 400 (4.7) or uses a separate model id (4.8)
assert _is_anthropic_fast_model("claude-opus-4-7") is False
assert _is_anthropic_fast_model("claude-opus-4-8") is False
assert _is_anthropic_fast_model("claude-sonnet-4-6") is False
assert _is_anthropic_fast_model("claude-haiku-4-5") is False
# Non-Claude
assert _is_anthropic_fast_model("gpt-5.4") is False
assert _is_anthropic_fast_model("") is False
def test_fast_command_exposed_for_anthropic_model(self):
cli_mod = _import_cli()
stub = SimpleNamespace(
provider="anthropic", requested_provider="anthropic",
model="claude-opus-4-6", agent=None,
)
assert cli_mod.HermesCLI._fast_command_available(stub) is True
def test_fast_command_hidden_for_anthropic_sonnet(self):
"""Sonnet doesn't support fast mode (Opus 4.6 only) — /fast must be hidden."""
cli_mod = _import_cli()
stub = SimpleNamespace(
provider="anthropic", requested_provider="anthropic",
model="claude-sonnet-4-6", agent=None,
)
assert cli_mod.HermesCLI._fast_command_available(stub) is False
def test_fast_command_hidden_for_anthropic_opus_47(self):
"""Opus 4.7 doesn't take the speed=fast parameter — /fast must hide."""
cli_mod = _import_cli()
stub = SimpleNamespace(
provider="anthropic", requested_provider="anthropic",
model="claude-opus-4-7", agent=None,
)
assert cli_mod.HermesCLI._fast_command_available(stub) is False
def test_fast_command_hidden_for_non_claude_non_openai(self):
"""Non-Claude, non-OpenAI models should not expose /fast."""
cli_mod = _import_cli()
stub = SimpleNamespace(
provider="gemini", requested_provider="gemini",
model="gemini-3-pro-preview", agent=None,
)
assert cli_mod.HermesCLI._fast_command_available(stub) is False
def test_turn_route_injects_speed_for_anthropic(self):
"""Anthropic models should get speed:'fast' override, not service_tier."""
cli_mod = _import_cli()
stub = SimpleNamespace(
model="claude-opus-4-6",
api_key="sk-ant-test",
base_url="https://api.anthropic.com",
provider="anthropic",
api_mode="anthropic_messages",
acp_command=None,
acp_args=[],
_credential_pool=None,
service_tier="priority",
)
route = cli_mod.HermesCLI._resolve_turn_agent_config(stub, "hi")
assert route["runtime"]["provider"] == "anthropic"
assert route["request_overrides"] == {"speed": "fast"}
class TestAnthropicFastModeAdapter(unittest.TestCase):
"""Verify build_anthropic_kwargs handles fast_mode parameter."""
def test_fast_mode_adds_speed_and_beta(self):
from agent.anthropic_adapter import build_anthropic_kwargs, _FAST_MODE_BETA
kwargs = build_anthropic_kwargs(
model="claude-opus-4-6",
messages=[{"role": "user", "content": [{"type": "text", "text": "hi"}]}],
tools=None,
max_tokens=None,
reasoning_config=None,
fast_mode=True,
)
assert kwargs.get("extra_body", {}).get("speed") == "fast"
assert "speed" not in kwargs
assert "extra_headers" in kwargs
assert _FAST_MODE_BETA in kwargs["extra_headers"].get("anthropic-beta", "")
def test_fast_mode_off_no_speed(self):
from agent.anthropic_adapter import build_anthropic_kwargs
kwargs = build_anthropic_kwargs(
model="claude-opus-4-6",
messages=[{"role": "user", "content": [{"type": "text", "text": "hi"}]}],
tools=None,
max_tokens=None,
reasoning_config=None,
fast_mode=False,
)
assert kwargs.get("extra_body", {}).get("speed") is None
assert "speed" not in kwargs
assert "extra_headers" not in kwargs
def test_fast_mode_skipped_for_third_party_endpoint(self):
from agent.anthropic_adapter import build_anthropic_kwargs
kwargs = build_anthropic_kwargs(
model="claude-opus-4-6",
messages=[{"role": "user", "content": [{"type": "text", "text": "hi"}]}],
tools=None,
max_tokens=None,
reasoning_config=None,
fast_mode=True,
base_url="https://api.minimax.io/anthropic/v1",
)
# Third-party endpoints should NOT get speed or fast-mode beta
assert kwargs.get("extra_body", {}).get("speed") is None
assert "speed" not in kwargs
assert "extra_headers" not in kwargs
def test_fast_mode_kwargs_are_safe_for_sdk_unpacking(self):
from agent.anthropic_adapter import build_anthropic_kwargs
kwargs = build_anthropic_kwargs(
model="claude-opus-4-6",
messages=[{"role": "user", "content": [{"type": "text", "text": "hi"}]}],
tools=None,
max_tokens=None,
reasoning_config=None,
fast_mode=True,
)
assert "speed" not in kwargs
assert kwargs.get("extra_body", {}).get("speed") == "fast"
class TestConfigDefault(unittest.TestCase):
def test_default_config_has_service_tier(self):
from hermes_cli.config import DEFAULT_CONFIG
agent = DEFAULT_CONFIG.get("agent", {})
self.assertIn("service_tier", agent)
self.assertEqual(agent["service_tier"], "")
+184
View File
@@ -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"
+82
View File
@@ -0,0 +1,82 @@
import queue
from unittest.mock import patch
from cli import HermesCLI
from hermes_cli.moa_config import decode_moa_turn
def _make_cli():
cli = HermesCLI.__new__(HermesCLI)
cli.config = {
"moa": {
"default_preset": "default",
"presets": {
"default": {
"reference_models": [{"provider": "openai-codex", "model": "gpt-5.5"}],
"aggregator": {"provider": "openrouter", "model": "anthropic/claude-opus-4.8"},
},
"review": {
"reference_models": [{"provider": "openrouter", "model": "deepseek/deepseek-v4-pro"}],
"aggregator": {"provider": "openrouter", "model": "anthropic/claude-opus-4.8"},
},
},
}
}
cli._pending_input = queue.Queue()
cli._pending_agent_seed = None
cli._pending_moa_config = None
cli._pending_moa_disable_after_turn = False
cli._pending_moa_restore_model = None
cli._agent_running = False
cli.agent = None
cli.provider = "openrouter"
cli.requested_provider = "openrouter"
cli.model = "anthropic/claude-opus-4.8"
cli.api_key = "test-key"
cli.base_url = "https://openrouter.ai/api/v1"
cli.api_mode = "chat_completions"
return cli
def test_moa_bare_shows_usage_no_switch():
# /moa with no prompt is usage-only now; switching to a preset for the
# session is done via the model picker, not /moa.
cli = _make_cli()
cli._pending_moa_disable_after_turn = False
with patch("cli._cprint"):
assert cli.process_command("/moa") is True
assert cli.provider != "moa"
assert cli._pending_agent_seed is None
assert cli._pending_moa_disable_after_turn is False
def test_moa_arg_is_always_one_shot_prompt():
# Any argument (even a string that matches a preset name) is treated as a
# one-shot prompt through the DEFAULT preset, then the model is restored.
cli = _make_cli()
with patch("cli._cprint"):
cli.process_command("/moa review")
assert cli._pending_agent_seed == "review"
assert cli._pending_moa_disable_after_turn is True
assert cli.provider == "moa"
assert cli.model == "default"
def test_moa_non_preset_is_one_shot_prompt():
cli = _make_cli()
with patch("cli._cprint"):
cli.process_command("/moa inspect the flaky test")
assert cli._pending_agent_seed == "inspect the flaky test"
assert cli._pending_moa_disable_after_turn is True
assert cli.provider == "moa"
assert cli.model == "default"
assert cli._pending_moa_restore_model["provider"] != "moa"
def test_decode_legacy_encoded_moa_turn_still_works():
from hermes_cli.moa_config import build_moa_turn_prompt
encoded = build_moa_turn_prompt("hello", _make_cli().config["moa"], preset="review")
prompt, cfg = decode_moa_turn(encoded)
assert prompt == "hello"
assert cfg["reference_models"] == [{"provider": "openrouter", "model": "deepseek/deepseek-v4-pro"}]
+198
View File
@@ -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
+224
View File
@@ -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."
+35
View File
@@ -0,0 +1,35 @@
"""Regression tests for CLI prefill config key compatibility."""
from __future__ import annotations
import cli
def test_resolve_prefill_messages_file_uses_top_level(monkeypatch):
monkeypatch.delenv("HERMES_PREFILL_MESSAGES_FILE", raising=False)
assert cli._resolve_prefill_messages_file(
{
"prefill_messages_file": "top.json",
"agent": {"prefill_messages_file": "legacy.json"},
}
) == "top.json"
def test_resolve_prefill_messages_file_accepts_legacy_agent_key(monkeypatch):
monkeypatch.delenv("HERMES_PREFILL_MESSAGES_FILE", raising=False)
assert cli._resolve_prefill_messages_file(
{"agent": {"prefill_messages_file": "legacy.json"}}
) == "legacy.json"
def test_resolve_prefill_messages_file_prefers_env(monkeypatch):
monkeypatch.setenv("HERMES_PREFILL_MESSAGES_FILE", "env.json")
assert cli._resolve_prefill_messages_file(
{
"prefill_messages_file": "top.json",
"agent": {"prefill_messages_file": "legacy.json"},
}
) == "env.json"
+80
View File
@@ -0,0 +1,80 @@
"""Tests for cli._prepend_note_to_message.
Regression coverage for the TypeError raised when a queued /model or
/reload-skills note was prepended to a multimodal (image-attached) message:
``can only concatenate str (not "list") to str``.
"""
from cli import _prepend_note_to_message
def test_string_message_gets_note_prepended():
assert _prepend_note_to_message("hello", "NOTE") == "NOTE\n\nhello"
def test_empty_note_returns_message_unchanged():
assert _prepend_note_to_message("hello", "") == "hello"
assert _prepend_note_to_message("hello", " ") == "hello"
parts = [{"type": "text", "text": "hi"}]
assert _prepend_note_to_message(parts, "") == parts
def test_note_is_stripped():
assert _prepend_note_to_message("hello", " NOTE ") == "NOTE\n\nhello"
def test_empty_string_message_yields_just_note():
# No trailing blank lines when the user message is empty.
assert _prepend_note_to_message("", "NOTE") == "NOTE"
def test_empty_text_part_yields_just_note():
message = [
{"type": "text", "text": ""},
{"type": "image_url", "image_url": {"url": "x"}},
]
result = _prepend_note_to_message(message, "NOTE")
assert result[0]["text"] == "NOTE"
assert result[1]["type"] == "image_url"
def test_list_message_folds_note_into_first_text_part():
message = [
{"type": "text", "text": "describe this"},
{"type": "image_url", "image_url": {"url": "data:..."}},
]
result = _prepend_note_to_message(message, "NOTE")
assert result[0]["type"] == "text"
assert result[0]["text"] == "NOTE\n\ndescribe this"
# Image part is preserved untouched.
assert result[1] == {"type": "image_url", "image_url": {"url": "data:..."}}
# Original message is not mutated.
assert message[0]["text"] == "describe this"
def test_image_only_list_gets_leading_text_part():
message = [{"type": "image_url", "image_url": {"url": "data:..."}}]
result = _prepend_note_to_message(message, "NOTE")
assert result[0] == {"type": "text", "text": "NOTE"}
assert result[1]["type"] == "image_url"
def test_list_message_does_not_raise_typeerror():
# The exact #repro shape: multimodal list + queued note must not raise
# "can only concatenate str (not 'list') to str".
message = [
{"type": "text", "text": "look"},
{"type": "image_url", "image_url": {"url": "x"}},
]
result = _prepend_note_to_message(
message, "Model switched to gpt-5.5 (provider: openai-codex)."
)
assert isinstance(result, list)
assert result[0]["text"].startswith("Model switched to gpt-5.5")
def test_unknown_shape_returned_unchanged():
assert _prepend_note_to_message(123, "NOTE") == 123
assert _prepend_note_to_message(None, "NOTE") is None
@@ -0,0 +1,99 @@
"""Tests for ``HermesCLI._prompt_text_input`` thread-safe input dispatch.
Raw ``input()`` prompts can race with prompt_toolkit when called from the TUI.
The normal slash confirmations now use a prompt_toolkit-native modal, but
``_prompt_text_input`` remains as a fallback for non-interactive calls and edge
cases.
"""
import threading
from unittest.mock import MagicMock, patch
def _make_cli():
"""Minimal HermesCLI shell exposing prompt fallback helpers."""
import cli as cli_mod
obj = object.__new__(cli_mod.HermesCLI)
obj._app = MagicMock()
obj._status_bar_visible = True
return obj
class TestPromptTextInputThreadSafety:
def test_main_thread_uses_run_in_terminal(self):
"""On the main thread with an active app, route through run_in_terminal."""
cli = _make_cli()
with patch("prompt_toolkit.application.run_in_terminal") as mock_rit, \
patch("builtins.input", return_value="2"):
cli._prompt_text_input("Choice: ")
# run_in_terminal was invoked; the _ask closure passed to it would
# call input() when driven by the event loop. We assert dispatch path,
# not the orphaned-coroutine result.
assert mock_rit.called
def test_background_thread_cancels_instead_of_hanging(self):
"""On a daemon thread with an active app, cancel cleanly (return None).
stdin is owned by the prompt_toolkit event loop / JSON-RPC pipe on the
non-main (process_loop / slash-worker) thread, so a bare input() there
would block until the worker's timeout (#23185 / billing auto-reload
hang). The guard cancels to None instead of hanging — it must NOT call
run_in_terminal (orphaned coroutine) and must NOT call input().
"""
cli = _make_cli()
result_holder = {}
def run_on_daemon():
with patch("prompt_toolkit.application.run_in_terminal") as mock_rit, \
patch("builtins.input", side_effect=AssertionError("input() must not be called off-main-thread")) as mock_input:
result_holder["value"] = cli._prompt_text_input("Choice [1/2/3]: ")
result_holder["rit_called"] = mock_rit.called
result_holder["input_called"] = mock_input.called
t = threading.Thread(target=run_on_daemon, daemon=True)
t.start()
t.join(timeout=2.0)
assert not t.is_alive(), "daemon thread hung — guard did not cancel cleanly"
# Cancelled cleanly: None returned, neither run_in_terminal nor input() called.
assert result_holder["value"] is None
assert result_holder["rit_called"] is False
assert result_holder["input_called"] is False
def test_no_app_uses_direct_input(self):
"""Without an active prompt_toolkit app, always call input() directly."""
cli = _make_cli()
cli._app = None
with patch("builtins.input", return_value="cancel") as mock_input:
result = cli._prompt_text_input("Choice: ")
assert mock_input.called
assert result == "cancel"
def test_run_in_terminal_exception_falls_back(self):
"""If run_in_terminal raises (WSL / Warp edge cases), fall back to input()."""
cli = _make_cli()
with patch(
"prompt_toolkit.application.run_in_terminal",
side_effect=RuntimeError("event loop dropped the coroutine"),
), patch("builtins.input", return_value="3") as mock_input:
result = cli._prompt_text_input("Choice: ")
assert mock_input.called
assert result == "3"
def test_eof_returns_none(self):
"""EOFError from input() yields None, not an unhandled exception."""
cli = _make_cli()
cli._app = None
with patch("builtins.input", side_effect=EOFError()):
result = cli._prompt_text_input("Choice: ")
assert result is None
+247
View File
@@ -0,0 +1,247 @@
"""Tests for user-defined quick commands that bypass the agent loop."""
import os
import subprocess
from unittest.mock import MagicMock, patch
from rich.text import Text
import pytest
# ── CLI tests ──────────────────────────────────────────────────────────────
class TestCLIQuickCommands:
"""Test quick command dispatch in HermesCLI.process_command."""
@staticmethod
def _printed_plain(call_arg):
if isinstance(call_arg, Text):
return call_arg.plain
return str(call_arg)
def _make_cli(self, quick_commands):
from cli import HermesCLI
cli = HermesCLI.__new__(HermesCLI)
cli.config = {"quick_commands": quick_commands}
cli.console = MagicMock()
cli.agent = None
cli.conversation_history = []
# session_id is accessed by the fallback skill/fuzzy-match path in
# process_command; without it, tests that exercise `/alias args`
# can trip an AttributeError when cross-test state leaks a skill
# command matching the alias target.
cli.session_id = "test-session"
return cli
def test_exec_command_runs_and_prints_output(self):
cli = self._make_cli({"dn": {"type": "exec", "command": "echo daily-note"}})
result = cli.process_command("/dn")
assert result is True
cli.console.print.assert_called_once()
printed = self._printed_plain(cli.console.print.call_args[0][0])
assert printed == "daily-note"
def test_exec_command_uses_chat_console_when_tui_is_live(self):
cli = self._make_cli({"dn": {"type": "exec", "command": "echo daily-note"}})
cli._app = object()
live_console = MagicMock()
with patch("cli.ChatConsole", return_value=live_console):
result = cli.process_command("/dn")
assert result is True
live_console.print.assert_called_once()
printed = self._printed_plain(live_console.print.call_args[0][0])
assert printed == "daily-note"
cli.console.print.assert_not_called()
def test_exec_command_stderr_shown_on_no_stdout(self):
cli = self._make_cli({"err": {"type": "exec", "command": "echo error >&2"}})
result = cli.process_command("/err")
assert result is True
# stderr fallback — should print something
cli.console.print.assert_called_once()
def test_exec_command_no_output_shows_fallback(self):
cli = self._make_cli({"empty": {"type": "exec", "command": "true"}})
cli.process_command("/empty")
cli.console.print.assert_called_once()
args = cli.console.print.call_args[0][0]
assert "no output" in args.lower()
def test_alias_command_routes_to_target(self):
"""Alias quick commands rewrite to the target command."""
cli = self._make_cli({"shortcut": {"type": "alias", "target": "/help"}})
with patch.object(cli, "process_command", wraps=cli.process_command) as spy:
cli.process_command("/shortcut")
# Should recursively call process_command with /help
spy.assert_any_call("/help")
def test_alias_command_passes_args(self):
"""Alias quick commands forward user arguments to the target."""
cli = self._make_cli({"sc": {"type": "alias", "target": "/context"}})
with patch.object(cli, "process_command", wraps=cli.process_command) as spy:
cli.process_command("/sc some args")
spy.assert_any_call("/context some args")
def test_alias_no_target_shows_error(self):
cli = self._make_cli({"broken": {"type": "alias", "target": ""}})
cli.process_command("/broken")
cli.console.print.assert_called_once()
args = cli.console.print.call_args[0][0]
assert "no target defined" in args.lower()
def test_unsupported_type_shows_error(self):
cli = self._make_cli({"bad": {"type": "prompt", "command": "echo hi"}})
cli.process_command("/bad")
cli.console.print.assert_called_once()
args = cli.console.print.call_args[0][0]
assert "unsupported type" in args.lower()
def test_missing_command_field_shows_error(self):
cli = self._make_cli({"oops": {"type": "exec"}})
cli.process_command("/oops")
cli.console.print.assert_called_once()
args = cli.console.print.call_args[0][0]
assert "no command defined" in args.lower()
def test_quick_command_takes_priority_over_skill_commands(self):
"""Quick commands must be checked before skill slash commands."""
cli = self._make_cli({"mygif": {"type": "exec", "command": "echo overridden"}})
with patch("cli._skill_commands", {"/mygif": {"name": "gif-search"}}):
cli.process_command("/mygif")
cli.console.print.assert_called_once()
printed = self._printed_plain(cli.console.print.call_args[0][0])
assert printed == "overridden"
def test_unknown_command_still_shows_error(self):
cli = self._make_cli({})
with patch("cli._cprint") as mock_cprint:
cli.process_command("/nonexistent")
mock_cprint.assert_called()
printed = " ".join(str(c) for c in mock_cprint.call_args_list)
assert "unknown command" in printed.lower()
def test_timeout_shows_error(self):
cli = self._make_cli({"slow": {"type": "exec", "command": "sleep 100"}})
with patch("subprocess.run", side_effect=subprocess.TimeoutExpired("sleep", 30)):
cli.process_command("/slow")
cli.console.print.assert_called_once()
args = cli.console.print.call_args[0][0]
assert "timed out" in args.lower()
# ── Gateway tests ──────────────────────────────────────────────────────────
class TestGatewayQuickCommands:
"""Test quick command dispatch in GatewayRunner._handle_message."""
def _make_event(self, command, args=""):
event = MagicMock()
event.get_command.return_value = command
event.get_command_args.return_value = args
event.text = f"/{command} {args}".strip()
event.source = MagicMock()
event.source.user_id = "test_user"
event.source.user_name = "Test User"
event.source.platform.value = "telegram"
event.source.chat_type = "dm"
event.source.chat_id = "123"
return event
@pytest.mark.asyncio
async def test_exec_command_returns_output(self):
from gateway.run import GatewayRunner
runner = GatewayRunner.__new__(GatewayRunner)
runner.config = {"quick_commands": {"limits": {"type": "exec", "command": "echo ok"}}}
runner._running_agents = {}
runner._pending_messages = {}
runner._is_user_authorized = MagicMock(return_value=True)
event = self._make_event("limits")
result = await runner._handle_message(event)
assert result == "ok"
@pytest.mark.asyncio
async def test_exec_command_does_not_leak_credentials(self):
"""Quick command exec must sanitize env — API keys must not appear in output."""
from gateway.run import GatewayRunner
runner = GatewayRunner.__new__(GatewayRunner)
runner.config = {"quick_commands": {"leak": {"type": "exec", "command": "env"}}}
runner._running_agents = {}
runner._pending_messages = {}
runner._is_user_authorized = MagicMock(return_value=True)
event = self._make_event("leak")
with patch.dict(os.environ, {"OPENROUTER_API_KEY": "sk-or-secret-12345"}):
result = await runner._handle_message(event)
assert "sk-or-secret-12345" not in result, \
"Quick command leaked OPENROUTER_API_KEY — exec runs without env sanitization"
@pytest.mark.asyncio
async def test_exec_command_output_is_redacted(self, monkeypatch):
"""Quick command output must redact sensitive patterns before returning."""
from gateway.run import GatewayRunner
# Ensure redaction is active regardless of host HERMES_REDACT_SECRETS state
# or test ordering
monkeypatch.setattr("agent.redact._REDACT_ENABLED", True)
runner = GatewayRunner.__new__(GatewayRunner)
runner.config = {"quick_commands": {"token": {"type": "exec", "command": "echo sk-ant-api03-supersecretkey1234567890"}}}
runner._running_agents = {}
runner._pending_messages = {}
runner._is_user_authorized = MagicMock(return_value=True)
event = self._make_event("token")
result = await runner._handle_message(event)
assert "supersecretkey1234567890" not in result, \
"Quick command output not redacted — raw API key returned to user"
@pytest.mark.asyncio
async def test_unsupported_type_returns_error(self):
from gateway.run import GatewayRunner
runner = GatewayRunner.__new__(GatewayRunner)
runner.config = {"quick_commands": {"bad": {"type": "prompt", "command": "echo hi"}}}
runner._running_agents = {}
runner._pending_messages = {}
runner._is_user_authorized = MagicMock(return_value=True)
event = self._make_event("bad")
result = await runner._handle_message(event)
assert result is not None
assert "unsupported type" in result.lower()
@pytest.mark.asyncio
async def test_timeout_returns_error(self):
from gateway.run import GatewayRunner
import asyncio
runner = GatewayRunner.__new__(GatewayRunner)
runner.config = {"quick_commands": {"slow": {"type": "exec", "command": "sleep 100"}}}
runner._running_agents = {}
runner._pending_messages = {}
runner._is_user_authorized = MagicMock(return_value=True)
event = self._make_event("slow")
with patch("asyncio.wait_for", side_effect=asyncio.TimeoutError):
result = await runner._handle_message(event)
assert result is not None
assert "timed out" in result.lower()
@pytest.mark.asyncio
async def test_gateway_config_object_supports_quick_commands(self):
from gateway.config import GatewayConfig
from gateway.run import GatewayRunner
runner = GatewayRunner.__new__(GatewayRunner)
runner.config = GatewayConfig(
quick_commands={"limits": {"type": "exec", "command": "echo ok"}}
)
runner._running_agents = {}
runner._pending_messages = {}
runner._is_user_authorized = MagicMock(return_value=True)
event = self._make_event("limits")
result = await runner._handle_message(event)
assert result == "ok"
+772
View File
@@ -0,0 +1,772 @@
"""Tests for the combined /reasoning command.
Covers both reasoning effort level management and reasoning display toggle,
plus the reasoning extraction and display pipeline from run_agent through CLI.
Combines functionality from:
- PR #789 (Aum08Desai): reasoning effort level management
- PR #790 (0xbyt4): reasoning display toggle and rendering
"""
import unittest
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
import re
# ---------------------------------------------------------------------------
# Effort level parsing
# ---------------------------------------------------------------------------
class TestParseReasoningConfig(unittest.TestCase):
"""Verify _parse_reasoning_config handles all effort levels."""
def _parse(self, effort):
from cli import _parse_reasoning_config
return _parse_reasoning_config(effort)
def test_none_disables(self):
result = self._parse("none")
self.assertEqual(result, {"enabled": False})
def test_valid_levels(self):
for level in ("low", "medium", "high", "xhigh", "max", "ultra", "minimal"):
result = self._parse(level)
self.assertIsNotNone(result)
self.assertTrue(result.get("enabled"))
self.assertEqual(result["effort"], level)
def test_empty_returns_none(self):
self.assertIsNone(self._parse(""))
self.assertIsNone(self._parse(" "))
def test_unknown_returns_none(self):
self.assertIsNone(self._parse("turbo"))
def test_case_insensitive(self):
result = self._parse("HIGH")
self.assertIsNotNone(result)
self.assertEqual(result["effort"], "high")
# ---------------------------------------------------------------------------
# /reasoning command handler (combined effort + display)
# ---------------------------------------------------------------------------
class TestHandleReasoningCommand(unittest.TestCase):
"""Test the combined _handle_reasoning_command method."""
def _make_cli(self, reasoning_config=None, show_reasoning=False):
"""Create a minimal CLI stub with the reasoning attributes."""
stub = SimpleNamespace(
reasoning_config=reasoning_config,
show_reasoning=show_reasoning,
agent=MagicMock(),
)
return stub
def test_show_enables_display(self):
stub = self._make_cli(show_reasoning=False)
# Simulate /reasoning show
arg = "show"
if arg in {"show", "on"}:
stub.show_reasoning = True
stub.agent.reasoning_callback = lambda x: None
self.assertTrue(stub.show_reasoning)
def test_hide_disables_display(self):
stub = self._make_cli(show_reasoning=True)
# Simulate /reasoning hide
arg = "hide"
if arg in {"hide", "off"}:
stub.show_reasoning = False
stub.agent.reasoning_callback = None
self.assertFalse(stub.show_reasoning)
self.assertIsNone(stub.agent.reasoning_callback)
def test_on_enables_display(self):
stub = self._make_cli(show_reasoning=False)
arg = "on"
if arg in {"show", "on"}:
stub.show_reasoning = True
self.assertTrue(stub.show_reasoning)
def test_off_disables_display(self):
stub = self._make_cli(show_reasoning=True)
arg = "off"
if arg in {"hide", "off"}:
stub.show_reasoning = False
self.assertFalse(stub.show_reasoning)
def test_effort_level_sets_config(self):
"""Setting an effort level should update reasoning_config."""
from cli import _parse_reasoning_config
stub = self._make_cli()
arg = "high"
parsed = _parse_reasoning_config(arg)
stub.reasoning_config = parsed
self.assertEqual(stub.reasoning_config, {"enabled": True, "effort": "high"})
def test_effort_none_disables_reasoning(self):
from cli import _parse_reasoning_config
stub = self._make_cli()
parsed = _parse_reasoning_config("none")
stub.reasoning_config = parsed
self.assertEqual(stub.reasoning_config, {"enabled": False})
def test_invalid_argument_rejected(self):
"""Invalid arguments should be rejected (parsed returns None)."""
from cli import _parse_reasoning_config
parsed = _parse_reasoning_config("turbo")
self.assertIsNone(parsed)
def test_no_args_shows_status(self):
"""With no args, should show current state (no crash)."""
stub = self._make_cli(reasoning_config=None, show_reasoning=False)
rc = stub.reasoning_config
if rc is None:
level = "medium (default)"
elif rc.get("enabled") is False:
level = "none (disabled)"
else:
level = rc.get("effort", "medium")
display_state = "on" if stub.show_reasoning else "off"
self.assertEqual(level, "medium (default)")
self.assertEqual(display_state, "off")
def test_status_with_disabled_reasoning(self):
stub = self._make_cli(reasoning_config={"enabled": False}, show_reasoning=True)
rc = stub.reasoning_config
if rc is None:
level = "medium (default)"
elif rc.get("enabled") is False:
level = "none (disabled)"
else:
level = rc.get("effort", "medium")
self.assertEqual(level, "none (disabled)")
def test_status_with_explicit_level(self):
stub = self._make_cli(
reasoning_config={"enabled": True, "effort": "xhigh"},
show_reasoning=True,
)
rc = stub.reasoning_config
level = rc.get("effort", "medium")
self.assertEqual(level, "xhigh")
# ---------------------------------------------------------------------------
# Reasoning extraction and result dict
# ---------------------------------------------------------------------------
class TestLastReasoningInResult(unittest.TestCase):
"""Verify reasoning extraction from the messages list."""
def _build_messages(self, reasoning=None):
return [
{"role": "user", "content": "hello"},
{
"role": "assistant",
"content": "Hi there!",
"reasoning": reasoning,
"finish_reason": "stop",
},
]
def test_reasoning_present(self):
messages = self._build_messages(reasoning="Let me think...")
last_reasoning = None
for msg in reversed(messages):
if msg.get("role") == "user":
break
if msg.get("role") == "assistant" and msg.get("reasoning"):
last_reasoning = msg["reasoning"]
break
self.assertEqual(last_reasoning, "Let me think...")
def test_reasoning_none(self):
messages = self._build_messages(reasoning=None)
last_reasoning = None
for msg in reversed(messages):
if msg.get("role") == "user":
break
if msg.get("role") == "assistant" and msg.get("reasoning"):
last_reasoning = msg["reasoning"]
break
self.assertIsNone(last_reasoning)
def test_picks_last_assistant(self):
messages = [
{"role": "user", "content": "hello"},
{"role": "assistant", "content": "...", "reasoning": "first thought"},
{"role": "tool", "content": "result"},
{"role": "assistant", "content": "done!", "reasoning": "final thought"},
]
last_reasoning = None
for msg in reversed(messages):
if msg.get("role") == "user":
break
if msg.get("role") == "assistant" and msg.get("reasoning"):
last_reasoning = msg["reasoning"]
break
self.assertEqual(last_reasoning, "final thought")
def test_empty_reasoning_treated_as_none(self):
messages = self._build_messages(reasoning="")
last_reasoning = None
for msg in reversed(messages):
if msg.get("role") == "user":
break
if msg.get("role") == "assistant" and msg.get("reasoning"):
last_reasoning = msg["reasoning"]
break
self.assertIsNone(last_reasoning)
# ---------------------------------------------------------------------------
# Reasoning display collapse
# ---------------------------------------------------------------------------
class TestReasoningCollapse(unittest.TestCase):
"""Verify long reasoning is collapsed to 10 lines in the box."""
def test_short_reasoning_not_collapsed(self):
reasoning = "\n".join(f"Line {i}" for i in range(5))
lines = reasoning.strip().splitlines()
self.assertLessEqual(len(lines), 10)
def test_long_reasoning_collapsed(self):
reasoning = "\n".join(f"Line {i}" for i in range(25))
lines = reasoning.strip().splitlines()
self.assertTrue(len(lines) > 10)
if len(lines) > 10:
display = "\n".join(lines[:10])
display += f"\n ... ({len(lines) - 10} more lines)"
display_lines = display.splitlines()
self.assertEqual(len(display_lines), 11)
self.assertIn("15 more lines", display_lines[-1])
def test_exactly_10_lines_not_collapsed(self):
reasoning = "\n".join(f"Line {i}" for i in range(10))
lines = reasoning.strip().splitlines()
self.assertEqual(len(lines), 10)
self.assertFalse(len(lines) > 10)
def test_intermediate_callback_collapses_to_5(self):
"""_on_reasoning shows max 5 lines."""
reasoning = "\n".join(f"Step {i}" for i in range(12))
lines = reasoning.strip().splitlines()
if len(lines) > 5:
preview = "\n".join(lines[:5])
preview += f"\n ... ({len(lines) - 5} more lines)"
else:
preview = reasoning.strip()
preview_lines = preview.splitlines()
self.assertEqual(len(preview_lines), 6)
self.assertIn("7 more lines", preview_lines[-1])
# ---------------------------------------------------------------------------
# Reasoning callback
# ---------------------------------------------------------------------------
class TestReasoningCallback(unittest.TestCase):
"""Verify reasoning_callback invocation."""
def test_callback_invoked_with_reasoning(self):
captured = []
agent = MagicMock()
agent.reasoning_callback = lambda t: captured.append(t)
agent._extract_reasoning = MagicMock(return_value="deep thought")
reasoning_text = agent._extract_reasoning(MagicMock())
if reasoning_text and agent.reasoning_callback:
agent.reasoning_callback(reasoning_text)
self.assertEqual(captured, ["deep thought"])
def test_callback_not_invoked_without_reasoning(self):
captured = []
agent = MagicMock()
agent.reasoning_callback = lambda t: captured.append(t)
agent._extract_reasoning = MagicMock(return_value=None)
reasoning_text = agent._extract_reasoning(MagicMock())
if reasoning_text and agent.reasoning_callback:
agent.reasoning_callback(reasoning_text)
self.assertEqual(captured, [])
def test_callback_none_does_not_crash(self):
reasoning_text = "some thought"
callback = None
if reasoning_text and callback:
callback(reasoning_text)
# No exception = pass
class TestReasoningPreviewBuffering(unittest.TestCase):
def _make_cli(self):
from cli import HermesCLI
cli = HermesCLI.__new__(HermesCLI)
cli.verbose = True
cli._spinner_text = ""
cli._reasoning_preview_buf = ""
cli._invalidate = lambda *args, **kwargs: None
return cli
@patch("cli._cprint")
def test_streamed_reasoning_chunks_wait_for_boundary(self, mock_cprint):
cli = self._make_cli()
cli._on_reasoning("Let")
cli._on_reasoning(" me")
cli._on_reasoning(" think")
self.assertEqual(mock_cprint.call_count, 0)
cli._on_reasoning(" about this.\n")
self.assertEqual(mock_cprint.call_count, 1)
rendered = mock_cprint.call_args[0][0]
self.assertIn("[thinking] Let me think about this.", rendered)
@patch("cli._cprint")
def test_pending_reasoning_flushes_when_thinking_stops(self, mock_cprint):
cli = self._make_cli()
cli._on_reasoning("see")
cli._on_reasoning(" how")
cli._on_reasoning(" this")
cli._on_reasoning(" plays")
cli._on_reasoning(" out")
self.assertEqual(mock_cprint.call_count, 0)
cli._on_thinking("")
self.assertEqual(mock_cprint.call_count, 1)
rendered = mock_cprint.call_args[0][0]
self.assertIn("[thinking] see how this plays out", rendered)
@patch("cli._cprint")
@patch("cli.shutil.get_terminal_size", return_value=SimpleNamespace(columns=50))
def test_reasoning_preview_compacts_newlines_and_wraps_to_terminal(self, _mock_term, mock_cprint):
cli = self._make_cli()
cli._emit_reasoning_preview(
"First line\nstill same thought\n\n\nSecond paragraph with more detail here."
)
rendered = mock_cprint.call_args[0][0]
plain = re.sub(r"\x1b\[[0-9;]*m", "", rendered)
normalized = " ".join(plain.split())
self.assertIn("[thinking] First line still same thought", plain)
self.assertIn("Second paragraph with more detail here.", normalized)
self.assertNotIn("\n\n\n", plain)
@patch("cli.shutil.get_terminal_size", return_value=SimpleNamespace(columns=60))
def test_reasoning_flush_threshold_tracks_terminal_width(self, _mock_term):
cli = self._make_cli()
cli._reasoning_preview_buf = "a" * 30
cli._flush_reasoning_preview(force=False)
self.assertEqual(cli._reasoning_preview_buf, "a" * 30)
class TestReasoningDisplayModeSelection(unittest.TestCase):
def _make_cli(self, *, show_reasoning=False, streaming_enabled=False, verbose=False):
from cli import HermesCLI
cli = HermesCLI.__new__(HermesCLI)
cli.show_reasoning = show_reasoning
cli.streaming_enabled = streaming_enabled
cli.verbose = verbose
cli._stream_reasoning_delta = lambda text: ("stream", text)
cli._on_reasoning = lambda text: ("preview", text)
return cli
def test_show_reasoning_non_streaming_uses_final_box_only(self):
cli = self._make_cli(show_reasoning=True, streaming_enabled=False, verbose=False)
self.assertIsNone(cli._current_reasoning_callback())
def test_show_reasoning_streaming_uses_live_reasoning_box(self):
cli = self._make_cli(show_reasoning=True, streaming_enabled=True, verbose=False)
callback = cli._current_reasoning_callback()
self.assertIsNotNone(callback)
self.assertEqual(callback("x"), ("stream", "x"))
def test_verbose_without_show_reasoning_uses_preview_callback(self):
cli = self._make_cli(show_reasoning=False, streaming_enabled=False, verbose=True)
callback = cli._current_reasoning_callback()
self.assertIsNotNone(callback)
self.assertEqual(callback("x"), ("preview", "x"))
# ---------------------------------------------------------------------------
# Real provider format extraction
# ---------------------------------------------------------------------------
class TestExtractReasoningFormats(unittest.TestCase):
"""Test _extract_reasoning with real provider response formats."""
def _get_extractor(self):
from run_agent import AIAgent
return AIAgent._extract_reasoning
def test_openrouter_reasoning_details(self):
extract = self._get_extractor()
msg = SimpleNamespace(
reasoning=None,
reasoning_content=None,
reasoning_details=[
{"type": "reasoning.summary", "summary": "Analyzing Python lists."},
],
)
result = extract(None, msg)
self.assertIn("Python lists", result)
def test_deepseek_reasoning_field(self):
extract = self._get_extractor()
msg = SimpleNamespace(
reasoning="Solving step by step.\nx + y = 8.",
reasoning_content=None,
)
result = extract(None, msg)
self.assertIn("x + y = 8", result)
def test_moonshot_reasoning_content(self):
extract = self._get_extractor()
msg = SimpleNamespace(
reasoning_content="Explaining async/await.",
)
result = extract(None, msg)
self.assertIn("async/await", result)
def test_no_reasoning_returns_none(self):
extract = self._get_extractor()
msg = SimpleNamespace(content="Hello!")
result = extract(None, msg)
self.assertIsNone(result)
# ---------------------------------------------------------------------------
# Inline <think> block extraction fallback
# ---------------------------------------------------------------------------
class TestInlineThinkBlockExtraction(unittest.TestCase):
"""Test _build_assistant_message extracts inline <think> blocks as reasoning
when no structured API-level reasoning fields are present."""
def _build_msg(self, content, reasoning=None, reasoning_content=None, reasoning_details=None, tool_calls=None):
"""Create a mock API response message."""
msg = SimpleNamespace(content=content, tool_calls=tool_calls)
if reasoning is not None:
msg.reasoning = reasoning
if reasoning_content is not None:
msg.reasoning_content = reasoning_content
if reasoning_details is not None:
msg.reasoning_details = reasoning_details
return msg
def _make_agent(self):
"""Create a minimal agent with _build_assistant_message."""
from run_agent import AIAgent
agent = MagicMock(spec=AIAgent)
agent._build_assistant_message = AIAgent._build_assistant_message.__get__(agent)
agent._extract_reasoning = AIAgent._extract_reasoning.__get__(agent)
agent.verbose_logging = False
agent.reasoning_callback = None
agent.stream_delta_callback = None # non-streaming by default
agent._stream_callback = None # non-streaming by default
return agent
def test_single_think_block_extracted(self):
agent = self._make_agent()
api_msg = self._build_msg("<think>Let me calculate 2+2=4.</think>The answer is 4.")
result = agent._build_assistant_message(api_msg, "stop")
self.assertEqual(result["reasoning"], "Let me calculate 2+2=4.")
def test_multiple_think_blocks_extracted(self):
agent = self._make_agent()
api_msg = self._build_msg("<think>First thought.</think>Some text<think>Second thought.</think>More text")
result = agent._build_assistant_message(api_msg, "stop")
self.assertIn("First thought.", result["reasoning"])
self.assertIn("Second thought.", result["reasoning"])
def test_no_think_blocks_no_reasoning(self):
agent = self._make_agent()
api_msg = self._build_msg("Just a plain response.")
result = agent._build_assistant_message(api_msg, "stop")
# No structured reasoning AND no inline think blocks → None
self.assertIsNone(result["reasoning"])
def test_structured_reasoning_takes_priority(self):
"""When structured API reasoning exists, inline think blocks should NOT override."""
agent = self._make_agent()
api_msg = self._build_msg(
"<think>Inline thought.</think>Response text.",
reasoning="Structured reasoning from API.",
)
result = agent._build_assistant_message(api_msg, "stop")
self.assertEqual(result["reasoning"], "Structured reasoning from API.")
def test_empty_think_block_ignored(self):
agent = self._make_agent()
api_msg = self._build_msg("<think></think>Hello!")
result = agent._build_assistant_message(api_msg, "stop")
# Empty think block should not produce reasoning
self.assertIsNone(result["reasoning"])
def test_multiline_think_block(self):
agent = self._make_agent()
api_msg = self._build_msg("<think>\nStep 1: Analyze.\nStep 2: Solve.\n</think>Done.")
result = agent._build_assistant_message(api_msg, "stop")
self.assertIn("Step 1: Analyze.", result["reasoning"])
self.assertIn("Step 2: Solve.", result["reasoning"])
def test_callback_fires_for_inline_think(self):
"""Reasoning callback should fire when reasoning is extracted from inline think blocks."""
agent = self._make_agent()
captured = []
agent.reasoning_callback = lambda t: captured.append(t)
api_msg = self._build_msg("<think>Deep analysis here.</think>Answer.")
agent._build_assistant_message(api_msg, "stop")
self.assertEqual(len(captured), 1)
self.assertIn("Deep analysis", captured[0])
# ---------------------------------------------------------------------------
# Config defaults
# ---------------------------------------------------------------------------
class TestConfigDefault(unittest.TestCase):
"""Verify config default for show_reasoning."""
def test_default_config_has_show_reasoning(self):
from hermes_cli.config import DEFAULT_CONFIG
display = DEFAULT_CONFIG.get("display", {})
self.assertIn("show_reasoning", display)
# Default ON (July 2026 TTFT-perception change): thinking models
# stream reasoning for tens of seconds; hiding it left users staring
# at a spinner. The key must exist and be a bool.
self.assertTrue(display["show_reasoning"])
class TestCommandRegistered(unittest.TestCase):
"""Verify /reasoning is in the COMMANDS dict."""
def test_reasoning_in_commands(self):
from hermes_cli.commands import COMMANDS
self.assertIn("/reasoning", COMMANDS)
# ---------------------------------------------------------------------------
# End-to-end pipeline
# ---------------------------------------------------------------------------
class TestEndToEndPipeline(unittest.TestCase):
"""Simulate the full pipeline: extraction -> result dict -> display."""
def test_openrouter_claude_pipeline(self):
from run_agent import AIAgent
api_message = SimpleNamespace(
role="assistant",
content="Lists support append().",
tool_calls=None,
reasoning=None,
reasoning_content=None,
reasoning_details=[
{"type": "reasoning.summary", "summary": "Python list methods."},
],
)
reasoning = AIAgent._extract_reasoning(None, api_message)
self.assertIsNotNone(reasoning)
messages = [
{"role": "user", "content": "How do I add items?"},
{"role": "assistant", "content": api_message.content, "reasoning": reasoning},
]
last_reasoning = None
for msg in reversed(messages):
if msg.get("role") == "user":
break
if msg.get("role") == "assistant" and msg.get("reasoning"):
last_reasoning = msg["reasoning"]
break
result = {
"final_response": api_message.content,
"last_reasoning": last_reasoning,
}
self.assertIn("last_reasoning", result)
self.assertIn("Python list methods", result["last_reasoning"])
def test_no_reasoning_model_pipeline(self):
from run_agent import AIAgent
api_message = SimpleNamespace(content="Paris.", tool_calls=None)
reasoning = AIAgent._extract_reasoning(None, api_message)
self.assertIsNone(reasoning)
result = {"final_response": api_message.content, "last_reasoning": reasoning}
self.assertIsNone(result["last_reasoning"])
# ---------------------------------------------------------------------------
# Duplicate reasoning box prevention (Bug fix: 3 boxes for 1 reasoning)
# ---------------------------------------------------------------------------
class TestReasoningDeltasFiredFlag(unittest.TestCase):
"""_build_assistant_message should not re-fire reasoning_callback when
reasoning was already streamed via _fire_reasoning_delta."""
def _make_agent(self):
from run_agent import AIAgent
agent = AIAgent.__new__(AIAgent)
agent.reasoning_callback = None
agent.stream_delta_callback = None
agent._stream_callback = None
agent.verbose_logging = False
return agent
def test_fire_reasoning_delta_calls_callback(self):
agent = self._make_agent()
captured = []
agent.reasoning_callback = lambda t: captured.append(t)
agent._fire_reasoning_delta("thinking...")
self.assertEqual(captured, ["thinking..."])
def test_build_assistant_message_skips_callback_when_already_streamed(self):
"""When streaming already fired reasoning deltas, the post-stream
_build_assistant_message should NOT re-fire the callback."""
agent = self._make_agent()
captured = []
agent.reasoning_callback = lambda t: captured.append(t)
agent.stream_delta_callback = lambda t: None # streaming is active
# Simulate streaming having already fired reasoning
msg = SimpleNamespace(
content="I'll merge that.",
tool_calls=None,
reasoning_content="Let me merge the PR.",
reasoning=None,
reasoning_details=None,
)
agent._build_assistant_message(msg, "stop")
# Callback should NOT have been fired again
self.assertEqual(captured, [])
def test_build_assistant_message_skips_callback_when_streaming_active(self):
"""When streaming is active, callback should NEVER fire from
_build_assistant_message — reasoning was already displayed during the
stream (either via reasoning_content deltas or content tag extraction).
Any missed reasoning is caught by the CLI post-response fallback."""
agent = self._make_agent()
captured = []
agent.reasoning_callback = lambda t: captured.append(t)
agent.stream_delta_callback = lambda t: None # streaming active
# Reasoning came through content tags, not reasoning_content deltas.
# Callback should not fire since streaming is active.
msg = SimpleNamespace(
content="I'll merge that.",
tool_calls=None,
reasoning_content="Let me merge the PR.",
reasoning=None,
reasoning_details=None,
)
agent._build_assistant_message(msg, "stop")
# Callback should NOT fire — streaming is active
self.assertEqual(captured, [])
def test_build_assistant_message_fires_callback_without_streaming(self):
"""When no streaming is active, callback always fires for structured
reasoning."""
agent = self._make_agent()
captured = []
agent.reasoning_callback = lambda t: captured.append(t)
# No streaming
agent.stream_delta_callback = None
msg = SimpleNamespace(
content="I'll merge that.",
tool_calls=None,
reasoning_content="Let me merge the PR.",
reasoning=None,
reasoning_details=None,
)
agent._build_assistant_message(msg, "stop")
self.assertEqual(captured, ["Let me merge the PR."])
class TestReasoningShownThisTurnFlag(unittest.TestCase):
"""Post-response reasoning display should be suppressed when reasoning
was already shown during streaming in a tool-calling loop."""
def _make_cli(self):
from cli import HermesCLI
cli = HermesCLI.__new__(HermesCLI)
cli.show_reasoning = True
cli.streaming_enabled = True
cli._stream_box_opened = False
cli._reasoning_box_opened = False
cli._reasoning_stream_started = False
cli._reasoning_shown_this_turn = False
cli._reasoning_buf = ""
cli._stream_buf = ""
cli._stream_started = False
cli._stream_text_ansi = ""
cli._stream_prefilt = ""
cli._in_reasoning_block = False
cli._reasoning_preview_buf = ""
return cli
@patch("cli._cprint")
def test_streaming_reasoning_sets_turn_flag(self, mock_cprint):
cli = self._make_cli()
self.assertFalse(cli._reasoning_shown_this_turn)
cli._stream_reasoning_delta("Thinking about it...")
self.assertTrue(cli._reasoning_shown_this_turn)
@patch("cli._cprint")
def test_turn_flag_survives_reset_stream_state(self, mock_cprint):
"""_reasoning_shown_this_turn must NOT be cleared by
_reset_stream_state (called at intermediate turn boundaries)."""
cli = self._make_cli()
cli._stream_reasoning_delta("Thinking...")
self.assertTrue(cli._reasoning_shown_this_turn)
# Simulate intermediate turn boundary (tool call)
cli._reset_stream_state()
# Flag must persist
self.assertTrue(cli._reasoning_shown_this_turn)
@patch("cli._cprint")
def test_turn_flag_cleared_before_new_turn(self, mock_cprint):
"""The turn flag should be reset at the start of a new user turn.
This happens outside _reset_stream_state, at the call site."""
cli = self._make_cli()
cli._reasoning_shown_this_turn = True
# Simulate new user turn setup
cli._reset_stream_state()
cli._reasoning_shown_this_turn = False # done by process_input
self.assertFalse(cli._reasoning_shown_this_turn)
if __name__ == "__main__":
unittest.main()
+730
View File
@@ -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"
+120
View File
@@ -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
+103
View File
@@ -0,0 +1,103 @@
from unittest.mock import MagicMock, patch
from types import SimpleNamespace
from hermes_cli.plugins import VALID_HOOKS, PluginManager
from cli import HermesCLI
def test_session_hooks_in_valid_hooks():
"""Verify on_session_finalize and on_session_reset are registered as valid hooks."""
assert "on_session_finalize" in VALID_HOOKS
assert "on_session_reset" in VALID_HOOKS
@patch("hermes_cli.plugins.invoke_hook")
def test_session_finalize_on_reset(mock_invoke_hook):
"""Verify on_session_finalize fires when /new or /reset is used."""
cli = HermesCLI()
cli.agent = MagicMock()
cli.agent.session_id = "test-session-id"
# Simulate /new command which triggers on_session_finalize for the old session
cli.new_session(silent=True)
# Check if on_session_finalize was called for the old session
assert any(
c.args == ("on_session_finalize",)
and c.kwargs["session_id"] == "test-session-id"
and c.kwargs["platform"] == "cli"
for c in mock_invoke_hook.call_args_list
)
# Check if on_session_reset was called for the new session
assert any(
c.args == ("on_session_reset",)
and c.kwargs["session_id"] == cli.session_id
and c.kwargs["platform"] == "cli"
for c in mock_invoke_hook.call_args_list
)
@patch("hermes_cli.plugins.invoke_hook")
def test_session_finalize_on_cleanup(mock_invoke_hook):
"""Verify on_session_finalize fires during CLI exit cleanup."""
import cli as cli_mod
mock_agent = MagicMock()
mock_agent.session_id = "cleanup-session-id"
cli_mod._active_agent_ref = mock_agent
cli_mod._cleanup_done = False
cli_mod._run_cleanup()
assert any(
c.args == ("on_session_finalize",)
and c.kwargs["session_id"] == "cleanup-session-id"
and c.kwargs["platform"] == "cli"
and c.kwargs["reason"] == "shutdown"
for c in mock_invoke_hook.call_args_list
)
@patch("hermes_cli.plugins.invoke_hook")
def test_interrupted_session_end_helper_emits_observer_shape(mock_invoke_hook):
"""Verify quiet single-query interruption emits a correlated session end."""
import cli as cli_mod
mock_agent = MagicMock()
mock_agent.session_id = "agent-session-id"
mock_agent.model = "test-model"
mock_agent.platform = "cli"
mock_agent._current_task_id = "task-1"
mock_agent._current_turn_id = "turn-1"
mock_agent._current_api_request_id = "api-1"
cli = SimpleNamespace(agent=mock_agent, session_id="cli-session-id")
cli_mod._emit_interrupted_session_end(cli, reason="keyboard_interrupt")
mock_agent.interrupt.assert_called_once_with("keyboard interrupt")
assert cli.session_id == "agent-session-id"
mock_invoke_hook.assert_called_once()
call = mock_invoke_hook.call_args
assert call.args == ("on_session_end",)
assert call.kwargs["session_id"] == "agent-session-id"
assert call.kwargs["task_id"] == "task-1"
assert call.kwargs["turn_id"] == "turn-1"
assert call.kwargs["api_request_id"] == "api-1"
assert call.kwargs["completed"] is False
assert call.kwargs["interrupted"] is True
assert call.kwargs["reason"] == "keyboard_interrupt"
@patch("hermes_cli.plugins.invoke_hook")
def test_hook_errors_are_caught(mock_invoke_hook):
"""Verify hook exceptions are caught and don't crash the agent."""
mgr = PluginManager()
# Register a hook that raises
def bad_callback(**kwargs):
raise Exception("Hook failed")
mgr._hooks["on_session_finalize"] = [bad_callback]
# This should not raise
results = mgr.invoke_hook("on_session_finalize", session_id="test", platform="cli")
assert results == []
@@ -0,0 +1,270 @@
from types import SimpleNamespace
import pytest
import cli
@pytest.fixture(autouse=True)
def reset_single_query_finalize_state(monkeypatch):
monkeypatch.setattr(cli, "_single_query_finalize_attempted_session_ids", set())
monkeypatch.setattr(cli, "_cleanup_done", False)
def test_finalize_single_query_runs_cleanup_without_reemitting_finalize_before_release(monkeypatch):
calls = []
fake_cli = SimpleNamespace(_release_active_session=lambda: calls.append(("release", {})))
def cleanup(**kwargs):
calls.append(("cleanup", kwargs))
monkeypatch.setattr(
cli,
"_notify_single_query_session_finalize",
lambda _cli: calls.append(("finalize", {})),
)
monkeypatch.setattr(cli, "_run_cleanup", cleanup)
cli._finalize_single_query(fake_cli)
assert calls == [
("finalize", {}),
("cleanup", {"notify_session_finalize": False}),
("release", {}),
]
def test_finalize_single_query_releases_session_when_cleanup_fails(monkeypatch):
calls = []
fake_cli = SimpleNamespace(_release_active_session=lambda: calls.append("release"))
def cleanup(**kwargs):
calls.append("cleanup")
raise RuntimeError("cleanup failed")
monkeypatch.setattr(
cli,
"_notify_single_query_session_finalize",
lambda _cli: calls.append("finalize"),
)
monkeypatch.setattr(cli, "_run_cleanup", cleanup)
with pytest.raises(RuntimeError, match="cleanup failed"):
cli._finalize_single_query(fake_cli)
assert calls == ["finalize", "cleanup", "release"]
def test_finalize_single_query_runs_cleanup_when_finalize_hook_fails(monkeypatch):
calls = []
fake_agent = SimpleNamespace(session_id="agent-session", platform="cli")
fake_cli = SimpleNamespace(
agent=fake_agent,
session_id="cli-session",
_release_active_session=lambda: calls.append("release"),
)
def invoke_hook(name, **kwargs):
calls.append("finalize")
raise RuntimeError("hook failed")
monkeypatch.setattr("hermes_cli.plugins.invoke_hook", invoke_hook)
monkeypatch.setattr(cli, "_run_cleanup", lambda **kwargs: calls.append("cleanup"))
cli._finalize_single_query(fake_cli)
assert calls == ["finalize", "cleanup", "release"]
def test_finalize_single_query_signal_window_does_not_reemit_during_atexit(monkeypatch):
calls = []
fake_agent = SimpleNamespace(session_id="agent-session", platform="cli")
fake_cli = SimpleNamespace(
agent=fake_agent,
session_id="cli-session",
_release_active_session=lambda: calls.append(("release", {})),
)
def invoke_hook(name, **kwargs):
calls.append((name, kwargs))
def interrupted_cleanup(**_kwargs):
raise KeyboardInterrupt()
expected_finalize = (
"on_session_finalize",
{
"session_id": "agent-session",
"platform": "cli",
"reason": "shutdown",
},
)
original_run_cleanup = cli._run_cleanup
monkeypatch.setattr("hermes_cli.plugins.invoke_hook", invoke_hook)
monkeypatch.setattr(cli, "_run_cleanup", interrupted_cleanup)
with pytest.raises(KeyboardInterrupt):
cli._finalize_single_query(fake_cli)
assert calls == [expected_finalize, ("release", {})]
# Simulate later atexit cleanup after the interrupted one-shot path. The
# active agent may already be unavailable by then.
monkeypatch.setattr(cli, "_run_cleanup", original_run_cleanup)
monkeypatch.setattr(cli, "_active_agent_ref", None)
monkeypatch.setattr(cli, "_reset_terminal_input_modes_on_exit", lambda: None)
monkeypatch.setattr(cli, "_cleanup_all_terminals", lambda: None)
monkeypatch.setattr(cli, "_cleanup_all_browsers", lambda: None)
monkeypatch.setattr("tools.mcp_tool.shutdown_mcp_servers", lambda: None)
monkeypatch.setattr("agent.auxiliary_client.shutdown_cached_clients", lambda: None)
cli._run_cleanup()
assert calls == [expected_finalize, ("release", {})]
def test_notify_single_query_session_finalize_uses_agent_session(monkeypatch):
calls = []
fake_agent = SimpleNamespace(session_id="agent-session", platform="cli")
fake_cli = SimpleNamespace(agent=fake_agent, session_id="cli-session")
def invoke_hook(name, **kwargs):
calls.append((name, kwargs))
monkeypatch.setattr("hermes_cli.plugins.invoke_hook", invoke_hook)
cli._notify_single_query_session_finalize(fake_cli)
assert calls == [
(
"on_session_finalize",
{
"session_id": "agent-session",
"platform": "cli",
"reason": "shutdown",
},
)
]
def test_human_single_query_main_finalizes_after_query(monkeypatch):
calls = []
import cli as cli_mod
class _Console:
def print(self, *_args, **_kwargs):
calls.append("query-label")
class FakeCLI:
def __init__(self, **_kwargs):
self.console = _Console()
self.session_id = "single-query-session"
self.agent = SimpleNamespace(
session_id="single-query-session",
platform="cli",
)
def _claim_active_session(self, surface, *, stderr=False):
calls.append(("claim", surface, stderr))
return True
def _show_security_advisories(self):
calls.append("advisories")
def chat(self, query, images=None):
calls.append(("chat", query, images))
return "done"
def _print_exit_summary(self, clear_screen=True):
calls.append("summary")
monkeypatch.setattr(cli_mod, "HermesCLI", FakeCLI)
monkeypatch.setattr(cli_mod.atexit, "register", lambda *_args, **_kwargs: None)
monkeypatch.setattr(
cli_mod,
"_finalize_single_query",
lambda fake_cli: calls.append(("finalize", fake_cli.session_id)),
)
cli_mod.main(query="hello", quiet=False, toolsets="terminal")
assert calls == [
("claim", "cli", False),
"query-label",
"advisories",
("chat", "hello", None),
"summary",
("finalize", "single-query-session"),
]
def test_quiet_single_query_main_finalizes_while_preserving_exit_code(monkeypatch):
calls = []
import cli as cli_mod
def run_conversation(*, user_message, conversation_history):
calls.append(("run", user_message, conversation_history))
return {
"final_response": "",
"error": "provider failed",
"failed": True,
}
class FakeCLI:
def __init__(self, **_kwargs):
self.provider = "test-provider"
self.model = "test-model"
self.session_id = "quiet-session"
self.conversation_history = []
self._active_agent_route_signature = "same-route"
self.agent = SimpleNamespace(
session_id="quiet-session",
platform="cli",
quiet_mode=False,
suppress_status_output=False,
stream_delta_callback=object(),
tool_gen_callback=object(),
run_conversation=run_conversation,
)
def _claim_active_session(self, surface, *, stderr=False):
calls.append(("claim", surface, stderr))
return True
def _ensure_runtime_credentials(self):
calls.append("credentials")
return True
def _resolve_turn_agent_config(self, effective_query):
calls.append(("resolve", effective_query))
return {
"signature": "same-route",
"model": None,
"runtime": None,
"request_overrides": None,
}
def _init_agent(self, **kwargs):
calls.append(("init", kwargs))
return True
monkeypatch.delenv("HERMES_KANBAN_TASK", raising=False)
monkeypatch.delenv("HERMES_KANBAN_GOAL_MODE", raising=False)
monkeypatch.setattr(cli_mod, "HermesCLI", FakeCLI)
monkeypatch.setattr(cli_mod.atexit, "register", lambda *_args, **_kwargs: None)
monkeypatch.setattr(
cli_mod,
"_finalize_single_query",
lambda fake_cli: calls.append(("finalize", fake_cli.session_id)),
)
with pytest.raises(SystemExit) as exc_info:
cli_mod.main(query="hello", quiet=True, toolsets="terminal")
assert exc_info.value.code == 1
assert ("claim", "cli", True) in calls
assert ("run", "hello", []) in calls
assert calls[-1] == ("finalize", "quiet-session")
+113
View File
@@ -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")
+404
View File
@@ -0,0 +1,404 @@
"""Regression tests for #30768, #32383, and #33961.
``_prompt_text_input_modal`` answers destructive-slash confirmations through a
queue-based modal driven by prompt_toolkit key bindings. When invoked from the
``process_loop`` daemon thread it sets the modal up on the app's event loop via
``call_soon_threadsafe``, so it is safe on every platform — including native
Windows (#33961), where the earlier ``sys.platform == "win32"`` → raw ``input()``
fallback deadlocked the daemon thread against prompt_toolkit's stdin ownership.
These tests verify:
1. Daemon-thread confirm uses the modal via the app loop on Linux AND native
Windows (#33961) — never the raw stdin fallback, never a hang.
2. Main-thread confirm with a running app uses the modal.
3. The raw stdin fallback is kept ONLY for the safe cases: no running app, and
(on win32, off-thread) a scheduling failure degrades to a clean cancel.
4. Empty choices returns None.
"""
import sys
import threading
import time
from unittest.mock import MagicMock, patch
import pytest
def _make_cli():
"""Minimal HermesCLI shell exposing the prompt/modal helpers."""
import cli as cli_mod
obj = object.__new__(cli_mod.HermesCLI)
obj._app = MagicMock()
obj._app.loop = MagicMock()
obj._status_bar_visible = True
obj._last_invalidate = 0.0
obj._modal_input_snapshot = None
obj._slash_confirm_state = None
obj._slash_confirm_deadline = 0
return obj
_SAMPLE_CHOICES = [
("once", "Approve Once", "proceed this time only"),
("always", "Always Approve", "proceed and silence this prompt permanently"),
("cancel", "Cancel", "keep current conversation"),
]
def _answer_modal_when_open(cli, response, stop=None):
"""Push ``response`` onto the modal's response_queue once it opens.
Gives up after ~2s, or early when ``stop`` is set (the modal will never open,
e.g. a scheduling failure) so degraded-path tests don't wait the full budget.
"""
for _ in range(100):
if stop is not None and stop.is_set():
return
state = cli._slash_confirm_state
if state and "response_queue" in state:
state["response_queue"].put(response)
return
time.sleep(0.02)
def _run_on_daemon(call, cli, *, platform, response, schedule=None):
"""Invoke ``call`` on a daemon thread — as the process_loop does — answering
the modal with ``response`` once it opens.
Returns ``{result, stdin_called, capture, restore}``. ``schedule`` overrides
the ``call_soon_threadsafe`` side effect (default: run the callback inline);
pass a raiser to simulate a scheduling failure. Fails if the worker hangs,
which is the deadlock canary for #33961.
"""
outcome = {"capture": [], "restore": [], "result": None, "stdin_called": False}
done = threading.Event()
def _worker():
try:
with patch.object(sys, "platform", platform), \
patch.object(cli._app.loop, "call_soon_threadsafe", side_effect=schedule or (lambda cb: cb())), \
patch.object(cli, "_prompt_text_input") as mock_stdin, \
patch.object(cli, "_invalidate"), \
patch.object(cli, "_capture_modal_input_snapshot", side_effect=lambda: outcome["capture"].append(1)), \
patch.object(cli, "_restore_modal_input_snapshot", side_effect=lambda: outcome["restore"].append(1)):
outcome["result"] = call()
outcome["stdin_called"] = mock_stdin.called
finally:
done.set()
worker = threading.Thread(target=_worker, daemon=True)
answerer = threading.Thread(target=_answer_modal_when_open, args=(cli, response, done), daemon=True)
answerer.start()
worker.start()
worker.join(timeout=2.0)
answerer.join(timeout=2.0)
assert not worker.is_alive(), "daemon thread hung — modal deadlocked"
return outcome
class TestModal:
"""Behaviour of _prompt_text_input_modal across platforms and threads."""
@pytest.mark.parametrize("platform", ["linux", "win32"])
def test_daemon_thread_uses_modal_via_app_loop(self, platform):
"""Off the process_loop daemon thread, the confirm uses the modal via
call_soon_threadsafe on every platform — including native Windows, where
the old win32 early-return deadlocked on raw input() (#33961)."""
cli = _make_cli()
outcome = _run_on_daemon(
lambda: cli._prompt_text_input_modal(
title="⚠️ /reset",
detail="This starts a fresh session.",
choices=_SAMPLE_CHOICES,
timeout=5,
),
cli,
platform=platform,
response="once",
)
assert outcome["stdin_called"] is False, "must use the modal, not raw input()"
assert outcome["result"] == "once"
assert outcome["capture"] == [1]
assert outcome["restore"] == [1]
assert cli._slash_confirm_state is None
def test_main_thread_with_app_uses_modal(self):
"""On the main thread with a running app, the queue-based modal is used."""
cli = _make_cli()
with patch.object(sys, "platform", "darwin"), \
patch.object(cli, "_capture_modal_input_snapshot"), \
patch.object(cli, "_restore_modal_input_snapshot"), \
patch.object(cli, "_invalidate"), \
patch.object(cli, "_prompt_text_input") as mock_stdin:
answerer = threading.Thread(target=_answer_modal_when_open, args=(cli, "once"), daemon=True)
answerer.start()
result = cli._prompt_text_input_modal(
title="⚠️ /new",
detail="This starts a fresh session.",
choices=_SAMPLE_CHOICES,
timeout=5,
)
answerer.join(timeout=2.0)
mock_stdin.assert_not_called()
assert result == "once"
def test_no_app_falls_back_to_stdin(self):
"""Without a running app (oneshot / non-interactive), use the stdin prompt."""
cli = _make_cli()
cli._app = None
with patch.object(cli, "_prompt_text_input", return_value="3") as mock_stdin:
result = cli._prompt_text_input_modal(
title="⚠️ /clear",
detail="This clears the screen.",
choices=_SAMPLE_CHOICES,
)
mock_stdin.assert_called_once_with("Choice [1/2/3]: ")
assert result == "3"
def test_windows_no_app_falls_back_to_stdin(self):
"""win32 without a running app keeps stdin — the only case where the raw
prompt is safe on Windows, since no app owns the console to deadlock."""
cli = _make_cli()
cli._app = None
with patch.object(sys, "platform", "win32"), \
patch.object(cli, "_prompt_text_input", return_value="1") as mock_stdin:
result = cli._prompt_text_input_modal(
title="⚠️ /new — destroys conversation state",
detail="This starts a fresh session.",
choices=_SAMPLE_CHOICES,
)
mock_stdin.assert_called_once_with("Choice [1/2/3]: ")
assert result == "1"
def test_windows_scheduling_failure_clean_cancels(self):
"""win32 off the main thread: if marshaling onto the app loop fails, cancel
cleanly (None) rather than fall to raw input() (which deadlocks on native
Windows) or hang. Asserts the _stdin_fallback guard (#33961)."""
cli = _make_cli()
def _raise(_cb):
raise RuntimeError("loop closed")
outcome = _run_on_daemon(
lambda: cli._prompt_text_input_modal(
title="⚠️ /reset",
detail="This starts a fresh session.",
choices=_SAMPLE_CHOICES,
timeout=5,
),
cli,
platform="win32",
response="once",
schedule=_raise,
)
assert outcome["stdin_called"] is False, "win32 off-thread must NOT call raw input()"
assert outcome["result"] is None
assert cli._slash_confirm_state is None
@pytest.mark.parametrize(
"platform, expect_stdin, expect_result",
[("win32", False, None), ("linux", True, "1")],
)
def test_daemon_thread_no_app_loop_uses_fallback(self, platform, expect_stdin, expect_result):
"""Off the daemon thread with no resolvable app loop (``self._app.loop``
is None / raises), the modal can never be scheduled, so the method short-
circuits at the app_loop-is-None site (cli.py ~7260) — a distinct path
from a call_soon_threadsafe failure. win32 clean-cancels (None) instead of
deadlocking on raw input(); other platforms keep the stdin prompt."""
cli = _make_cli()
cli._app.loop = None # forces app_loop is None, off the main thread
outcome = {"result": None, "stdin_called": False}
done = threading.Event()
def _worker():
try:
with patch.object(sys, "platform", platform), \
patch.object(cli, "_prompt_text_input", return_value="1") as mock_stdin, \
patch.object(cli, "_invalidate"):
outcome["result"] = cli._prompt_text_input_modal(
title="⚠️ /reset",
detail="This starts a fresh session.",
choices=_SAMPLE_CHOICES,
timeout=5,
)
outcome["stdin_called"] = mock_stdin.called
finally:
done.set()
worker = threading.Thread(target=_worker, daemon=True)
worker.start()
worker.join(timeout=2.0)
assert not worker.is_alive(), "daemon thread hung — modal deadlocked"
assert outcome["stdin_called"] is expect_stdin
assert outcome["result"] == expect_result
assert cli._slash_confirm_state is None
def test_empty_choices_returns_none(self):
"""Empty choices returns None without prompting."""
cli = _make_cli()
with patch.object(cli, "_prompt_text_input") as mock_stdin:
result = cli._prompt_text_input_modal(title="Test", detail="Test", choices=[])
mock_stdin.assert_not_called()
assert result is None
class TestConfirmDestructiveSlashWindows:
"""End-to-end _confirm_destructive_slash on the native-Windows daemon thread."""
def _make_interactive_cli(self):
cli = _make_cli()
cli.model = "test-model"
cli._agent_running = False
cli._spinner_text = ""
cli._should_exit = False
cli._command_running = False
cli.session_id = "test-session"
cli._pending_tool_info = {}
cli._tool_start_time = 0.0
cli._last_scrollback_tool = ""
return cli
@pytest.mark.parametrize(
"response, expected",
[("once", "once"), ("cancel", None)],
)
def test_confirm_destructive_slash_uses_modal_on_windows(self, response, expected):
"""On native Windows, the bare /new confirm drives the modal (not stdin)
and returns the chosen outcome — the bug #33961 froze this path."""
cli = self._make_interactive_cli()
with patch("cli.load_cli_config", return_value={"approvals": {"destructive_slash_confirm": True}}):
outcome = _run_on_daemon(
lambda: cli._confirm_destructive_slash(
"new",
"This starts a fresh session.\nThe current conversation history will be discarded.",
),
cli,
platform="win32",
response=response,
)
assert outcome["stdin_called"] is False
assert outcome["result"] == expected
class TestNativeWindowsNoRawInputDeadlock:
"""Anti-regression guard exercising the REAL ``_prompt_text_input``.
Every other test here mocks ``_prompt_text_input`` away, so they only
assert *routing* (modal vs. stdin) — they cannot observe the actual hang
that #33961 was. The historical regression was precisely that
``_prompt_text_input_modal`` delegated to the *real* ``_prompt_text_input``
on native Windows, which on a non-main thread runs a bare ``input()`` that
blocks forever against prompt_toolkit's stdin ownership.
These tests let the real ``_prompt_text_input`` run with a blocking
``input()`` and assert the worker thread never hangs. They fail on the
pre-#33961 code (win32 → ``_prompt_text_input`` → off-main ``input()``)
and pass once the modal path / clean-cancel fallback is in place.
"""
def test_win32_daemon_thread_never_blocks_on_real_input(self):
"""A blocking input() must NOT hang the daemon thread on win32.
Drives the genuine helper chain (no mock of ``_prompt_text_input``)
with ``builtins.input`` patched to block forever. The confirm must
resolve via the app-loop modal (answered on a background thread, as
the real key bindings would) and never sit in ``input()``. On the
pre-#33961 code the win32 early-return routed to the real
``_prompt_text_input`` → off-main ``input()`` → permanent hang.
"""
cli = _make_cli()
cli._app.loop.call_soon_threadsafe = lambda cb: cb()
def _blocking_input(prompt=""): # stands in for "no line ever arrives"
time.sleep(30)
return "1"
outcome = {}
done = threading.Event()
def _worker():
try:
with patch.object(sys, "platform", "win32"), \
patch("builtins.input", side_effect=_blocking_input), \
patch.object(cli, "_capture_modal_input_snapshot"), \
patch.object(cli, "_restore_modal_input_snapshot"), \
patch.object(cli, "_invalidate"):
outcome["result"] = cli._prompt_text_input_modal(
title="/new",
detail="destroys conversation state",
choices=_SAMPLE_CHOICES,
timeout=3,
)
finally:
done.set()
worker = threading.Thread(target=_worker, daemon=True)
answerer = threading.Thread(
target=_answer_modal_when_open, args=(cli, "cancel", done), daemon=True
)
answerer.start()
worker.start()
worker.join(timeout=5.0)
answerer.join(timeout=5.0)
assert not worker.is_alive(), (
"daemon thread hung in real input() — native-Windows confirm "
"deadlock regressed (#33961)"
)
# cancel → None; the point is it RETURNED rather than blocking forever.
assert outcome.get("result") in (None, "cancel")
def test_win32_scheduling_failure_cleanly_cancels_no_input(self):
"""If the modal can't be marshaled onto the app loop on native Windows
(scheduling failure) the off-main-thread path must cancel cleanly —
NOT fall through to a blocking raw ``input()``.
This is the degraded branch the pre-#33961 code handled with
``return self._prompt_text_input(...)`` (which deadlocks); the fix
returns ``None`` instead.
"""
cli = _make_cli()
def _raise(cb): # call_soon_threadsafe scheduling failure
raise RuntimeError("event loop closed")
cli._app.loop.call_soon_threadsafe = _raise
input_called = {"n": 0}
def _tracking_input(prompt=""):
input_called["n"] += 1
time.sleep(30)
return "1"
outcome = {}
def _worker():
with patch.object(sys, "platform", "win32"), \
patch("builtins.input", side_effect=_tracking_input), \
patch.object(cli, "_invalidate"):
outcome["result"] = cli._prompt_text_input_modal(
title="/new",
detail="destroys conversation state",
choices=_SAMPLE_CHOICES,
timeout=3,
)
worker = threading.Thread(target=_worker, daemon=True)
worker.start()
worker.join(timeout=5.0)
assert not worker.is_alive(), (
"daemon thread hung — win32 scheduling-failure fallback used raw "
"input() instead of cleanly cancelling (#33961)"
)
assert input_called["n"] == 0, "win32 off-thread fallback must not call input()"
assert outcome.get("result") is None
@@ -0,0 +1,116 @@
"""Regression guard for issue #34569 — inline /steer (and /model) submit
must repaint the input area after clearing the buffer.
Mechanism of the bug
--------------------
``handle_enter`` dispatches ``/steer`` (and ``/model``) inline on the UI
thread while the agent is running. Those branches called
``buffer.reset(append_to_history=True)`` but — unlike every *other*
early-return branch in the handler — did NOT call ``event.app.invalidate()``.
Because ``process_command()`` prints through ``patch_stdout`` (which scrolls
output above the prompt and never triggers a prompt_toolkit redraw), the
just-cleared input area could keep showing the submitted ``/steer <text>``
until some unrelated redraw fired. The user saw their submitted text as if
it were unsent and could accidentally re-submit it.
This test pins the contract structurally: inside ``handle_enter``, any
inline-command early-return that resets the buffer must be followed by an
``event.app.invalidate()`` before its ``return``. It is an *invariant*
(every reset-then-return repaints), not a snapshot of current source.
"""
from __future__ import annotations
import ast
from pathlib import Path
def _load_handle_enter_node() -> ast.FunctionDef:
"""Extract the ``handle_enter`` nested function node from cli.py."""
cli_path = Path(__file__).resolve().parents[2] / "cli.py"
tree = ast.parse(cli_path.read_text(encoding="utf-8"))
target = None
for node in ast.walk(tree):
if isinstance(node, ast.FunctionDef) and node.name == "handle_enter":
target = node
break
assert target is not None, "handle_enter closure not found in cli.py"
return target
def _is_buffer_reset(node: ast.stmt) -> bool:
"""True if the statement is ``...current_buffer.reset(...)``."""
if not isinstance(node, ast.Expr):
return False
call = node.value
if not isinstance(call, ast.Call):
return False
func = call.func
return isinstance(func, ast.Attribute) and func.attr == "reset"
def _is_invalidate(node: ast.stmt) -> bool:
"""True if the statement is ``event.app.invalidate()``."""
if not isinstance(node, ast.Expr):
return False
call = node.value
if not isinstance(call, ast.Call):
return False
func = call.func
return isinstance(func, ast.Attribute) and func.attr == "invalidate"
def _collect_reset_blocks(func: ast.FunctionDef) -> list[list[ast.stmt]]:
"""Find every statement sequence (a block body/orelse/finalbody) within
``handle_enter`` that contains a ``buffer.reset()`` call."""
blocks: list[list[ast.stmt]] = []
for node in ast.walk(func):
for attr in ("body", "orelse", "finalbody"):
seq = getattr(node, attr, None)
if not isinstance(seq, list):
continue
if any(isinstance(s, ast.stmt) and _is_buffer_reset(s) for s in seq):
blocks.append(seq)
return blocks
def test_inline_command_reset_branches_invalidate():
"""Every handle_enter branch that resets the buffer and then returns must
invalidate the app first (issue #34569)."""
func = _load_handle_enter_node()
reset_blocks = _collect_reset_blocks(func)
assert reset_blocks, "expected to find buffer.reset() calls in handle_enter"
offenders = []
for seq in reset_blocks:
for i, stmt in enumerate(seq):
if not _is_buffer_reset(stmt):
continue
# Find the next return after this reset in the same block.
ret_idx = None
for j in range(i + 1, len(seq)):
if isinstance(seq[j], ast.Return):
ret_idx = j
break
if ret_idx is None:
# reset not directly followed by a return in this block
# (e.g. the fall-through reset at the end of the handler) —
# the next user input naturally repaints, so skip.
continue
between = seq[i + 1 : ret_idx]
if not any(_is_invalidate(s) for s in between):
offenders.append(ast.dump(stmt))
assert not offenders, (
"handle_enter has reset-then-return branch(es) that never call "
"event.app.invalidate() — the input area can keep showing the "
"submitted text (issue #34569). Offending reset stmts:\n"
+ "\n".join(offenders)
)
if __name__ == "__main__": # pragma: no cover
test_inline_command_reset_branches_invalidate()
print("ok")
+152
View File
@@ -0,0 +1,152 @@
"""Tests for _stream_delta's handling of <think> tags in prose vs real reasoning blocks."""
import sys
import os
import pytest
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", ".."))
def _make_cli_stub():
"""Create a minimal HermesCLI-like object with stream state."""
from cli import HermesCLI
cli = HermesCLI.__new__(HermesCLI)
cli.show_reasoning = False
cli._stream_buf = ""
cli._stream_started = False
cli._stream_box_opened = False
cli._stream_prefilt = ""
cli._in_reasoning_block = False
cli._reasoning_stream_started = False
cli._reasoning_box_opened = False
cli._reasoning_buf = ""
cli._reasoning_preview_buf = ""
cli._deferred_content = ""
cli._stream_text_ansi = ""
cli._stream_needs_break = False
cli._emitted = []
# Mock _emit_stream_text to capture output
def mock_emit(text):
cli._emitted.append(text)
cli._emit_stream_text = mock_emit
# Mock _stream_reasoning_delta
cli._reasoning_emitted = []
def mock_reasoning(text):
cli._reasoning_emitted.append(text)
cli._stream_reasoning_delta = mock_reasoning
return cli
class TestThinkTagInProse:
"""<think> mentioned in prose should NOT trigger reasoning suppression."""
def test_think_tag_mid_sentence(self):
"""'(/think not producing <think> tags)' should pass through."""
cli = _make_cli_stub()
tokens = [
" 1. Fix reasoning mode in eval ",
"(/think not producing ",
"<think>",
" tags — ~2% gap)",
"\n 2. Launch production",
]
for t in tokens:
cli._stream_delta(t)
assert not cli._in_reasoning_block, "<think> in prose should not enter reasoning block"
full = "".join(cli._emitted)
assert "<think>" in full, "The literal <think> tag should be in the emitted text"
assert "Launch production" in full
def test_think_tag_after_text_on_same_line(self):
"""'some text <think>' should NOT trigger reasoning."""
cli = _make_cli_stub()
cli._stream_delta("Here is the <think> tag explanation")
assert not cli._in_reasoning_block
full = "".join(cli._emitted)
assert "<think>" in full
def test_think_tag_in_backticks(self):
"""'`<think>`' should NOT trigger reasoning."""
cli = _make_cli_stub()
cli._stream_delta("Use the `<think>` tag for reasoning")
assert not cli._in_reasoning_block
class TestRealReasoningBlock:
"""Real <think> tags at block boundaries should still be caught."""
def test_think_at_start_of_stream(self):
"""'<think>reasoning</think>answer' should suppress reasoning."""
cli = _make_cli_stub()
cli._stream_delta("<think>")
assert cli._in_reasoning_block
cli._stream_delta("I need to analyze this")
cli._stream_delta("</think>")
assert not cli._in_reasoning_block
cli._stream_delta("Here is my answer")
full = "".join(cli._emitted)
assert "Here is my answer" in full
assert "I need to analyze" not in full # reasoning was suppressed
def test_think_after_newline(self):
"""'text\\n<think>' should trigger reasoning block."""
cli = _make_cli_stub()
cli._stream_delta("Some preamble\n<think>")
assert cli._in_reasoning_block
full = "".join(cli._emitted)
assert "Some preamble" in full
def test_think_after_newline_with_whitespace(self):
"""'text\\n <think>' should trigger reasoning block."""
cli = _make_cli_stub()
cli._stream_delta("Some preamble\n <think>")
assert cli._in_reasoning_block
def test_think_with_only_whitespace_before(self):
"""' <think>' (whitespace only prefix) should trigger."""
cli = _make_cli_stub()
cli._stream_delta(" <think>")
assert cli._in_reasoning_block
@pytest.mark.parametrize(
"tag",
["THINK", "Think", "ThInK", "THOUGHT", "REASONING", "Thinking"],
)
def test_reasoning_tags_are_case_insensitive(self, tag):
cli = _make_cli_stub()
cli._stream_delta(f"<{tag}>hidden reasoning</{tag}>Visible answer")
assert not cli._in_reasoning_block
full = "".join(cli._emitted)
assert full == "Visible answer"
assert "hidden reasoning" not in full
class TestFlushRecovery:
"""_flush_stream should recover content from false-positive reasoning blocks."""
def test_flush_recovers_buffered_content(self):
"""If somehow in reasoning block at flush, content is recovered."""
cli = _make_cli_stub()
# Manually set up a false-positive state
cli._in_reasoning_block = True
cli._stream_prefilt = " tags — ~2% gap)\n 2. Launch production"
cli._stream_box_opened = True
# Mock _close_reasoning_box and box closing
cli._close_reasoning_box = lambda: None
# Call flush
from unittest.mock import patch
import shutil
with patch.object(shutil, "get_terminal_size", return_value=os.terminal_size((80, 24))):
with patch("cli._cprint"):
cli._flush_stream()
assert not cli._in_reasoning_block
full = "".join(cli._emitted)
assert "Launch production" in full
@@ -0,0 +1,94 @@
"""Streaming display force-flush: long partial lines must paint before the
first newline arrives (TTFT-perception fix, July 2026).
Previously ``_emit_stream_text`` only emitted on ``"\\n"``, so a response
opening with a long paragraph stayed invisible until the model produced a
newline — seconds of blank box on slow models. Now partial lines are
force-flushed at terminal width (mirroring the reasoning box's 80-char rule).
"""
import os
import re
import sys
import pytest
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", ".."))
def _strip_ansi(s: str) -> str:
return re.sub(r"\x1b\[[0-9;]*m", "", s)
@pytest.fixture
def cli_stub(monkeypatch):
from cli import HermesCLI
import cli as climod
cli = HermesCLI.__new__(HermesCLI)
cli.show_reasoning = False
cli.final_response_markdown = "raw"
cli.show_timestamps = False
cli._reset_stream_state()
emitted = []
monkeypatch.setattr(climod, "_cprint", lambda s: emitted.append(s))
# Deterministic width regardless of the test runner's terminal
monkeypatch.setattr(climod, "_terminal_width_for_streaming", lambda: 74)
return cli, emitted
class TestPartialLineForceFlush:
def test_long_paragraph_paints_before_first_newline(self, cli_stub):
cli, emitted = cli_stub
text = (
"This is a long opening paragraph that would previously sit "
"invisible in the buffer until the model finally produced a "
"newline character, which on a slow model could take seconds. "
) * 3
for i in range(0, len(text), 12):
cli._stream_delta(text[i : i + 12])
# Box header + several wrapped lines painted with NO newline seen yet
assert len(emitted) > 3
def test_no_content_lost_across_wraps(self, cli_stub):
cli, emitted = cli_stub
words = [f"word{i}" for i in range(120)]
text = " ".join(words)
for i in range(0, len(text), 7):
cli._stream_delta(text[i : i + 7])
cli._flush_stream()
plain = " ".join(_strip_ansi("\n".join(emitted)).split())
for w in words:
assert w in plain, f"lost {w} at a wrap boundary"
def test_short_partial_stays_buffered(self, cli_stub):
cli, emitted = cli_stub
cli._stream_delta("short line, no newline")
# Under wrap width: the box header may open, but the text itself
# stays buffered until a newline or the width threshold.
plain = _strip_ansi("\n".join(emitted))
assert "short line" not in plain
assert cli._stream_buf == "short line, no newline"
def test_table_rows_not_force_flushed(self, cli_stub):
cli, emitted = cli_stub
# A long partial table row must stay buffered for block realignment
row = "| " + " | ".join(f"cell{i}" for i in range(20)) + " |"
cli._stream_delta(row) # no newline
plain = _strip_ansi("\n".join(emitted))
assert "cell19" not in plain
def test_newline_lines_still_emit_normally(self, cli_stub):
cli, emitted = cli_stub
cli._stream_delta("line one\nline two\n")
plain = _strip_ansi("\n".join(emitted))
assert "line one" in plain
assert "line two" in plain
def test_unbreakable_run_hard_wraps(self, cli_stub):
cli, emitted = cli_stub
blob = "x" * 300 # no spaces
cli._stream_delta(blob)
cli._flush_stream()
plain = _strip_ansi("\n".join(emitted))
assert plain.count("x") == 300
+335
View File
@@ -0,0 +1,335 @@
"""Tests for surrogate character sanitization in user input.
Surrogates (U+D800..U+DFFF) are invalid in UTF-8 and crash json.dumps()
inside the OpenAI SDK. They can appear via clipboard paste from rich-text
editors like Google Docs, OR from byte-level reasoning models (xiaomi/mimo,
kimi, glm) emitting lone halves in reasoning output.
"""
import json
import pytest
from unittest.mock import MagicMock, patch
from run_agent import (
_sanitize_surrogates,
_sanitize_messages_surrogates,
_sanitize_structure_surrogates,
)
class TestSanitizeSurrogates:
"""Test the _sanitize_surrogates() helper."""
def test_normal_text_unchanged(self):
text = "Hello, this is normal text with unicode: café ñ 日本語 🎉"
assert _sanitize_surrogates(text) == text
def test_empty_string(self):
assert _sanitize_surrogates("") == ""
def test_single_surrogate_replaced(self):
result = _sanitize_surrogates("Hello \udce2 world")
assert result == "Hello \ufffd world"
def test_multiple_surrogates_replaced(self):
result = _sanitize_surrogates("a\ud800b\udc00c\udfff")
assert result == "a\ufffdb\ufffdc\ufffd"
def test_all_surrogate_range(self):
"""Verify the regex catches the full surrogate range."""
for cp in [0xD800, 0xD900, 0xDA00, 0xDB00, 0xDC00, 0xDD00, 0xDE00, 0xDF00, 0xDFFF]:
text = f"test{chr(cp)}end"
result = _sanitize_surrogates(text)
assert '\ufffd' in result, f"Surrogate U+{cp:04X} not caught"
def test_result_is_json_serializable(self):
"""Sanitized text must survive json.dumps + utf-8 encoding."""
dirty = "data \udce2\udcb0 from clipboard"
clean = _sanitize_surrogates(dirty)
serialized = json.dumps({"content": clean}, ensure_ascii=False)
# Must not raise UnicodeEncodeError
serialized.encode("utf-8")
def test_original_surrogates_fail_encoding(self):
"""Confirm the original bug: surrogates crash utf-8 encoding."""
dirty = "data \udce2 from clipboard"
serialized = json.dumps({"content": dirty}, ensure_ascii=False)
with pytest.raises(UnicodeEncodeError):
serialized.encode("utf-8")
class TestSanitizeMessagesSurrogates:
"""Test the _sanitize_messages_surrogates() helper for message lists."""
def test_clean_messages_returns_false(self):
msgs = [
{"role": "user", "content": "all clean"},
{"role": "assistant", "content": "me too"},
]
assert _sanitize_messages_surrogates(msgs) is False
def test_dirty_string_content_sanitized(self):
msgs = [
{"role": "user", "content": "text with \udce2 surrogate"},
]
assert _sanitize_messages_surrogates(msgs) is True
assert "\ufffd" in msgs[0]["content"]
assert "\udce2" not in msgs[0]["content"]
def test_dirty_multimodal_content_sanitized(self):
msgs = [
{"role": "user", "content": [
{"type": "text", "text": "multimodal \udce2 content"},
{"type": "image_url", "image_url": {"url": "http://example.com"}},
]},
]
assert _sanitize_messages_surrogates(msgs) is True
assert "\ufffd" in msgs[0]["content"][0]["text"]
assert "\udce2" not in msgs[0]["content"][0]["text"]
def test_mixed_clean_and_dirty(self):
msgs = [
{"role": "user", "content": "clean text"},
{"role": "user", "content": "dirty \udce2 text"},
{"role": "assistant", "content": "clean response"},
]
assert _sanitize_messages_surrogates(msgs) is True
assert msgs[0]["content"] == "clean text"
assert "\ufffd" in msgs[1]["content"]
assert msgs[2]["content"] == "clean response"
def test_non_dict_items_skipped(self):
msgs = ["not a dict", {"role": "user", "content": "ok"}]
assert _sanitize_messages_surrogates(msgs) is False
def test_tool_messages_sanitized(self):
"""Tool results could also contain surrogates from file reads etc."""
msgs = [
{"role": "tool", "content": "result with \udce2 data", "tool_call_id": "x"},
]
assert _sanitize_messages_surrogates(msgs) is True
assert "\ufffd" in msgs[0]["content"]
class TestReasoningFieldSurrogates:
"""Surrogates in reasoning fields (byte-level reasoning models).
xiaomi/mimo, kimi, glm and similar byte-level tokenizers can emit lone
surrogates in reasoning output. These fields are carried through to the
API as `reasoning_content` on assistant messages, and must be sanitized
or json.dumps() crashes with 'utf-8' codec can't encode surrogates.
"""
def test_reasoning_field_sanitized(self):
msgs = [
{"role": "assistant", "content": "ok", "reasoning": "thought \udce2 here"},
]
assert _sanitize_messages_surrogates(msgs) is True
assert "\udce2" not in msgs[0]["reasoning"]
assert "\ufffd" in msgs[0]["reasoning"]
def test_reasoning_content_field_sanitized(self):
"""api_messages carry `reasoning_content` built from `reasoning`."""
msgs = [
{"role": "assistant", "content": "ok", "reasoning_content": "thought \udce2 here"},
]
assert _sanitize_messages_surrogates(msgs) is True
assert "\udce2" not in msgs[0]["reasoning_content"]
assert "\ufffd" in msgs[0]["reasoning_content"]
def test_reasoning_details_nested_sanitized(self):
"""reasoning_details is a list of dicts with nested string fields."""
msgs = [
{
"role": "assistant",
"content": "ok",
"reasoning_details": [
{"type": "reasoning.summary", "summary": "summary \udce2 text"},
{"type": "reasoning.text", "text": "chain \udc00 of thought"},
],
},
]
assert _sanitize_messages_surrogates(msgs) is True
assert "\udce2" not in msgs[0]["reasoning_details"][0]["summary"]
assert "\ufffd" in msgs[0]["reasoning_details"][0]["summary"]
assert "\udc00" not in msgs[0]["reasoning_details"][1]["text"]
assert "\ufffd" in msgs[0]["reasoning_details"][1]["text"]
def test_deeply_nested_reasoning_sanitized(self):
"""Nested dicts / lists inside extra fields are recursed into."""
msgs = [
{
"role": "assistant",
"content": "ok",
"reasoning_details": [
{
"type": "reasoning.encrypted",
"content": {
"encrypted_content": "opaque",
"text_parts": ["part1", "part2 \udce2 part"],
},
},
],
},
]
assert _sanitize_messages_surrogates(msgs) is True
assert (
msgs[0]["reasoning_details"][0]["content"]["text_parts"][1]
== "part2 \ufffd part"
)
def test_reasoning_end_to_end_json_serialization(self):
"""After sanitization, the full message dict must serialize clean."""
msgs = [
{
"role": "assistant",
"content": "answer",
"reasoning_content": "reasoning with \udce2 surrogate",
"reasoning_details": [
{"summary": "nested \udcb0 surrogate"},
],
},
]
_sanitize_messages_surrogates(msgs)
# Must round-trip through json + utf-8 encoding without error
payload = json.dumps(msgs, ensure_ascii=False).encode("utf-8")
assert b"\\" not in payload[:0] # sanity — just ensure we got bytes
assert len(payload) > 0
def test_no_surrogates_returns_false(self):
"""Clean reasoning fields don't trigger a modification."""
msgs = [
{
"role": "assistant",
"content": "ok",
"reasoning": "clean thought",
"reasoning_content": "also clean",
"reasoning_details": [{"summary": "clean summary"}],
},
]
assert _sanitize_messages_surrogates(msgs) is False
class TestSanitizeStructureSurrogates:
"""Test the _sanitize_structure_surrogates() helper for nested payloads."""
def test_empty_payload(self):
assert _sanitize_structure_surrogates({}) is False
assert _sanitize_structure_surrogates([]) is False
def test_flat_dict(self):
payload = {"a": "clean", "b": "dirty \udce2 text"}
assert _sanitize_structure_surrogates(payload) is True
assert payload["a"] == "clean"
assert "\ufffd" in payload["b"]
def test_flat_list(self):
payload = ["clean", "dirty \udce2"]
assert _sanitize_structure_surrogates(payload) is True
assert payload[0] == "clean"
assert "\ufffd" in payload[1]
def test_nested_dict_in_list(self):
payload = [{"x": "dirty \udce2"}, {"x": "clean"}]
assert _sanitize_structure_surrogates(payload) is True
assert "\ufffd" in payload[0]["x"]
assert payload[1]["x"] == "clean"
def test_deeply_nested(self):
payload = {
"level1": {
"level2": [
{"level3": "deep \udce2 surrogate"},
],
},
}
assert _sanitize_structure_surrogates(payload) is True
assert "\ufffd" in payload["level1"]["level2"][0]["level3"]
def test_clean_payload_returns_false(self):
payload = {"a": "clean", "b": [{"c": "also clean"}]}
assert _sanitize_structure_surrogates(payload) is False
def test_non_string_values_ignored(self):
payload = {"int": 42, "list": [1, 2, 3], "dict": {"none": None}, "bool": True}
assert _sanitize_structure_surrogates(payload) is False
# Non-string values survive unchanged
assert payload["int"] == 42
assert payload["list"] == [1, 2, 3]
class TestApiMessagesSurrogateRecovery:
"""Integration: verify the recovery block sanitizes api_messages.
The bug this guards against: a surrogate in `reasoning_content` on
api_messages (transformed from `reasoning` during build) crashes the
OpenAI SDK's json.dumps(), and the recovery block previously only
sanitized the canonical `messages` list — not `api_messages` — so the
next retry would send the same broken payload and fail 3 times.
"""
def test_api_messages_reasoning_content_sanitized(self):
"""The extended sanitizer catches reasoning_content in api_messages."""
api_messages = [
{"role": "system", "content": "sys"},
{
"role": "assistant",
"content": "response",
"reasoning_content": "thought \udce2 trail",
"tool_calls": [
{
"id": "call_1",
"function": {"name": "tool", "arguments": "{}"},
}
],
},
{"role": "tool", "content": "result", "tool_call_id": "call_1"},
]
assert _sanitize_messages_surrogates(api_messages) is True
assert "\udce2" not in api_messages[1]["reasoning_content"]
# Full payload must now serialize clean
json.dumps(api_messages, ensure_ascii=False).encode("utf-8")
class TestRunConversationSurrogateSanitization:
"""Integration: verify run_conversation sanitizes user_message."""
@patch("run_agent.AIAgent._build_system_prompt")
@patch("run_agent.AIAgent._interruptible_streaming_api_call")
@patch("run_agent.AIAgent._interruptible_api_call")
def test_user_message_surrogates_sanitized(self, mock_api, mock_stream, mock_sys):
"""Surrogates in user_message are stripped before API call."""
from run_agent import AIAgent
mock_sys.return_value = "system prompt"
# Mock streaming to return a simple response
mock_choice = MagicMock()
mock_choice.message.content = "response"
mock_choice.message.tool_calls = None
mock_choice.message.refusal = None
mock_choice.finish_reason = "stop"
mock_choice.message.reasoning_content = None
mock_response = MagicMock()
mock_response.choices = [mock_choice]
mock_response.usage = MagicMock(prompt_tokens=10, completion_tokens=5, total_tokens=15)
mock_response.model = "test-model"
mock_response.id = "test-id"
mock_stream.return_value = mock_response
mock_api.return_value = mock_response
agent = AIAgent(model="test/model", api_key="test-key", base_url="http://localhost:1234/v1", quiet_mode=True, skip_memory=True, skip_context_files=True)
agent.client = MagicMock()
# Pass a message with surrogates
result = agent.run_conversation(
user_message="test \udce2 message",
conversation_history=[],
)
# The message stored in history should have surrogates replaced
for msg in result.get("messages", []):
if msg.get("role") == "user":
assert "\udce2" not in msg["content"], "Surrogate leaked into stored message"
assert "\ufffd" in msg["content"], "Replacement char not in stored message"
@@ -0,0 +1,112 @@
"""Regression tests for #33271: terminal recovery after interrupt.
When the user interrupts a running agent turn by typing a new message,
prompt_toolkit may have an in-flight ``CSI 6n`` cursor-position query whose
reply (``ESC[<row>;<col>R``) arrives on stdin after the input parser has torn
down. The reply then leaks as literal text (``^[[19;1R``) and the VT100 parser
can stall, accepting no further keystrokes — the terminal appears frozen.
The recovery path lives in ``HermesCLI._recover_terminal_after_interrupt()``,
which is invoked from ``process_loop``'s ``finally`` block only when
``self._last_turn_interrupted`` is set. It must:
1. Drain stray escape bytes from the OS input buffer (``flush_stdin``).
2. Force a clean prompt_toolkit renderer redraw (``_force_full_redraw``).
These tests exercise the real method (not a re-implementation of its logic),
and assert that the finally block actually wires it in behind the interrupt
guard.
"""
import inspect
import re
from unittest.mock import MagicMock, patch
import pytest
import cli as cli_mod
from cli import HermesCLI
@pytest.fixture
def bare_cli():
"""A HermesCLI with no __init__ — we only exercise the recovery helper."""
return object.__new__(HermesCLI)
class TestRecoverTerminalAfterInterrupt:
"""Directly exercise HermesCLI._recover_terminal_after_interrupt()."""
def test_drains_stdin_then_redraws(self, bare_cli):
"""Happy path: flush_stdin runs, then a full redraw is forced."""
bare_cli._force_full_redraw = MagicMock()
with patch("hermes_cli.curses_ui.flush_stdin") as mock_flush:
bare_cli._recover_terminal_after_interrupt()
mock_flush.assert_called_once()
bare_cli._force_full_redraw.assert_called_once()
def test_redraw_still_runs_when_flush_fails(self, bare_cli):
"""A flush_stdin failure (no TTY, non-POSIX) must not skip the redraw.
The two recovery steps are independent — losing the stdin drain must
never leave the renderer un-repainted.
"""
bare_cli._force_full_redraw = MagicMock()
with patch(
"hermes_cli.curses_ui.flush_stdin", side_effect=OSError("no tty")
):
bare_cli._recover_terminal_after_interrupt() # must not raise
bare_cli._force_full_redraw.assert_called_once()
def test_flush_runs_before_redraw(self, bare_cli):
"""Order matters: drain stray bytes first so they don't arrive mid-redraw."""
events = []
bare_cli._force_full_redraw = MagicMock(
side_effect=lambda: events.append("redraw")
)
with patch(
"hermes_cli.curses_ui.flush_stdin",
side_effect=lambda: events.append("flush"),
):
bare_cli._recover_terminal_after_interrupt()
assert events == ["flush", "redraw"]
def test_flush_stdin_is_tty_gated(self):
"""The real flush_stdin is a no-op on non-TTY stdin (piped/redirected).
Under pytest stdin is not a TTY, so this must return cleanly without
touching termios.
"""
from hermes_cli.curses_ui import flush_stdin
flush_stdin() # must not raise in a non-TTY test environment
class TestFinallyBlockWiring:
"""The recovery helper is only useful if process_loop actually calls it.
These guard against the helper silently becoming dead code (the fix being
present but never invoked), which a unit test of the helper alone can't
catch.
"""
def test_recovery_is_invoked_behind_interrupt_guard(self):
src = inspect.getsource(HermesCLI.run)
# The recovery call must be gated on _last_turn_interrupted so it only
# fires after an actual interrupt, not on every normal turn.
guard = re.search(
r"if self\._last_turn_interrupted:\s*\n\s*"
r"self\._recover_terminal_after_interrupt\(\)",
src,
)
assert guard, (
"process_loop's finally block must call "
"_recover_terminal_after_interrupt() guarded by "
"self._last_turn_interrupted"
)
def test_recovery_helper_exists(self):
assert hasattr(HermesCLI, "_recover_terminal_after_interrupt")
assert callable(HermesCLI._recover_terminal_after_interrupt)
+295
View File
@@ -0,0 +1,295 @@
"""Tests for stacked tool progress scrollback lines in the CLI TUI.
When tool_progress_mode is "all" or "new", _on_tool_progress should print
persistent lines to scrollback on tool.completed, restoring the stacked
tool history that was lost when the TUI switched to a single-line spinner.
"""
import os
import sys
import importlib
from unittest.mock import MagicMock, patch
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
# Module-level reference to the cli module (set by _make_cli on first call)
_cli_mod = None
_UNSET = object()
def _make_cli(tool_progress="all", verbose=_UNSET):
"""Create a HermesCLI instance with minimal mocking."""
global _cli_mod
_clean_config = {
"model": {
"default": "anthropic/claude-opus-4.6",
"base_url": "https://openrouter.ai/api/v1",
"provider": "auto",
},
"display": {"compact": False, "tool_progress": tool_progress},
"agent": {},
"terminal": {"env_type": "local"},
}
clean_env = {"LLM_MODEL": "", "HERMES_MAX_ITERATIONS": ""}
prompt_toolkit_stubs = {
"prompt_toolkit": MagicMock(),
"prompt_toolkit.history": MagicMock(),
"prompt_toolkit.styles": MagicMock(),
"prompt_toolkit.patch_stdout": MagicMock(),
"prompt_toolkit.application": MagicMock(),
"prompt_toolkit.layout": MagicMock(),
"prompt_toolkit.layout.processors": MagicMock(),
"prompt_toolkit.filters": MagicMock(),
"prompt_toolkit.layout.dimension": MagicMock(),
"prompt_toolkit.layout.menus": MagicMock(),
"prompt_toolkit.widgets": MagicMock(),
"prompt_toolkit.key_binding": MagicMock(),
"prompt_toolkit.completion": MagicMock(),
"prompt_toolkit.formatted_text": MagicMock(),
"prompt_toolkit.auto_suggest": MagicMock(),
}
with patch.dict(sys.modules, prompt_toolkit_stubs), \
patch.dict("os.environ", clean_env, clear=False):
import cli as mod
mod = importlib.reload(mod)
_cli_mod = mod
with patch.object(mod, "get_tool_definitions", return_value=[]), \
patch.dict(mod.__dict__, {"CLI_CONFIG": _clean_config}):
if verbose is _UNSET:
return mod.HermesCLI()
return mod.HermesCLI(verbose=verbose)
class TestToolProgressScrollback:
"""Stacked scrollback lines for 'all' and 'new' modes."""
def test_all_mode_prints_scrollback_on_completed(self):
"""In 'all' mode, tool.completed prints a stacked line."""
cli = _make_cli(tool_progress="all")
# Simulate tool.started
cli._on_tool_progress("tool.started", "terminal", "git log", {"command": "git log"})
# Simulate tool.completed
with patch.object(_cli_mod, "_cprint") as mock_print:
cli._on_tool_progress("tool.completed", "terminal", None, None, duration=1.5, is_error=False)
mock_print.assert_called_once()
line = mock_print.call_args[0][0]
# Should contain tool info (the cute message format has "git log" for terminal)
assert "git log" in line or "$" in line
def test_all_mode_prints_every_call(self):
"""In 'all' mode, consecutive calls to the same tool each get a line."""
cli = _make_cli(tool_progress="all")
with patch.object(_cli_mod, "_cprint") as mock_print:
# First call
cli._on_tool_progress("tool.started", "read_file", "cli.py", {"path": "cli.py"})
cli._on_tool_progress("tool.completed", "read_file", None, None, duration=0.1, is_error=False)
# Second call (same tool)
cli._on_tool_progress("tool.started", "read_file", "run_agent.py", {"path": "run_agent.py"})
cli._on_tool_progress("tool.completed", "read_file", None, None, duration=0.2, is_error=False)
assert mock_print.call_count == 2
def test_new_mode_skips_consecutive_repeats(self):
"""In 'new' mode, consecutive calls to the same tool only print once."""
cli = _make_cli(tool_progress="new")
with patch.object(_cli_mod, "_cprint") as mock_print:
cli._on_tool_progress("tool.started", "read_file", "cli.py", {"path": "cli.py"})
cli._on_tool_progress("tool.completed", "read_file", None, None, duration=0.1, is_error=False)
cli._on_tool_progress("tool.started", "read_file", "run_agent.py", {"path": "run_agent.py"})
cli._on_tool_progress("tool.completed", "read_file", None, None, duration=0.2, is_error=False)
assert mock_print.call_count == 1 # Only the first read_file
def test_new_mode_prints_when_tool_changes(self):
"""In 'new' mode, a different tool name triggers a new line."""
cli = _make_cli(tool_progress="new")
with patch.object(_cli_mod, "_cprint") as mock_print:
cli._on_tool_progress("tool.started", "read_file", "cli.py", {"path": "cli.py"})
cli._on_tool_progress("tool.completed", "read_file", None, None, duration=0.1, is_error=False)
cli._on_tool_progress("tool.started", "search_files", "pattern", {"pattern": "test"})
cli._on_tool_progress("tool.completed", "search_files", None, None, duration=0.3, is_error=False)
cli._on_tool_progress("tool.started", "read_file", "run_agent.py", {"path": "run_agent.py"})
cli._on_tool_progress("tool.completed", "read_file", None, None, duration=0.2, is_error=False)
# read_file, search_files, read_file (3rd prints because search_files broke the streak)
assert mock_print.call_count == 3
def test_off_mode_no_scrollback(self):
"""In 'off' mode, no stacked lines are printed."""
cli = _make_cli(tool_progress="off")
with patch.object(_cli_mod, "_cprint") as mock_print:
cli._on_tool_progress("tool.started", "terminal", "ls", {"command": "ls"})
cli._on_tool_progress("tool.completed", "terminal", None, None, duration=0.5, is_error=False)
mock_print.assert_not_called()
def test_error_suffix_on_failed_tool(self):
"""When a failed tool's result is forwarded, the stacked line surfaces
the specific error (e.g. ``[exit 1]`` or ``[File not found: x]``)
instead of the legacy generic ``[error]`` suffix."""
import json
cli = _make_cli(tool_progress="all")
cli._on_tool_progress("tool.started", "terminal", "false", {"command": "false"})
with patch.object(_cli_mod, "_cprint") as mock_print:
cli._on_tool_progress(
"tool.completed", "terminal", None, None,
duration=0.5, is_error=True,
result=json.dumps({"output": "", "exit_code": 1}),
)
line = mock_print.call_args[0][0]
assert "[exit 1]" in line
def test_spinner_still_updates_on_started(self):
"""tool.started still updates the spinner text for live display."""
cli = _make_cli(tool_progress="all")
cli._on_tool_progress("tool.started", "terminal", "git status", {"command": "git status"})
assert "git status" in cli._spinner_text
def test_spinner_timer_clears_on_completed(self):
"""tool.completed still clears the tool timer."""
cli = _make_cli(tool_progress="all")
cli._on_tool_progress("tool.started", "terminal", "git status", {"command": "git status"})
assert cli._tool_start_time > 0
with patch.object(_cli_mod, "_cprint"):
cli._on_tool_progress("tool.completed", "terminal", None, None, duration=0.5, is_error=False)
assert cli._tool_start_time == 0.0
def test_concurrent_tools_produce_stacked_lines(self):
"""Multiple tool.started followed by multiple tool.completed all produce lines."""
cli = _make_cli(tool_progress="all")
with patch.object(_cli_mod, "_cprint") as mock_print:
# All start first (concurrent pattern)
cli._on_tool_progress("tool.started", "web_search", "query 1", {"query": "test 1"})
cli._on_tool_progress("tool.started", "web_search", "query 2", {"query": "test 2"})
# All complete
cli._on_tool_progress("tool.completed", "web_search", None, None, duration=1.0, is_error=False)
cli._on_tool_progress("tool.completed", "web_search", None, None, duration=1.5, is_error=False)
assert mock_print.call_count == 2
def test_verbose_mode_commits_scrollback_line(self):
"""In 'verbose' mode, tool.completed commits a persistent scrollback line.
Regression: 'verbose' used to be omitted from the scrollback gate on
the premise that run_agent renders verbose output. That premise is
false in the interactive CLI — run_agent's verbose prints are gated on
``not quiet_mode`` and the interactive CLI runs quiet_mode=True. So a
non-streaming model call (MoA aggregator, copilot-acp) under 'verbose'
rendered each tool only into the self-overwriting spinner, building no
scrollable history. 'verbose' is strictly more than 'all', so it must
commit at least the same line.
"""
cli = _make_cli(tool_progress="verbose")
with patch.object(_cli_mod, "_cprint") as mock_print:
cli._on_tool_progress("tool.started", "terminal", "ls", {"command": "ls"})
cli._on_tool_progress("tool.completed", "terminal", None, None, duration=0.5, is_error=False)
mock_print.assert_called_once()
def test_verbose_mode_commits_every_call(self):
"""In 'verbose' mode, consecutive same-tool calls each commit a line.
Mirrors 'all' (no consecutive-repeat suppression — that is 'new'-only),
so a multi-step turn builds a full scrollable tool history.
"""
cli = _make_cli(tool_progress="verbose")
with patch.object(_cli_mod, "_cprint") as mock_print:
cli._on_tool_progress("tool.started", "terminal", "echo one", {"command": "echo one"})
cli._on_tool_progress("tool.completed", "terminal", None, None, duration=0.1, is_error=False)
cli._on_tool_progress("tool.started", "terminal", "echo two", {"command": "echo two"})
cli._on_tool_progress("tool.completed", "terminal", None, None, duration=0.1, is_error=False)
assert mock_print.call_count == 2
def test_verbose_mode_config_does_not_enable_global_debug_logging(self):
"""display.tool_progress=verbose controls TOOL-CALL DISPLAY ONLY.
It must NOT auto-flip self.verbose, which controls root-logger DEBUG
level for the entire process (every module spews to console). PR
#6a1aa420e had coupled them, causing all debug logs to flood the
terminal whenever a user picked tool_progress: verbose for richer
per-tool rendering.
"""
cli = _make_cli(tool_progress="verbose")
assert cli.tool_progress_mode == "verbose"
assert cli.verbose is False
def test_explicit_verbose_argument_wins_over_config(self):
"""Explicit verbose=True from the CLI flag still enables DEBUG logging
regardless of tool_progress_mode."""
cli = _make_cli(tool_progress="off", verbose=True)
assert cli.tool_progress_mode == "off"
assert cli.verbose is True
def test_explicit_non_verbose_argument_keeps_debug_logging_off(self):
"""Explicit verbose=False overrides any default to enable DEBUG."""
cli = _make_cli(tool_progress="verbose", verbose=False)
assert cli.tool_progress_mode == "verbose"
assert cli.verbose is False
def test_pending_info_stores_on_started(self):
"""tool.started stores args for later use by tool.completed."""
cli = _make_cli(tool_progress="all")
cli._on_tool_progress("tool.started", "terminal", "ls", {"command": "ls"})
assert "terminal" in cli._pending_tool_info
assert len(cli._pending_tool_info["terminal"]) == 1
assert cli._pending_tool_info["terminal"][0] == {"command": "ls"}
def test_pending_info_consumed_on_completed(self):
"""tool.completed consumes stored args (FIFO for concurrent)."""
cli = _make_cli(tool_progress="all")
cli._on_tool_progress("tool.started", "terminal", "ls", {"command": "ls"})
cli._on_tool_progress("tool.started", "terminal", "pwd", {"command": "pwd"})
assert len(cli._pending_tool_info["terminal"]) == 2
with patch.object(_cli_mod, "_cprint"):
cli._on_tool_progress("tool.completed", "terminal", None, None, duration=0.1, is_error=False)
# First entry consumed, second remains
assert len(cli._pending_tool_info.get("terminal", [])) == 1
assert cli._pending_tool_info["terminal"][0] == {"command": "pwd"}
class TestMoAReferenceBlocks:
"""moa.reference renders a labelled thinking-style block; moa.aggregating
updates the spinner. Both are display-only and must commit regardless of
tool_progress_mode (MoA is non-streaming)."""
def test_reference_event_prints_labelled_block(self):
cli = _make_cli(tool_progress="all")
with patch.object(_cli_mod, "_cprint") as mock_print:
cli._on_tool_progress(
"moa.reference",
"openrouter:openai/gpt-5.5",
"Paris is the capital.",
None,
moa_index=1,
moa_count=2,
)
printed = " ".join(str(c.args[0]) for c in mock_print.call_args_list)
# Header names the source model + index/count; body carries the text.
assert "openrouter:openai/gpt-5.5" in printed
assert "Reference 1/2" in printed
assert "Paris is the capital." in printed
def test_reference_event_prints_even_when_progress_off(self):
"""Reference blocks are the MoA process view, not tool progress — they
must show even with tool_progress: off."""
cli = _make_cli(tool_progress="off")
with patch.object(_cli_mod, "_cprint") as mock_print:
cli._on_tool_progress(
"moa.reference", "openrouter:anthropic/claude-opus-4.8", "Four.", None,
moa_index=2, moa_count=2,
)
assert mock_print.called
def test_aggregating_event_updates_spinner_only(self):
cli = _make_cli(tool_progress="all")
with patch.object(_cli_mod, "_cprint") as mock_print:
cli._on_tool_progress("moa.aggregating", "openrouter:anthropic/claude-opus-4.8", None, None)
assert "aggregating" in cli._spinner_text
# aggregating is a spinner-only transition; no committed scrollback line.
mock_print.assert_not_called()
@@ -0,0 +1,212 @@
"""Regression tests for GitHub #36823 — the TUI must reset terminal input
modes on exit so focus-reporting / mouse-tracking escape sequences don't leak
into the next shell session sharing the tab.
prompt_toolkit restores these on a clean teardown, but Ctrl+C, SIGTERM/SIGHUP
and crashes can bypass its unwind. ``_run_cleanup`` (the once-only cleanup that
runs on every catchable exit path, including ``atexit``) now emits the disable
sequence as its first step via ``_reset_terminal_input_modes_on_exit`` — gated
on ``_tui_input_modes_active`` so non-TUI one-shot CLI runs (which share
``_run_cleanup`` via ``atexit``) don't emit codes for modes they never set.
"""
import unittest
from unittest.mock import mock_open, patch
def _import_cli():
import hermes_cli.config as config_mod
if not hasattr(config_mod, "save_env_value_secure"):
config_mod.save_env_value_secure = lambda key, value: {
"success": True,
"stored_as": key,
"validated": False,
}
import cli as cli_mod
return cli_mod
class _FakeStream:
def __init__(self, isatty: bool = True):
self._isatty = isatty
self.written: list[str] = []
self.flushed = 0
def isatty(self) -> bool:
return self._isatty
def write(self, s: str) -> int:
self.written.append(s)
return len(s)
def flush(self) -> None:
self.flushed += 1
class TestResetTerminalInputModes(unittest.TestCase):
def test_emits_reset_seq_on_tty_when_tui_ran(self):
cli_mod = _import_cli()
fake = _FakeStream(isatty=True)
with (
patch.object(cli_mod, "_tui_input_modes_active", True),
patch.object(cli_mod.sys, "stdout", fake),
):
cli_mod._reset_terminal_input_modes_on_exit()
written = "".join(fake.written)
self.assertEqual(written, cli_mod._TERMINAL_INPUT_MODE_RESET_SEQ)
self.assertGreaterEqual(fake.flushed, 1)
# The focus-reporting disable is the specific leak the issue reports.
self.assertIn("\x1b[?1004l", written)
def test_noop_when_tui_never_ran(self):
"""Non-TUI one-shot CLI runs share _run_cleanup via atexit — they must
not emit terminal escape codes they never needed (review finding #1)."""
cli_mod = _import_cli()
fake = _FakeStream(isatty=True)
with (
patch.object(cli_mod, "_tui_input_modes_active", False),
patch.object(cli_mod.sys, "stdout", fake),
# Guard: must not touch the real /dev/tty either.
patch("builtins.open", mock_open()) as m_open,
):
cli_mod._reset_terminal_input_modes_on_exit()
self.assertEqual(fake.written, [])
m_open.assert_not_called()
def test_noop_when_not_a_tty_and_no_dev_tty(self):
"""stdout redirected and /dev/tty unavailable → nothing written, no raise."""
cli_mod = _import_cli()
fake = _FakeStream(isatty=False)
with (
patch.object(cli_mod, "_tui_input_modes_active", True),
patch.object(cli_mod.sys, "stdout", fake),
patch("builtins.open", side_effect=OSError("no /dev/tty")),
):
cli_mod._reset_terminal_input_modes_on_exit()
self.assertEqual(fake.written, [], "must not pollute the redirected stream")
def test_falls_back_to_dev_tty_when_stdout_redirected(self):
"""When stdout isn't the terminal, reset via /dev/tty (issue's own
suggestion) so a TUI that drove /dev/tty still gets cleaned up."""
cli_mod = _import_cli()
fake = _FakeStream(isatty=False)
m_open = mock_open()
with (
patch.object(cli_mod, "_tui_input_modes_active", True),
patch.object(cli_mod.sys, "stdout", fake),
patch("builtins.open", m_open),
):
cli_mod._reset_terminal_input_modes_on_exit()
self.assertEqual(fake.written, [])
m_open.assert_called_once_with("/dev/tty", "w", encoding="ascii")
m_open().write.assert_called_once_with(cli_mod._TERMINAL_INPUT_MODE_RESET_SEQ)
def test_swallows_stdout_errors(self):
cli_mod = _import_cli()
class _Boom:
def isatty(self):
raise OSError("stdout closed")
with (
patch.object(cli_mod, "_tui_input_modes_active", True),
patch.object(cli_mod.sys, "stdout", _Boom()),
patch("builtins.open", side_effect=OSError("no /dev/tty")),
):
# Cleanup runs at process teardown — it must never raise.
cli_mod._reset_terminal_input_modes_on_exit()
def test_mark_tui_input_modes_active_sets_flag(self):
cli_mod = _import_cli()
original = cli_mod._tui_input_modes_active
cli_mod._tui_input_modes_active = False
try:
cli_mod._mark_tui_input_modes_active()
self.assertTrue(cli_mod._tui_input_modes_active)
finally:
cli_mod._tui_input_modes_active = original
def test_flag_cleared_after_reset(self):
"""Once the modes are disabled they are no longer active — the flag must
flip back so a re-armed cleanup doesn't re-emit the sequence."""
cli_mod = _import_cli()
fake = _FakeStream(isatty=True)
original = cli_mod._tui_input_modes_active
cli_mod._tui_input_modes_active = True
try:
with patch.object(cli_mod.sys, "stdout", fake):
cli_mod._reset_terminal_input_modes_on_exit()
self.assertIn("\x1b[?1004l", "".join(fake.written))
self.assertFalse(
cli_mod._tui_input_modes_active, "flag must clear after reset"
)
finally:
cli_mod._tui_input_modes_active = original
class TestRunCleanupWiring(unittest.TestCase):
"""_run_cleanup must call the reset, as its first step, on every invocation
— even if a later cleanup step raises."""
def _run_cleanup_isolated(self, cli_mod, **extra_patches):
"""Invoke _run_cleanup with heavy/real teardown steps stubbed out so the
test is hermetic (review finding #5)."""
original_done = cli_mod._cleanup_done
cli_mod._cleanup_done = False
patches = {
"_cleanup_all_terminals": lambda: None,
"_cleanup_all_browsers": lambda: None,
}
try:
with (
patch.object(
cli_mod, "_reset_terminal_input_modes_on_exit"
) as mock_reset,
patch.object(
cli_mod, "_cleanup_all_terminals", patches["_cleanup_all_terminals"]
),
patch.object(
cli_mod, "_cleanup_all_browsers", patches["_cleanup_all_browsers"]
),
patch("tools.mcp_tool.shutdown_mcp_servers", lambda *a, **k: None),
patch(
"agent.auxiliary_client.shutdown_cached_clients",
lambda *a, **k: None,
),
patch("hermes_cli.plugins.invoke_hook", lambda *a, **k: None),
):
if extra_patches.get("terminals_raise"):
with patch.object(
cli_mod,
"_cleanup_all_terminals",
side_effect=RuntimeError("boom"),
):
cli_mod._run_cleanup()
else:
cli_mod._run_cleanup()
return mock_reset
finally:
cli_mod._cleanup_done = original_done
def test_run_cleanup_calls_reset(self):
cli_mod = _import_cli()
mock_reset = self._run_cleanup_isolated(cli_mod)
mock_reset.assert_called_once()
def test_reset_runs_even_when_a_cleanup_step_raises(self):
"""The reset is the first step, so a failing teardown step can't skip
it — covering the Ctrl+C / crash paths the issue is about."""
cli_mod = _import_cli()
mock_reset = self._run_cleanup_isolated(cli_mod, terminals_raise=True)
mock_reset.assert_called_once()
if __name__ == "__main__":
unittest.main()
+150
View File
@@ -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
+28
View File
@@ -0,0 +1,28 @@
"""Tests for the /version slash command."""
from unittest.mock import patch
from cli import HermesCLI
from hermes_cli.commands import GATEWAY_KNOWN_COMMANDS, resolve_command
def test_version_command_is_registered():
cmd = resolve_command("version")
assert cmd is not None
assert cmd.name == "version"
assert cmd.category == "Info"
assert resolve_command("v") is cmd
def test_version_is_gateway_known():
assert "version" in GATEWAY_KNOWN_COMMANDS
assert "v" in GATEWAY_KNOWN_COMMANDS
def test_process_command_version_prints_version_info():
cli_obj = HermesCLI.__new__(HermesCLI)
with patch("hermes_cli.main._print_version_info") as mock_print:
assert cli_obj.process_command("/version") is True
mock_print.assert_called_once_with(check_updates=True)
File diff suppressed because it is too large Load Diff
+130
View File
@@ -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)
+124
View File
@@ -0,0 +1,124 @@
"""Tests for worktree base-ref resolution — branch from the fresh remote tip.
A worktree created off the standalone clone's local ``HEAD`` roots the new
branch on a stale base when that clone lags the remote. ``_resolve_worktree_base``
fetches and branches from the remote tip instead so the worktree starts current.
These tests exercise the REAL ``cli._resolve_worktree_base`` /
``cli._setup_worktree`` against a real local "remote" repo (so ``git fetch``
works offline in the hermetic sandbox), proving the worktree includes commits
that exist on the remote but not on the stale local HEAD.
"""
import subprocess
from pathlib import Path
import pytest
import cli
def _run(args, cwd):
return subprocess.run(args, cwd=cwd, capture_output=True, text=True, timeout=30)
def _commit(repo, name, msg):
(Path(repo) / name).write_text(msg + "\n")
_run(["git", "add", "."], repo)
_run(["git", "commit", "-m", msg], repo)
def _head(repo):
return _run(["git", "rev-parse", "HEAD"], repo).stdout.strip()
@pytest.fixture
def remote_and_clone(tmp_path):
"""A bare 'remote' + a clone that is intentionally BEHIND the remote.
Returns (clone_path, remote_head_sha, stale_local_head_sha).
"""
remote = tmp_path / "remote.git"
seed = tmp_path / "seed"
seed.mkdir()
_run(["git", "init"], seed)
_run(["git", "config", "user.email", "t@t.com"], seed)
_run(["git", "config", "user.name", "T"], seed)
# Pin the seed repo's branch name so push + remote default are 'main'.
_run(["git", "checkout", "-b", "main"], seed)
_commit(seed, "README.md", "base commit")
_run(["git", "init", "--bare", str(remote)], tmp_path)
_run(["git", "remote", "add", "origin", str(remote)], seed)
_run(["git", "push", "origin", "main"], seed)
# Set the bare remote's default branch so a clone gets origin/HEAD ->
# origin/main and a tracking branch (mirrors a real GitHub remote).
_run(["git", "symbolic-ref", "HEAD", "refs/heads/main"], remote)
# Clone it (this clone tracks origin/main).
clone = tmp_path / "clone"
_run(["git", "clone", str(remote), str(clone)], tmp_path)
_run(["git", "config", "user.email", "t@t.com"], clone)
_run(["git", "config", "user.name", "T"], clone)
stale_local_head = _head(clone)
# Advance the REMOTE past the clone (simulating other merges landing on
# main while this clone sat stale).
_commit(seed, "feature.txt", "remote-only commit")
_run(["git", "push", "origin", "main"], seed)
remote_head = _head(seed)
assert remote_head != stale_local_head
return clone, remote_head, stale_local_head
class TestResolveWorktreeBase:
def test_resolves_to_fetched_upstream(self, remote_and_clone):
clone, remote_head, stale_local_head = remote_and_clone
base_ref, label = cli._resolve_worktree_base(str(clone))
# Should resolve to the upstream tracking ref and have fetched it.
assert base_ref == "origin/main"
assert "fetched" in label
# The fetched ref now points at the remote tip, not the stale local HEAD.
resolved = _run(["git", "rev-parse", base_ref], clone).stdout.strip()
assert resolved == remote_head
assert resolved != stale_local_head
def test_falls_back_to_head_without_remote(self, tmp_path):
repo = tmp_path / "no-remote"
repo.mkdir()
_run(["git", "init"], repo)
_run(["git", "config", "user.email", "t@t.com"], repo)
_run(["git", "config", "user.name", "T"], repo)
_commit(repo, "README.md", "only commit")
base_ref, label = cli._resolve_worktree_base(str(repo))
assert base_ref == "HEAD"
assert "HEAD" in label
class TestSetupWorktreeSyncBase:
def test_sync_true_branches_from_remote_tip(self, remote_and_clone, monkeypatch):
clone, remote_head, stale_local_head = remote_and_clone
info = cli._setup_worktree(str(clone), sync_base=True)
assert info is not None
# The new worktree's HEAD must be the REMOTE tip, not the stale local one.
wt_head = _head(info["path"])
assert wt_head == remote_head, "worktree should start from the fetched remote tip"
assert wt_head != stale_local_head
# And it must contain the remote-only file.
assert (Path(info["path"]) / "feature.txt").exists()
def test_sync_false_branches_from_local_head(self, remote_and_clone):
clone, remote_head, stale_local_head = remote_and_clone
info = cli._setup_worktree(str(clone), sync_base=False)
assert info is not None
# Opted out -> branch from the stale local HEAD (old behavior).
wt_head = _head(info["path"])
assert wt_head == stale_local_head
assert not (Path(info["path"]) / "feature.txt").exists()
def test_default_is_sync_true(self, remote_and_clone):
"""The default path (no sync_base arg) branches from the remote tip."""
clone, remote_head, _ = remote_and_clone
info = cli._setup_worktree(str(clone))
assert info is not None
assert _head(info["path"]) == remote_head