mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-31 19:16:29 +00:00
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:
commit
92856bc28a
2093 changed files with 2139 additions and 377634 deletions
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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~"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 = []
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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]"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) ───────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue