Merge pull request #74383 from NousResearch/tests/prune-low-value

test: prune low-value tests suite-wide — 58% fewer tests, half the wall time, zero flakes
This commit is contained in:
Teknium 2026-07-29 15:46:36 -07:00 committed by GitHub
commit 92856bc28a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2093 changed files with 2139 additions and 377634 deletions

View file

@ -90,21 +90,6 @@ class TestBracketedPasteTimeout:
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."""
@ -118,12 +103,6 @@ class TestBracketedPasteTimeout:
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."""

View file

@ -85,25 +85,7 @@ class TestBranchCommandCLI:
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."""
@ -114,24 +96,7 @@ class TestBranchCommandCLI:
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."""
@ -143,20 +108,6 @@ class TestBranchCommandCLI:
# 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."""
@ -166,24 +117,6 @@ class TestBranchCommandCLI:
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.
@ -212,12 +145,6 @@ class TestBranchCommandCLI:
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:
@ -229,11 +156,6 @@ class TestBranchCommandDef:
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."""

View file

@ -53,17 +53,6 @@ class TestHandleBusyCommand(unittest.TestCase):
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()
@ -79,20 +68,6 @@ class TestHandleBusyCommand(unittest.TestCase):
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()

View file

@ -93,11 +93,6 @@ class TestCliApprovalUi:
thread.join(timeout=2)
assert result["value"] == "deny"
def test_non_smart_non_permanent_callback_preserves_session_choice(self):
cli = _make_cli_stub()
assert cli._approval_choices(
"rm -rf /tmp/example", allow_permanent=False, smart_denied=False
) == ["once", "session", "deny"]
def test_sudo_prompt_restores_existing_draft_after_response(self):
cli = _make_cli_stub()
@ -128,27 +123,6 @@ class TestCliApprovalUi:
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()
@ -168,151 +142,10 @@ class TestCliApprovalUi:
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.
@ -445,10 +278,6 @@ class TestModalPaintNow:
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 = {}
@ -483,26 +312,8 @@ class TestModalPaintNow:
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,
@ -630,17 +441,6 @@ class TestPersistPromptSummary:
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()
@ -722,13 +522,6 @@ class TestClearOverlaysForInterrupt:
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

View file

@ -41,15 +41,6 @@ def test_snapshot_counts_live_background_tasks():
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():
@ -58,41 +49,12 @@ def test_plain_text_status_omits_indicator_when_idle():
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():
@ -126,69 +88,18 @@ def _patch_process_registry(monkeypatch, count: int) -> None:
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) ─────────────────────────────
@ -210,11 +121,6 @@ def test_snapshot_reports_zero_when_no_background_subagents(monkeypatch):
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):

View file

@ -4,9 +4,6 @@ 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~"
@ -16,29 +13,17 @@ class TestStripLeakedBracketedPasteWrappers:
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~"

View file

@ -57,51 +57,7 @@ class TestChromeDebugLaunch:
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), \
@ -111,70 +67,10 @@ class TestChromeDebugLaunch:
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"
@ -186,23 +82,6 @@ class TestChromeDebugLaunch:
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:
@ -219,51 +98,7 @@ class TestChromeDebugLaunch:
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"
@ -326,40 +161,4 @@ class TestChromeDebugLaunch:
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)
# The default-local path now resolves the endpoint via
# discover_local_cdp_url (dual-stack probe); patch it at the
# mixin's import site so no real network probe or browser
# launch happens on the test runner.
with patch(
"hermes_cli.cli_commands_mixin.discover_local_cdp_url",
return_value="http://127.0.0.1:9222",
), \
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

View file

@ -59,17 +59,6 @@ class TestLowContextWarning:
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)."""
@ -117,17 +106,6 @@ class TestLowContextWarning:
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."""
@ -141,16 +119,6 @@ class TestLowContextWarning:
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."""

View file

@ -60,16 +60,6 @@ def test_copy_strips_reasoning_blocks_before_copy():
mock_copy.assert_called_once_with("Visible answer")
def test_copy_falls_back_to_osc52_when_native_tools_fail():
cli_obj = _make_cli()
cli_obj.conversation_history = [{"role": "assistant", "content": "hello"}]
with patch("hermes_cli.clipboard.write_clipboard_text", return_value=False), \
patch("hermes_cli.clipboard.is_remote_shell_session", return_value=False), \
patch.object(cli_obj, "_write_osc52_clipboard") as mock_osc52:
cli_obj.process_command("/copy")
mock_osc52.assert_called_once_with("hello")
def test_copy_prefers_osc52_in_ssh_sessions():
@ -100,15 +90,3 @@ def test_copy_native_first_when_local():
mock_osc52.assert_not_called()
def test_copy_invalid_index_does_not_copy():
cli_obj = _make_cli()
cli_obj.conversation_history = [{"role": "assistant", "content": "only"}]
with patch("hermes_cli.clipboard.write_clipboard_text") as mock_copy, \
patch.object(cli_obj, "_write_osc52_clipboard") as mock_osc52, \
patch("cli._cprint") as mock_print:
cli_obj.process_command("/copy 99")
mock_copy.assert_not_called()
mock_osc52.assert_not_called()
assert any("Invalid response number" in str(call) for call in mock_print.call_args_list)

View file

@ -50,34 +50,9 @@ def test_open_external_editor_rejects_when_no_tui():
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):
@ -120,15 +95,6 @@ def test_inline_pastes_stores_full_content(tmp_path):
assert cli_obj._skip_paste_collapse is True
def test_inline_pastes_leaves_plain_text_untouched():
"""No placeholder → buffer text and collapse flag are unchanged."""
cli_obj = _make_cli()
buffer = _FakeBuffer(text="just a normal message")
cli_obj._inline_pastes(buffer)
assert buffer.text == "just a normal message"
assert cli_obj._skip_paste_collapse is False
def test_inline_pastes_missing_file_keeps_placeholder(tmp_path):

View file

@ -43,27 +43,16 @@ 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>`
@ -89,12 +78,6 @@ class TestNonFileInputs:
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
# ---------------------------------------------------------------------------
@ -109,13 +92,6 @@ class TestImageFileDrop:
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"])
@ -126,12 +102,6 @@ class TestImageFileDrop:
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
# ---------------------------------------------------------------------------
@ -146,12 +116,6 @@ class TestNonImageFileDrop:
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"
# ---------------------------------------------------------------------------
@ -167,13 +131,6 @@ class TestEscapedSpaces:
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))
@ -182,12 +139,6 @@ class TestEscapedSpaces:
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"
@ -199,12 +150,6 @@ class TestEscapedSpaces:
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"
@ -241,9 +186,3 @@ class TestEdgeCases:
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

View file

@ -30,80 +30,8 @@ class TestForceFullRedraw:
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.
@ -138,15 +66,6 @@ class TestForceFullRedraw:
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 = []

View file

@ -67,35 +67,6 @@ def _make_cli_with_goal(session_id: str, goal_text: str = "build a thing"):
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."""
@ -113,44 +84,6 @@ class TestInterruptAutoPause:
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:

View file

@ -31,14 +31,6 @@ class TestImageCommand:
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"
@ -62,13 +54,6 @@ class TestCollectQueryImages:
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"
@ -100,10 +85,3 @@ class TestImageBadgeFormatting:
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]"

View file

@ -65,29 +65,13 @@ class TestMaxTurnsResolution:
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 == 500
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 == 500
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 == 500
class TestVerboseAndToolProgress:
@ -124,9 +108,6 @@ class TestBusyInputMode:
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."""
@ -135,32 +116,8 @@ class TestBusyInputMode:
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."""
@ -246,47 +203,7 @@ class TestPromptToolkitTerminalCompatibility:
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_posix_local_and_windows_preserve(self, monkeypatch):
"""POSIX suppresses CPR without SSH; native Windows keeps PT default.
@ -354,33 +271,6 @@ class TestHistoryDisplay:
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()
@ -409,60 +299,7 @@ class TestHistoryDisplay:
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).
@ -494,26 +331,6 @@ class TestHistoryDisplay:
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>.
@ -530,22 +347,6 @@ class TestHistoryDisplay:
"/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:
@ -597,46 +398,7 @@ class TestRootLevelProviderOverride:
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."""
@ -653,96 +415,16 @@ class TestRootLevelProviderOverride:
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."""
@ -752,24 +434,7 @@ class TestRootLevelProviderOverride:
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."""
@ -779,45 +444,6 @@ class TestRootLevelProviderOverride:
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

View file

@ -154,46 +154,6 @@ def test_unacknowledged_interrupt_message_is_requeued_not_dropped():
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
def test_chat_persists_clean_input_when_a_queued_note_changes_api_message():
@ -454,89 +414,6 @@ def test_chat_clears_previous_turn_persistence_override_before_staging():
assert agent.staged_message == {"role": "user", "content": "new prompt"}
def test_chat_close_does_not_persist_previous_turn_override(tmp_path, monkeypatch):
"""A close after input staging writes the new prompt, not old API-only text."""
from hermes_state import SessionDB
from run_agent import AIAgent
monkeypatch.setenv("HERMES_HOME", str(tmp_path / ".hermes"))
cli = _make_cli()
session_id = cli.session_id
db = SessionDB(db_path=tmp_path / "state.db")
db.create_session(session_id=session_id, source="cli")
prefix = [
{"role": "user", "content": "old prompt"},
{"role": "assistant", "content": "old answer"},
]
for message in prefix:
db.append_message(
session_id=session_id,
role=message["role"],
content=message["content"],
)
agent = object.__new__(AIAgent)
agent._session_db = db
agent._session_db_created = True
agent.session_id = session_id
agent.platform = "cli"
agent.model = "test-model"
agent._session_messages = []
agent._last_flushed_db_idx = 0
agent._flushed_db_message_ids = set()
agent._flushed_db_message_session_id = None
agent._persist_disabled = False
agent._cached_system_prompt = "test system prompt"
agent._session_init_model_config = None
agent._parent_session_id = None
agent._session_json_enabled = False
agent._pending_cli_user_message = None
agent._session_persist_lock = threading.RLock()
agent._persist_user_message_idx = len(prefix)
agent._persist_user_message_override = "previous clean prompt"
agent._persist_user_message_timestamp = 123.0
agent._active_children = []
agent._interrupt_requested = False
entered = threading.Event()
release = threading.Event()
def _block_run(**_kwargs):
entered.set()
assert release.wait(timeout=5)
return {
"final_response": "done",
"messages": prefix + [{"role": "assistant", "content": "done"}],
"api_calls": 1,
"completed": True,
"partial": True,
"response_previewed": True,
}
agent.run_conversation = _block_run
cli.agent = agent
cli.conversation_history = list(prefix)
cli._interrupt_queue = queue.Queue()
cli._pending_input = queue.Queue()
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):
chat_thread = threading.Thread(target=lambda: cli.chat("new prompt"))
chat_thread.start()
assert entered.wait(timeout=5)
cli._persist_active_session_before_close()
release.set()
chat_thread.join(timeout=10)
assert not chat_thread.is_alive()
assert [m["content"] for m in db.get_messages_as_conversation(session_id)] == [
"old prompt",
"old answer",
"new prompt",
]
def test_close_waits_for_atomic_cli_staging_before_snapshot(tmp_path, monkeypatch):

View file

@ -38,11 +38,6 @@ class TestLightModeDetection:
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)
@ -51,13 +46,6 @@ class TestLightModeDetection:
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)
@ -75,32 +63,9 @@ class TestLightModeDetection:
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")
@ -109,28 +74,8 @@ class TestLightModeRemap:
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:
@ -144,15 +89,6 @@ class TestSkinConfigHook:
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
@ -168,9 +104,3 @@ class TestSkinConfigHook:
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"

View file

@ -22,13 +22,6 @@ def test_final_assistant_content_uses_markdown_renderable():
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():
@ -39,29 +32,8 @@ def test_final_assistant_content_keeps_non_path_markdown_escapes():
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():
@ -77,16 +49,6 @@ def test_strip_mode_preserves_lists():
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():
@ -100,54 +62,8 @@ def test_strip_mode_preserves_blockquotes():
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():
@ -162,11 +78,6 @@ def test_strip_mode_preserves_cron_asterisks_in_plain_text():
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():

View file

@ -31,29 +31,7 @@ def _make_cli(tmp_path, mcp_servers=None, extra_config=None):
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."""
@ -83,27 +61,7 @@ class TestMCPConfigWatch:
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()
def test_optout_disables_auto_reload(self, tmp_path, capsys):
"""When mcp.auto_reload_on_config_change is False, a changed

View file

@ -175,41 +175,8 @@ def test_new_command_creates_real_fresh_session_and_resets_agent_state(tmp_path)
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):
@ -256,30 +223,8 @@ def test_run_cleanup_flushes_pending_memory_manager_work(tmp_path):
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):
@ -352,38 +297,3 @@ def test_new_session_with_title(capsys):
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

View file

@ -22,52 +22,7 @@ class TestSlashCommandPrefixMatching:
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."""
@ -77,20 +32,7 @@ class TestSlashCommandPrefixMatching:
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."""

View file

@ -172,32 +172,6 @@ def test_runtime_resolution_failure_is_not_sticky(monkeypatch):
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):
@ -214,139 +188,14 @@ def test_cli_turn_routing_uses_primary_when_disabled(monkeypatch):
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_cli_init_wires_moa_preset_model_to_moa_provider(monkeypatch):
# #56828: constructing the CLI with `-m moa:<preset>` (the -Q one-shot
# path) must strip the prefix off self.model AND force
# requested_provider="moa", so the existing resolve_runtime_provider /
# agent_init MoA route runs non-interactively. The unit tests cover
# _normalize_moa_model() in isolation; this asserts the __init__ wiring
# the sweeper flagged as untested.
cli = _import_cli()
# Neutralize any config/env provider so a failure here can only come from
# the moa override, not an ambient default.
config_copy = dict(cli.CLI_CONFIG)
model_copy = dict(config_copy.get("model", {}))
model_copy["provider"] = None
config_copy["model"] = model_copy
monkeypatch.setattr(cli, "CLI_CONFIG", config_copy)
monkeypatch.delenv("HERMES_INFERENCE_PROVIDER", raising=False)
shell = cli.HermesCLI(model="moa:strategy", compact=True, max_turns=1)
assert shell.requested_provider == "moa"
assert shell.model == "strategy"
def test_cli_init_moa_prefix_overrides_explicit_provider(monkeypatch):
# The #56828 regression case: `--provider deepseek -m moa:strategy`
# silently dropped MoA because the explicit provider won. __init__ resolves
# requested_provider as `_moa_provider_override or provider or ...`, so the
# moa: prefix must win over the explicit --provider.
cli = _import_cli()
monkeypatch.delenv("HERMES_INFERENCE_PROVIDER", raising=False)
shell = cli.HermesCLI(
model="moa:strategy", provider="deepseek", compact=True, max_turns=1
)
assert shell.requested_provider == "moa"
assert shell.model == "strategy"
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):
@ -445,124 +294,10 @@ def _seed_stale_custom_model(tmp_path, monkeypatch):
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):
@ -608,126 +343,12 @@ def test_codex_provider_uses_config_model(monkeypatch):
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):
@ -902,15 +523,8 @@ def test_auto_provider_name_localhost():
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):
@ -961,32 +575,6 @@ def test_save_custom_provider_references_the_key_instead_of_inlining_it(monkeypa
assert "sk-secret" not in yaml.safe_dump(saved)
def test_save_custom_provider_migrates_an_existing_plaintext_entry(monkeypatch, tmp_path):
"""Re-saving a known URL swaps its inline key for the .env reference."""
import yaml
from hermes_cli.main import _save_custom_provider
existing = {
"custom_providers": [
{
"name": "Ollama",
"base_url": "http://localhost:11434/v1",
"api_key": "sk-legacy",
}
]
}
monkeypatch.setattr("hermes_cli.config.load_config", lambda: existing)
saved = {}
monkeypatch.setattr("hermes_cli.config.save_config", lambda cfg: saved.update(cfg))
_save_custom_provider(
"http://localhost:11434/v1",
key_env="HERMES_CUSTOM_LOCALHOST_11434_API_KEY",
)
entry = saved["custom_providers"][0]
assert entry["key_env"] == "HERMES_CUSTOM_LOCALHOST_11434_API_KEY"
assert "api_key" not in entry
def test_custom_endpoint_key_env_is_a_valid_posix_name_for_ip_endpoints():
@ -1005,9 +593,3 @@ def test_custom_endpoint_key_env_is_a_valid_posix_name_for_ip_endpoints():
assert _ENV_VAR_NAME_RE.match(custom_endpoint_key_env(identity)), identity
def test_custom_endpoint_key_env_separates_ports_on_one_host():
"""Two servers on one machine must not collapse onto one .env slot."""
from hermes_cli.config import custom_endpoint_key_env
assert custom_endpoint_key_env("127.0.0.1_8000") != custom_endpoint_key_env("127.0.0.1_8001")
assert custom_endpoint_key_env("acme") == custom_endpoint_key_env("ACME")

View file

@ -58,20 +58,6 @@ class TestCliResumeCommand:
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()
@ -115,46 +101,7 @@ class TestCliResumeCommand:
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_resume_conversations.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 TestCliResumeRestoresCwd:
@ -198,19 +145,6 @@ class TestCliResumeRestoresCwd:
mock_chdir.assert_called_once_with(recorded)
def test_handle_resume_without_recorded_cwd_does_not_chdir(self):
# Gateway/remote/older sessions record no cwd — restore must no-op.
cli_obj = self._resumable_cli({"id": "sess_dir", "title": "Dir"})
with (
patch("hermes_cli.main._resolve_session_by_name_or_id", return_value="sess_dir"),
patch("cli._cprint"),
patch.object(cli_obj, "_console_print"),
patch("os.chdir") as mock_chdir,
):
cli_obj._handle_resume_command("/resume Dir")
mock_chdir.assert_not_called()
def test_sessions_command_restores_recorded_cwd(self, tmp_path):
# /sessions <id> delegates to the resume flow, so it restores cwd too.
@ -254,15 +188,6 @@ class TestPendingResumeNumberedSelection:
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()
@ -293,37 +218,8 @@ class TestPendingResumeNumberedSelection:
# 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()
@ -337,70 +233,6 @@ class TestPendingResumeNumberedSelection:
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:

View file

@ -38,16 +38,6 @@ class TestSaveConfigValueAtomic:
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."""
@ -57,31 +47,7 @@ class TestSaveConfigValueAtomic:
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_model_write_runs_shared_cron_drift_warning(self, config_env, monkeypatch):
warning = MagicMock()
@ -95,46 +61,7 @@ class TestSaveConfigValueAtomic:
assert save_config_value("model.default", "new-model") is True
warning.assert_called_once_with("model.default", "new-model")
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."""

View file

@ -43,15 +43,6 @@ def test_install_registers_all_three_sequences():
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():
@ -71,11 +62,6 @@ def test_csi_u_shift_enter_parses_as_alt_enter():
)
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():

View file

@ -47,50 +47,8 @@ def test_cleanup_forwards_session_messages(mock_invoke_hook):
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")
@ -114,81 +72,12 @@ def test_cleanup_provider_exception_is_swallowed(mock_invoke_hook):
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, None)
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()
def test_cli_close_uses_distinct_history_as_baseline():
"""A pre-flush shutdown keeps the distinct CLI prefix as a DB baseline."""
import cli as cli_mod
history = [{"role": "user", "content": "resumed prompt"}]
live_messages = history + [{"role": "assistant", "content": "partial response"}]
cli = object.__new__(cli_mod.HermesCLI)
cli.conversation_history = history
cli.session_id = "session-id"
agent = MagicMock()
agent.session_id = "session-id"
agent._session_messages = live_messages
cli.agent = agent
cli._persist_active_session_before_close()
agent._persist_session.assert_called_once_with(live_messages, history)
def _real_agent(db, session_id, session_messages):
@ -215,43 +104,6 @@ def _real_agent(db, session_id, session_messages):
return agent
def test_cli_close_persist_real_db_survives_history_alias(tmp_path, monkeypatch):
"""CLI close safety-net must persist even when history aliases messages.
In the real CLI, ``conversation_history`` and ``agent._session_messages`` can
point at the same live list during interrupted shutdown. Passing that list
as ``conversation_history`` makes ``_flush_messages_to_session_db`` treat
every message as already durable and write zero rows. The close safety-net
should use marker-based dedup instead.
"""
monkeypatch.setenv("HERMES_HOME", str(tmp_path / ".hermes"))
import cli as cli_mod
from hermes_state import SessionDB
db = SessionDB(db_path=tmp_path / "state.db")
session_id = "cli-close-alias"
db.create_session(session_id=session_id, source="cli")
transcript = [
{"role": "user", "content": "long task"},
{"role": "assistant", "content": "partial answer"},
]
agent = _real_agent(db, session_id, transcript)
cli = object.__new__(cli_mod.HermesCLI)
cli.conversation_history = transcript
cli.session_id = "old-session"
cli.agent = agent
assert db.get_messages_as_conversation(session_id) == []
cli._persist_active_session_before_close()
stored = db.get_messages_as_conversation(session_id)
assert [m["content"] for m in stored] == ["long task", "partial answer"]
assert cli.session_id == session_id
def test_cli_close_preflush_resumed_prefix_is_not_duplicated(tmp_path, monkeypatch):
@ -347,38 +199,6 @@ def test_cli_close_preflush_resumed_prefix_is_not_duplicated(tmp_path, monkeypat
]
def test_cli_close_preserves_unflushed_tail_after_prior_prefix_flush(tmp_path, monkeypatch):
"""Marker-only alias close writes only a new tail after a prior flush."""
monkeypatch.setenv("HERMES_HOME", str(tmp_path / ".hermes"))
import cli as cli_mod
from hermes_state import SessionDB
db = SessionDB(db_path=tmp_path / "state.db")
session_id = "cli-close-tail"
db.create_session(session_id=session_id, source="cli")
prefix = [
{"role": "user", "content": "old prompt"},
{"role": "assistant", "content": "old answer"},
]
agent = _real_agent(db, session_id, prefix)
agent._flush_messages_to_session_db(prefix, [])
live_messages = prefix + [{"role": "assistant", "content": "new tail"}]
agent._session_messages = live_messages
cli = object.__new__(cli_mod.HermesCLI)
cli.conversation_history = live_messages
cli.session_id = session_id
cli.agent = agent
cli._persist_active_session_before_close()
stored = db.get_messages_as_conversation(session_id)
assert [m["content"] for m in stored] == [
"old prompt",
"old answer",
"new tail",
]
def test_cli_close_hands_staged_user_marker_to_turn_start(tmp_path, monkeypatch):
@ -424,72 +244,8 @@ def test_cli_close_hands_staged_user_marker_to_turn_start(tmp_path, monkeypatch)
]
def test_cli_chat_staging_does_not_mutate_live_agent_snapshot():
"""The next CLI input must be outside the prior live agent transcript."""
import cli as cli_mod
previous = [{"role": "assistant", "content": "done"}]
agent = MagicMock()
agent._session_messages = previous
agent._pending_cli_user_message = None
cli = object.__new__(cli_mod.HermesCLI)
cli.agent = agent
cli.conversation_history = previous
# Model the narrow staging operation in ``chat`` without starting a provider.
if cli.conversation_history is agent._session_messages:
cli.conversation_history = list(cli.conversation_history)
staged = {"role": "user", "content": "next"}
agent._pending_cli_user_message = staged
cli.conversation_history.append(staged)
assert agent._session_messages == [{"role": "assistant", "content": "done"}]
assert cli.conversation_history == [
{"role": "assistant", "content": "done"},
{"role": "user", "content": "next"},
]
def test_cli_close_persists_pending_user_when_agent_snapshot_is_empty(tmp_path, monkeypatch):
"""Close before worker startup persists only the CLI-staged user input."""
monkeypatch.setenv("HERMES_HOME", str(tmp_path / ".hermes"))
import cli as cli_mod
from hermes_state import SessionDB
db = SessionDB(db_path=tmp_path / "state.db")
session_id = "cli-close-before-worker"
db.create_session(session_id=session_id, source="cli")
prefix = [
{"role": "user", "content": "old prompt"},
{"role": "assistant", "content": "old answer"},
]
for message in prefix:
db.append_message(
session_id=session_id,
role=message["role"],
content=message["content"],
)
agent = _real_agent(db, session_id, [])
staged = {"role": "user", "content": "new prompt"}
agent._pending_cli_user_message = staged
cli = object.__new__(cli_mod.HermesCLI)
cli.conversation_history = list(prefix) + [staged]
cli.session_id = session_id
cli.agent = agent
cli._persist_active_session_before_close()
stored = db.get_messages_as_conversation(session_id)
assert [m["content"] for m in stored] == [
"old prompt",
"old answer",
"new prompt",
]
assert staged["_db_persisted"] is True
def test_cli_close_uses_clean_override_for_shortened_pending_snapshot(tmp_path, monkeypatch):
@ -537,96 +293,6 @@ def test_cli_close_uses_clean_override_for_shortened_pending_snapshot(tmp_path,
assert staged["_db_persisted"] is True
def test_cli_close_preserves_clean_staged_user_across_noted_worker_turn(tmp_path, monkeypatch):
"""A noted API-only turn reuses the close-marked clean staged user row."""
monkeypatch.setenv("HERMES_HOME", str(tmp_path / ".hermes"))
import cli as cli_mod
from hermes_state import SessionDB
db = SessionDB(db_path=tmp_path / "state.db")
session_id = "cli-close-noted-staged-user"
db.create_session(session_id=session_id, source="cli")
prefix = [
{"role": "user", "content": "old prompt"},
{"role": "assistant", "content": "old answer"},
]
agent = _real_agent(db, session_id, prefix)
agent._flush_messages_to_session_db(prefix, [])
staged = {"role": "user", "content": "new prompt"}
agent._pending_cli_user_message = staged
cli = object.__new__(cli_mod.HermesCLI)
cli.conversation_history = list(prefix) + [staged]
cli.session_id = session_id
cli.agent = agent
cli._persist_active_session_before_close()
assert staged["_db_persisted"] is True
# A queued model/skills note changes only the API message. The worker
# reuses the marked clean dict, so the normal persistence seam cannot append
# a second noted user row.
from agent.turn_context import build_turn_context
agent.quiet_mode = True
agent.max_iterations = 1
agent.provider = "test"
agent.base_url = ""
agent.api_key = ""
agent.api_mode = "chat_completions"
agent.tools = []
agent.valid_tool_names = set()
agent.enabled_toolsets = None
agent.disabled_toolsets = None
agent._skip_mcp_refresh = True
agent.compression_enabled = False
agent.context_compressor = types.SimpleNamespace(protect_first_n=2, protect_last_n=2)
agent._memory_store = None
agent._memory_manager = None
agent._memory_nudge_interval = 0
agent._turns_since_memory = 0
agent._user_turn_count = 0
agent._todo_store = types.SimpleNamespace(has_items=lambda: True)
agent._tool_guardrails = types.SimpleNamespace(reset_for_turn=lambda: None)
agent._compression_warning = None
agent._interrupt_requested = False
agent._memory_write_origin = "assistant_tool"
agent._stream_context_scrubber = None
agent._stream_think_scrubber = None
agent._restore_primary_runtime = lambda: None
agent._cleanup_dead_connections = lambda: False
agent._emit_status = lambda _message: None
agent._replay_compression_warning = lambda: None
agent._hydrate_todo_store = lambda *_args: None
agent._safe_print = lambda *_args: None
worker = build_turn_context(
agent,
"[MODEL SWITCH NOTE]\n\nnew prompt",
None,
prefix,
"task",
None,
"new prompt",
None,
restore_or_build_system_prompt=lambda *_args: None,
install_safe_stdio=lambda: None,
sanitize_surrogates=lambda value: value,
summarize_user_message_for_log=lambda value: value,
set_session_context=lambda _session_id: None,
set_current_write_origin=lambda _origin: None,
ra=lambda: types.SimpleNamespace(_set_interrupt=lambda *_args: None),
)
assert worker.messages[-1] is staged
assert worker.messages[-1]["content"] == "[MODEL SWITCH NOTE]\n\nnew prompt"
stored = db.get_messages_as_conversation(session_id)
assert [m["content"] for m in stored] == [
"old prompt",
"old answer",
"new prompt",
]
def test_cli_close_builds_prompt_before_creating_first_session_row(tmp_path, monkeypatch):

View file

@ -30,11 +30,6 @@ def _make_cli_stub():
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()
@ -49,12 +44,6 @@ class TestCliSkinPromptIntegration:
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()
@ -68,24 +57,7 @@ class TestCliSkinPromptIntegration:
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()

View file

@ -82,28 +82,6 @@ class TestCLIStatusBar:
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
@ -114,55 +92,10 @@ class TestCLIStatusBar:
# 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(
@ -180,101 +113,11 @@ class TestCLIStatusBar:
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()
@ -282,54 +125,8 @@ class TestCLIStatusBar:
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."""
@ -349,45 +146,8 @@ class TestCLIStatusBar:
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()
@ -396,30 +156,6 @@ class TestCLIStatusBar:
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()
@ -433,15 +169,6 @@ class TestCLIStatusBar:
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
@ -464,46 +191,8 @@ class TestCLIStatusBar:
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:
@ -587,17 +276,6 @@ class TestStatusBarWidthSource:
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."""
@ -615,18 +293,6 @@ class TestStatusBarWidthSource:
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:
@ -643,9 +309,6 @@ class TestIdleSinceLastTurn:
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()
@ -655,26 +318,4 @@ class TestIdleSinceLastTurn:
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

View file

@ -43,13 +43,6 @@ class TestStatusBarGoalSegment:
assert snapshot["goal_max_turns"] == 20
assert cli_obj._status_bar_goal_segment(snapshot) == "⊙ goal 3/20"
def test_goal_segment_absent_without_goal(self):
cli_obj = _make_cli() # no session_id → no goal manager
snapshot = cli_obj._get_status_bar_snapshot()
assert snapshot["goal_active"] is False
assert cli_obj._status_bar_goal_segment(snapshot) == ""
def test_goal_segment_absent_when_paused(self):
# Paused goals must NOT occupy the status bar (active-only contract).
@ -60,12 +53,6 @@ class TestStatusBarGoalSegment:
assert snapshot["goal_active"] is False
assert cli_obj._status_bar_goal_segment(snapshot) == ""
def test_goal_segment_without_budget_omits_counter(self):
segment = HermesCLI._status_bar_goal_segment(
{"goal_active": True, "goal_turns_used": 0, "goal_max_turns": 0}
)
assert segment == "⊙ goal"
def test_active_goal_rendered_in_wide_status_bar(self):
cli_obj = _attach_goal(_make_cli(), active=True, turns_used=5, max_turns=20)
@ -88,9 +75,3 @@ class TestStatusBarGoalSegment:
assert "⊙ goal" in text
def test_no_goal_segment_in_status_bar_without_goal(self):
cli_obj = _make_cli()
text = cli_obj._build_status_bar_text(width=120)
assert "⊙ goal" not in text

View file

@ -39,14 +39,6 @@ def test_egress_command_is_available_in_cli_registry():
assert "status" in cmd.subcommands
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_process_command_egress_prints_proxy_status(monkeypatch):
@ -63,11 +55,6 @@ def test_process_command_egress_prints_proxy_status(monkeypatch):
assert "Egress proxy status" in printed
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():

View file

@ -9,64 +9,28 @@ 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"
@ -76,6 +40,3 @@ class TestStripLeakedTerminalResponses:
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

View file

@ -25,11 +25,6 @@ class TestToolsSlashNoSubcommand:
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 ─────────────────────────────────────────────────────────────
@ -46,13 +41,6 @@ class TestToolsSlashList:
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) ──────────────────────────────────────────
@ -73,18 +61,6 @@ class TestToolsSlashDisableWithReset:
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."""
@ -98,11 +74,6 @@ class TestToolsSlashDisableWithReset:
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) ───────────────────────────────────────────

View file

@ -86,26 +86,7 @@ class TestToggleYoloIsSessionScoped:
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
@ -175,20 +156,6 @@ class TestToggleYoloEndToEnd:
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:
@ -212,26 +179,7 @@ class TestSessionRotationTransfersYolo:
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")

View file

@ -33,9 +33,6 @@ def test_compact_resolves_to_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():
@ -43,27 +40,13 @@ def test_compact_listed_in_flat_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():
@ -72,19 +55,8 @@ def test_dry_run_is_preview():
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():
@ -95,15 +67,8 @@ def test_flags_coexist_with_focus_topic():
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 ────────────────────────────────────────
@ -133,19 +98,8 @@ def test_preview_partial_boundary_counts():
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():

View file

@ -31,30 +31,7 @@ def _clear_cpr_env(monkeypatch):
class TestClassicCliOutputSelection:
def test_posix_local_without_ssh_selects_cpr_disabled_output(self, monkeypatch):
"""Changed contract: no SSH vars, still CPR-disabled on POSIX."""
monkeypatch.setattr(sys, "platform", "linux")
assert _terminal_may_leak_cpr() is True
out = _select_classic_cli_pt_output(sys.stdout)
assert out is not None
assert out.enable_cpr is False
def test_application_receives_cpr_not_supported_without_ssh(self, monkeypatch):
"""Classic-CLI Application construction must get CPR-disabled output."""
from prompt_toolkit.application import Application
from prompt_toolkit.layout import FormattedTextControl, Layout, Window
from prompt_toolkit.renderer import CPR_Support
monkeypatch.setattr(sys, "platform", "linux")
out = _select_classic_cli_pt_output(sys.stdout)
assert out is not None
app = Application(
layout=Layout(Window(FormattedTextControl("x"))),
output=out,
full_screen=False,
)
assert app.renderer.cpr_support == CPR_Support.NOT_SUPPORTED
def test_windows_preserves_default_output_selection(self, monkeypatch):
monkeypatch.setattr(sys, "platform", "win32")

View file

@ -62,62 +62,6 @@ def test_cprint_app_not_running_direct_print(monkeypatch):
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):
@ -156,27 +100,6 @@ def test_cprint_same_thread_as_app_loop_direct_print(monkeypatch):
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):
@ -215,33 +138,8 @@ def test_cprint_swallows_prompt_toolkit_import_error(monkeypatch):
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):
@ -264,19 +162,6 @@ def test_replay_output_history_rerenders_callable_entries(monkeypatch):
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):

View file

@ -22,11 +22,6 @@ def test_native_windows_preserves_newline():
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():
@ -37,11 +32,6 @@ def test_ssh_tty_alone_preserves_newline():
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():
@ -63,15 +53,6 @@ def test_ghostty_tmux_session_preserves_ctrl_j_newline():
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():
@ -94,24 +75,5 @@ def test_proc_version_microsoft_marker_preserves_newline():
# ---------------------------------------------------------------------------
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

View file

@ -31,22 +31,6 @@ def _make_self(prompt_response):
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():
@ -66,55 +50,10 @@ def test_gate_on_choice_once_returns_once():
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():
@ -141,74 +80,10 @@ def test_gate_on_choice_always_persists_and_returns_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
# ---------------------------------------------------------------------------
@ -230,21 +105,8 @@ def test_split_destructive_skip_recognized_tokens():
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():

View file

@ -27,17 +27,7 @@ def _make_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()
@ -58,22 +48,7 @@ class TestExitDeleteFlag:
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()
@ -81,15 +56,6 @@ class TestExitDeleteFlag:
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()

View file

@ -39,19 +39,7 @@ class TestSignalArmLogic:
cli._arm_exit_watchdog_on_shutdown_signal()
arm.assert_called_once_with(timeout_s=14.0)
def test_idempotent_across_repeated_signals(self, monkeypatch):
monkeypatch.setenv("HERMES_EXIT_WATCHDOG_S", "7")
with patch.object(cli, "_arm_exit_watchdog") as arm:
cli._arm_exit_watchdog_on_shutdown_signal()
cli._arm_exit_watchdog_on_shutdown_signal()
cli._arm_exit_watchdog_on_shutdown_signal()
assert arm.call_count == 1
def test_disabled_via_env_zero(self, monkeypatch):
monkeypatch.setenv("HERMES_EXIT_WATCHDOG_S", "0")
with patch.object(cli, "_arm_exit_watchdog") as arm:
cli._arm_exit_watchdog_on_shutdown_signal()
arm.assert_not_called()
def test_bad_env_value_falls_back_to_default(self, monkeypatch):
monkeypatch.setenv("HERMES_EXIT_WATCHDOG_S", "not-a-number")

View file

@ -29,10 +29,6 @@ class TestParseServiceTierConfig(unittest.TestCase):
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):
@ -61,18 +57,6 @@ class TestHandleFastCommand(unittest.TestCase):
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()
@ -88,18 +72,6 @@ class TestHandleFastCommand(unittest.TestCase):
self.assertIsNone(stub.service_tier)
self.assertIsNone(stub.agent)
def test_global_flag_persists_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 --global")
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()
@ -141,33 +113,6 @@ class TestPriorityProcessingModels(unittest.TestCase):
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."""
@ -176,27 +121,7 @@ class TestPriorityProcessingModels(unittest.TestCase):
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
@ -207,12 +132,6 @@ class TestPriorityProcessingModels(unittest.TestCase):
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):
@ -222,13 +141,6 @@ class TestFastModeRouting(unittest.TestCase):
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."""
@ -304,20 +216,7 @@ class TestAnthropicFastMode(unittest.TestCase):
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
@ -328,53 +227,9 @@ class TestAnthropicFastMode(unittest.TestCase):
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."""
@ -385,23 +240,7 @@ class TestAnthropicFastMode(unittest.TestCase):
)
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."""
@ -475,19 +314,6 @@ class TestAnthropicFastModeAdapter(unittest.TestCase):
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):

View file

@ -44,20 +44,9 @@ class TestToggleStateMachine:
def test_bare_toggles_from_off_to_on(self):
assert resolve_focus_arg("", False) == ("set", True)
def test_bare_toggles_from_on_to_off(self):
assert resolve_focus_arg("", True) == ("set", False)
def test_explicit_toggle_word_behaves_like_bare(self):
assert resolve_focus_arg("toggle", False) == ("set", True)
assert resolve_focus_arg("toggle", True) == ("set", False)
@pytest.mark.parametrize("word", ["on", "ON", " on ", "enable", "true", "yes", "1"])
def test_on_words(self, word):
assert resolve_focus_arg(word, False) == ("set", True)
@pytest.mark.parametrize("word", ["off", "OFF", "disable", "false", "no", "0"])
def test_off_words(self, word):
assert resolve_focus_arg(word, True) == ("set", False)
@pytest.mark.parametrize("word", ["status", "show", "?", "STATUS"])
def test_status_words_never_mutate(self, word):
@ -69,10 +58,6 @@ class TestToggleStateMachine:
def test_garbage_reports_usage(self, word):
assert resolve_focus_arg(word, False) == ("usage", None)
def test_explicit_set_is_idempotent(self):
# /focus on while already on stays on (no accidental toggle).
assert resolve_focus_arg("on", True) == ("set", True)
assert resolve_focus_arg("off", False) == ("set", False)
# =========================================================================
@ -91,23 +76,8 @@ class TestComposesWithVerboseModes:
def test_focus_off_leaves_the_configured_verbose_mode_untouched(self, configured):
assert effective_tool_progress_mode(False, configured) == configured
def test_yaml_boolean_off_is_normalised(self):
# YAML 1.1 parses a bare `off` as False.
assert normalize_tool_progress_mode(False) == "off"
assert normalize_tool_progress_mode(True) == "all"
assert normalize_tool_progress_mode(None) == "all"
assert normalize_tool_progress_mode("bogus") == "all"
assert normalize_tool_progress_mode("log") == "log"
@pytest.mark.parametrize("mode", ["new", "all", "verbose"])
def test_counts_lines_that_the_mode_would_have_shown(self, mode):
assert would_display_tool_line(mode, "terminal") is True
def test_does_not_count_when_verbose_was_already_off(self):
# A user who already ran /verbose off is hiding nothing extra — focus
# view must not claim credit for suppressing lines nobody would see.
assert would_display_tool_line("off", "terminal") is False
assert would_display_tool_line(False, "terminal") is False
def test_new_mode_skips_consecutive_repeats_like_the_renderer(self):
assert would_display_tool_line("new", "terminal", "terminal") is False
@ -115,8 +85,6 @@ class TestComposesWithVerboseModes:
# "all" always counts, even repeats.
assert would_display_tool_line("all", "terminal", "terminal") is True
def test_empty_tool_name_never_counts(self):
assert would_display_tool_line("all", "") is False
# =========================================================================
@ -129,18 +97,11 @@ class TestHiddenCountFormatter:
assert format_hidden_line(0) is None
assert format_hidden_line(-3) is None
def test_singular_noun(self):
assert format_hidden_line(1) == "⋯ 1 tool line hidden · /focus off to show"
def test_plural_noun(self):
assert format_hidden_line(7) == "⋯ 7 tool lines hidden · /focus off to show"
def test_line_always_names_the_recovery_command(self):
assert "/focus off" in format_hidden_line(2)
def test_non_numeric_is_tolerated(self):
assert format_hidden_line(None) is None
assert format_hidden_line("many") is None
class _FocusHost(CLICommandsMixin):
@ -162,23 +123,8 @@ class TestHiddenCounterAccumulation:
host._note_focus_hidden_line(name)
assert host._focus_hidden_lines == 3
def test_counts_nothing_when_focus_is_off(self):
host = _FocusHost(enabled=False, saved="all")
host._note_focus_hidden_line("terminal")
assert host._focus_hidden_lines == 0
def test_counts_nothing_when_verbose_was_already_off(self):
host = _FocusHost(enabled=True, saved="off")
for _ in range(5):
host._note_focus_hidden_line("terminal")
assert host._focus_hidden_lines == 0
def test_new_mode_dedupes_consecutive_repeats(self):
host = _FocusHost(enabled=True, saved="new")
host._note_focus_hidden_line("terminal")
host._note_focus_hidden_line("terminal")
host._note_focus_hidden_line("read_file")
assert host._focus_hidden_lines == 2
def test_recovery_line_is_emitted_then_counter_resets(self):
host = _FocusHost(enabled=True, saved="all")
@ -195,19 +141,7 @@ class TestHiddenCounterAccumulation:
assert host._focus_hidden_lines == 0
assert host._focus_last_counted_tool is None
def test_no_recovery_line_when_nothing_was_hidden(self):
host = _FocusHost(enabled=True, saved="all")
with patch("cli._cprint") as printer:
host._emit_focus_recovery_line()
printer.assert_not_called()
def test_no_recovery_line_when_focus_is_off(self):
host = _FocusHost(enabled=False, saved="all")
host._focus_hidden_lines = 4
with patch("cli._cprint") as printer:
host._emit_focus_recovery_line()
printer.assert_not_called()
assert host._focus_hidden_lines == 0
# =========================================================================
@ -227,43 +161,9 @@ class TestFocusCommandHandler:
assert host._focus_saved_tool_progress == "verbose"
saver.assert_called_once_with(FOCUS_CONFIG_KEY, True)
def test_off_restores_the_stashed_verbose_mode(self):
host = _FocusHost(enabled=True, saved="new", tool_progress="off")
with patch("cli.save_config_value", return_value=True) as saver, \
patch("cli._cprint"):
host._handle_focus_command("/focus off")
assert host._focus_view_enabled is False
assert host.tool_progress_mode == "new"
assert host._focus_saved_tool_progress is None
saver.assert_called_once_with(FOCUS_CONFIG_KEY, False)
def test_round_trip_returns_to_the_original_mode(self):
host = _FocusHost(enabled=False, saved=None, tool_progress="verbose")
with patch("cli.save_config_value", return_value=True), patch("cli._cprint"):
host._handle_focus_command("/focus")
assert host.tool_progress_mode == "off"
host._handle_focus_command("/focus")
assert host.tool_progress_mode == "verbose"
assert host._focus_view_enabled is False
def test_status_never_writes_config_or_changes_mode(self):
host = _FocusHost(enabled=True, saved="all", tool_progress="off")
with patch("cli.save_config_value") as saver, patch("cli._cprint") as printer:
host._handle_focus_command("/focus status")
saver.assert_not_called()
assert host.tool_progress_mode == "off"
assert host._focus_view_enabled is True
assert "Focus view" in printer.call_args[0][0]
def test_garbage_argument_prints_usage_and_changes_nothing(self):
host = _FocusHost(enabled=False, saved=None, tool_progress="all")
with patch("cli.save_config_value") as saver, patch("cli._cprint") as printer:
host._handle_focus_command("/focus sideways")
saver.assert_not_called()
assert host._focus_view_enabled is False
assert host.tool_progress_mode == "all"
assert "Usage: /focus" in printer.call_args[0][0]
def test_idempotent_on_does_not_reclobber_the_stash(self):
host = _FocusHost(enabled=True, saved="verbose", tool_progress="off")
@ -282,18 +182,7 @@ class TestFocusCommandHandler:
# suppression take effect this turn instead of after an agent rebuild.
assert host.agent.tool_progress_mode == "off"
def test_status_text_names_the_mode_focus_off_will_restore(self):
body = format_focus_status(True, "verbose")
assert "ON" in body
assert "VERBOSE" in body
off_body = format_focus_status(False, "new")
assert "OFF" in off_body
assert "NEW" in off_body
def test_toggle_messages_mirror_claude_code_wording(self):
assert "enabled" in format_focus_toggle_message(True, "all")
assert "disabled" in format_focus_toggle_message(False, "all")
assert "ALL" in format_focus_toggle_message(False, "all")
# =========================================================================
@ -306,23 +195,6 @@ class TestStatusBarSegment:
assert focus_statusbar_segment(True) == FOCUS_STATUSBAR_LABEL
assert focus_statusbar_segment(False) == ""
def test_snapshot_exposes_focus_label(self):
from cli import HermesCLI
host = HermesCLI.__new__(HermesCLI)
host.model = "anthropic/claude-opus-4.6"
from datetime import datetime
host.session_start = datetime.now()
host.conversation_history = []
host.agent = None
host._focus_view_enabled = True
snapshot = HermesCLI._get_status_bar_snapshot(host)
assert snapshot["focus_label"] == FOCUS_STATUSBAR_LABEL
host._focus_view_enabled = False
assert HermesCLI._get_status_bar_snapshot(host)["focus_label"] == ""
@pytest.mark.parametrize("width", [40, 60, 120])
def test_text_renderer_includes_the_badge_at_every_width_tier(self, width):
@ -356,75 +228,7 @@ class TestStatusBarSegment:
assert "focus" in text
@pytest.mark.parametrize("width", [40, 60, 120])
def test_fragment_renderer_includes_the_badge_at_every_width_tier(self, width):
from cli import HermesCLI
host = HermesCLI.__new__(HermesCLI)
host.model = "opus"
host._status_bar_visible = True
host._model_picker_state = None
host._focus_view_enabled = True
snapshot = {
"model_name": "opus",
"model_short": "opus",
"duration": "1m",
"context_percent": 12,
"context_tokens": 1000,
"context_length": 200000,
"compressions": 0,
"active_background_tasks": 0,
"active_background_processes": 0,
"active_background_subagents": 0,
"battery_label": "",
"battery_category": "dim",
"focus_label": FOCUS_STATUSBAR_LABEL,
"prompt_elapsed": "",
"idle_since": "",
}
with patch.object(HermesCLI, "_get_status_bar_snapshot", return_value=snapshot), \
patch.object(HermesCLI, "_get_tui_terminal_width", return_value=width), \
patch.object(HermesCLI, "_is_session_yolo_active", return_value=False):
frags = HermesCLI._get_status_bar_fragments(host)
rendered = "".join(text for _, text in frags)
assert "focus" in rendered
def test_badge_absent_from_fragments_when_focus_is_off(self):
from cli import HermesCLI
host = HermesCLI.__new__(HermesCLI)
host.model = "opus"
host._status_bar_visible = True
host._model_picker_state = None
host._focus_view_enabled = False
snapshot = {
"model_name": "opus",
"model_short": "opus",
"duration": "1m",
"context_percent": 12,
"context_tokens": 1000,
"context_length": 200000,
"compressions": 0,
"active_background_tasks": 0,
"active_background_processes": 0,
"active_background_subagents": 0,
"battery_label": "",
"battery_category": "dim",
"focus_label": "",
"prompt_elapsed": "",
"idle_since": "",
}
with patch.object(HermesCLI, "_get_status_bar_snapshot", return_value=snapshot), \
patch.object(HermesCLI, "_get_tui_terminal_width", return_value=120), \
patch.object(HermesCLI, "_is_session_yolo_active", return_value=False):
frags = HermesCLI._get_status_bar_fragments(host)
assert "focus" not in "".join(text for _, text in frags)
# =========================================================================
@ -532,12 +336,6 @@ class TestModelFacingMessagesUnchanged:
assert [m["role"] for m in focus_on].count("tool") == 3
assert json.loads(focus_on[-1]["content"]) == {"ok": "gamma"}
def test_every_verbose_mode_produces_the_same_messages(self):
# /focus composes with /verbose, so no tool-progress mode may change
# the payload — otherwise the composition itself would be unsafe.
baseline = _run_fake_turn("all")
for mode in ("off", "new", "verbose"):
assert _run_fake_turn(mode) == baseline, f"mode {mode} altered messages"
def test_toggling_focus_does_not_touch_conversation_history(self):
host = _FocusHost(enabled=False, saved=None, tool_progress="all")

View file

@ -43,59 +43,8 @@ def test_manual_compress_keeps_tui_composer_editable(capsys):
assert observed == {"running": True, "blocks_input": False}
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), "")
# Explicitly signal this is NOT a lock-skip to avoid MagicMock
# getattr returning a truthy mock for unset attributes.
shell.agent._compression_skipped_due_to_lock = False
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_reports_aborted_summary_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
shell.agent.context_compressor._last_compress_aborted = True
shell.agent.context_compressor._last_summary_fallback_used = False
shell.agent.context_compressor._last_summary_error = (
"Provider 'opencode-zen' is set in config.yaml but no API key was found."
)
shell.agent._compress_context.return_value = (list(history), "")
# Explicit non-lock-skip: MagicMock getattr would return a truthy mock.
shell.agent._compression_skipped_due_to_lock = False
with patch("agent.model_metadata.estimate_request_tokens_rough", return_value=100):
shell._manual_compress()
output = capsys.readouterr().out
assert "⚠️ Compression aborted: 4 messages preserved" in output
assert "no messages were removed" in output
assert "no API key was found" in output
assert "✅ Compressed:" not in output
def test_manual_compress_explains_when_token_estimate_rises(capsys):
@ -209,21 +158,6 @@ def test_manual_compress_flushes_compressed_history_to_child_session_db():
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), "")
shell.agent._compression_skipped_due_to_lock = False
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_runs_when_auto_compaction_disabled(capsys):
@ -262,27 +196,6 @@ def test_manual_compress_runs_when_auto_compaction_disabled(capsys):
assert shell.conversation_history == compressed
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.agent._compression_skipped_due_to_lock = False
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"
def test_manual_compress_shows_lock_skip_without_confirmed_holder(capsys):

View file

@ -73,15 +73,6 @@ def test_moa_non_preset_is_one_shot_prompt():
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", "enabled": True}
]
class TestNormalizeMoaModel:
@ -96,18 +87,8 @@ class TestNormalizeMoaModel:
from cli import _normalize_moa_model
assert _normalize_moa_model("moa:strategy") == ("moa", "strategy")
def test_moa_prefix_is_case_insensitive_and_trims(self):
from cli import _normalize_moa_model
assert _normalize_moa_model(" MOA:code-review ") == ("moa", "code-review")
def test_bare_moa_without_preset_is_not_treated_as_virtual(self):
from cli import _normalize_moa_model
# No preset after the colon → leave untouched (no provider override).
assert _normalize_moa_model("moa:") == (None, "moa:")
def test_non_moa_model_unchanged(self):
from cli import _normalize_moa_model
assert _normalize_moa_model("anthropic/claude-opus-4.8") == (None, "anthropic/claude-opus-4.8")
def test_none_model_unchanged(self):
from cli import _normalize_moa_model

View file

@ -25,18 +25,8 @@ def _history(n_pairs: int) -> list[dict[str, str]]:
# ── 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():
@ -46,11 +36,6 @@ def test_here_with_count():
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():
@ -61,10 +46,6 @@ def test_keep_flag_forms():
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():
@ -74,31 +55,13 @@ def test_here_count_clamped_low_and_high():
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():
@ -119,19 +82,8 @@ def test_split_tail_always_starts_on_user():
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():
@ -185,9 +137,6 @@ def test_rejoin_assistant_assistant_seam_merges():
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():

View file

@ -20,17 +20,7 @@ class TestCLIPersonalityNone:
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()
@ -38,36 +28,10 @@ class TestCLIPersonalityNone:
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 ──────────────────────────────────────────────────────────
@ -91,19 +55,6 @@ class TestGatewayPersonalityNone:
}
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):
@ -118,18 +69,6 @@ class TestGatewayPersonalityNone:
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):
@ -182,16 +121,6 @@ class TestPersonalityDictFormat:
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({

View file

@ -12,30 +12,14 @@ 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():
@ -61,18 +45,6 @@ def test_image_only_list_gets_leading_text_part():
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():

View file

@ -53,55 +53,12 @@ class TestCLIQuickCommands:
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."""
@ -112,21 +69,7 @@ class TestCLIQuickCommands:
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 ──────────────────────────────────────────────────────────
@ -199,19 +142,6 @@ class TestGatewayQuickCommands:
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):

View file

@ -36,17 +36,10 @@ class TestParseReasoningConfig(unittest.TestCase):
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")
# ---------------------------------------------------------------------------
@ -84,28 +77,8 @@ class TestHandleReasoningCommand(unittest.TestCase):
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
@ -114,45 +87,9 @@ class TestHandleReasoningCommand(unittest.TestCase):
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")
def test_effort_defaults_to_session_only(self):
"""Plain /reasoning <level> is session-scoped — no config write."""
@ -166,33 +103,7 @@ class TestHandleReasoningCommand(unittest.TestCase):
self.assertEqual(stub.reasoning_config, {"enabled": True, "effort": "high"})
self.assertIsNone(stub.agent)
def test_effort_global_flag_persists_config(self):
"""--global opts into persisting the effort to config.yaml."""
from cli import CLI_CONFIG
from hermes_cli.cli_commands_mixin import CLICommandsMixin
stub = self._make_cli(reasoning_config={"enabled": True, "effort": "medium"})
with patch.dict(CLI_CONFIG.setdefault("agent", {}), {"reasoning_effort": "medium"}), \
patch("cli.save_config_value", return_value=True) as save_config, \
patch("cli._cprint"):
CLICommandsMixin._handle_reasoning_command(stub, "/reasoning high --global")
self.assertEqual(CLI_CONFIG["agent"]["reasoning_effort"], "high")
save_config.assert_called_once_with("agent.reasoning_effort", "high")
self.assertEqual(stub.reasoning_config, {"enabled": True, "effort": "high"})
self.assertIsNone(stub.agent)
def test_effort_session_flag_does_not_persist_config(self):
"""--session (explicit no-op alias for the default) stays session-only."""
from hermes_cli.cli_commands_mixin import CLICommandsMixin
stub = self._make_cli(reasoning_config={"enabled": True, "effort": "medium"})
with patch("cli.save_config_value") as save_config, patch("cli._cprint"):
CLICommandsMixin._handle_reasoning_command(stub, "/reasoning high --session")
save_config.assert_not_called()
self.assertEqual(stub.reasoning_config, {"enabled": True, "effort": "high"})
self.assertIsNone(stub.agent)
def test_new_session_clears_session_reasoning_override(self):
"""/new and /clear must not carry a session-only effort override forward."""
@ -307,16 +218,6 @@ class TestLastReasoningInResult(unittest.TestCase):
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 = [
@ -334,16 +235,6 @@ class TestLastReasoningInResult(unittest.TestCase):
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)
# ---------------------------------------------------------------------------
@ -358,22 +249,7 @@ class TestReasoningCollapse(unittest.TestCase):
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."""
@ -407,22 +283,7 @@ class TestReasoningCallback(unittest.TestCase):
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
@ -471,21 +332,6 @@ class TestReasoningPreviewBuffering(unittest.TestCase):
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):
@ -568,11 +414,6 @@ class TestExtractReasoningFormats(unittest.TestCase):
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)
# ---------------------------------------------------------------------------
@ -612,19 +453,7 @@ class TestInlineThinkBlockExtraction(unittest.TestCase):
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."""
@ -636,19 +465,7 @@ class TestInlineThinkBlockExtraction(unittest.TestCase):
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."""
@ -731,15 +548,6 @@ class TestEndToEndPipeline(unittest.TestCase):
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"])
# ---------------------------------------------------------------------------
@ -766,52 +574,7 @@ class TestReasoningDeltasFiredFlag(unittest.TestCase):
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
@ -863,19 +626,6 @@ class TestReasoningShownThisTurnFlag(unittest.TestCase):
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):

View file

@ -134,12 +134,6 @@ class TestDisplayResumedHistory:
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_timeline_markers_render_as_events_not_user_input(self):
cli = _make_cli()
@ -156,30 +150,7 @@ class TestDisplayResumedHistory:
assert "You:" not in output
assert "opaque" 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
@ -194,113 +165,13 @@ class TestDisplayResumedHistory:
# 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"}})
@ -311,69 +182,10 @@ class TestDisplayResumedHistory:
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."""
@ -391,73 +203,9 @@ class TestDisplayResumedHistory:
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."""
@ -475,65 +223,8 @@ class TestDisplayResumedHistory:
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 ──────────────────────────────
@ -546,10 +237,6 @@ class TestPreloadResumedSession:
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")
@ -565,39 +252,7 @@ class TestPreloadResumedSession:
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_resume_conversations.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_resume_conversations.return_value = (messages, 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")
@ -619,26 +274,6 @@ class TestPreloadResumedSession:
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_resume_conversations.return_value = (messages, 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 ───────────────────
@ -672,23 +307,6 @@ class TestHandleResumeCommandRecap:
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_resume_conversations.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()
def test_resume_command_replaces_stale_display_history(self):
"""In-session /resume B after startup --resume A must show B's recap,
@ -761,18 +379,6 @@ class TestResumeDisplayConfig:
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"
class TestResumeDisplaySanitization:
@ -801,19 +407,3 @@ class TestResumeDisplaySanitization:
assert "hi" in output and "there" in output
assert "fine" in output
def test_multimodal_text_part_sanitized(self):
cli = _make_cli()
cli.conversation_history = [
{
"role": "user",
"content": [
{"type": "text", "text": "look \x1b[3J\x1b[H at this"},
{"type": "image_url", "image_url": {"url": "https://x/y.png"}},
],
},
{"role": "assistant", "content": "sure"},
]
output = self._capture_display(cli)
assert "\x1b[3J" not in output
assert "\x1b[H" not in output
assert "[image]" in output

View file

@ -11,27 +11,6 @@ def reset_single_query_finalize_state(monkeypatch):
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):
@ -76,52 +55,6 @@ def test_finalize_single_query_runs_cleanup_when_finalize_hook_fails(monkeypatch
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):

View file

@ -144,37 +144,7 @@ class TestModal:
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
@ -201,54 +171,7 @@ class TestModal:
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:

View file

@ -62,13 +62,6 @@ class TestThinkTagInProse:
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."""
@ -101,11 +94,6 @@ class TestRealReasoningBlock:
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."""

View file

@ -63,15 +63,6 @@ class TestLogicalLineStreaming:
assert cli._spinner_text.startswith("")
assert "newline" in cli._spinner_text
def test_logical_line_emitted_whole_at_newline(self, cli_stub):
cli, emitted = cli_stub
long_line = "word " * 60 # ~300 chars, far beyond terminal width
cli._stream_delta(long_line.rstrip() + "\n")
content = [
_strip_ansi(e) for e in emitted if "word" in _strip_ansi(e)
]
assert len(content) == 1, "logical line was split across prints"
assert content[0] == long_line.rstrip()
def test_no_content_lost_across_stream(self, cli_stub):
cli, emitted = cli_stub
@ -84,12 +75,6 @@ class TestLogicalLineStreaming:
for w in words:
assert w in plain, f"lost {w}"
def test_short_partial_stays_buffered(self, cli_stub):
cli, emitted = cli_stub
cli._stream_delta("short line, no newline")
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_previewed_in_spinner(self, cli_stub):
cli, emitted = cli_stub

View file

@ -23,23 +23,12 @@ class TestSanitizeSurrogates:
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."""
@ -49,12 +38,6 @@ class TestSanitizeSurrogates:
# 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:
@ -86,20 +69,7 @@ class TestSanitizeMessagesSurrogates:
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."""
@ -127,14 +97,6 @@ class TestReasoningFieldSurrogates:
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."""
@ -154,28 +116,6 @@ class TestReasoningFieldSurrogates:
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."""
@ -195,26 +135,11 @@ class TestReasoningFieldSurrogates:
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"}
@ -222,39 +147,10 @@ class TestSanitizeStructureSurrogates:
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:

View file

@ -88,30 +88,7 @@ class TestToolProgressScrollback:
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."""
@ -122,37 +99,8 @@ class TestToolProgressScrollback:
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."""
@ -167,24 +115,6 @@ class TestToolProgressScrollback:
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.
@ -201,34 +131,8 @@ class TestToolProgressScrollback:
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."""

View file

@ -62,51 +62,8 @@ class TestResetTerminalInputModes(unittest.TestCase):
# 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()

View file

@ -201,12 +201,6 @@ class TestGitRepoDetection:
assert root is not None
assert Path(root).resolve() == git_repo.resolve()
def test_detects_subdirectory(self, git_repo):
subdir = git_repo / "src" / "lib"
subdir.mkdir(parents=True)
root = _git_repo_root(cwd=str(subdir))
assert root is not None
assert Path(root).resolve() == git_repo.resolve()
def test_returns_none_outside_repo(self, tmp_path):
# tmp_path itself is not a git repo
@ -259,16 +253,7 @@ class TestWorktreeCreation:
# It should NOT appear in worktree 2
assert not (Path(info2["path"]) / "only-in-wt1.txt").exists()
def test_worktrees_dir_created(self, git_repo):
info = _setup_worktree(str(git_repo))
assert info is not None
assert (git_repo / ".worktrees").is_dir()
def test_worktree_has_repo_files(self, git_repo):
"""Worktree should contain the repo's tracked files."""
info = _setup_worktree(str(git_repo))
assert info is not None
assert (Path(info["path"]) / "README.md").exists()
class TestWorktreeCleanup:
@ -283,69 +268,9 @@ class TestWorktreeCleanup:
assert result is True
assert not Path(info["path"]).exists()
def test_dirty_worktree_cleaned_when_no_unpushed(self, git_repo):
"""Dirty working tree without unpushed commits is cleaned up.
Agent sessions typically leave untracked files / artifacts behind.
Since all real work is in pushed commits, these don't warrant
keeping the worktree.
"""
info = _setup_worktree(str(git_repo))
assert info is not None
# Make uncommitted changes (untracked file)
(Path(info["path"]) / "new-file.txt").write_text("uncommitted")
subprocess.run(
["git", "add", "new-file.txt"],
cwd=info["path"], capture_output=True,
)
# The git_repo fixture already has a fake remote ref so the initial
# commit is seen as "pushed". No unpushed commits → cleanup proceeds.
result = _cleanup_worktree(info)
assert result is True # Cleaned up despite dirty working tree
assert not Path(info["path"]).exists()
def test_worktree_with_unpushed_commits_kept(self, git_repo):
"""Worktree with unpushed commits is preserved."""
info = _setup_worktree(str(git_repo))
assert info is not None
# Make a commit that is NOT on any remote
(Path(info["path"]) / "work.txt").write_text("real work")
subprocess.run(["git", "add", "work.txt"], cwd=info["path"], capture_output=True)
subprocess.run(
["git", "commit", "-m", "agent work"],
cwd=info["path"], capture_output=True,
)
result = _cleanup_worktree(info)
assert result is False # Kept — has unpushed commits
assert Path(info["path"]).exists()
def test_clean_worktree_removed_without_remote(self, git_repo_no_remote):
"""Clean worktrees in repos without remotes should still be removed."""
info = _setup_worktree(str(git_repo_no_remote))
assert info is not None
assert Path(info["path"]).exists()
assert _has_unpushed_commits(info["path"], timeout=10) is False
result = _cleanup_worktree(info)
assert result is True
assert not Path(info["path"]).exists()
def test_clean_worktree_removed_without_remote_tracking_refs(
self, git_repo_remote_no_tracking
):
"""Configured remotes without fetched refs should not block cleanup."""
info = _setup_worktree(str(git_repo_remote_no_tracking))
assert info is not None
assert Path(info["path"]).exists()
assert _has_unpushed_commits(info["path"], timeout=10) is False
result = _cleanup_worktree(info)
assert result is True
assert not Path(info["path"]).exists()
def test_branch_deleted_on_cleanup(self, git_repo):
info = _setup_worktree(str(git_repo))
@ -412,15 +337,6 @@ class TestWorktreeInclude:
assert (wt_path / ".env").exists()
assert (wt_path / ".env").read_text() == "SECRET=abc123"
def test_ignores_comments_and_blanks(self, git_repo):
"""Comments and blank lines in .worktreeinclude should be skipped."""
(git_repo / ".worktreeinclude").write_text(
"# This is a comment\n"
"\n"
" # Another comment\n"
)
info = _setup_worktree(str(git_repo))
assert info is not None
# Should not crash — just skip all lines
@ -449,14 +365,6 @@ class TestGitignoreManagement:
content = gitignore.read_text()
assert ".worktrees/" in content
def test_does_not_duplicate_gitignore_entry(self, git_repo):
"""If .worktrees/ is already in .gitignore, don't add again."""
gitignore = git_repo / ".gitignore"
gitignore.write_text(".worktrees/\n")
# The check should see it's already there
existing = gitignore.read_text()
assert ".worktrees/" in existing.splitlines()
class TestMultipleWorktrees:
@ -619,113 +527,8 @@ class TestStaleWorktreePruning:
assert not pruned
assert Path(info["path"]).exists()
def test_keeps_old_worktree_with_unpushed_commits(self, git_repo):
"""Old worktrees (24-72h) with unpushed commits should NOT be pruned."""
import time
info = _setup_worktree(str(git_repo))
assert info is not None
# Make an unpushed commit
(Path(info["path"]) / "work.txt").write_text("real work")
subprocess.run(["git", "add", "work.txt"], cwd=info["path"], capture_output=True)
subprocess.run(
["git", "commit", "-m", "agent work"],
cwd=info["path"], capture_output=True,
)
# Make it old (25h — in the 24-72h soft tier)
old_time = time.time() - (25 * 3600)
os.utime(info["path"], (old_time, old_time))
# Check for unpushed commits (simulates prune logic)
has_unpushed = _has_unpushed_commits(info["path"])
assert has_unpushed # Has unpushed commits → not pruned in soft tier
assert Path(info["path"]).exists()
def test_prunes_old_clean_worktree_without_remote(self, git_repo_no_remote):
"""Old clean worktrees in repos without remotes should not be kept."""
import time
info = _setup_worktree(str(git_repo_no_remote))
assert info is not None
assert Path(info["path"]).exists()
old_time = time.time() - (25 * 3600)
os.utime(info["path"], (old_time, old_time))
worktrees_dir = git_repo_no_remote / ".worktrees"
cutoff = time.time() - (24 * 3600)
for entry in worktrees_dir.iterdir():
if not entry.is_dir() or not entry.name.startswith("hermes-"):
continue
mtime = entry.stat().st_mtime
if mtime > cutoff:
continue
if _has_unpushed_commits(str(entry), timeout=5):
continue
branch_result = subprocess.run(
["git", "branch", "--show-current"],
capture_output=True, text=True, timeout=5, cwd=str(entry),
)
branch = branch_result.stdout.strip()
subprocess.run(
["git", "worktree", "remove", str(entry), "--force"],
capture_output=True, text=True, timeout=15, cwd=str(git_repo_no_remote),
)
if branch:
subprocess.run(
["git", "branch", "-D", branch],
capture_output=True, text=True, timeout=10, cwd=str(git_repo_no_remote),
)
assert not Path(info["path"]).exists()
def test_prunes_old_clean_worktree_without_remote_tracking_refs(
self, git_repo_remote_no_tracking
):
"""Old clean worktrees with no fetched remote refs should be pruned."""
import time
info = _setup_worktree(str(git_repo_remote_no_tracking))
assert info is not None
assert Path(info["path"]).exists()
old_time = time.time() - (25 * 3600)
os.utime(info["path"], (old_time, old_time))
worktrees_dir = git_repo_remote_no_tracking / ".worktrees"
cutoff = time.time() - (24 * 3600)
for entry in worktrees_dir.iterdir():
if not entry.is_dir() or not entry.name.startswith("hermes-"):
continue
mtime = entry.stat().st_mtime
if mtime > cutoff:
continue
if _has_unpushed_commits(str(entry), timeout=5):
continue
branch_result = subprocess.run(
["git", "branch", "--show-current"],
capture_output=True, text=True, timeout=5, cwd=str(entry),
)
branch = branch_result.stdout.strip()
subprocess.run(
["git", "worktree", "remove", str(entry), "--force"],
capture_output=True, text=True, timeout=15,
cwd=str(git_repo_remote_no_tracking),
)
if branch:
subprocess.run(
["git", "branch", "-D", branch],
capture_output=True, text=True, timeout=10,
cwd=str(git_repo_remote_no_tracking),
)
assert not Path(info["path"]).exists()
def test_force_prunes_very_old_worktree(self, git_repo):
"""Worktrees older than 72h should be force-pruned regardless."""
@ -809,21 +612,7 @@ class TestCLIFlagLogic:
use_worktree = worktree or w or config_worktree
assert use_worktree
def test_w_flag_triggers(self):
"""-w flag should trigger worktree creation."""
worktree = False
w = True
config_worktree = False
use_worktree = worktree or w or config_worktree
assert use_worktree
def test_config_triggers(self):
"""worktree: true in config should trigger worktree creation."""
worktree = False
w = False
config_worktree = True
use_worktree = worktree or w or config_worktree
assert use_worktree
def test_none_set_no_trigger(self):
"""No flags and no config should not trigger."""
@ -850,16 +639,6 @@ class TestTerminalCWDIntegration:
# Clean up env
del os.environ["TERMINAL_CWD"]
def test_terminal_cwd_is_valid_git_repo(self, git_repo):
"""The TERMINAL_CWD worktree should be a valid git working tree."""
info = _setup_worktree(str(git_repo))
assert info is not None
result = subprocess.run(
["git", "rev-parse", "--is-inside-work-tree"],
capture_output=True, text=True, cwd=info["path"],
)
assert result.stdout.strip() == "true"
class TestOrphanedBranchPruning:
@ -956,21 +735,6 @@ class TestOrphanedBranchPruning:
assert "pr-1234" not in remaining
assert "pr-5678" not in remaining
def test_preserves_active_worktree_branch(self, git_repo):
"""Branches with active worktrees should NOT be pruned."""
info = _setup_worktree(str(git_repo))
assert info is not None
result = subprocess.run(
["git", "worktree", "list", "--porcelain"],
capture_output=True, text=True, cwd=str(git_repo),
)
active_branches = set()
for line in result.stdout.split("\n"):
if line.startswith("branch refs/heads/"):
active_branches.add(line.split("branch refs/heads/", 1)[-1].strip())
assert info["branch"] in active_branches # Protected
def test_preserves_main_branch(self, git_repo):
"""main branch should never be pruned."""
@ -1094,11 +858,6 @@ class TestWorktreeLockReaping:
cli._prune_stale_worktrees(str(git_repo))
assert wt.exists(), "dirty worktree must survive even past the 72h tier"
def test_recent_worktree_untouched(self, git_repo):
import cli
wt = self._mk(cli, git_repo, "hermes-fresh", pid=None, age_h=1)
cli._prune_stale_worktrees(str(git_repo))
assert wt.exists(), "worktree under 24h must never be pruned"
class TestWorktreeLockPredicate:
@ -1127,15 +886,7 @@ class TestWorktreeLockPredicate:
)
assert cli._worktree_lock_is_live(str(git_repo), str(p)) is None
def test_live_pid_returns_live(self, git_repo):
import cli
p = self._mk_locked(git_repo, "hermes-live", f"hermes pid={os.getpid()}")
assert cli._worktree_lock_is_live(str(git_repo), str(p)) == "live"
def test_dead_pid_returns_dead(self, git_repo):
import cli
p = self._mk_locked(git_repo, "hermes-dead", "hermes pid=999999")
assert cli._worktree_lock_is_live(str(git_repo), str(p)) == "dead"
def test_foreign_lock_reason_returns_dead(self, git_repo):
import cli
@ -1222,23 +973,8 @@ class TestWidenedPruner:
cli._prune_stale_worktrees(str(git_repo))
assert wt.exists(), "named tree under 72h must be kept (3x scratch timeline)"
def test_named_dirty_tree_survives_any_age(self, git_repo):
import cli
wt, _ = self._mk(git_repo, "salvage-dirty", dirty=True, age_h=24 * 30)
cli._prune_stale_worktrees(str(git_repo))
assert wt.exists(), "dirty named tree must never be reaped"
def test_named_unpushed_tree_survives_any_age(self, git_repo):
import cli
wt, _ = self._mk(git_repo, "salvage-unpushed", commit=True, age_h=24 * 30)
cli._prune_stale_worktrees(str(git_repo))
assert wt.exists(), "unique unpushed work must never be reaped"
def test_kanban_task_tree_never_touched(self, git_repo):
import cli
wt, _ = self._mk(git_repo, "t_0a1b2c3d", age_h=24 * 90)
cli._prune_stale_worktrees(str(git_repo))
assert wt.exists(), "kanban t_<hex> trees belong to kanban gc, not the pruner"
# -- squash-merge escape hatch ------------------------------------------
@ -1255,35 +991,11 @@ class TestWidenedPruner:
"work and should be reaped"
)
def test_partially_merged_tree_survives(self, git_repo):
import cli
wt, sha = self._mk(git_repo, "hermes-partial", commit=True, age_h=100)
self._merge_upstream(git_repo, sha)
# add a second, unmerged commit on top
(wt / "extra.txt").write_text("unique work\n")
subprocess.run(["git", "add", "extra.txt"], cwd=wt, capture_output=True)
subprocess.run(["git", "commit", "-m", "extra"], cwd=wt, capture_output=True)
self._age(wt, 100)
cli._prune_stale_worktrees(str(git_repo))
assert wt.exists(), "any non-equivalent local commit must preserve the tree"
# -- _worktree_commits_all_merged_upstream unit contracts ----------------
def test_merged_predicate_true_on_patch_equivalence(self, git_repo):
import cli
wt, sha = self._mk(git_repo, "hermes-eq", commit=True)
self._merge_upstream(git_repo, sha)
assert cli._worktree_commits_all_merged_upstream(str(wt)) is True
def test_merged_predicate_false_on_unique_work(self, git_repo):
import cli
wt, _ = self._mk(git_repo, "hermes-uniq", commit=True)
assert cli._worktree_commits_all_merged_upstream(str(wt)) is False
def test_merged_predicate_true_at_zero_ahead(self, git_repo):
import cli
wt, _ = self._mk(git_repo, "hermes-zero")
assert cli._worktree_commits_all_merged_upstream(str(wt)) is True
def test_merged_predicate_fails_safe_without_upstream(self, git_repo_no_remote):
import cli
@ -1296,34 +1008,10 @@ class TestWidenedPruner:
)
assert cli._worktree_commits_all_merged_upstream(str(p)) is False
def test_merged_predicate_fails_safe_on_stale_base(self, git_repo):
import cli
wt, _ = self._mk(git_repo, "hermes-manyahead", commit=True)
assert cli._worktree_commits_all_merged_upstream(str(wt), max_ahead=0) is False
# -- preserved-work warning ----------------------------------------------
def test_preserved_stale_work_emits_warning(self, git_repo, caplog):
import logging
import cli
wt, _ = self._mk(git_repo, "salvage-old-work", commit=True, age_h=24 * 10)
with caplog.at_level(logging.WARNING, logger="cli"):
cli._prune_stale_worktrees(str(git_repo))
assert wt.exists()
assert any(
"salvage-old-work" in rec.getMessage() for rec in caplog.records
), "worktree with >7d-old unmerged work should be named in a WARNING"
def test_no_warning_for_recent_work(self, git_repo, caplog):
import logging
import cli
wt, _ = self._mk(git_repo, "salvage-new-work", commit=True, age_h=100)
with caplog.at_level(logging.WARNING, logger="cli"):
cli._prune_stale_worktrees(str(git_repo))
assert wt.exists()
assert not any(
"salvage-new-work" in rec.getMessage() for rec in caplog.records
), "under-7d preserved work should not warn"
class TestMergeVerdictCache:
@ -1355,13 +1043,6 @@ class TestMergeVerdictCache:
assert cache, "verdict should have been memoized"
assert uncached is cold is warm is True
def test_cache_records_negative_verdicts_too(self, git_repo):
import cli
wt, _ = self._mk(git_repo, "hermes-cacheneg", commit=True)
cache = {}
assert cli._worktree_commits_all_merged_upstream(str(wt), cache=cache) is False
assert cli._worktree_commits_all_merged_upstream(str(wt), cache=cache) is False
assert set(cache.values()) == {False}
def test_new_commit_invalidates_cached_verdict(self, git_repo):
"""Moving HEAD must not reuse the old entry — the key includes head_sha.
@ -1385,42 +1066,7 @@ class TestMergeVerdictCache:
assert cli._worktree_commits_all_merged_upstream(str(wt), cache=cache) is False
assert set(cache) != key_after_merge, "moved HEAD must produce a new key"
def test_cached_tree_with_new_work_is_still_preserved(self, git_repo):
"""End-to-end: a warm cache must never let the pruner delete new work."""
import cli
wt, sha = self._mk(git_repo, "hermes-warmsafe", commit=True)
self._merge_upstream(git_repo, sha)
# Warm the on-disk cache with the 'fully merged' verdict.
cli._prune_stale_worktrees(str(git_repo))
assert not wt.exists(), "merged tree should be reaped on the cold pass"
# Recreate the same-named tree, now carrying unmerged work.
wt2, _ = self._mk(git_repo, "hermes-warmsafe", commit=True)
(wt2 / "precious.txt").write_text("do not delete\n")
subprocess.run(["git", "add", "precious.txt"], cwd=wt2, capture_output=True)
subprocess.run(["git", "commit", "-m", "precious"], cwd=wt2, capture_output=True)
self._age(wt2, 100)
cli._prune_stale_worktrees(str(git_repo))
assert wt2.exists(), "warm cache must not authorize deleting unmerged work"
def test_corrupt_cache_file_is_ignored(self, git_repo, monkeypatch, tmp_path):
"""A garbage cache must degrade to recomputation, not crash startup."""
import json
import cli
bad = tmp_path / "worktree_merge_verdicts.json"
bad.write_text("{not json at all")
monkeypatch.setattr(cli, "_worktree_merge_cache_path", lambda: bad)
assert cli._load_worktree_merge_cache() == {}
# Non-bool verdicts must be dropped rather than fed into the decision.
bad.write_text(json.dumps({"version": 1, "verdicts": {"a..b:20": "yes"}}))
assert cli._load_worktree_merge_cache() == {}
wt, _ = self._mk(git_repo, "hermes-corrupt", commit=True)
cli._prune_stale_worktrees(str(git_repo))
assert wt.exists(), "unmerged work survives a corrupt cache"
def test_cache_is_bounded(self, monkeypatch, tmp_path):
"""The cache file must not grow without limit across sessions."""

View file

@ -136,21 +136,6 @@ class TestWorktreeIncludeEncoding:
swallowed at DEBUG so no include is copied), and a Notepad BOM glues to
the first line on every platform."""
def test_bom_in_worktreeinclude_does_not_hide_first_entry(self, git_repo):
import cli as cli_mod
(git_repo / ".env").write_text("SECRET=***\n")
# Notepad-style UTF-8 with BOM; the BOM must not become part of the
# first entry's path.
(git_repo / ".worktreeinclude").write_bytes(".env\n".encode("utf-8"))
info = None
try:
info = cli_mod._setup_worktree(str(git_repo))
assert info is not None
assert (Path(info["path"]) / ".env").exists()
finally:
_force_remove_worktree(info)
def test_non_ascii_worktreeinclude_entry_copied(self, git_repo):
import cli as cli_mod
@ -169,23 +154,3 @@ class TestWorktreeIncludeEncoding:
finally:
_force_remove_worktree(info)
def test_bom_in_gitignore_does_not_duplicate_worktrees_entry(self, git_repo):
import cli as cli_mod
(git_repo / ".gitignore").write_bytes(".worktrees/\n".encode("utf-8"))
info = None
try:
info = cli_mod._setup_worktree(str(git_repo))
assert info is not None
lines = (
(git_repo / ".gitignore")
.read_text(encoding="utf-8-sig")
.splitlines()
)
assert lines.count(".worktrees/") == 1, (
"BOM glued to the first line must not defeat the membership "
f"check and duplicate the entry: {lines!r}"
)
finally:
_force_remove_worktree(info)