chore: anchor fresh-start history to upstream

This commit is contained in:
2026-09-16 15:06:54 +08:00
parent 64a9b43261
commit 012c9c6bed
3514 changed files with 48322 additions and 187346 deletions
View File
+50
View File
@@ -0,0 +1,50 @@
"""Shared fixtures for CLI tests.
prompt_toolkit / capsys isolation
---------------------------------
``cli._cprint`` renders through ``prompt_toolkit.print_formatted_text``,
which — when called with no explicit ``output=`` — lazily creates an
``Output`` from ``sys.stdout`` **and caches it on the process-global default
``AppSession``** (``prompt_toolkit.application.current._current_app_session``,
a ``ContextVar`` with a module-level default). The cache is keyed to nothing
and never re-reads ``sys.stdout``.
Under pytest, ``capsys`` swaps ``sys.stdout`` for a fresh buffer per test.
So the first CLI test that emits through ``_cprint`` (e.g. one exercising
``/queue``, which prints a "Queued: …" line) locks prompt_toolkit's cached
output onto *its* captured stdout. Every later ``capsys`` test that asserts
on ``_cprint`` output then reads an empty buffer, because the render went to
the first test's now-dead capture target. That is the mechanism behind the
order-dependent ``test_resume_quiet_stderr`` failure: it passes in isolation
and in its own file, but fails in a full ``tests/cli`` run.
Reset the cached output before every CLI test so each one re-creates a fresh
prompt_toolkit ``Output`` bound to its own ``sys.stdout`` on first use. This
is a no-op when prompt_toolkit isn't importable and cheap otherwise (the
property re-creates lazily).
"""
import pytest
@pytest.fixture(autouse=True)
def _reset_prompt_toolkit_output_cache():
"""Clear prompt_toolkit's cached AppSession output around each CLI test.
See the module docstring for the capsys/prompt_toolkit interaction this
guards against.
"""
def _clear() -> None:
try:
from prompt_toolkit.application.current import get_app_session
get_app_session()._output = None
except Exception:
# prompt_toolkit not importable / internal shape changed — the
# tests that rely on this simply keep their prior behavior.
pass
_clear()
yield
_clear()
+313
View File
@@ -0,0 +1,313 @@
"""Tests for `!<command>` shell mode in the interactive CLI.
Covers bang detection/parsing, that the terminal tool's approval gate is
invoked for a dangerous command, that non-zero exit codes surface, and the
load-bearing invariant: a bang command leaves conversation_history
byte-identical because it never becomes a turn.
"""
import copy
import json
import os
from unittest.mock import MagicMock, patch
import pytest
from hermes_cli.bang_shell import (
USAGE_HINT,
bang_shell_enabled,
is_bang_command,
parse_bang_command,
run_bang_command,
)
# ── detection / parsing ────────────────────────────────────────────────────
class TestBangDetection:
@pytest.mark.parametrize("text", [
"!ls",
"!git status",
" !ls -la", # leading whitespace still counts
"! spaced out", # `!` followed by spaces
"!!", # double bang
"!", # bare bang
])
def test_leading_bang_is_bang(self, text):
assert is_bang_command(text) is True
@pytest.mark.parametrize("text", [
"fix the bug!", # trailing `!` in prose
"echo hi! please", # mid-text `!`
"run this: !ls", # `!` not at the start
"/help",
"hello world",
"",
" ",
None,
123,
])
def test_non_leading_bang_is_not_bang(self, text):
assert is_bang_command(text) is False
@pytest.mark.parametrize("text,expected", [
("!ls", "ls"),
("!git status", "git status"),
(" !ls -la ", "ls -la"),
("! echo hi", "echo hi"),
("!!", "!"), # second bang belongs to the command
("!!ls", "!ls"),
("!", ""), # bare bang → no command
("! ", ""),
("not a bang", ""),
])
def test_parse_strips_exactly_one_bang(self, text, expected):
assert parse_bang_command(text) == expected
class TestBangContextGating:
"""Bang mode is CLI-only — gateway/cron users have their own shells."""
def test_enabled_in_plain_cli(self, monkeypatch):
for var in ("HERMES_GATEWAY_SESSION", "HERMES_CRON_SESSION",
"HERMES_SESSION_PLATFORM"):
monkeypatch.delenv(var, raising=False)
assert bang_shell_enabled() is True
@pytest.mark.parametrize("var,value", [
("HERMES_GATEWAY_SESSION", "1"),
("HERMES_CRON_SESSION", "true"),
("HERMES_SESSION_PLATFORM", "discord"),
])
def test_disabled_in_non_cli_contexts(self, monkeypatch, var, value):
for v in ("HERMES_GATEWAY_SESSION", "HERMES_CRON_SESSION",
"HERMES_SESSION_PLATFORM"):
monkeypatch.delenv(v, raising=False)
monkeypatch.setenv(var, value)
assert bang_shell_enabled() is False
# ── execution ──────────────────────────────────────────────────────────────
class TestBangExecution:
def test_output_is_streamed_to_writer(self):
lines = []
code = run_bang_command("echo bang-one; echo bang-two", writer=lines.append)
assert code == 0
assert "bang-one" in lines
assert "bang-two" in lines
def test_stderr_is_merged_into_output(self):
lines = []
run_bang_command("echo to-stderr >&2", writer=lines.append)
assert "to-stderr" in lines
def test_nonzero_exit_code_is_returned(self):
lines = []
code = run_bang_command("exit 42", writer=lines.append)
assert code == 42
def test_runs_in_requested_cwd(self, tmp_path):
lines = []
code = run_bang_command("pwd", cwd=str(tmp_path), writer=lines.append)
assert code == 0
# macOS resolves /tmp through /private, so compare realpaths.
assert os.path.realpath(lines[-1].strip()) == os.path.realpath(str(tmp_path))
def test_missing_cwd_falls_back_without_crashing(self, tmp_path):
lines = []
code = run_bang_command(
"echo ok", cwd=str(tmp_path / "does-not-exist"), writer=lines.append
)
assert code == 0
assert "ok" in lines
# ── CLI handler: approval gate, usage hint, exit codes ─────────────────────
def _make_cli(history=None):
"""Build a HermesCLI shell with only what handle_bang_shell touches."""
from cli import HermesCLI
cli = HermesCLI.__new__(HermesCLI)
cli.config = {}
cli.console = MagicMock()
cli.agent = None
cli.session_id = "test-session"
cli.conversation_history = [] if history is None else history
cli._app = None
return cli
def _printed(cli):
"""All text the CLI printed, flattened to plain strings."""
out = []
for call in cli.console.print.call_args_list:
if not call.args:
continue
arg = call.args[0]
out.append(getattr(arg, "plain", None) or str(arg))
return out
class TestBangHandlerDispatch:
def test_non_bang_text_is_not_handled(self):
cli = _make_cli()
assert cli.handle_bang_shell("please fix this bug!") is False
assert cli.handle_bang_shell("/help") is False
def test_bare_bang_prints_usage_and_runs_nothing(self):
cli = _make_cli()
with patch("hermes_cli.bang_shell.run_bang_command") as runner:
assert cli.handle_bang_shell("!") is True
runner.assert_not_called()
assert any(USAGE_HINT in line for line in _printed(cli))
def test_command_output_is_printed(self):
cli = _make_cli()
assert cli.handle_bang_shell("!echo hello-bang") is True
assert any("hello-bang" in line for line in _printed(cli))
def test_nonzero_exit_is_surfaced_to_the_user(self):
cli = _make_cli()
assert cli.handle_bang_shell("!exit 3") is True
assert any("exited 3" in line for line in _printed(cli))
def test_zero_exit_prints_no_exit_line(self):
cli = _make_cli()
cli.handle_bang_shell("!true")
assert not any("exited" in line for line in _printed(cli))
def test_disabled_context_falls_through(self, monkeypatch):
"""Gateway sessions must not execute bang commands."""
cli = _make_cli()
monkeypatch.setenv("HERMES_GATEWAY_SESSION", "1")
with patch("hermes_cli.bang_shell.run_bang_command") as runner:
assert cli.handle_bang_shell("!echo nope") is False
runner.assert_not_called()
class TestBangApprovalGate:
"""A user-typed command still goes through the terminal tool's gate."""
def test_approval_gate_is_invoked_for_a_dangerous_command(self):
cli = _make_cli()
gate = MagicMock(return_value={"approved": True, "message": None})
with patch("tools.terminal_tool._check_all_guards", gate), \
patch("hermes_cli.bang_shell.run_bang_command", return_value=0):
cli.handle_bang_shell("!rm -rf ./build")
gate.assert_called_once()
assert gate.call_args.args[0] == "rm -rf ./build"
def test_gate_is_invoked_for_every_command_not_just_dangerous_ones(self):
cli = _make_cli()
gate = MagicMock(return_value={"approved": True, "message": None})
with patch("tools.terminal_tool._check_all_guards", gate), \
patch("hermes_cli.bang_shell.run_bang_command", return_value=0):
cli.handle_bang_shell("!ls")
gate.assert_called_once()
def test_denied_command_is_not_executed(self):
cli = _make_cli()
gate = MagicMock(return_value={
"approved": False,
"message": "Command denied: recursive delete",
})
with patch("tools.terminal_tool._check_all_guards", gate), \
patch("hermes_cli.bang_shell.run_bang_command") as runner:
assert cli.handle_bang_shell("!rm -rf /important") is True
runner.assert_not_called()
assert any("denied" in line.lower() for line in _printed(cli))
def test_real_gate_blocks_a_hardline_command(self):
"""End-to-end through the real approval module — no execution."""
cli = _make_cli()
with patch("hermes_cli.bang_shell.run_bang_command") as runner:
assert cli.handle_bang_shell("!rm -rf /") is True
runner.assert_not_called()
# ── THE load-bearing invariant ─────────────────────────────────────────────
_SEED_HISTORY = [
{"role": "system", "content": "You are Hermes."},
{"role": "user", "content": "hello"},
{"role": "assistant", "content": "Hi there."},
{"role": "user", "content": "list the files"},
{
"role": "assistant",
"content": "",
"tool_calls": [{
"id": "call_1",
"type": "function",
"function": {"name": "terminal", "arguments": '{"command": "ls"}'},
}],
},
{"role": "tool", "tool_call_id": "call_1", "content": "a.py b.py"},
]
class TestBangLeavesHistoryByteIdentical:
"""Nothing about a bang command may enter conversation history.
This is what makes `!` free (zero tokens, prompt cache untouched) and
unable to break role alternation. Compared as serialized JSON so an added,
removed, or mutated message anywhere in the list fails the assertion.
"""
@pytest.mark.parametrize("submission", [
"!echo history-check", # succeeds
"!exit 7", # non-zero exit
"!", # bare bang / usage hint
"!definitely-not-a-real-binary-xyz", # command not found
])
def test_history_is_byte_identical_before_and_after(self, submission):
cli = _make_cli(history=copy.deepcopy(_SEED_HISTORY))
before = json.dumps(cli.conversation_history, sort_keys=True)
cli.handle_bang_shell(submission)
after = json.dumps(cli.conversation_history, sort_keys=True)
assert after == before, (
f"bang command {submission!r} mutated conversation history"
)
assert len(cli.conversation_history) == len(_SEED_HISTORY)
def test_history_unchanged_when_command_is_denied(self):
cli = _make_cli(history=copy.deepcopy(_SEED_HISTORY))
before = json.dumps(cli.conversation_history, sort_keys=True)
gate = MagicMock(return_value={"approved": False, "message": "nope"})
with patch("tools.terminal_tool._check_all_guards", gate):
cli.handle_bang_shell("!rm -rf /important")
assert json.dumps(cli.conversation_history, sort_keys=True) == before
def test_agent_is_never_invoked(self):
"""No model turn: the agent object is not touched at all."""
cli = _make_cli(history=copy.deepcopy(_SEED_HISTORY))
agent = MagicMock()
cli.agent = agent
cli.handle_bang_shell("!echo no-model-turn")
# No chat/steer/run/redirect call of any kind.
assert agent.mock_calls == []
assert json.dumps(cli.conversation_history, sort_keys=True) == json.dumps(
_SEED_HISTORY, sort_keys=True
)
def test_role_alternation_is_preserved_across_many_bangs(self):
cli = _make_cli(history=copy.deepcopy(_SEED_HISTORY))
before = json.dumps(cli.conversation_history, sort_keys=True)
for _ in range(5):
cli.handle_bang_shell("!echo repeated")
assert json.dumps(cli.conversation_history, sort_keys=True) == before
roles = [m["role"] for m in cli.conversation_history]
assert roles == ["system", "user", "assistant", "user", "assistant", "tool"]
+136
View File
@@ -0,0 +1,136 @@
"""Tests for bracketed-paste timeout safety valve (#16263).
Verifies the production helper in cli.py monkey-patches prompt_toolkit's
Vt100Parser.feed() so the parser auto-escapes from bracketed-paste mode when
the ESC[201~ end mark is never received.
"""
import ast
import importlib
import logging
import time
from pathlib import Path
from unittest.mock import MagicMock
from prompt_toolkit.keys import Keys
ROOT = Path(__file__).resolve().parents[2]
CLI_PATH = ROOT / "cli.py"
def _load_production_patch_helper():
"""Load cli._apply_bracketed_paste_timeout_patch without importing cli.
Importing cli.py pulls optional runtime deps that aren't required for this
parser-level regression. AST-loading the exact helper keeps the test tied
to production code while avoiding unrelated import side effects. If the
production helper is removed, this test fails.
"""
source = CLI_PATH.read_text(encoding="utf-8")
tree = ast.parse(source)
helper_node = next(
(
node
for node in tree.body
if isinstance(node, ast.FunctionDef)
and node.name == "_apply_bracketed_paste_timeout_patch"
),
None,
)
assert helper_node is not None, (
"cli.py must define _apply_bracketed_paste_timeout_patch()"
)
helper_source = ast.get_source_segment(source, helper_node)
namespace = {"time": time, "logger": logging.getLogger("test.cli")}
exec(helper_source, namespace)
return namespace["_apply_bracketed_paste_timeout_patch"]
def _reset_and_apply_production_patch():
"""Reload prompt_toolkit's parser and apply Hermes' production patch."""
import prompt_toolkit.input.vt100_parser as vt100_mod
vt100_mod = importlib.reload(vt100_mod)
# importlib.reload() preserves module dict entries that the reloaded source
# does not redefine, so clear Hermes' sentinel before re-applying.
if hasattr(vt100_mod, "_hermes_bp_timeout_patched"):
delattr(vt100_mod, "_hermes_bp_timeout_patched")
_load_production_patch_helper()()
assert getattr(vt100_mod, "_hermes_bp_timeout_patched", False)
return vt100_mod
class TestBracketedPasteTimeout:
"""Verify the Vt100Parser monkey-patch prevents frozen bracketed-paste."""
def _make_parser(self):
"""Create a Vt100Parser after applying the production patch."""
vt100_mod = _reset_and_apply_production_patch()
callback = MagicMock()
parser = vt100_mod.Vt100Parser(callback)
return parser, callback
def test_normal_bracketed_paste_works(self):
"""A complete bracketed-paste sequence should work normally."""
parser, callback = self._make_parser()
parser.feed("\x1b[200~hello world\x1b[201~")
callback.assert_called_once()
call_args = callback.call_args[0][0]
assert call_args.data == "hello world"
def test_incomplete_paste_times_out(self):
"""If ESC[201~ is never received, parser should recover after timeout."""
parser, callback = self._make_parser()
parser.feed("\x1b[200~some pasted text")
assert parser._in_bracketed_paste
parser._hermes_bp_start = time.monotonic() - 3.0
parser.feed("more data")
assert not parser._in_bracketed_paste
assert callback.called
def test_normal_keys_after_timeout_recovery(self):
"""After timeout recovery, normal key processing should resume."""
parser, callback = self._make_parser()
parser.feed("\x1b[200~stuck")
parser._hermes_bp_start = time.monotonic() - 3.0
parser.feed("")
assert not parser._in_bracketed_paste
callback.reset_mock()
parser.feed("a")
assert not parser._in_bracketed_paste
def test_subsequent_data_after_incomplete_paste(self):
"""Data arriving after a stuck paste should be processable."""
parser, callback = self._make_parser()
parser.feed("\x1b[200~content")
parser._hermes_bp_start = time.monotonic() - 5.0
parser.feed("x")
assert not parser._in_bracketed_paste
assert callback.call_count >= 1
def test_torn_end_mark_recovers(self):
"""If end mark arrives split across feeds within timeout, it still works."""
parser, callback = self._make_parser()
parser.feed("\x1b[200~some content\x1b[20")
assert parser._in_bracketed_paste
parser.feed("1~")
assert not parser._in_bracketed_paste
callback.assert_called_once()
assert callback.call_args[0][0].data == "some content"
def test_no_timeout_under_threshold(self):
"""Bracketed-paste mode should not timeout within the 2s window."""
parser, callback = self._make_parser()
parser.feed("\x1b[200~waiting")
parser._hermes_bp_start = time.monotonic() - 0.5
parser.feed("more waiting")
assert parser._in_bracketed_paste
assert not callback.called
+248
View File
@@ -0,0 +1,248 @@
"""Tests for the /branch (/fork) command — session branching.
Verifies that:
- Branching creates a new session with copied conversation history
- The original session is preserved (ended with "branched" reason)
- Auto-generated titles use lineage numbering
- Custom branch names are used when provided
- parent_session_id links are set correctly
- Structured reasoning fields survive the copy
- Edge cases: empty conversation, missing session DB
"""
import os
from datetime import datetime
from unittest.mock import MagicMock
import pytest
@pytest.fixture
def session_db(tmp_path):
"""Create a real SessionDB for testing."""
os.environ["HERMES_HOME"] = str(tmp_path / ".hermes")
os.makedirs(tmp_path / ".hermes", exist_ok=True)
from hermes_state import SessionDB
db = SessionDB(db_path=tmp_path / ".hermes" / "test_sessions.db")
yield db
db.close()
@pytest.fixture
def cli_instance(tmp_path, session_db):
"""Create a minimal HermesCLI-like object for testing _handle_branch_command."""
# We'll mock the CLI enough to test the branch logic without full init
from unittest.mock import MagicMock
cli = MagicMock()
cli._session_db = session_db
cli.session_id = "20260403_120000_abc123"
cli.model = "anthropic/claude-sonnet-4.6"
cli.max_turns = 90
cli.reasoning_config = {"enabled": True, "effort": "medium"}
cli.session_start = datetime.now()
cli._pending_title = None
cli._resumed = False
cli.agent = None
cli.conversation_history = [
{"role": "user", "content": "Hello, can you help me?"},
{"role": "assistant", "content": "Of course! How can I help?"},
{"role": "user", "content": "Write a Python function to sort a list."},
{"role": "assistant", "content": "def sort_list(lst): return sorted(lst)"},
]
# Create the original session in the DB
session_db.create_session(
session_id=cli.session_id,
source="cli",
model=cli.model,
)
session_db.set_session_title(cli.session_id, "My Coding Session")
return cli
class TestBranchCommandCLI:
"""Test the /branch command logic for the CLI."""
def test_branch_creates_new_session(self, cli_instance, session_db):
"""Branching should create a new session in the DB."""
from cli import HermesCLI
# Call the real method on the mock, using the real implementation
HermesCLI._handle_branch_command(cli_instance, "/branch")
# Verify a new session was created
assert cli_instance.session_id != "20260403_120000_abc123"
new_session = session_db.get_session(cli_instance.session_id)
assert new_session is not None
def test_failed_branch_creation_leaves_original_session_open(self, cli_instance, session_db):
"""Branching is child-first: when create_session fails the user stays on the original
session, so it must not be marked ended as "branched" (#11030)."""
from unittest.mock import patch
from cli import HermesCLI
original = cli_instance.session_id
with patch.object(session_db, "create_session", side_effect=RuntimeError("boom")):
HermesCLI._handle_branch_command(cli_instance, "/branch")
assert cli_instance.session_id == original
row = session_db.get_session(original)
assert row["end_reason"] is None and row["ended_at"] is None
def test_branch_copies_history(self, cli_instance, session_db):
"""Branching should copy all messages to the new session."""
from cli import HermesCLI
HermesCLI._handle_branch_command(cli_instance, "/branch")
messages = session_db.get_messages_as_conversation(cli_instance.session_id)
assert len(messages) == 4 # All 4 messages copied
def test_branch_with_custom_name(self, cli_instance, session_db):
"""Custom branch name should be used as the title."""
from cli import HermesCLI
HermesCLI._handle_branch_command(cli_instance, "/branch refactor approach")
title = session_db.get_session_title(cli_instance.session_id)
assert title == "refactor approach"
def test_branch_no_session_db(self, cli_instance):
"""Branching without a session DB should show an error."""
from cli import HermesCLI
cli_instance._session_db = None
HermesCLI._handle_branch_command(cli_instance, "/branch")
# session_id should not have changed
assert cli_instance.session_id == "20260403_120000_abc123"
def test_branch_sets_resumed_flag(self, cli_instance, session_db):
"""Branch should set _resumed=True to prevent auto-title generation."""
from cli import HermesCLI
HermesCLI._handle_branch_command(cli_instance, "/branch")
assert cli_instance._resumed is True
def test_branch_fires_on_session_switch_hook(self, cli_instance, session_db):
"""The /branch command must notify memory providers of the rotation.
Without this, providers that cache per-session state in
initialize() keep writing under the old session_id. See #6672.
"""
from cli import HermesCLI
# Wire a real-ish agent object with a MagicMock memory_manager
agent = MagicMock()
mm = MagicMock()
agent._memory_manager = mm
cli_instance.agent = agent
original_id = cli_instance.session_id
HermesCLI._handle_branch_command(cli_instance, "/branch")
# Hook must have been called exactly once with the new session_id,
# parent pointing at the branched-from session, reset=False, and
# reason="branch" for diagnostics.
assert mm.on_session_switch.call_count == 1
_, kwargs = mm.on_session_switch.call_args
assert mm.on_session_switch.call_args.args[0] == cli_instance.session_id
assert kwargs["parent_session_id"] == original_id
assert kwargs["reset"] is False
assert kwargs["reason"] == "branch"
class TestBranchCommandDef:
"""Test the CommandDef registration for /branch."""
def test_branch_in_registry(self):
"""The branch command should be in the command registry."""
from hermes_cli.commands import COMMAND_REGISTRY
names = [c.name for c in COMMAND_REGISTRY]
assert "branch" in names
def test_branch_in_session_category(self):
"""The branch command should be in the Session category."""
from hermes_cli.commands import COMMAND_REGISTRY
branch = next(c for c in COMMAND_REGISTRY if c.name == "branch")
assert branch.category == "Session"
class TestBranchFlushesBeforeEndSession:
"""Regression for #47202: /branch must flush un-persisted messages to
the session DB before ending the old session, just like /new and
compress_context() already do."""
def test_branch_flushes_when_agent_present(self, cli_instance, session_db):
from cli import HermesCLI
agent = MagicMock()
cli_instance.agent = agent
HermesCLI._handle_branch_command(cli_instance, "/branch")
agent._flush_messages_to_session_db.assert_called_once_with(
cli_instance.conversation_history,
conversation_history=cli_instance.conversation_history,
)
REASONING_DETAILS = [
{"type": "reasoning.text", "text": "sort in place instead", "format": "unknown"}
]
CODEX_REASONING_ITEMS = [
{"id": "rs_1", "type": "reasoning", "encrypted_content": "opaque-blob"}
]
CODEX_MESSAGE_ITEMS = [
{
"id": "msg_1",
"type": "message",
"role": "assistant",
"content": [{"type": "output_text", "text": "done"}],
}
]
class TestBranchPreservesReasoningFields:
"""The copy loop must carry the structured reasoning columns across.
Only ``reasoning`` was forwarded to append_message, so a branch dropped
the preserved Anthropic thinking blocks and the Codex
encrypted-reasoning/message-item continuation state that the parent had
accumulated — the branch then replayed without them, because every
consumer gates on isinstance(..., list) and a missing field reads as None.
"""
def test_reasoning_fields_survive_branch(self, cli_instance, session_db):
from cli import HermesCLI
cli_instance.conversation_history = [
{"role": "user", "content": "Sort this list."},
{
"role": "assistant",
"content": "def sort_list(lst): return sorted(lst)",
"reasoning": "picked sorted()",
"reasoning_details": REASONING_DETAILS,
"codex_reasoning_items": CODEX_REASONING_ITEMS,
"codex_message_items": CODEX_MESSAGE_ITEMS,
},
]
HermesCLI._handle_branch_command(cli_instance, "/branch")
messages = session_db.get_messages_as_conversation(cli_instance.session_id)
assistant = next(m for m in messages if m["role"] == "assistant")
assert assistant["reasoning_details"] == REASONING_DETAILS
assert assistant["codex_reasoning_items"] == CODEX_REASONING_ITEMS
assert assistant["codex_message_items"] == CODEX_MESSAGE_ITEMS
+98
View File
@@ -0,0 +1,98 @@
"""Tests for the /busy CLI command and busy-input-mode config handling."""
import unittest
from types import SimpleNamespace
from unittest.mock import patch
def _import_cli():
import hermes_cli.config as config_mod
if not hasattr(config_mod, "save_env_value_secure"):
config_mod.save_env_value_secure = lambda key, value: {
"success": True,
"stored_as": key,
"validated": False,
}
import cli as cli_mod
return cli_mod
class TestHandleBusyCommand(unittest.TestCase):
def _make_cli(self, busy_input_mode="interrupt"):
return SimpleNamespace(
busy_input_mode=busy_input_mode,
agent=None,
)
def test_no_args_shows_status(self):
cli_mod = _import_cli()
stub = self._make_cli("queue")
with (
patch.object(cli_mod, "_cprint") as mock_cprint,
patch.object(cli_mod, "save_config_value") as mock_save,
):
cli_mod.HermesCLI._handle_busy_command(stub, "/busy")
mock_save.assert_not_called()
printed = " ".join(str(c) for c in mock_cprint.call_args_list)
self.assertIn("queue", printed)
self.assertIn("interrupt", printed)
def test_queue_argument_sets_queue_mode_and_saves(self):
cli_mod = _import_cli()
stub = self._make_cli("interrupt")
with (
patch.object(cli_mod, "_cprint"),
patch.object(cli_mod, "save_config_value", return_value=True) as mock_save,
):
cli_mod.HermesCLI._handle_busy_command(stub, "/busy queue")
self.assertEqual(stub.busy_input_mode, "queue")
mock_save.assert_called_once_with("display.busy_input_mode", "queue")
def test_steer_argument_sets_steer_mode_and_saves(self):
cli_mod = _import_cli()
stub = self._make_cli("interrupt")
with (
patch.object(cli_mod, "_cprint") as mock_cprint,
patch.object(cli_mod, "save_config_value", return_value=True) as mock_save,
):
cli_mod.HermesCLI._handle_busy_command(stub, "/busy steer")
self.assertEqual(stub.busy_input_mode, "steer")
mock_save.assert_called_once_with("display.busy_input_mode", "steer")
printed = " ".join(str(c) for c in mock_cprint.call_args_list)
self.assertIn("steer", printed.lower())
def test_invalid_argument_prints_usage(self):
cli_mod = _import_cli()
stub = self._make_cli()
with (
patch.object(cli_mod, "_cprint") as mock_cprint,
patch.object(cli_mod, "save_config_value") as mock_save,
):
cli_mod.HermesCLI._handle_busy_command(stub, "/busy nonsense")
mock_save.assert_not_called()
printed = " ".join(str(c) for c in mock_cprint.call_args_list)
self.assertIn("Usage: /busy", printed)
class TestBusyCommandRegistry(unittest.TestCase):
def test_busy_in_registry(self):
from hermes_cli.commands import COMMAND_REGISTRY
names = [c.name for c in COMMAND_REGISTRY]
assert "busy" in names
def test_busy_subcommands_documented(self):
from hermes_cli.commands import COMMAND_REGISTRY
busy = next(c for c in COMMAND_REGISTRY if c.name == "busy")
assert busy.args_hint == "[queue|steer|interrupt|status]"
assert busy.category == "Configuration"
+149
View File
@@ -0,0 +1,149 @@
"""Regression tests for #53009: chat -q final response erased by exit-summary clear."""
from types import SimpleNamespace
import pytest
import cli as cli_mod
# ── A3.1 Test-First: verify _clear_terminal_on_exit gating ──────────────────
def test_print_exit_summary_clears_screen_by_default(monkeypatch):
"""Default behavior: _print_exit_summary() calls _clear_terminal_on_exit()."""
calls = []
class FakeCLI:
conversation_history = []
session_start = None
def _clear_terminal_on_exit(self):
calls.append("clear")
monkeypatch.setattr(cli_mod, "datetime", SimpleNamespace(
now=lambda: SimpleNamespace(
__sub__=lambda self, other: SimpleNamespace(
total_seconds=lambda: 0
)
)
))
fake = FakeCLI()
cli_mod.HermesCLI._print_exit_summary(fake) # default clear_screen=True
assert "clear" in calls, "_clear_terminal_on_exit should be called by default"
def test_print_exit_summary_skips_clear_when_clear_screen_false(monkeypatch):
"""With clear_screen=False, _print_exit_summary() does NOT clear."""
calls = []
class FakeCLI:
conversation_history = []
session_start = None
def _clear_terminal_on_exit(self):
calls.append("clear")
monkeypatch.setattr(cli_mod, "datetime", SimpleNamespace(
now=lambda: SimpleNamespace(
__sub__=lambda self, other: SimpleNamespace(
total_seconds=lambda: 0
)
)
))
fake = FakeCLI()
cli_mod.HermesCLI._print_exit_summary(fake, clear_screen=False)
assert "clear" not in calls, (
"_clear_terminal_on_exit should NOT be called when clear_screen=False"
)
# ── Production-path test: single-query -q path skips the clear ──────────────
def test_single_query_main_skips_clear_on_exit_summary(monkeypatch):
"""The single-query (-q) path calls _print_exit_summary without clearing."""
calls = []
clear_calls = []
class FakeCLI:
def __init__(self, **_kwargs):
self.console = SimpleNamespace(print=lambda *_a, **_kw: calls.append("query-label"))
self.session_id = "sq-test"
self.agent = SimpleNamespace(
session_id="sq-test",
platform="cli",
)
def _claim_active_session(self, surface, *, stderr=False):
calls.append(("claim", surface, stderr))
return True
def _show_security_advisories(self):
calls.append("advisories")
def chat(self, query, images=None):
calls.append(("chat", query, images))
return "done"
def _print_exit_summary(self, clear_screen=True):
calls.append(("summary", clear_screen))
if clear_screen:
clear_calls.append("CLEARED") # should NOT happen
monkeypatch.setattr(cli_mod, "HermesCLI", FakeCLI)
monkeypatch.setattr(cli_mod.atexit, "register", lambda *_a, **_kw: None)
monkeypatch.setattr(
cli_mod,
"_finalize_single_query",
lambda fake_cli: calls.append(("finalize", fake_cli.session_id)),
)
cli_mod.main(query="hello", quiet=False, toolsets="terminal")
assert calls == [
("claim", "cli", False),
"query-label",
"advisories",
("chat", "hello", None),
("summary", False), # <-- clear_screen=False for single-query
("finalize", "sq-test"),
]
assert len(clear_calls) == 0, (
"_clear_terminal_on_exit must NOT be called in single-query mode"
)
# ── Verify interactive mode still clears ────────────────────────────────────
def test_print_exit_summary_still_clears_in_interactive_path(monkeypatch):
"""Interactive mode should still clear the screen (preserving #38928)."""
from datetime import datetime as real_datetime
calls = []
class FakeCLI:
conversation_history = [
{"role": "user", "content": "hello"},
{"role": "assistant", "content": "hi"},
]
session_start = real_datetime(2026, 1, 1, 12, 0, 0)
session_id = "test-session"
_session_db = None
agent = None
def _clear_terminal_on_exit(self):
calls.append("clear")
monkeypatch.setattr(cli_mod, "datetime", SimpleNamespace(
now=lambda: real_datetime(2026, 1, 1, 12, 1, 0) # 1 min elapsed
))
fake = FakeCLI()
cli_mod.HermesCLI._print_exit_summary(fake) # default clear_screen=True
assert "clear" in calls, (
"Interactive mode should still clear the screen (regression test for #38928)"
)
@@ -0,0 +1,70 @@
"""Regression test for #49287 — the CLI memory-provider ``on_session_end``
hook stopped firing on ``/exit`` after the god-file Phase 4 refactor
(094aa85c37) moved agent construction into ``CLIAgentSetupMixin``.
``_run_cleanup`` (in ``cli.py``) gates the memory-shutdown call on the
module global ``cli._active_agent_ref``. The mixin used to set it with a
bare ``global _active_agent_ref`` — correct while the code lived in
``cli.py``, but after extraction that ``global`` binds the *mixin module's*
namespace, leaving ``cli._active_agent_ref`` ``None`` forever. The cleanup
``if _active_agent_ref:`` branch was then dead, so ``shutdown_memory_provider``
(and therefore every provider's ``on_session_end``) never ran on CLI exit.
The fix writes the reference onto the ``cli`` module explicitly. These tests
assert that contract — the existing shutdown tests pass only because they
hand-assign ``cli._active_agent_ref``, which is exactly what masked the bug.
"""
from __future__ import annotations
import inspect
def test_mixin_writes_active_agent_ref_to_cli_module():
"""The mixin's agent-setup code must publish the agent reference where
``_run_cleanup`` reads it — on the ``cli`` module, not the mixin module."""
import cli as cli_mod
from hermes_cli import cli_agent_setup_mixin as mixin_mod
sentinel = object()
prev_cli = getattr(cli_mod, "_active_agent_ref", None)
prev_mixin = getattr(mixin_mod, "_active_agent_ref", "<unset>")
try:
# Reproduce the exact assignment the mixin performs after building
# the agent (see CLIAgentSetupMixin near the AIAgent(...) construction).
import cli as _cli
_cli._active_agent_ref = sentinel
# The cleanup path reads cli._active_agent_ref — it must see the value.
assert cli_mod._active_agent_ref is sentinel
finally:
cli_mod._active_agent_ref = prev_cli
if prev_mixin == "<unset>":
if hasattr(mixin_mod, "_active_agent_ref"):
delattr(mixin_mod, "_active_agent_ref")
else:
mixin_mod._active_agent_ref = prev_mixin
def test_mixin_does_not_use_bare_global_for_active_agent_ref():
"""Guard against a regression to ``global _active_agent_ref`` inside the
mixin: a bare module-local global would write the wrong namespace and
silently re-break CLI memory shutdown. The source must target ``cli``."""
from hermes_cli import cli_agent_setup_mixin as mixin_mod
src = inspect.getsource(mixin_mod)
assert "_active_agent_ref = self.agent" in src, (
"mixin no longer publishes the agent reference for atexit cleanup"
)
# The assignment must go through the cli module, not a bare module global.
# Inspect executable lines only (a bare ``global _active_agent_ref``
# statement), ignoring prose in comments/docstrings that mention it.
code_lines = [ln.split("#", 1)[0].strip() for ln in src.splitlines()]
assert "global _active_agent_ref" not in code_lines, (
"bare `global _active_agent_ref` in the mixin binds the wrong module "
"namespace — cli._active_agent_ref stays None and memory shutdown dies "
"(#49287). Write `cli._active_agent_ref = self.agent` instead."
)
assert "_cli._active_agent_ref = self.agent" in src, (
"expected the agent reference to be published onto the cli module"
)
+596
View File
@@ -0,0 +1,596 @@
import queue
import threading
import time
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
import cli as cli_module
from cli import HermesCLI
class _FakeBuffer:
def __init__(self, text="", cursor_position=None):
self.text = text
self.cursor_position = len(text) if cursor_position is None else cursor_position
def reset(self, append_to_history=False):
self.text = ""
self.cursor_position = 0
def _make_cli_stub():
cli = HermesCLI.__new__(HermesCLI)
cli._approval_state = None
cli._approval_deadline = 0
cli._approval_lock = threading.Lock()
cli._sudo_state = None
cli._sudo_deadline = 0
cli._modal_input_snapshot = None
cli._invalidate = MagicMock()
cli._app = SimpleNamespace(invalidate=MagicMock(), current_buffer=_FakeBuffer())
return cli
def _make_background_cli_stub():
cli = _make_cli_stub()
cli._background_task_counter = 0
cli._background_tasks = {}
cli._ensure_runtime_credentials = MagicMock(return_value=True)
cli._resolve_turn_agent_config = MagicMock(return_value={
"model": "test-model",
"runtime": {
"api_key": "test-key",
"base_url": "https://example.test/v1",
"provider": "test",
"api_mode": "chat_completions",
},
"request_overrides": None,
})
cli.max_turns = 90
cli.enabled_toolsets = []
cli._session_db = None
cli.reasoning_config = {}
cli.service_tier = None
cli._providers_only = None
cli._providers_ignore = None
cli._providers_order = None
cli._provider_sort = None
cli._provider_require_params = None
cli._provider_data_collection = None
cli._openrouter_min_coding_score = None
cli._fallback_model = None
cli._agent_running = False
cli._spinner_text = ""
cli.bell_on_complete = False
cli.final_response_markdown = "strip"
return cli
class TestCliApprovalUi:
def test_smart_denied_callback_offers_only_once_and_deny(self):
cli = _make_cli_stub()
result = {}
def _run_callback():
result["value"] = cli._approval_callback(
"rm -rf /tmp/example",
"recursive delete",
allow_permanent=False,
smart_denied=True,
)
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 cli._approval_state["choices"] == ["once", "deny"]
cli._approval_state["response_queue"].put("deny")
thread.join(timeout=2)
assert result["value"] == "deny"
def test_session_less_gate_offers_only_once_and_deny(self):
"""A gate that re-asks every time must not advertise a session scope.
The protected agent-instruction gate (tools/file_tools.py) grants one
operation and persists nothing, so offering "session" here makes every
later write re-prompt and reads as a broken gate (#81887).
"""
cli = _make_cli_stub()
result = {}
def _run_callback():
result["value"] = cli._approval_callback(
"<write to AGENTS.md>",
"protected agent-instruction file",
allow_permanent=False,
allow_session=False,
)
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 cli._approval_state["choices"] == ["once", "deny"]
cli._approval_state["response_queue"].put("once")
thread.join(timeout=2)
assert result["value"] == "once"
def test_sudo_prompt_restores_existing_draft_after_response(self):
cli = _make_cli_stub()
cli._app.current_buffer = _FakeBuffer("draft command", cursor_position=5)
result = {}
def _run_callback():
result["value"] = cli._sudo_password_callback()
with patch.object(cli_module, "_cprint"):
thread = threading.Thread(target=_run_callback, daemon=True)
thread.start()
deadline = time.time() + 2
while cli._sudo_state is None and time.time() < deadline:
time.sleep(0.01)
assert cli._sudo_state is not None
assert cli._app.current_buffer.text == ""
cli._app.current_buffer.text = "secret"
cli._app.current_buffer.cursor_position = len("secret")
cli._sudo_state["response_queue"].put("secret")
thread.join(timeout=2)
assert result["value"] == "secret"
assert cli._app.current_buffer.text == "draft command"
assert cli._app.current_buffer.cursor_position == 5
def test_handle_approval_selection_view_expands_in_place(self):
cli = _make_cli_stub()
cli._approval_state = {
"command": "sudo dd if=/tmp/in of=/usr/share/keyrings/githubcli-archive-keyring.gpg bs=4M status=progress",
"description": "disk copy",
"choices": ["once", "session", "always", "deny", "view"],
"selected": 4,
"response_queue": queue.Queue(),
}
cli._handle_approval_selection()
assert cli._approval_state is not None
assert cli._approval_state["show_full"] is True
assert "view" not in cli._approval_state["choices"]
assert cli._approval_state["selected"] == 3
assert cli._approval_state["response_queue"].empty()
def test_approval_display_truncates_giant_command_in_view_mode(self):
"""If the user hits /view on a massive command, choices still render.
The command gets truncated with a marker; the description gets dropped
if there's no remaining row budget.
"""
cli = _make_cli_stub()
# 50 lines of command when wrapped at ~64 chars.
giant_cmd = "bash -c 'echo " + ("x" * 3000) + "'"
cli._approval_state = {
"command": giant_cmd,
"description": "shell command via -c/-lc flag",
"choices": ["once", "session", "always", "deny"],
"selected": 0,
"show_full": True,
"response_queue": queue.Queue(),
}
import shutil as _shutil
with patch("cli.shutil.get_terminal_size",
return_value=_shutil.os.terminal_size((100, 24))):
fragments = cli._get_approval_display_fragments()
rendered = "".join(text for _style, text in fragments)
# All four choices visible even with a huge command.
for label in ("Allow once", "Allow for this session",
"Add to permanent allowlist", "Deny"):
assert label in rendered, f"choice {label!r} missing"
# Command got truncated with a marker.
assert "(command truncated" in rendered
def test_background_task_registers_thread_local_approval_callbacks(self):
"""Background /btw tasks must use the prompt_toolkit approval UI.
The foreground chat path registers dangerous-command callbacks inside
its worker thread because tools.terminal_tool stores them in
threading.local(). /background used to skip that, so dangerous commands
fell back to raw input() in a background thread and timed out under
prompt_toolkit.
"""
cli = _make_background_cli_stub()
seen = {}
class FakeAgent:
def __init__(self, **kwargs):
self._print_fn = None
self.thinking_callback = None
def run_conversation(self, **kwargs):
from tools.terminal_tool import (
_get_approval_callback,
_get_sudo_password_callback,
)
seen["approval"] = _get_approval_callback()
seen["sudo"] = _get_sudo_password_callback()
return {
"final_response": "done",
"messages": [],
"completed": True,
"failed": False,
}
with patch("run_agent.AIAgent", FakeAgent), \
patch.object(cli_module, "_cprint"), \
patch.object(cli_module, "ChatConsole") as chat_console:
chat_console.return_value.print = MagicMock()
cli._handle_background_command("/bg check weather")
# Join the worker thread deterministically rather than polling a
# wall-clock deadline — under load the thread's finally-block pop
# of _background_tasks can lag a fixed timeout, which flaked CI.
for _thread in list(cli._background_tasks.values()):
_thread.join(timeout=10)
assert seen["approval"].__self__ is cli
assert seen["approval"].__func__ is HermesCLI._approval_callback
assert seen["sudo"].__self__ is cli
assert seen["sudo"].__func__ is HermesCLI._sudo_password_callback
assert not cli._background_tasks
def _make_real_paint_cli_stub():
"""A stub whose modal repaint path runs the REAL _paint_now / _invalidate.
Both gates are set adversarially: _resize_recovery_pending=True and a recent
_last_invalidate inside the throttle window. A throttled _invalidate() would
be dropped under these conditions — _paint_now must paint regardless.
"""
cli = HermesCLI.__new__(HermesCLI)
cli._approval_state = None
cli._approval_deadline = 0
cli._approval_lock = threading.Lock()
cli._sudo_state = None
cli._sudo_deadline = 0
cli._clarify_state = None
cli._clarify_freetext = False
cli._clarify_deadline = 0
cli._modal_input_snapshot = None
# Real methods, not mocks.
cli._paint_now = HermesCLI._paint_now.__get__(cli, HermesCLI)
cli._invalidate = HermesCLI._invalidate.__get__(cli, HermesCLI)
cli._resize_recovery_pending = True # gate 1: resize in flight
cli._last_invalidate = time.monotonic() # gate 2: inside throttle window
cli._app = SimpleNamespace(invalidate=MagicMock(), current_buffer=_FakeBuffer())
return cli
class TestModalPaintNow:
"""Regression for #41098 — modal prompts must paint immediately.
The dangerous-command approval, clarify, and sudo prompts run their wait
loop on a background thread, set modal state a ConditionalContainer reads,
then must repaint so the panel becomes visible. They used the throttled
_invalidate(), whose paint is silently dropped on a 250ms window collision
or while a resize is pending — so the prompt timed out unseen. They now use
_paint_now(), which paints directly like the modal key-binding handlers.
"""
def test_paint_now_bypasses_throttle_and_resize_guard(self):
cli = _make_real_paint_cli_stub()
# A bare _invalidate() is suppressed under both gates...
cli._invalidate()
assert not cli._app.invalidate.called
# ...but _paint_now() always paints.
cli._paint_now()
assert cli._app.invalidate.called
def _drive(self, cli, target, state_attr):
result = {}
def _run():
result["value"] = target()
with patch.object(cli_module, "_cprint"):
thread = threading.Thread(target=_run, daemon=True)
thread.start()
deadline = time.time() + 2
while getattr(cli, state_attr) is None and time.time() < deadline:
time.sleep(0.01)
assert getattr(cli, state_attr) is not None
assert cli._app.invalidate.called, (
f"{state_attr} panel was not painted despite throttle + resize gates"
)
# Reset so we can prove the response-received teardown also repaints
# (the panel must clear at once, not be held by the throttle).
cli._app.invalidate.reset_mock()
getattr(cli, state_attr)["response_queue"].put(
"deny" if state_attr == "_approval_state" else
("a" if state_attr == "_clarify_state" else "pw")
)
thread.join(timeout=2)
# clarify returns immediately on a response (no teardown repaint);
# approval and sudo repaint to tear the panel down.
if state_attr != "_clarify_state":
assert cli._app.invalidate.called, (
f"{state_attr} panel was not repainted on teardown"
)
assert not thread.is_alive()
return result["value"]
def test_secret_response_teardown_paints(self):
"""_submit_secret_response tears the secret panel down via _paint_now,
so the panel clears immediately rather than being held by the throttle."""
cli = _make_real_paint_cli_stub()
cli._secret_state = {"response_queue": queue.Queue()}
cli._secret_deadline = 0
cli._submit_secret_response("hunter2")
assert cli._secret_state is None
assert cli._app.invalidate.called
assert cli._secret_state is None # cleared
class TestApprovalCallbackThreadLocalWiring:
"""Regression guard for the thread-local callback freeze (#13617 / #13618).
After 62348cff made _approval_callback / _sudo_password_callback thread-local
(ACP GHSA-qg5c-hvr5-hjgr), the CLI agent thread could no longer see callbacks
registered in the main thread — the dangerous-command prompt silently fell
back to stdin input() and deadlocked against prompt_toolkit. The fix is to
register the callbacks INSIDE the agent worker thread (matching the ACP
pattern). These tests lock in that invariant.
"""
def test_main_thread_registration_is_invisible_to_child_thread(self):
"""Confirms the underlying threading.local semantics that drove the bug.
If this ever starts passing as "visible", the thread-local isolation
is gone and the ACP race GHSA-qg5c-hvr5-hjgr may be back.
"""
from tools.terminal_tool import (
set_approval_callback,
_get_approval_callback,
)
def main_cb(_cmd, _desc):
return "once"
set_approval_callback(main_cb)
try:
seen = {}
def _child():
seen["value"] = _get_approval_callback()
t = threading.Thread(target=_child, daemon=True)
t.start()
t.join(timeout=2)
assert seen["value"] is None
finally:
set_approval_callback(None)
def test_child_thread_registration_is_visible_and_cleared_in_finally(self):
"""The fix pattern: register INSIDE the worker thread, clear in finally.
This is exactly what cli.py's run_agent() closure does. If this test
fails, the CLI approval prompt freeze (#13617) has regressed.
"""
from tools.terminal_tool import (
set_approval_callback,
set_sudo_password_callback,
_get_approval_callback,
_get_sudo_password_callback,
)
def approval_cb(_cmd, _desc):
return "once"
def sudo_cb():
return "hunter2"
seen = {}
def _worker():
# Mimic cli.py's run_agent() thread target.
set_approval_callback(approval_cb)
set_sudo_password_callback(sudo_cb)
try:
seen["approval"] = _get_approval_callback()
seen["sudo"] = _get_sudo_password_callback()
finally:
set_approval_callback(None)
set_sudo_password_callback(None)
seen["approval_after"] = _get_approval_callback()
seen["sudo_after"] = _get_sudo_password_callback()
t = threading.Thread(target=_worker, daemon=True)
t.start()
t.join(timeout=2)
assert seen["approval"] is approval_cb
assert seen["sudo"] is sudo_cb
# Finally block must clear both slots — otherwise a reused thread
# would hold a stale reference to a disposed CLI instance.
assert seen["approval_after"] is None
assert seen["sudo_after"] is None
class TestPersistPromptSummary:
"""display.persist_prompts — one-line scrollback record of resolved modals."""
def _resolve_approval(self, cli, answer, command="rm -rf /tmp/scratch"):
result = {}
def _run():
result["value"] = cli._approval_callback(command, "danger")
t = threading.Thread(target=_run, daemon=True)
t.start()
deadline = time.time() + 2
while cli._approval_state is None and time.time() < deadline:
time.sleep(0.01)
cli._approval_state["response_queue"].put(answer)
t.join(timeout=2)
return result["value"]
def test_approval_resolution_prints_summary_line(self):
cli = _make_cli_stub()
printed = []
with patch.object(cli_module, "_cprint", printed.append):
verdict = self._resolve_approval(cli, "session")
assert verdict == "session"
summary = "\n".join(printed)
assert "Approval" in summary
assert "rm -rf /tmp/scratch" in summary
assert "allowed for session" in summary
def test_persist_prompts_false_suppresses_summary(self):
cli = _make_cli_stub()
printed = []
with patch.dict(cli_module.CLI_CONFIG.get("display", {}), {"persist_prompts": False}), \
patch.object(cli_module, "_cprint", printed.append):
verdict = self._resolve_approval(cli, "once")
assert verdict == "once"
assert not any("Approval" in p for p in printed)
def test_clarify_resolution_prints_summary_line(self):
cli = _make_cli_stub()
cli._clarify_state = None
cli._clarify_freetext = False
cli._clarify_deadline = 0
printed = []
result = {}
def _run():
result["value"] = cli._clarify_callback("Pick a path?", ["A", "B"])
with patch.object(cli_module, "_cprint", printed.append):
t = threading.Thread(target=_run, daemon=True)
t.start()
deadline = time.time() + 2
while cli._clarify_state is None and time.time() < deadline:
time.sleep(0.01)
cli._clarify_state["response_queue"].put("B")
t.join(timeout=2)
assert result["value"] == "B"
summary = "\n".join(printed)
assert "Clarify" in summary
assert "Pick a path?" in summary
assert "B" in summary
class TestClearOverlaysForInterrupt:
"""Regression tests for #14026 — interrupting a running agent must clear
every input-blocking overlay (approval/clarify/sudo/secret) so the CLI
isn't left frozen with no thread servicing the prompt."""
def _make_cli(self):
cli = _make_cli_stub()
# Attributes the helper touches that the base stub doesn't set.
cli._clarify_state = None
cli._clarify_freetext = False
cli._secret_state = None
cli._secret_deadline = 0
cli._paint_now = MagicMock()
return cli
def test_clears_all_four_overlays_and_unblocks_queues(self):
cli = self._make_cli()
approval_q = queue.Queue()
clarify_q = queue.Queue()
sudo_q = queue.Queue()
secret_q = queue.Queue()
cli._approval_state = {"response_queue": approval_q}
cli._clarify_state = {"response_queue": clarify_q}
cli._clarify_freetext = True
cli._sudo_state = {"response_queue": sudo_q, "timeout": 60}
cli._sudo_deadline = 99999.0
cli._secret_state = {"response_queue": secret_q, "var_name": "X"}
cli._clear_active_overlays_for_interrupt()
# All states nilled out.
assert cli._approval_state is None
assert cli._clarify_state is None
assert cli._clarify_freetext is False
assert cli._sudo_state is None
assert cli._sudo_deadline == 0
assert cli._secret_state is None
# Each blocked thread would have received a terminal value.
assert approval_q.get_nowait() == "deny"
assert clarify_q.get_nowait() # cancellation sentinel string
assert sudo_q.get_nowait() == ""
assert secret_q.get_nowait() == ""
def test_dead_queue_does_not_block_clearing_others(self):
"""A queue that raises on put() must not prevent the remaining
overlays from being cleared."""
cli = self._make_cli()
class _DeadQueue:
def put(self, *_a, **_k):
raise RuntimeError("queue gone")
clarify_q = queue.Queue()
cli._approval_state = {"response_queue": _DeadQueue()}
cli._clarify_state = {"response_queue": clarify_q}
cli._clear_active_overlays_for_interrupt()
assert cli._approval_state is None # cleared despite dead queue
assert cli._clarify_state is None
assert clarify_q.get_nowait()
def test_interrupt_unblocks_thread_blocked_on_approval(self):
"""End-to-end: a worker blocked on the approval queue unblocks when the
interrupt helper drains it."""
cli = self._make_cli()
approval_q = queue.Queue()
cli._approval_state = {"response_queue": approval_q}
result = {}
def _worker():
result["value"] = approval_q.get(timeout=2)
t = threading.Thread(target=_worker, daemon=True)
t.start()
time.sleep(0.05)
cli._clear_active_overlays_for_interrupt()
t.join(timeout=2)
assert not t.is_alive(), "worker thread never unblocked"
assert result["value"] == "deny"
@@ -0,0 +1,76 @@
"""Regression coverage for CLI async-delegation completion ownership."""
import queue
from cli import HermesCLI
def test_cli_completion_drain_uses_visible_session_identity(monkeypatch):
"""A CLI window must not claim another window's restored completion."""
cli = HermesCLI.__new__(HermesCLI)
cli.session_id = "visible-session"
cli._pending_input = queue.Queue()
event = {
"type": "async_delegation",
"delegation_id": "deleg_visible",
"session_key": "visible-session",
}
calls = []
class FakeRegistry:
def drain_notifications(self, *, session_key="", owns_event=None):
calls.append((session_key, owns_event(event)))
return [(event, "completion payload")]
claimed = []
completed = []
monkeypatch.setattr(
"tools.process_registry.process_registry",
FakeRegistry(),
)
monkeypatch.setattr(
"tools.async_delegation.claim_event_delivery",
lambda evt, consumer: claimed.append((evt, consumer)) or "claim-token",
)
monkeypatch.setattr(
"tools.async_delegation.complete_event_delivery",
lambda evt, token: completed.append((evt, token)),
)
cli._drain_process_notifications("cli-idle")
assert calls == [("visible-session", True)]
assert cli._pending_input.get_nowait() == "completion payload"
assert claimed == [(event, "cli-idle")]
assert completed == [(event, "claim-token")]
def test_cli_completion_ownership_rejects_foreign_session():
cli = HermesCLI.__new__(HermesCLI)
cli.session_id = "visible-session"
cli._session_db = None
assert not cli._owns_process_notification(
{"type": "async_delegation", "session_key": "foreign-session"}
)
def test_cli_completion_ownership_accepts_compression_lineage():
cli = HermesCLI.__new__(HermesCLI)
cli.session_id = "visible-session"
class FakeSessionDB:
def resolve_resume_session_id(self, session_id):
assert session_id == "pre-compression-session"
return "visible-session"
cli._session_db = FakeSessionDB()
assert cli._owns_process_notification(
{
"type": "async_delegation",
"session_key": "pre-compression-session",
}
)
+140
View File
@@ -0,0 +1,140 @@
"""Regression tests for classic-CLI mid-run /bg and /btw dispatch.
Background
----------
``/bg`` (formerly ``/background``) exists to start independent work while
the current turn keeps running. Typed while the agent was busy it went into
``self._pending_input`` like ordinary input, and ``process_loop`` is blocked
inside ``self.chat()`` for the whole run, so the background task only started
once the foreground turn had finished. That is the one moment it was not
needed (#75221).
``/steer`` had the identical problem and was fixed by dispatching inline on
the UI thread; the command's own ``CommandDef`` already declares
``busy_policy="dispatch"``, which the gateway honours and the classic CLI
never consulted.
These tests exercise the detector without starting a prompt_toolkit app,
mirroring tests/cli/test_cli_steer_busy_path.py.
"""
from __future__ import annotations
import importlib
import sys
from unittest.mock import MagicMock, patch
def _make_cli():
"""Create a HermesCLI instance with prompt_toolkit stubbed out."""
_clean_config = {
"model": {
"default": "anthropic/claude-opus-4.6",
"base_url": "https://openrouter.ai/api/v1",
"provider": "auto",
},
"display": {"compact": False, "tool_progress": "all"},
"agent": {},
"terminal": {"env_type": "local"},
}
clean_env = {"LLM_MODEL": "", "HERMES_MAX_ITERATIONS": ""}
prompt_toolkit_stubs = {
"prompt_toolkit": MagicMock(),
"prompt_toolkit.history": MagicMock(),
"prompt_toolkit.styles": MagicMock(),
"prompt_toolkit.patch_stdout": MagicMock(),
"prompt_toolkit.application": MagicMock(),
"prompt_toolkit.layout": MagicMock(),
"prompt_toolkit.layout.processors": MagicMock(),
"prompt_toolkit.filters": MagicMock(),
"prompt_toolkit.layout.dimension": MagicMock(),
"prompt_toolkit.layout.menus": MagicMock(),
"prompt_toolkit.widgets": MagicMock(),
"prompt_toolkit.key_binding": MagicMock(),
"prompt_toolkit.completion": MagicMock(),
"prompt_toolkit.formatted_text": MagicMock(),
"prompt_toolkit.auto_suggest": MagicMock(),
}
with patch.dict(sys.modules, prompt_toolkit_stubs), patch.dict(
"os.environ", clean_env, clear=False
):
import cli as _cli_mod
_cli_mod = importlib.reload(_cli_mod)
with patch.object(_cli_mod, "get_tool_definitions", return_value=[]), patch.dict(
_cli_mod.__dict__, {"CLI_CONFIG": _clean_config}
):
return _cli_mod.HermesCLI()
class TestBackgroundInlineDetector:
def test_detects_background_when_agent_running(self):
cli = _make_cli()
cli._agent_running = True
assert cli._should_handle_background_command_inline(
"/bg inspect the test failures"
) is True
def test_detects_both_commands(self):
cli = _make_cli()
cli._agent_running = True
assert cli._should_handle_background_command_inline("/bg do work") is True
assert cli._should_handle_background_command_inline("/btw do work") is True
def test_background_alias_still_resolves_to_bg(self):
"""The retired /background spelling no longer resolves to a command."""
cli = _make_cli()
cli._agent_running = True
assert cli._should_handle_background_command_inline(
"/background do work"
) is False
def test_ignores_background_when_agent_idle(self):
"""Idle input falls through to the normal process_loop dispatch."""
cli = _make_cli()
cli._agent_running = False
assert cli._should_handle_background_command_inline("/bg do work") is False
def test_ignores_non_slash_input(self):
cli = _make_cli()
cli._agent_running = True
assert cli._should_handle_background_command_inline("bg without slash") is False
assert cli._should_handle_background_command_inline("") is False
def test_ignores_other_slash_commands(self):
cli = _make_cli()
cli._agent_running = True
assert cli._should_handle_background_command_inline("/steer hello") is False
assert cli._should_handle_background_command_inline("/queue hello") is False
assert cli._should_handle_background_command_inline("/stop") is False
def test_ignores_background_with_attached_images(self):
"""Image payloads take the normal path."""
cli = _make_cli()
cli._agent_running = True
assert cli._should_handle_background_command_inline(
"/bg look at this", has_images=True
) is False
def test_case_and_whitespace_tolerant(self):
cli = _make_cli()
cli._agent_running = True
assert cli._should_handle_background_command_inline("/BG do work") is True
class TestBackgroundBusyPolicyContract:
"""The registry already declares the intent this detector implements."""
def test_bg_and_btw_declare_dispatch_while_busy(self):
from hermes_cli.commands import resolve_command
for name in ("bg", "btw"):
cmd = resolve_command(name)
assert cmd is not None
assert cmd.name == name
assert cmd.busy_policy == "dispatch"
def test_background_name_is_retired(self):
from hermes_cli.commands import resolve_command
assert resolve_command("background") is None
@@ -0,0 +1,176 @@
"""Tests for the /bg indicator in the CLI status bar.
The classic prompt_toolkit status bar shows `▶ N` when N tasks launched via
`/bg` are still running. Source of truth is `self._background_tasks`
(a Dict[str, threading.Thread]); entries are removed in the task thread's
finally block, so len() reflects truly-running tasks.
"""
import threading
from datetime import datetime
from cli import HermesCLI
def _stub_thread() -> threading.Thread:
"""Return a Thread instance that's never started — pure dict-value stand-in."""
return threading.Thread(target=lambda: None)
def _make_cli():
"""Bare-metal HermesCLI for snapshot/build tests (no __init__ side effects)."""
cli_obj = HermesCLI.__new__(HermesCLI)
cli_obj.model = "anthropic/claude-opus-4.6"
cli_obj.agent = None
cli_obj._background_tasks = {}
# The snapshot reads session_start to compute duration; supply a stub.
cli_obj.session_start = datetime.now()
return cli_obj
def test_snapshot_reports_zero_when_no_background_tasks():
cli_obj = _make_cli()
snap = cli_obj._get_status_bar_snapshot()
assert snap["active_background_tasks"] == 0
def test_snapshot_counts_live_background_tasks():
cli_obj = _make_cli()
cli_obj._background_tasks = {"bg_a": _stub_thread(), "bg_b": _stub_thread()}
snap = cli_obj._get_status_bar_snapshot()
assert snap["active_background_tasks"] == 2
def test_plain_text_status_omits_indicator_when_idle():
cli_obj = _make_cli()
text = cli_obj._build_status_bar_text(width=80)
assert "▶" not in text
def test_fragments_omit_bg_segment_when_idle():
cli_obj = _make_cli()
cli_obj._status_bar_visible = True
cli_obj._get_tui_terminal_width = lambda: 120 # type: ignore[method-assign]
frags = cli_obj._get_status_bar_fragments()
rendered = "".join(text for _style, text in frags)
assert "▶" not in rendered
# ── Background terminal-process indicator (⚙ N) ───────────────────────────
# Source of truth is tools.process_registry.process_registry._running (a dict
# of currently-running shell processes spawned by terminal(background=true)).
# Distinct from /bg tasks above: ▶ counts agent threads, ⚙ counts
# shell processes. Both can be active simultaneously.
class _FakeRunningRegistry:
"""Minimal stand-in for process_registry; exposes count_running()."""
def __init__(self, count: int) -> None:
self._count = count
def count_running(self) -> int:
return self._count
def _patch_process_registry(monkeypatch, count: int) -> None:
import tools.process_registry as pr_mod
monkeypatch.setattr(pr_mod, "process_registry", _FakeRunningRegistry(count))
# ── Background/async subagent indicator (⛓ N) ─────────────────────────────
# Source of truth is tools.async_delegation.active_count() — the count of
# delegate_task delegations (batch + background single) still in the
# "running" state. Distinct from ▶ (/bg agent threads) and ⚙ (shell
# processes); all three can be active at once.
def _patch_async_active(monkeypatch, count: int) -> None:
import tools.async_delegation as ad_mod
monkeypatch.setattr(ad_mod, "active_count", lambda: count)
def test_snapshot_reports_zero_when_no_background_subagents(monkeypatch):
cli_obj = _make_cli()
_patch_async_active(monkeypatch, 0)
snap = cli_obj._get_status_bar_snapshot()
assert snap["active_background_subagents"] == 0
def test_snapshot_safe_when_async_active_count_raises(monkeypatch):
"""If active_count() raises the snapshot stays at 0; no propagate."""
cli_obj = _make_cli()
import tools.async_delegation as ad_mod
def _boom():
raise RuntimeError("boom")
monkeypatch.setattr(ad_mod, "active_count", _boom)
snap = cli_obj._get_status_bar_snapshot()
assert snap["active_background_subagents"] == 0
def test_plain_text_status_shows_subagent_indicator_when_active(monkeypatch):
cli_obj = _make_cli()
_patch_async_active(monkeypatch, 3)
text = cli_obj._build_status_bar_text(width=80)
assert "⛓ 3" in text
def test_plain_text_status_omits_subagent_indicator_when_idle(monkeypatch):
cli_obj = _make_cli()
_patch_async_active(monkeypatch, 0)
text = cli_obj._build_status_bar_text(width=80)
assert "⛓" not in text
def test_fragments_include_subagent_segment_when_active(monkeypatch):
cli_obj = _make_cli()
_patch_async_active(monkeypatch, 2)
cli_obj._status_bar_visible = True
cli_obj._get_tui_terminal_width = lambda: 120 # type: ignore[method-assign]
frags = cli_obj._get_status_bar_fragments()
rendered = "".join(text for _style, text in frags)
assert "⛓ 2" in rendered
def test_all_three_background_indicators_independent(monkeypatch):
"""▶ (agent tasks), ⚙ (shell processes), ⛓ (subagents) all coexist."""
cli_obj = _make_cli()
cli_obj._background_tasks = {"bg_a": _stub_thread()}
_patch_process_registry(monkeypatch, 2)
_patch_async_active(monkeypatch, 5)
cli_obj._status_bar_visible = True
cli_obj._get_tui_terminal_width = lambda: 120 # type: ignore[method-assign]
frags = cli_obj._get_status_bar_fragments()
rendered = "".join(text for _style, text in frags)
assert "▶ 1" in rendered
assert "⚙ 2" in rendered
assert "⛓ 5" in rendered
@@ -0,0 +1,102 @@
"""Tests for CLI background command TUI refresh behavior.
Ensures the TUI is properly refreshed before printing background task output
to prevent spinner/status bar overlap (#2718).
"""
from unittest.mock import MagicMock, patch
from cli import HermesCLI
def _make_cli():
"""Create a minimal HermesCLI instance for testing."""
cli_obj = HermesCLI.__new__(HermesCLI)
cli_obj.model = "test-model"
cli_obj._background_tasks = {}
cli_obj._background_task_counter = 0
cli_obj.conversation_history = []
cli_obj.agent = None
cli_obj._app = None
return cli_obj
class TestBackgroundCommandTuiRefresh:
"""Tests for TUI refresh in background command output."""
def test_invalidate_called_before_success_output(self):
"""App.invalidate() is called before printing background success output."""
cli_obj = _make_cli()
mock_app = MagicMock()
cli_obj._app = mock_app
# Track call order
call_order = []
original_invalidate = mock_app.invalidate
def track_invalidate():
call_order.append("invalidate")
return original_invalidate()
mock_app.invalidate = track_invalidate
# Patch print to track when it's called
with patch("builtins.print") as mock_print:
mock_print.side_effect = lambda *args, **kwargs: call_order.append("print")
# Simulate the background task output code path
if cli_obj._app:
cli_obj._app.invalidate()
import time
time.sleep(0.01) # reduced for test
print()
# Verify invalidate was called before print
assert call_order[0] == "invalidate"
assert "print" in call_order
def test_invalidate_called_before_error_output(self):
"""App.invalidate() is called before printing background error output."""
cli_obj = _make_cli()
mock_app = MagicMock()
cli_obj._app = mock_app
call_order = []
mock_app.invalidate.side_effect = lambda: call_order.append("invalidate")
with patch("builtins.print") as mock_print:
mock_print.side_effect = lambda *args, **kwargs: call_order.append("print")
# Simulate error path
if cli_obj._app:
cli_obj._app.invalidate()
import time
time.sleep(0.01)
print()
assert call_order[0] == "invalidate"
assert "print" in call_order
def test_no_crash_when_app_is_none(self):
"""No crash when _app is None (non-TUI mode)."""
cli_obj = _make_cli()
cli_obj._app = None
# This should not raise
if cli_obj._app:
cli_obj._app.invalidate()
# If we get here without exception, test passes
def test_background_task_thread_safety(self):
"""Background task tracking is thread-safe."""
cli_obj = _make_cli()
# Simulate adding and removing background tasks
task_id = "test_task_1"
cli_obj._background_tasks[task_id] = MagicMock()
assert task_id in cli_obj._background_tasks
# Clean up
cli_obj._background_tasks.pop(task_id, None)
assert task_id not in cli_obj._background_tasks
@@ -0,0 +1,34 @@
"""Tests for defensive bracketed-paste wrapper stripping in the CLI."""
from cli import _strip_leaked_bracketed_paste_wrappers
class TestStripLeakedBracketedPasteWrappers:
def test_strips_canonical_escape_wrappers(self):
text = "\x1b[200~hello\x1b[201~"
assert _strip_leaked_bracketed_paste_wrappers(text) == "hello"
def test_strips_visible_caret_escape_wrappers(self):
text = "^[[200~hello^[[201~"
assert _strip_leaked_bracketed_paste_wrappers(text) == "hello"
def test_strips_degraded_bracket_only_wrappers_after_whitespace(self):
text = "prefix [200~hello[201~ suffix"
assert _strip_leaked_bracketed_paste_wrappers(text) == "prefix hello suffix"
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_preserves_multiline_content_while_stripping_wrappers(self):
text = "^[[200~line 1\nline 2\nline 3^[[201~"
assert _strip_leaked_bracketed_paste_wrappers(text) == "line 1\nline 2\nline 3"
def test_preserves_multiline_content_while_stripping_degraded_bracket_only_wrappers(self):
text = "[200~line 1\nline 2\nline 3[201~"
assert _strip_leaked_bracketed_paste_wrappers(text) == "line 1\nline 2\nline 3"
+223
View File
@@ -0,0 +1,223 @@
"""Tests for CLI browser CDP auto-launch helpers."""
from contextlib import redirect_stdout
from io import StringIO
import os
from queue import Queue
import subprocess
from unittest.mock import patch
from cli import HermesCLI
from hermes_cli.browser_connect import (
_wait_for_browser_debug_ready_or_exit,
get_chrome_debug_candidates,
is_browser_debug_ready,
launch_chrome_debug,
manual_chrome_debug_command,
)
def _assert_chrome_debug_cmd(cmd, expected_chrome, expected_port):
"""Verify the auto-launch command has all required flags."""
assert cmd[0] == expected_chrome
assert f"--remote-debugging-port={expected_port}" in cmd
assert "--no-first-run" in cmd
assert "--no-default-browser-check" in cmd
user_data_args = [a for a in cmd if a.startswith("--user-data-dir=")]
assert len(user_data_args) == 1, "Expected exactly one --user-data-dir flag"
assert "chrome-debug" in user_data_args[0]
class _FakeResponse:
status = 200
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb):
return False
class TestChromeDebugLaunch:
def test_browser_debug_ready_requires_http_cdp_endpoint(self):
requested = []
def fake_urlopen(url, timeout):
requested.append(url)
if url.endswith("/json/version"):
return _FakeResponse()
raise OSError("unexpected probe")
with patch("urllib.request.urlopen", side_effect=fake_urlopen):
assert is_browser_debug_ready("http://127.0.0.1:9222", timeout=0.1) is True
assert requested == ["http://127.0.0.1:9222/json/version"]
def test_browser_debug_ready_rejects_non_cdp_listener(self):
with patch("urllib.request.urlopen", side_effect=OSError("not cdp")):
assert is_browser_debug_ready("http://127.0.0.1:9222", timeout=0.1) is False
def test_manual_command_uses_detected_linux_browser(self):
with patch("hermes_cli.browser_connect.shutil.which", side_effect=lambda name: "/usr/bin/chromium" if name == "chromium" else None), \
patch("hermes_cli.browser_connect.os.path.isfile", side_effect=lambda path: path == "/usr/bin/chromium"):
command = manual_chrome_debug_command(9222, "Linux")
assert command is not None
assert command.startswith("/usr/bin/chromium --remote-debugging-port=9222")
def test_linux_candidates_include_brave_origin_binary_name(self):
brave = "/usr/bin/brave-origin"
with patch("hermes_cli.browser_connect.shutil.which", side_effect=lambda name: brave if name == "brave-origin" 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_brave_origin_install_path(self):
brave = "/opt/brave.com/brave-origin/brave-origin"
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_origin_nightly_binary_name(self):
brave = "/usr/bin/brave-origin-nightly"
with patch("hermes_cli.browser_connect.shutil.which", side_effect=lambda name: brave if name == "brave-origin-nightly" 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_brave_origin_nightly_install_path(self):
brave = "/opt/brave.com/brave-origin-nightly/brave-origin"
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_official_brave_and_edge_stable_paths(self):
brave = "/usr/bin/brave-browser-stable"
edge = "/usr/bin/microsoft-edge-stable"
with patch("hermes_cli.browser_connect.shutil.which", return_value=None), \
patch("hermes_cli.browser_connect.os.path.isfile", side_effect=lambda path: path in {brave, edge}):
candidates = get_chrome_debug_candidates("Linux")
assert candidates == [brave, edge]
def test_wsl_install_candidates_keep_posix_separators_on_nt_host(self):
expected = "/mnt/c/Program Files/Google/Chrome/Application/chrome.exe"
with patch("hermes_cli.browser_connect.shutil.which", return_value=None), \
patch("hermes_cli.browser_connect.os.path.isfile", side_effect=lambda path: path == expected):
candidates = get_chrome_debug_candidates("Linux")
assert candidates == [expected]
assert "\\" not in candidates[0]
def test_wait_for_browser_debug_ready_or_exit_detects_early_exit(self, monkeypatch):
class _Proc:
def __init__(self):
self.calls = 0
def poll(self):
self.calls += 1
return 1 if self.calls >= 2 else None
monkeypatch.setattr("hermes_cli.browser_connect.time.sleep", lambda _seconds: None)
with patch("hermes_cli.browser_connect.is_browser_debug_ready", return_value=False):
state = _wait_for_browser_debug_ready_or_exit(_Proc(), 9222, timeout=0.3, interval=0.01)
assert state == "exited"
def test_launch_result_surfaces_stderr_tail_on_crash(self, tmp_path, monkeypatch):
chrome = "/usr/bin/google-chrome"
class _Proc:
pid = 4321
returncode = 127
def __init__(self, stderr_path):
# Simulate the browser writing to the redirected stderr file.
with open(stderr_path, "w", encoding="utf-8") as fh:
fh.write("error while loading shared libraries: libnspr4.so\n")
def poll(self):
return 127
monkeypatch.setattr(
"hermes_cli.browser_connect.chrome_debug_data_dir", lambda: str(tmp_path)
)
stderr_path = tmp_path / "launch-stderr.log"
with patch("hermes_cli.browser_connect.get_chrome_debug_candidates", return_value=[chrome]), \
patch("hermes_cli.browser_connect.is_browser_debug_ready", return_value=False), \
patch("subprocess.Popen", side_effect=lambda *a, **k: _Proc(stderr_path)):
result = launch_chrome_debug(9222, "Linux")
assert result.launched is False
assert result.attempts[0].returncode == 127
assert "libnspr4.so" in result.attempts[0].stderr_tail
assert result.hint is not None
assert "libnspr4.so" in result.hint
def test_launch_result_no_hint_when_no_candidates(self):
with patch("hermes_cli.browser_connect.get_chrome_debug_candidates", return_value=[]):
result = launch_chrome_debug(9222, "Linux")
assert result.launched is False
assert result.attempts == []
assert result.hint is None
def test_manual_command_uses_wsl_windows_chrome_when_available(self):
chrome = "/mnt/c/Program Files/Google/Chrome/Application/chrome.exe"
with patch("hermes_cli.browser_connect.shutil.which", return_value=None), \
patch("hermes_cli.browser_connect.os.path.isfile", side_effect=lambda path: path == chrome):
command = manual_chrome_debug_command(9222, "Linux")
assert command is not None
# Linux/WSL uses POSIX shell quoting (single quotes around paths with spaces).
assert command.startswith(f"'{chrome}' --remote-debugging-port=9222")
def test_manual_command_uses_windows_quoting_on_windows(self):
chrome = r"C:\Program Files\Google\Chrome\Application\chrome.exe"
with patch("hermes_cli.browser_connect.shutil.which", side_effect=lambda name: chrome if name == "chrome.exe" else None), \
patch("hermes_cli.browser_connect.os.path.isfile", side_effect=lambda path: path == chrome):
command = manual_chrome_debug_command(9222, "Windows")
assert command is not None
# Windows uses cmd.exe-compatible quoting via subprocess.list2cmdline.
assert command.startswith(f'"{chrome}" --remote-debugging-port=9222')
assert "'" not in command
+373
View File
@@ -0,0 +1,373 @@
"""Batch (multi-question) clarify panel state machine — CLI side.
Drives ``_clarify_callback`` with a ``questions`` list on a background
thread (the way the agent thread calls it) and simulates the keybinding
handlers by calling the same helper methods they call
(``_clarify_batch_set_active`` for Tab, ``_clarify_batch_enter`` for
Enter, ``_clarify_batch_lock`` for the freetext submit path). No real
terminal needed — mirrors tests/cli/test_cli_approval_ui.py.
"""
import json
import threading
import time
from unittest.mock import MagicMock, patch
from cli import HermesCLI
def _make_cli_stub():
cli = HermesCLI.__new__(HermesCLI)
cli._clarify_state = None
cli._clarify_freetext = False
cli._clarify_multi_base = None
cli._clarify_prefill = ""
cli._clarify_deadline = None
cli._paint_now = MagicMock()
cli._persist_prompt_summary = MagicMock()
return cli
def _q(index, question, choices=None, multi_select=False):
"""One normalized batch entry, shaped like _normalize_questions output."""
return {
"qid": f"q{index}",
"id": None,
"question": question,
"choices": list(choices) if choices else None,
"choices_offered": list(choices) if choices else None,
"multi_select": bool(multi_select) and bool(choices),
}
def _start_batch(cli, questions):
"""Run the batch callback on a thread; wait for the panel state."""
result = {}
def _run():
result["value"] = cli._clarify_callback("", None, questions=questions)
thread = threading.Thread(target=_run, daemon=True)
thread.start()
deadline = time.time() + 2
while cli._clarify_state is None and time.time() < deadline:
time.sleep(0.01)
assert cli._clarify_state is not None
return thread, result
class TestClarifyBatchPanel:
def test_all_locked_returns_answers_dict_keyed_by_qid(self):
cli = _make_cli_stub()
questions = [
_q(0, "Color?", ["red", "blue"]),
_q(1, "Size?", ["small", "large"]),
]
thread, result = _start_batch(cli, questions)
state = cli._clarify_state
assert state["active"] == 0
assert state["choices"] == ["red", "blue"]
# Enter locks the active question's highlighted choice, then the
# cursor advances to the next unanswered question.
cli._clarify_batch_enter(state)
assert state["answers"] == {"q0": "red"}
assert state["active"] == 1
state["selected"] = 1
cli._clarify_batch_enter(state)
thread.join(timeout=2)
assert result["value"] == {"answers": {"q0": "red", "q1": "large"}}
assert cli._clarify_state is None
def test_any_order_answering_via_tab_cycle(self):
cli = _make_cli_stub()
questions = [
_q(0, "First?", ["a", "b"]),
_q(1, "Second?", ["c", "d"]),
_q(2, "Third?", ["e", "f"]),
]
thread, result = _start_batch(cli, questions)
state = cli._clarify_state
# Tab twice: q0 -> q1 -> q2 (what the tab keybinding does).
cli._clarify_batch_set_active(state, (state["active"] + 1) % 3)
cli._clarify_batch_set_active(state, (state["active"] + 1) % 3)
assert state["active"] == 2
cli._clarify_batch_enter(state) # lock q2 = "e"
# Advance wraps to the next unanswered question (q0).
assert state["active"] == 0
state["selected"] = 1
cli._clarify_batch_enter(state) # lock q0 = "b"
assert state["active"] == 1
cli._clarify_batch_enter(state) # lock q1 = "c"
thread.join(timeout=2)
assert result["value"] == {
"answers": {"q0": "b", "q1": "c", "q2": "e"}
}
def test_reanswer_overwrites_before_completion(self):
cli = _make_cli_stub()
questions = [
_q(0, "Approach?", ["quick", "thorough"]),
_q(1, "Scope?", ["narrow", "wide"]),
]
thread, result = _start_batch(cli, questions)
state = cli._clarify_state
cli._clarify_batch_enter(state) # lock q0 = "quick"
assert state["answers"]["q0"] == "quick"
assert state["active"] == 1
# Tab back to the answered question and change the answer.
cli._clarify_batch_set_active(state, 0)
state["selected"] = 1
cli._clarify_batch_enter(state) # overwrite q0 = "thorough"
assert state["answers"]["q0"] == "thorough"
# Advance lands on the still-unanswered q1.
assert state["active"] == 1
cli._clarify_batch_enter(state) # lock q1 = "narrow"
thread.join(timeout=2)
assert result["value"] == {
"answers": {"q0": "thorough", "q1": "narrow"}
}
def test_timeout_returns_partials_with_timed_out_flag(self):
cli = _make_cli_stub()
questions = [
_q(0, "Answered?", ["yes", "no"]),
_q(1, "Never answered?", ["x", "y"]),
]
with patch(
"tools.clarify_gateway.resolve_clarify_timeout", return_value=1
):
thread, result = _start_batch(cli, questions)
state = cli._clarify_state
cli._clarify_batch_enter(state) # lock q0 only
thread.join(timeout=5)
assert not thread.is_alive()
assert result["value"] == {"answers": {"q0": "yes"}, "timed_out": True}
assert cli._clarify_state is None
def test_multi_select_lock_produces_json_array_string(self):
cli = _make_cli_stub()
questions = [
_q(0, "Toppings?", ["ham", "olives", "basil"], multi_select=True),
]
thread, result = _start_batch(cli, questions)
state = cli._clarify_state
assert state["multi_select"] is True
state["selected_indices"].update({0, 2})
cli._clarify_batch_enter(state)
thread.join(timeout=2)
answer = result["value"]["answers"]["q0"]
assert isinstance(answer, str)
assert json.loads(answer) == ["ham", "basil"]
def test_open_ended_question_locks_typed_answer(self):
cli = _make_cli_stub()
questions = [
_q(0, "Anything else?"),
_q(1, "Pick one", ["a", "b"]),
]
thread, result = _start_batch(cli, questions)
state = cli._clarify_state
# Open-ended active question drops straight into freetext.
assert cli._clarify_freetext is True
# The Enter freetext submit path locks the typed text.
cli._clarify_freetext = False
cli._clarify_batch_lock(state, "custom words")
assert state["active"] == 1
cli._clarify_batch_enter(state)
thread.join(timeout=2)
assert result["value"] == {
"answers": {"q0": "custom words", "q1": "a"}
}
def test_locked_question_persists_scrollback_summary(self):
cli = _make_cli_stub()
questions = [
_q(0, "Color?", ["red", "blue"]),
_q(1, "Size?", ["small", "large"]),
]
thread, result = _start_batch(cli, questions)
state = cli._clarify_state
cli._clarify_batch_enter(state)
cli._clarify_batch_enter(state)
thread.join(timeout=2)
calls = cli._persist_prompt_summary.call_args_list
assert len(calls) == 2
assert calls[0].args == ("?", "Clarify", "Color?", "red")
assert calls[1].args == ("?", "Clarify", "Size?", "small")
def test_single_question_path_returns_plain_string(self):
cli = _make_cli_stub()
result = {}
def _run():
result["value"] = cli._clarify_callback("Pick?", ["a", "b"])
thread = threading.Thread(target=_run, daemon=True)
thread.start()
deadline = time.time() + 2
while cli._clarify_state is None and time.time() < deadline:
time.sleep(0.01)
assert cli._clarify_state is not None
assert "questions" not in cli._clarify_state
cli._clarify_state["response_queue"].put("a")
thread.join(timeout=2)
assert result["value"] == "a"
class TestClarifyBatchNavigation:
"""Shift-Tab, answer restore on re-visit, and Other edit-prefill."""
def test_backward_navigation_wraps(self):
cli = _make_cli_stub()
questions = [
_q(0, "Color?", ["red", "blue"]),
_q(1, "Size?", ["small", "large"]),
_q(2, "Speed?", ["slow", "fast"]),
]
thread, result = _start_batch(cli, questions)
state = cli._clarify_state
# Shift-Tab from question 0 wraps to the last question.
cli._clarify_batch_set_active(state, (state["active"] - 1) % 3)
assert state["active"] == 2
cli._clarify_batch_set_active(state, (state["active"] - 1) % 3)
assert state["active"] == 1
state["response_queue"].put("cancel")
thread.join(timeout=2)
def test_revisit_choice_answer_restores_cursor(self):
cli = _make_cli_stub()
questions = [
_q(0, "Color?", ["red", "blue"]),
_q(1, "Size?", ["small", "large"]),
]
thread, result = _start_batch(cli, questions)
state = cli._clarify_state
# Lock "blue" (index 1) on q0; the cursor advances to q1.
state["selected"] = 1
cli._clarify_batch_enter(state)
assert state["active"] == 1
# Tab back to q0: the cursor sits on the earlier answer, not row 0.
cli._clarify_batch_set_active(state, 0)
assert state["selected"] == 1
state["response_queue"].put("cancel")
thread.join(timeout=2)
def test_revisit_other_answer_highlights_other_and_prefills_edit(self):
cli = _make_cli_stub()
questions = [
_q(0, "Color?", ["red", "blue"]),
_q(1, "Size?", ["small", "large"]),
]
thread, result = _start_batch(cli, questions)
state = cli._clarify_state
# Answer q0 via Other: select the Other row, then the freetext
# submit path locks the typed text with its meta.
state["selected"] = 2
cli._clarify_batch_enter(state)
assert cli._clarify_freetext is True
cli._clarify_freetext = False
cli._clarify_batch_lock(
state, "chartreuse", meta={"kind": "other", "other_text": "chartreuse"}
)
assert state["active"] == 1
# Tab back to q0: the cursor highlights the Other row.
cli._clarify_batch_set_active(state, 0)
assert state["selected"] == 2
# Enter on the answered Other switches to freetext and prefills the
# earlier text for editing.
cli._clarify_batch_enter(state)
assert cli._clarify_freetext is True
assert cli._clarify_prefill == "chartreuse"
state["response_queue"].put("cancel")
thread.join(timeout=2)
def test_reanswer_overwrites_and_updates_meta(self):
cli = _make_cli_stub()
questions = [
_q(0, "Color?", ["red", "blue"]),
_q(1, "Size?", ["small", "large"]),
]
thread, result = _start_batch(cli, questions)
state = cli._clarify_state
# First answer via Other.
cli._clarify_batch_lock(
state, "teal", meta={"kind": "other", "other_text": "teal"}
)
# Re-visit and overwrite with a plain choice.
cli._clarify_batch_set_active(state, 0)
assert state["selected"] == 2
state["selected"] = 0
cli._clarify_batch_enter(state)
assert state["answers"]["q0"] == "red"
assert state["answer_meta"]["q0"] == {"kind": "choice"}
# Finish q1 so the batch resolves with the overwritten answer.
cli._clarify_batch_set_active(state, 1)
cli._clarify_batch_enter(state)
thread.join(timeout=2)
assert result["value"] == {"answers": {"q0": "red", "q1": "small"}}
class TestClarifyBellOnPrompt:
"""display.bell_on_prompt rings BEL when a clarify modal opens; off is silent."""
@staticmethod
def _run_clarify(bell_on_prompt):
import io
cli = _make_cli_stub()
cli.bell_on_prompt = bell_on_prompt
out = io.StringIO()
with patch("cli.sys.stdout", out), patch(
"tools.clarify_gateway.resolve_clarify_timeout", return_value=60
):
thread = threading.Thread(
target=cli._clarify_callback, args=("Color?", ["red", "blue"]), daemon=True
)
thread.start()
deadline = time.time() + 2
while cli._clarify_state is None and time.time() < deadline:
time.sleep(0.01)
assert cli._clarify_state is not None
cli._clarify_state["response_queue"].put("red")
thread.join(timeout=2)
return out.getvalue()
def test_bell_on_prompt_rings_and_off_is_silent(self):
assert "\a" in self._run_clarify(True)
assert "\a" not in self._run_clarify(False)
+79
View File
@@ -0,0 +1,79 @@
"""Verify Cmd+Backspace / Cmd+ForwardDelete byte sequences from CSI-u
terminals reach prompt_toolkit's readline kill bindings instead of leaking
into the buffer as literal text.
"""
from __future__ import annotations
import pytest
from prompt_toolkit.input.ansi_escape_sequences import ANSI_SEQUENCES
from prompt_toolkit.input.vt100_parser import Vt100Parser
from prompt_toolkit.keys import Keys
from hermes_cli.pt_input_extras import install_cmd_backspace_alias
# Cmd rides as the super modifier bit (8), so modifier = 9 (super) or
# 10 (super+shift).
CMD_BACKSPACE_SEQUENCES = (
"\x1b[127;9u", # Kitty CSI-u
"\x1b[127;10u", # Kitty CSI-u, +shift
"\x1b[27;9;127~", # xterm modifyOtherKeys
)
# Forward-delete is a CSI tilde key, not a CSI-u codepoint.
CMD_FWD_DELETE_SEQUENCES = (
"\x1b[3;9~",
"\x1b[3;10~",
)
@pytest.fixture(autouse=True)
def _ensure_alias_installed():
install_cmd_backspace_alias()
def _parse(byte_seq: str):
out = []
parser = Vt100Parser(out.append)
for ch in byte_seq:
parser.feed(ch)
parser.flush()
return [kp.key for kp in out]
@pytest.mark.parametrize("seq", CMD_BACKSPACE_SEQUENCES)
def test_cmd_backspace_parses_as_ctrl_u(seq):
"""Cmd+Backspace must reach unix-line-discard, exactly as Ctrl+U does."""
assert _parse(seq) == _parse("\x15")
@pytest.mark.parametrize("seq", CMD_FWD_DELETE_SEQUENCES)
def test_cmd_forward_delete_parses_as_ctrl_k(seq):
"""Cmd+ForwardDelete must reach kill-line, exactly as Ctrl+K does."""
assert _parse(seq) == _parse("\x0b")
@pytest.mark.parametrize("seq", CMD_BACKSPACE_SEQUENCES + CMD_FWD_DELETE_SEQUENCES)
def test_sequences_emit_exactly_one_keypress(seq):
"""The whole sequence is consumed. A partial match would emit Escape
plus the remainder as literal text — the bug this alias exists to fix."""
assert len(_parse(seq)) == 1
def test_install_is_idempotent():
install_cmd_backspace_alias()
assert install_cmd_backspace_alias() == 0
def test_unmodified_keys_keep_their_own_bindings():
"""Aliasing the Cmd variants must not disturb the bare keys."""
assert ANSI_SEQUENCES["\x1b[3~"] == Keys.Delete
assert ANSI_SEQUENCES["\x7f"] == Keys.ControlH
def test_ctrl_forward_delete_is_not_remapped():
"""Ctrl+ForwardDelete (modifier 5) is delete-word on Linux/Windows and
must keep prompt_toolkit's own binding, not become kill-line."""
assert ANSI_SEQUENCES["\x1b[3;5~"] == Keys.ControlDelete
@@ -0,0 +1,48 @@
"""Regression coverage for provider-aware @-context sizing in the CLI."""
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
def test_at_context_resolution_passes_active_provider():
"""The CLI @-reference path must preserve the active Codex provider."""
from cli import HermesCLI
cli = HermesCLI.__new__(HermesCLI)
cli.model = "gpt-5.6-terra"
cli.base_url = "https://chatgpt.com/backend-api/codex"
cli.api_key = "token"
cli.provider = "openai-codex"
cli.agent = SimpleNamespace(_config_context_length=None)
cli._active_agent_route_signature = "route"
cli._secret_capture_callback = lambda *_args, **_kwargs: None
cli._last_turn_interrupted = False
cli._ensure_runtime_credentials = lambda: True
cli._resolve_turn_agent_config = lambda _message: {
"signature": "route",
"model": cli.model,
"runtime": None,
"request_overrides": None,
}
cli._init_agent = lambda **_kwargs: True
blocked_result = SimpleNamespace(
expanded=False,
blocked=True,
references=[],
injected_tokens=0,
warnings=["blocked for test"],
)
with patch("agent.context_references.preprocess_context_references", return_value=blocked_result), \
patch("agent.model_metadata.get_model_context_length", return_value=372_000) as mock_context, \
patch("cli._cprint"):
result = cli.chat("inspect @file:example.py")
assert result == "blocked for test"
mock_context.assert_called_once_with(
"gpt-5.6-terra",
base_url="https://chatgpt.com/backend-api/codex",
api_key="token",
provider="openai-codex",
config_context_length=None,
)
+133
View File
@@ -0,0 +1,133 @@
"""Tests for the low context length warning in the CLI banner."""
import os
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
import pytest
from agent.model_metadata import MINIMUM_CONTEXT_LENGTH
@pytest.fixture
def _isolate(tmp_path, monkeypatch):
"""Isolate HERMES_HOME so tests don't touch real config."""
home = tmp_path / ".hermes"
home.mkdir()
monkeypatch.setenv("HERMES_HOME", str(home))
@pytest.fixture
def cli_obj(_isolate):
"""Create a minimal HermesCLI instance for banner testing."""
with patch("cli.load_cli_config", return_value={
"display": {"tool_progress": "new"},
"terminal": {},
}), patch("cli.get_tool_definitions", return_value=[]), \
patch("hermes_cli.banner.build_welcome_banner"):
from cli import HermesCLI
obj = HermesCLI.__new__(HermesCLI)
obj.model = "test-model"
obj.enabled_toolsets = ["hermes-core"]
obj.compact = False
obj.console = MagicMock()
obj.session_id = None
obj.api_key = "test"
obj.base_url = ""
obj.provider = "test"
obj._provider_source = None
# Mock agent with context compressor
obj.agent = SimpleNamespace(
context_compressor=SimpleNamespace(context_length=None)
)
return obj
class TestLowContextWarning:
"""Tests that the CLI warns about low context lengths."""
def test_warning_for_below_minimum_context(self, cli_obj):
"""Warning shown when context is below Hermes' minimum."""
cli_obj.agent.context_compressor.context_length = 32768
with patch("cli.get_tool_definitions", return_value=[]), \
patch("hermes_cli.banner.build_welcome_banner"):
cli_obj.show_banner()
calls = [str(c) for c in cli_obj.console.print.call_args_list]
warning_calls = [c for c in calls if "too low" in c]
assert len(warning_calls) == 1
minimum_calls = [c for c in calls if f"{MINIMUM_CONTEXT_LENGTH:,}" in c]
assert minimum_calls
def test_warning_for_2048_context(self, cli_obj):
"""Warning shown for 2048 tokens (common LM Studio default)."""
cli_obj.agent.context_compressor.context_length = 2048
with patch("cli.get_tool_definitions", return_value=[]), \
patch("hermes_cli.banner.build_welcome_banner"):
cli_obj.show_banner()
calls = [str(c) for c in cli_obj.console.print.call_args_list]
warning_calls = [c for c in calls if "too low" in c]
assert len(warning_calls) == 1
def test_no_warning_at_boundary(self, cli_obj):
"""No warning at exactly Hermes' minimum context length."""
cli_obj.agent.context_compressor.context_length = MINIMUM_CONTEXT_LENGTH
with patch("cli.get_tool_definitions", return_value=[]), \
patch("hermes_cli.banner.build_welcome_banner"):
cli_obj.show_banner()
calls = [str(c) for c in cli_obj.console.print.call_args_list]
warning_calls = [c for c in calls if "too low" in c]
assert len(warning_calls) == 0
def test_no_warning_above_boundary(self, cli_obj):
"""No warning above Hermes' minimum context length."""
cli_obj.agent.context_compressor.context_length = MINIMUM_CONTEXT_LENGTH + 1
with patch("cli.get_tool_definitions", return_value=[]), \
patch("hermes_cli.banner.build_welcome_banner"):
cli_obj.show_banner()
calls = [str(c) for c in cli_obj.console.print.call_args_list]
warning_calls = [c for c in calls if "too low" in c]
assert len(warning_calls) == 0
def test_ollama_specific_hint(self, cli_obj):
"""Ollama-specific fix shown when port 11434 detected."""
cli_obj.agent.context_compressor.context_length = 4096
cli_obj.base_url = "http://localhost:11434/v1"
with patch("cli.get_tool_definitions", return_value=[]), \
patch("hermes_cli.banner.build_welcome_banner"):
cli_obj.show_banner()
calls = [str(c) for c in cli_obj.console.print.call_args_list]
ollama_hints = [c for c in calls if "OLLAMA_CONTEXT_LENGTH" in c]
assert len(ollama_hints) == 1
assert str(MINIMUM_CONTEXT_LENGTH) in ollama_hints[0]
def test_generic_hint_for_other_servers(self, cli_obj):
"""Generic fix shown for unknown servers."""
cli_obj.agent.context_compressor.context_length = 4096
cli_obj.base_url = "http://localhost:8080/v1"
with patch("cli.get_tool_definitions", return_value=[]), \
patch("hermes_cli.banner.build_welcome_banner"):
cli_obj.show_banner()
calls = [str(c) for c in cli_obj.console.print.call_args_list]
generic_hints = [c for c in calls if "config.yaml" in c]
assert len(generic_hints) == 1
def test_compact_banner_does_not_crash_on_narrow_terminal(self, cli_obj):
"""Compact mode should still have ctx_len defined for warning logic."""
cli_obj.agent.context_compressor.context_length = 4096
with patch("shutil.get_terminal_size", return_value=os.terminal_size((70, 40))), \
patch("cli._build_compact_banner", return_value="compact banner"):
cli_obj.show_banner()
calls = [str(c) for c in cli_obj.console.print.call_args_list]
warning_calls = [c for c in calls if "too low" in c]
assert len(warning_calls) == 1
+92
View File
@@ -0,0 +1,92 @@
"""Tests for CLI /copy command."""
from unittest.mock import MagicMock, patch
from cli import HermesCLI
def _make_cli() -> HermesCLI:
cli_obj = HermesCLI.__new__(HermesCLI)
cli_obj.config = {}
cli_obj.console = MagicMock()
cli_obj.agent = None
cli_obj.conversation_history = []
cli_obj.session_id = "sess-copy-test"
cli_obj._pending_input = MagicMock()
cli_obj._app = None
return cli_obj
def test_copy_copies_latest_assistant_message():
cli_obj = _make_cli()
cli_obj.conversation_history = [
{"role": "user", "content": "hi"},
{"role": "assistant", "content": "first"},
{"role": "assistant", "content": "latest"},
]
with patch("hermes_cli.clipboard.write_clipboard_text", return_value=True) as mock_copy:
result = cli_obj.process_command("/copy")
assert result is True
mock_copy.assert_called_once_with("latest")
def test_copy_with_index_uses_requested_assistant_message():
cli_obj = _make_cli()
cli_obj.conversation_history = [
{"role": "assistant", "content": "one"},
{"role": "assistant", "content": "two"},
]
with patch("hermes_cli.clipboard.write_clipboard_text", return_value=True) as mock_copy:
cli_obj.process_command("/copy 1")
mock_copy.assert_called_once_with("one")
def test_copy_strips_reasoning_blocks_before_copy():
cli_obj = _make_cli()
cli_obj.conversation_history = [
{
"role": "assistant",
"content": "<REASONING_SCRATCHPAD>internal</REASONING_SCRATCHPAD>\nVisible answer",
}
]
with patch("hermes_cli.clipboard.write_clipboard_text", return_value=True) as mock_copy:
cli_obj.process_command("/copy")
mock_copy.assert_called_once_with("Visible answer")
def test_copy_prefers_osc52_in_ssh_sessions():
"""Over SSH, native tools write the REMOTE clipboard — OSC 52 reaches
the local terminal instead (#31528)."""
cli_obj = _make_cli()
cli_obj.conversation_history = [{"role": "assistant", "content": "remote answer"}]
with patch("hermes_cli.clipboard.write_clipboard_text", return_value=True) as mock_native, \
patch("hermes_cli.clipboard.is_remote_shell_session", return_value=True), \
patch.object(cli_obj, "_write_osc52_clipboard") as mock_osc52:
cli_obj.process_command("/copy")
mock_osc52.assert_called_once_with("remote answer")
mock_native.assert_not_called()
def test_copy_native_first_when_local():
cli_obj = _make_cli()
cli_obj.conversation_history = [{"role": "assistant", "content": "local answer"}]
with patch("hermes_cli.clipboard.write_clipboard_text", return_value=True) as mock_native, \
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_native.assert_called_once_with("local answer")
mock_osc52.assert_not_called()
@@ -0,0 +1,69 @@
"""The CLI spells out auto-resume when a delegate_task goes to the background.
A top-level ``delegate_task`` returns a handle immediately and runs the subagent
in the background; the result re-enters the conversation as a fresh turn when it
finishes. ``_on_tool_complete`` prints a one-line, no-spinner reassurance at
dispatch so the idle prompt doesn't read as "nothing happened".
"""
import json
import cli
from cli import HermesCLI
def _make_cli():
cli_obj = HermesCLI.__new__(HermesCLI)
cli_obj._pending_edit_snapshots = {}
return cli_obj
def _capture(monkeypatch):
printed: list[str] = []
monkeypatch.setattr(cli, "_cprint", lambda text: printed.append(text))
return printed
def test_background_dispatch_prints_resume_notice(monkeypatch):
cli_obj = _make_cli()
printed = _capture(monkeypatch)
result = json.dumps({"status": "dispatched", "mode": "background", "count": 1})
cli_obj._on_tool_complete("tc1", "delegate_task", {"goal": "x"}, result)
joined = "\n".join(printed)
assert "resume" in joined.lower()
assert "it finishes" in joined
def test_background_batch_dispatch_pluralizes(monkeypatch):
cli_obj = _make_cli()
printed = _capture(monkeypatch)
result = json.dumps({"status": "dispatched", "mode": "background", "count": 3})
cli_obj._on_tool_complete("tc2", "delegate_task", {"tasks": []}, result)
joined = "\n".join(printed)
assert "3 tasks" in joined
assert "they finish" in joined
def test_synchronous_delegate_result_prints_no_notice(monkeypatch):
"""A non-background result (e.g. the stateless sync fallback) must not claim
a background dispatch."""
cli_obj = _make_cli()
printed = _capture(monkeypatch)
result = json.dumps({"results": [{"status": "completed", "summary": "done"}]})
cli_obj._on_tool_complete("tc3", "delegate_task", {"goal": "x"}, result)
assert not any("resume" in p.lower() for p in printed)
def test_non_delegate_tool_prints_no_notice(monkeypatch):
cli_obj = _make_cli()
printed = _capture(monkeypatch)
cli_obj._on_tool_complete("tc4", "read_file", {"path": "a"}, '{"ok": true}')
assert not any("resume" in p.lower() for p in printed)
+138
View File
@@ -0,0 +1,138 @@
"""Tests for protected HermesCLI TUI extension hooks.
Verifies that wrapper CLIs can extend the TUI via:
- _get_extra_tui_widgets()
- _register_extra_tui_keybindings()
- _build_tui_layout_children()
without overriding run().
"""
from __future__ import annotations
import importlib
import sys
from unittest.mock import MagicMock, patch
from prompt_toolkit.key_binding import KeyBindings
def _make_cli(**kwargs):
"""Create a HermesCLI with prompt_toolkit stubs (same pattern as test_cli_init)."""
_clean_config = {
"model": {
"default": "anthropic/claude-opus-4.6",
"base_url": "https://openrouter.ai/api/v1",
"provider": "auto",
},
"display": {"compact": False, "tool_progress": "all"},
"agent": {},
"terminal": {"env_type": "local"},
}
clean_env = {"LLM_MODEL": "", "HERMES_MAX_ITERATIONS": ""}
prompt_toolkit_stubs = {
"prompt_toolkit": MagicMock(),
"prompt_toolkit.history": MagicMock(),
"prompt_toolkit.styles": MagicMock(),
"prompt_toolkit.patch_stdout": MagicMock(),
"prompt_toolkit.application": MagicMock(),
"prompt_toolkit.layout": MagicMock(),
"prompt_toolkit.layout.processors": MagicMock(),
"prompt_toolkit.filters": MagicMock(),
"prompt_toolkit.layout.dimension": MagicMock(),
"prompt_toolkit.layout.menus": MagicMock(),
"prompt_toolkit.widgets": MagicMock(),
"prompt_toolkit.key_binding": MagicMock(),
"prompt_toolkit.completion": MagicMock(),
"prompt_toolkit.formatted_text": MagicMock(),
"prompt_toolkit.auto_suggest": MagicMock(),
}
with patch.dict(sys.modules, prompt_toolkit_stubs), patch.dict(
"os.environ", clean_env, clear=False
):
import cli as _cli_mod
_cli_mod = importlib.reload(_cli_mod)
with patch.object(_cli_mod, "get_tool_definitions", return_value=[]), patch.dict(
_cli_mod.__dict__, {"CLI_CONFIG": _clean_config}
):
return _cli_mod.HermesCLI(**kwargs)
class TestExtensionHookDefaults:
def test_extra_tui_widgets_default_empty(self):
cli = _make_cli()
assert cli._get_extra_tui_widgets() == []
def test_register_extra_tui_keybindings_default_noop(self):
cli = _make_cli()
kb = KeyBindings()
result = cli._register_extra_tui_keybindings(kb, input_area=None)
assert result is None
assert kb.bindings == []
def test_build_tui_layout_children_returns_all_widgets_in_order(self):
cli = _make_cli()
children = cli._build_tui_layout_children(
sudo_widget="sudo",
secret_widget="secret",
approval_widget="approval",
clarify_widget="clarify",
spinner_widget="spinner",
spacer="spacer",
status_bar="status",
input_rule_top="top-rule",
image_bar="image-bar",
input_area="input-area",
input_rule_bot="bottom-rule",
voice_status_bar="voice-status",
completions_menu="completions-menu",
)
# First element is Window(height=0), rest are the named widgets
assert children[1:] == [
"sudo", "secret", "approval", "clarify", "spinner",
"spacer", "status", "top-rule", "image-bar", "input-area",
"bottom-rule", "voice-status", "completions-menu",
]
class TestExtensionHookSubclass:
def test_extra_widgets_inserted_before_status_bar(self):
cli = _make_cli()
# Monkey-patch to simulate subclass override
cli._get_extra_tui_widgets = lambda: ["radio-menu", "mini-player"]
children = cli._build_tui_layout_children(
sudo_widget="sudo",
secret_widget="secret",
approval_widget="approval",
clarify_widget="clarify",
spinner_widget="spinner",
spacer="spacer",
status_bar="status",
input_rule_top="top-rule",
image_bar="image-bar",
input_area="input-area",
input_rule_bot="bottom-rule",
voice_status_bar="voice-status",
completions_menu="completions-menu",
)
# Extra widgets should appear between spacer and status bar
spacer_idx = children.index("spacer")
status_idx = children.index("status")
assert children[spacer_idx + 1] == "radio-menu"
assert children[spacer_idx + 2] == "mini-player"
assert children[spacer_idx + 3] == "status"
assert status_idx == spacer_idx + 3
def test_extra_keybindings_can_add_bindings(self):
cli = _make_cli()
kb = KeyBindings()
def _custom_hook(kb, *, input_area):
@kb.add("f2")
def _toggle(event):
return None
cli._register_extra_tui_keybindings = _custom_hook
cli._register_extra_tui_keybindings(kb, input_area=None)
assert len(kb.bindings) == 1
+109
View File
@@ -0,0 +1,109 @@
"""Tests for CLI external-editor support."""
from unittest.mock import patch
from cli import HermesCLI
class _FakeBuffer:
def __init__(self, text=""):
self.calls = []
self.text = text
self.cursor_position = len(text)
def open_in_editor(self, validate_and_handle=False):
self.calls.append(validate_and_handle)
class _FakeApp:
def __init__(self):
self.current_buffer = _FakeBuffer()
def _make_cli(with_app=True):
cli_obj = HermesCLI.__new__(HermesCLI)
cli_obj._app = _FakeApp() if with_app else None
cli_obj._command_running = False
cli_obj._command_status = ""
cli_obj._command_display = ""
cli_obj._sudo_state = None
cli_obj._secret_state = None
cli_obj._approval_state = None
cli_obj._clarify_state = None
cli_obj._skip_paste_collapse = False
return cli_obj
def test_open_external_editor_uses_prompt_toolkit_buffer_editor():
cli_obj = _make_cli()
assert cli_obj._open_external_editor() is True
assert cli_obj._app.current_buffer.calls == [False]
def test_open_external_editor_rejects_when_no_tui():
cli_obj = _make_cli(with_app=False)
with patch("cli._cprint") as mock_cprint:
assert cli_obj._open_external_editor() is False
assert mock_cprint.called
assert "interactive cli" in str(mock_cprint.call_args).lower()
def test_open_external_editor_expands_paste_placeholders_before_open(tmp_path):
cli_obj = _make_cli()
paste_file = tmp_path / "paste.txt"
paste_file.write_text("alpha\nbeta", encoding="utf-8")
buffer = _FakeBuffer(text=f"[Pasted text #1: 2 lines → {paste_file}]")
assert cli_obj._open_external_editor(buffer=buffer) is True
assert buffer.text == "alpha\nbeta"
assert buffer.cursor_position == len("alpha\nbeta")
assert buffer.calls == [False]
def test_open_external_editor_sets_skip_collapse_flag_during_expansion(tmp_path):
cli_obj = _make_cli()
paste_file = tmp_path / "paste.txt"
paste_file.write_text("a\nb\nc\nd\ne\nf", encoding="utf-8")
buffer = _FakeBuffer(text=f"[Pasted text #1: 6 lines \u2192 {paste_file}]")
# After expansion the flag should have been set (to prevent re-collapse)
assert cli_obj._open_external_editor(buffer=buffer) is True
# Flag is consumed by _on_text_changed, but since no handler is attached
# in tests it stays True until the handler resets it.
assert cli_obj._skip_paste_collapse is True
def test_inline_pastes_stores_full_content(tmp_path):
"""History should recall the actual pasted text, not the placeholder."""
cli_obj = _make_cli()
paste_file = tmp_path / "paste.txt"
paste_file.write_text("line one\nline two", encoding="utf-8")
buffer = _FakeBuffer(text=f"[Pasted text #1: 2 lines \u2192 {paste_file}]")
cli_obj._inline_pastes(buffer)
assert buffer.text == "line one\nline two"
assert buffer.cursor_position == len("line one\nline two")
# Skip flag set so the resulting text-change doesn't re-collapse.
assert cli_obj._skip_paste_collapse is True
def test_inline_pastes_missing_file_keeps_placeholder(tmp_path):
"""A recalled reference whose file is gone stays as the placeholder."""
cli_obj = _make_cli()
placeholder = f"[Pasted text #1: 2 lines \u2192 {tmp_path / 'gone.txt'}]"
buffer = _FakeBuffer(text=placeholder)
cli_obj._inline_pastes(buffer)
assert buffer.text == placeholder
assert cli_obj._skip_paste_collapse is False
+206
View File
@@ -0,0 +1,206 @@
"""Tests for _detect_file_drop — file path detection that prevents
dragged/pasted absolute paths from being mistaken for slash commands."""
import pytest
from cli import _detect_file_drop
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture()
def tmp_image(tmp_path):
"""Create a temporary .png file and return its path."""
img = tmp_path / "screenshot.png"
img.write_bytes(b"\x89PNG\r\n\x1a\n") # minimal PNG header
return img
@pytest.fixture()
def tmp_text(tmp_path):
"""Create a temporary .py file and return its path."""
f = tmp_path / "main.py"
f.write_text("print('hello')\n")
return f
@pytest.fixture()
def tmp_image_with_spaces(tmp_path):
"""Create a file whose name contains spaces (like macOS screenshots)."""
img = tmp_path / "Screenshot 2026-04-01 at 7.25.32 PM.png"
img.write_bytes(b"\x89PNG\r\n\x1a\n")
return img
# ---------------------------------------------------------------------------
# Tests: returns None for non-file inputs
# ---------------------------------------------------------------------------
class TestNonFileInputs:
def test_regular_slash_command(self):
assert _detect_file_drop("/help") is None
def test_empty_string(self):
assert _detect_file_drop("") is None
def test_nonexistent_path(self):
assert _detect_file_drop("/nonexistent/path/to/file.png") is None
def test_long_slash_command_does_not_raise(self):
"""Regression: long pasted slash commands like `/goal <long prose>`
used to raise OSError(ENAMETOOLONG, errno 63 macOS / 36 Linux)
from `Path.exists()` inside `_resolve_attachment_path`, which
propagated up to `process_loop`'s catch-all and silently lost
the user's input. The fix wraps the stat call in a try/except
OSError and returns None, letting the slash-command dispatch
path handle the input downstream.
Reproducer: paste a `/goal` followed by ~430 chars of prose.
Without the fix this triggers ENAMETOOLONG; with the fix it
cleanly returns None (file-drop = no), so `_looks_like_slash_command`
gets a chance to dispatch it.
"""
# 430-char `/goal` payload — well above NAME_MAX (255 bytes) on
# all common filesystems.
long_goal = (
"/goal " + ("Drive the board: triage triage-status items, "
"unblock spillover tasks where work is shipped, "
"advance P1 items by decomposing where needed. ") * 4
)
assert len(long_goal) > 255 # confirms it would have triggered ENAMETOOLONG
assert _detect_file_drop(long_goal) is None
# ---------------------------------------------------------------------------
# Tests: image file detection
# ---------------------------------------------------------------------------
class TestImageFileDrop:
def test_simple_image_path(self, tmp_image):
result = _detect_file_drop(str(tmp_image))
assert result is not None
assert result["path"] == tmp_image
assert result["is_image"] is True
assert result["remainder"] == ""
@pytest.mark.parametrize("ext", [".png", ".jpg", ".jpeg", ".gif", ".webp",
".bmp", ".tiff", ".tif", ".svg", ".ico"])
def test_all_image_extensions(self, tmp_path, ext):
img = tmp_path / f"test{ext}"
img.write_bytes(b"fake")
result = _detect_file_drop(str(img))
assert result is not None
assert result["is_image"] is True
# ---------------------------------------------------------------------------
# Tests: non-image file detection
# ---------------------------------------------------------------------------
class TestNonImageFileDrop:
def test_python_file(self, tmp_text):
result = _detect_file_drop(str(tmp_text))
assert result is not None
assert result["path"] == tmp_text
assert result["is_image"] is False
assert result["remainder"] == ""
# ---------------------------------------------------------------------------
# Tests: backslash-escaped spaces (macOS drag-and-drop)
# ---------------------------------------------------------------------------
class TestEscapedSpaces:
def test_escaped_spaces_in_path(self, tmp_image_with_spaces):
r"""macOS drags produce paths like /path/to/my\ file.png"""
escaped = str(tmp_image_with_spaces).replace(' ', '\\ ')
result = _detect_file_drop(escaped)
assert result is not None
assert result["path"] == tmp_image_with_spaces
assert result["is_image"] is True
def test_unquoted_spaces_in_path(self, tmp_image_with_spaces):
result = _detect_file_drop(str(tmp_image_with_spaces))
assert result is not None
assert result["path"] == tmp_image_with_spaces
assert result["is_image"] is True
assert result["remainder"] == ""
def test_mixed_escaped_and_literal_spaces_in_path(self, tmp_path):
img = tmp_path / "Screenshot 2026-04-21 at 1.04.43 PM.png"
img.write_bytes(b"\x89PNG\r\n\x1a\n")
mixed = str(img).replace("Screenshot ", "Screenshot\\ ").replace("2026-04-21 ", "2026-04-21\\ ").replace("at ", "at\\ ")
result = _detect_file_drop(mixed)
assert result is not None
assert result["path"] == img
assert result["is_image"] is True
assert result["remainder"] == ""
def test_tilde_prefixed_path(self, tmp_path, monkeypatch):
home = tmp_path / "home"
img = home / "storage" / "shared" / "Pictures" / "cat.png"
img.parent.mkdir(parents=True, exist_ok=True)
img.write_bytes(b"\x89PNG\r\n\x1a\n")
monkeypatch.setenv("HOME", str(home))
# ntpath.expanduser ignores HOME (Python 3.8+) — it wants USERPROFILE.
monkeypatch.setenv("USERPROFILE", str(home))
result = _detect_file_drop("~/storage/shared/Pictures/cat.png what is this?")
assert result is not None
assert result["path"] == img
assert result["is_image"] is True
assert result["remainder"] == "what is this?"
# ``windows_only`` rather than ``skipif(os.name != "nt")``: the Windows CI
# job selects ``-m windows_only``, so a bare skipif would leave this
# skipped on Linux AND unselected there — dead on every host.
@pytest.mark.windows_only
def test_windows_drive_letter_file_uri_drops_url_leading_slash(self, tmp_path):
image = tmp_path / "drive-uri.png"
image.write_bytes(b"\x89PNG\r\n\x1a\n")
uri = image.as_uri()
assert uri.startswith("file:///") and ":/" in uri
result = _detect_file_drop(uri)
assert result is not None
assert result["path"] == image
# ---------------------------------------------------------------------------
# Tests: edge cases
# ---------------------------------------------------------------------------
class TestEdgeCases:
def test_path_with_no_extension(self, tmp_path):
f = tmp_path / "Makefile"
f.write_text("all:\n\techo hi\n")
result = _detect_file_drop(str(f))
assert result is not None
assert result["is_image"] is False
def test_path_that_looks_like_command_but_is_file(self, tmp_path):
"""A file literally named 'help' inside a directory starting with /."""
f = tmp_path / "help"
f.write_text("not a command\n")
result = _detect_file_drop(str(f))
assert result is not None
assert result["is_image"] is False
+252
View File
@@ -0,0 +1,252 @@
"""First-run onboarding routing for a completely unconfigured install.
Regression tests for the "keyless first run boots into a broken chat" bug:
a fresh install with zero providers accepted a message, spun for ~30s, then
failed with a provider-specific error ("Set OPENROUTER_API_KEY") the user
never chose, and never offered setup.
Covers:
- ``_runtime_credentials_ready()`` silent probe semantics
- ``_offer_first_run_setup()`` routing into the shared provider picker
- the provider-aware (non-OpenRouter-specific) empty-key error message
"""
import importlib
import sys
import types
import pytest
from hermes_cli.auth import AuthError
def _reset_modules(prefixes: tuple[str, ...]):
for name in list(sys.modules):
if any(name == p or name.startswith(p + ".") for p in prefixes):
sys.modules.pop(name, None)
@pytest.fixture(autouse=True)
def _restore_cli_and_tool_modules():
prefixes = ("tools", "cli", "run_agent")
original_modules = {
name: module
for name, module in sys.modules.items()
if any(name == p or name.startswith(p + ".") for p in prefixes)
}
try:
yield
finally:
_reset_modules(prefixes)
sys.modules.update(original_modules)
def _import_cli():
for name in list(sys.modules):
if name == "cli" or name == "run_agent" or name == "tools" or name.startswith("tools."):
sys.modules.pop(name, None)
if "firecrawl" not in sys.modules:
sys.modules["firecrawl"] = types.SimpleNamespace(Firecrawl=object)
return importlib.import_module("cli")
def _make_shell(cli, monkeypatch):
shell = cli.HermesCLI(compact=True, max_turns=1)
return shell
# ---------------------------------------------------------------------------
# _runtime_credentials_ready
# ---------------------------------------------------------------------------
def test_credentials_ready_false_when_no_provider(monkeypatch):
cli = _import_cli()
def _raise(**kwargs):
raise AuthError("No inference provider configured.", code="no_provider_configured")
monkeypatch.setattr("hermes_cli.runtime_provider.resolve_runtime_provider", _raise)
shell = _make_shell(cli, monkeypatch)
assert shell._runtime_credentials_ready() is False
def test_credentials_ready_false_on_empty_openrouter_key(monkeypatch):
"""The exact broken-chat state: provider resolves but api_key is empty."""
cli = _import_cli()
monkeypatch.setattr(
"hermes_cli.runtime_provider.resolve_runtime_provider",
lambda **kw: {
"provider": "openrouter",
"api_key": "",
"base_url": "https://openrouter.ai/api/v1",
"source": "env/config",
},
)
shell = _make_shell(cli, monkeypatch)
assert shell._runtime_credentials_ready() is False
def test_credentials_ready_true_with_key(monkeypatch):
cli = _import_cli()
monkeypatch.setattr(
"hermes_cli.runtime_provider.resolve_runtime_provider",
lambda **kw: {
"provider": "openrouter",
"api_key": "sk-test",
"base_url": "https://openrouter.ai/api/v1",
"source": "env/config",
},
)
shell = _make_shell(cli, monkeypatch)
assert shell._runtime_credentials_ready() is True
def test_credentials_ready_true_for_keyless_local_endpoint(monkeypatch):
"""ollama/llama.cpp-style custom endpoints need no key."""
cli = _import_cli()
monkeypatch.setattr(
"hermes_cli.runtime_provider.resolve_runtime_provider",
lambda **kw: {
"provider": "custom",
"api_key": "",
"base_url": "http://localhost:11434/v1",
"source": "custom_provider",
},
)
shell = _make_shell(cli, monkeypatch)
assert shell._runtime_credentials_ready() is True
def test_credentials_ready_true_for_callable_bearer_provider(monkeypatch):
cli = _import_cli()
monkeypatch.setattr(
"hermes_cli.runtime_provider.resolve_runtime_provider",
lambda **kw: {
"provider": "azure-foundry",
"api_key": lambda: "tok",
"base_url": "https://foundry.example/v1",
"source": "entra",
},
)
shell = _make_shell(cli, monkeypatch)
assert shell._runtime_credentials_ready() is True
def test_credentials_ready_never_prints(monkeypatch, capsys):
cli = _import_cli()
def _raise(**kwargs):
raise AuthError("No inference provider configured.", code="no_provider_configured")
monkeypatch.setattr("hermes_cli.runtime_provider.resolve_runtime_provider", _raise)
shell = _make_shell(cli, monkeypatch)
capsys.readouterr() # drain construction output
shell._runtime_credentials_ready()
out = capsys.readouterr()
assert out.out == ""
# ---------------------------------------------------------------------------
# _offer_first_run_setup
# ---------------------------------------------------------------------------
def test_offer_first_run_setup_routes_into_shared_picker(monkeypatch):
cli = _import_cli()
shell = _make_shell(cli, monkeypatch)
picker_calls = {"count": 0}
def _fake_picker():
picker_calls["count"] += 1
monkeypatch.setattr("hermes_cli.main.select_provider_and_model", _fake_picker)
monkeypatch.setattr("builtins.input", lambda *a, **k: "y")
# After the picker "runs", config has a provider and creds resolve.
monkeypatch.setattr(
"hermes_cli.config.load_config",
lambda: {"model": {"provider": "nous", "default": "hermes-4-405b"}},
)
monkeypatch.setattr(
"hermes_cli.runtime_provider.resolve_runtime_provider",
lambda **kw: {
"provider": "nous",
"api_key": "portal-token",
"base_url": "https://inference-api.nousresearch.com/v1",
"source": "oauth",
},
)
assert shell._offer_first_run_setup() is True
assert picker_calls["count"] == 1
assert shell.requested_provider == "nous"
assert shell.model == "hermes-4-405b"
# Agent must be rebuilt with the new credentials on next use.
assert shell.agent is None
def test_offer_first_run_setup_declined(monkeypatch):
cli = _import_cli()
shell = _make_shell(cli, monkeypatch)
def _fail_picker():
raise AssertionError("picker must not run when declined")
monkeypatch.setattr("hermes_cli.main.select_provider_and_model", _fail_picker)
monkeypatch.setattr("builtins.input", lambda *a, **k: "n")
assert shell._offer_first_run_setup() is False
def test_offer_first_run_setup_picker_cancel_is_graceful(monkeypatch):
cli = _import_cli()
shell = _make_shell(cli, monkeypatch)
def _cancel_picker():
raise KeyboardInterrupt()
monkeypatch.setattr("hermes_cli.main.select_provider_and_model", _cancel_picker)
monkeypatch.setattr("builtins.input", lambda *a, **k: "")
# Empty answer defaults to yes -> picker runs -> cancels -> False, no raise.
assert shell._offer_first_run_setup() is False
# ---------------------------------------------------------------------------
# Provider-aware empty-key error (replaces the OpenRouter-specific one)
# ---------------------------------------------------------------------------
def test_empty_key_error_names_actual_provider(monkeypatch, capsys):
cli = _import_cli()
monkeypatch.setattr(
"hermes_cli.runtime_provider.resolve_runtime_provider",
lambda **kw: {
"provider": "fireworks",
"api_key": "",
"base_url": "https://api.fireworks.ai/inference/v1/extra",
"source": "env/config",
},
)
shell = _make_shell(cli, monkeypatch)
# A custom base_url would get the no-key placeholder; force the
# openrouter-shaped branch by pointing base_url at openrouter.
monkeypatch.setattr(
"hermes_cli.runtime_provider.resolve_runtime_provider",
lambda **kw: {
"provider": "fireworks",
"api_key": "",
"base_url": "https://openrouter.ai/api/v1",
"source": "env/config",
},
)
capsys.readouterr()
assert shell._ensure_runtime_credentials() is False
out = capsys.readouterr().out
assert "fireworks" in out
assert "OPENROUTER_API_KEY" not in out
assert "hermes model" in out or "hermes setup" in out
+428
View File
@@ -0,0 +1,428 @@
"""Tests for CLI redraw helpers used to recover from terminal buffer drift.
Covers:
- _force_full_redraw (#8688 cmux tab switch, /redraw, Ctrl+L)
- the resize handler we install over prompt_toolkit's _on_resize (#5474)
Both behaviors are exercised against fake prompt_toolkit renderer/output
objects — we're asserting the escape sequences the CLI sends, not that
the terminal physically repainted.
"""
from unittest.mock import MagicMock
import pytest
import cli as cli_mod
from cli import HermesCLI
@pytest.fixture
def bare_cli():
"""A HermesCLI with no __init__ — we only exercise the redraw helper."""
cli = object.__new__(HermesCLI)
return cli
class TestForceFullRedraw:
def test_no_app_is_safe(self, bare_cli):
# _force_full_redraw must be a no-op when the TUI isn't running.
bare_cli._app = None
bare_cli._force_full_redraw() # must not raise
def test_resize_recovery_clears_viewport_on_width_change(self, bare_cli, monkeypatch):
"""A WIDTH change must wipe the visible viewport (CSI 2J) and replay.
On column shrink the terminal reflows the old full-width chrome into
extra rows that prompt_toolkit's stale-cursor erase cannot reach,
leaving a duplicated status bar (#19280/#5474 class). We route through
the same recovery as Ctrl+L: erase_screen (2J) + replay transcript.
It must be banner-safe — CSI 3J (write_raw) must NOT fire.
"""
app = MagicMock()
events = []
app.renderer.output.erase_screen.side_effect = lambda: events.append("erase")
app.renderer.output.write_raw.side_effect = lambda *_: events.append("scrollback_wipe")
original_on_resize = lambda: events.append("original_resize")
bare_cli._status_bar_suppressed_after_resize = False
bare_cli._last_resize_width = 200
monkeypatch.setattr(bare_cli, "_get_tui_terminal_width", lambda: 90)
monkeypatch.setattr(bare_cli, "_schedule_status_bar_unsuppress", lambda *_: None)
monkeypatch.setattr(cli_mod, "_replay_output_history", lambda: events.append("replay"))
monkeypatch.setattr(
cli_mod,
"CLI_CONFIG",
{"display": {"cli_rebuild_scrollback_on_redraw": False}},
)
bare_cli._recover_after_resize(app, original_on_resize)
# Viewport cleared and transcript replayed BEFORE prompt_toolkit's resize.
assert "erase" in events
assert "replay" in events
assert events.index("erase") < events.index("original_resize")
# Banner-safe: scrollback (CSI 3J) must never be wiped on a resize.
assert "scrollback_wipe" not in events
# New width recorded for the next comparison.
assert bare_cli._last_resize_width == 90
assert bare_cli._status_bar_suppressed_after_resize is True
def test_force_redraw_uses_full_screen_clear_without_scrollback_clear(self, bare_cli, monkeypatch):
app = MagicMock()
bare_cli._app = app
monkeypatch.setattr(
cli_mod,
"CLI_CONFIG",
{"display": {"cli_rebuild_scrollback_on_redraw": False}},
)
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_force_redraw_can_clear_scrollback_when_configured(self, bare_cli, monkeypatch):
app = MagicMock()
bare_cli._app = app
monkeypatch.setattr(
cli_mod,
"CLI_CONFIG",
{"display": {"cli_rebuild_scrollback_on_redraw": True}},
)
bare_cli._force_full_redraw()
app.renderer.output.erase_screen.assert_called_once()
app.renderer.output.write_raw.assert_called_once_with("\x1b[3J")
def test_resize_recovery_can_clear_scrollback_when_configured(self, bare_cli, monkeypatch):
app = MagicMock()
events = []
app.renderer.output.erase_screen.side_effect = lambda: events.append("erase")
app.renderer.output.write_raw.side_effect = lambda *_: events.append("scrollback_wipe")
original_on_resize = lambda: events.append("original_resize")
bare_cli._status_bar_suppressed_after_resize = False
bare_cli._last_resize_width = 200
monkeypatch.setattr(bare_cli, "_get_tui_terminal_width", lambda: 90)
monkeypatch.setattr(bare_cli, "_schedule_status_bar_unsuppress", lambda *_: None)
monkeypatch.setattr(cli_mod, "_replay_output_history", lambda: events.append("replay"))
monkeypatch.setattr(
cli_mod,
"CLI_CONFIG",
{"display": {"cli_rebuild_scrollback_on_redraw": "true"}},
)
bare_cli._recover_after_resize(app, original_on_resize)
assert events[:3] == ["erase", "scrollback_wipe", "replay"]
assert events.index("scrollback_wipe") < events.index("original_resize")
def test_same_width_sigwinch_is_left_untouched(self, bare_cli, monkeypatch):
"""Same-width SIGWINCH (tmux attach, benign focus/tab signals) must not
clear the viewport or replay: a 2J without replay erases the visible
transcript, and a replay duplicates it (#65293). The tmux-attach
stale-paint crash is handled by _hermes_call_output_screen_diff's
retry instead (#83874)."""
app = MagicMock()
events = []
app.renderer.output.erase_screen.side_effect = lambda: events.append("erase")
app.renderer.output.write_raw.side_effect = lambda *_: events.append("scrollback_wipe")
original_on_resize = lambda: events.append("original_resize")
bare_cli._status_bar_suppressed_after_resize = False
bare_cli._last_resize_width = 120
monkeypatch.setattr(bare_cli, "_get_tui_terminal_width", lambda: 120)
monkeypatch.setattr(bare_cli, "_schedule_status_bar_unsuppress", lambda *_: None)
monkeypatch.setattr(cli_mod, "_replay_output_history", lambda: events.append("replay"))
bare_cli._recover_after_resize(app, original_on_resize)
assert "erase" not in events
assert "replay" not in events
assert "scrollback_wipe" not in events
assert events == ["original_resize"]
assert bare_cli._last_resize_width == 120
assert bare_cli._status_bar_suppressed_after_resize is True
def test_output_screen_diff_retries_on_corrupt_previous_screen(self, bare_cli):
"""Corrupt previous_screen must not wedge the paint loop.
After tmux attach, _output_screen_diff can raise AttributeError
('cell' object has no attribute 'char'). Retry with previous_screen=None.
"""
calls = []
def fake_osd(
app, output, screen, current_pos, color_depth,
previous_screen, last_style, is_done, full_screen,
attrs_for_style_string, style_string_has_style,
size, previous_width,
):
calls.append((previous_screen, previous_width, last_style))
if previous_screen is not None:
# Exact failure mode from the classic CLI event loop.
raise AttributeError("'cell' object has no attribute 'char'")
return ("ok", current_pos, last_style)
screen = MagicMock()
screen.height = 10
previous = MagicMock()
previous.height = 8
result = cli_mod._hermes_call_output_screen_diff(
fake_osd,
app=None,
output=None,
screen=screen,
current_pos=None,
color_depth=None,
previous_screen=previous,
last_style="style",
is_done=False,
full_screen=False,
attrs_for_style_string=None,
style_string_has_style=None,
size=None,
previous_width=80,
)
assert result[0] == "ok"
assert len(calls) == 2
assert calls[0][0] is previous
assert previous.height == 10 # height inflate still applied first
assert calls[1] == (None, 0, None)
def test_resize_recovery_is_debounced(self, bare_cli, monkeypatch):
timers = []
calls = []
class FakeTimer:
def __init__(self, delay, callback):
self.delay = delay
self.callback = callback
self.cancelled = False
self.daemon = False
timers.append(self)
def start(self):
calls.append(("start", self.delay))
def cancel(self):
self.cancelled = True
calls.append(("cancel", self.delay))
def fire(self):
self.callback()
app = MagicMock()
app.loop.call_soon_threadsafe.side_effect = lambda cb: cb()
monkeypatch.setattr(cli_mod.threading, "Timer", FakeTimer)
monkeypatch.setattr(
bare_cli,
"_recover_after_resize",
lambda _app, _orig: calls.append(("recover", _orig())),
)
original_one = lambda: "first"
original_two = lambda: "second"
bare_cli._schedule_resize_recovery(app, original_one, delay=0.25)
assert bare_cli._resize_recovery_pending is True
bare_cli._schedule_resize_recovery(app, original_two, delay=0.25)
assert len(timers) == 2
assert timers[0].cancelled is True
timers[0].fire()
assert ("recover", "first") not in calls
timers[1].fire()
assert ("recover", "second") in calls
assert bare_cli._resize_recovery_pending is False
def test_invalidate_is_suppressed_while_resize_recovery_is_pending(self, bare_cli):
app = MagicMock()
bare_cli._app = app
bare_cli._last_invalidate = 0.0
bare_cli._resize_recovery_pending = True
bare_cli._invalidate(min_interval=0)
app.invalidate.assert_not_called()
def test_swallows_renderer_exceptions(self, bare_cli):
# If the renderer blows up for any reason, the helper must not
# propagate — otherwise a stray Ctrl+L would crash the CLI.
app = MagicMock()
app.renderer.output.erase_screen.side_effect = RuntimeError("boom")
bare_cli._app = app
bare_cli._force_full_redraw() # must not raise
# invalidate() is still attempted after a renderer failure.
app.invalidate.assert_called_once()
def test_swallows_invalidate_exceptions(self, bare_cli):
app = MagicMock()
app.invalidate.side_effect = RuntimeError("boom")
bare_cli._app = app
bare_cli._force_full_redraw() # must not raise
class TestFirstSigwinchBaseline:
"""Bug #65293: the session's FIRST SIGWINCH used to be force-treated as a
width change (no prior width to compare against), so a benign resize
signal — GNOME Terminal tab bar appearing, monitor-scale change, focus
events — cleared the viewport and replayed ``_OUTPUT_HISTORY``. After a
resume that deque holds the whole "Previous Conversation" recap plus the
first live exchange, so everything reprinted as a duplicate. A replay
must require an OBSERVED width change against a recorded baseline.
"""
def test_first_sigwinch_with_unchanged_width_does_not_replay(
self, bare_cli, monkeypatch
):
app = MagicMock()
events = []
app.renderer.output.erase_screen.side_effect = lambda: events.append("erase")
original_on_resize = lambda: events.append("original_resize")
bare_cli._status_bar_suppressed_after_resize = False
# No baseline recorded yet — the pre-fix code forced width_changed=True.
assert getattr(bare_cli, "_last_resize_width", None) is None
monkeypatch.setattr(bare_cli, "_get_tui_terminal_width", lambda: 120)
monkeypatch.setattr(bare_cli, "_schedule_status_bar_unsuppress", lambda *_: None)
monkeypatch.setattr(
cli_mod, "_replay_output_history", lambda: events.append("replay")
)
bare_cli._recover_after_resize(app, original_on_resize)
# Width did not change — no clear, no replay, straight to prompt_toolkit.
assert events == ["original_resize"]
app.renderer.output.erase_screen.assert_not_called()
# The signal still records the baseline for the next comparison.
assert bare_cli._last_resize_width == 120
def test_real_width_change_after_baseline_still_replays(
self, bare_cli, monkeypatch
):
"""The #49120 recovery (2J + replay) must still fire on a real change."""
app = MagicMock()
events = []
app.renderer.output.erase_screen.side_effect = lambda: events.append("erase")
original_on_resize = lambda: events.append("original_resize")
bare_cli._status_bar_suppressed_after_resize = False
bare_cli._last_resize_width = 120
monkeypatch.setattr(bare_cli, "_get_tui_terminal_width", lambda: 90)
monkeypatch.setattr(bare_cli, "_schedule_status_bar_unsuppress", lambda *_: None)
monkeypatch.setattr(
cli_mod, "_replay_output_history", lambda: events.append("replay")
)
bare_cli._recover_after_resize(app, original_on_resize)
assert "erase" in events and "replay" in events
assert bare_cli._last_resize_width == 90
def test_install_resize_recovery_seeds_width_baseline(self, bare_cli):
"""Hook installation records the CURRENT width as the baseline, so an
initial maximize/restore (a real change vs that baseline) is still
recovered while a same-size first signal is not.
The baseline must come from ``app.output`` — the same object the
running app measures on SIGWINCH — not from ``get_app()``, which
before ``app.run()`` is a DummyApplication reporting a fake 80 cols.
"""
app = MagicMock()
app.output.get_size.return_value.columns = 132
scheduled = []
bare_cli._schedule_resize_recovery = lambda *a, **k: scheduled.append(a)
original = app._on_resize
bare_cli._install_resize_recovery(app)
assert bare_cli._last_resize_width == 132
assert app._on_resize is not original # hook installed
app._on_resize() # simulated SIGWINCH → routes to the debouncer
assert len(scheduled) == 1
assert scheduled[0][0] is app
assert scheduled[0][1] is original
def test_install_resize_recovery_falls_back_to_shutil(
self, bare_cli, monkeypatch
):
"""A dead app.output probe falls back to shutil, never to the
DummyApplication's fake width."""
import os as os_mod
app = MagicMock()
app.output.get_size.side_effect = RuntimeError("not attached")
monkeypatch.setattr(
cli_mod.shutil,
"get_terminal_size",
lambda _default: os_mod.terminal_size((97, 40)),
)
bare_cli._install_resize_recovery(app)
assert bare_cli._last_resize_width == 97
def test_install_resize_recovery_survives_width_probe_failure(
self, bare_cli, monkeypatch
):
app = MagicMock()
app.output.get_size.side_effect = RuntimeError("not attached")
def _boom(_default):
raise RuntimeError("no tty")
monkeypatch.setattr(cli_mod.shutil, "get_terminal_size", _boom)
bare_cli._install_resize_recovery(app) # must not raise
assert getattr(bare_cli, "_last_resize_width", None) is None
class TestFocusRegainRedraw:
"""Focus-in (CSI I) routes through the same recovery as Ctrl+L, rate-limited.
While the tab/window is hidden the emulator may coalesce output or repaint
the surface; on regain prompt_toolkit's incremental diff stacks a fresh
copy of the prompt chrome on top of the stale one (#60920 focus-regain
variant, #25337).
"""
def test_focus_regain_triggers_full_redraw(self, bare_cli):
calls = []
bare_cli._force_full_redraw = lambda: calls.append("redraw")
bare_cli._schedule_focus_regain_redraw()
assert calls == ["redraw"]
def test_focus_regain_redraw_is_rate_limited(self, bare_cli):
calls = []
bare_cli._force_full_redraw = lambda: calls.append("redraw")
bare_cli._schedule_focus_regain_redraw(min_interval=60.0)
bare_cli._schedule_focus_regain_redraw(min_interval=60.0)
bare_cli._schedule_focus_regain_redraw(min_interval=60.0)
assert calls == ["redraw"]
def test_focus_regain_redraw_fires_again_after_interval(self, bare_cli):
calls = []
bare_cli._force_full_redraw = lambda: calls.append("redraw")
bare_cli._schedule_focus_regain_redraw(min_interval=0.0)
bare_cli._schedule_focus_regain_redraw(min_interval=0.0)
assert calls == ["redraw", "redraw"]
+153
View File
@@ -0,0 +1,153 @@
"""Tests for CLI goal-continuation interrupt handling.
Covers:
- Ctrl+C during a /goal turn auto-pauses the goal (no more continuations).
- Empty/whitespace-only responses skip the judge (no phantom continuations).
- Clean response without interrupt still drives the judge + enqueues.
These tests exercise ``_maybe_continue_goal_after_turn`` directly on a
minimal ``HermesCLI`` stub (pattern used elsewhere in tests/cli).
"""
from __future__ import annotations
import queue
import uuid
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
# ──────────────────────────────────────────────────────────────────────
# Fixtures
# ──────────────────────────────────────────────────────────────────────
@pytest.fixture
def hermes_home(tmp_path, monkeypatch):
"""Isolated HERMES_HOME so SessionDB.state_meta writes stay hermetic."""
home = tmp_path / ".hermes"
home.mkdir()
monkeypatch.setattr(Path, "home", lambda: tmp_path)
monkeypatch.setenv("HERMES_HOME", str(home))
# Bust the goal module's DB cache so it re-resolves HERMES_HOME each test.
from hermes_cli import goals
goals._DB_CACHE.clear()
yield home
goals._DB_CACHE.clear()
def _make_cli_with_goal(session_id: str, goal_text: str = "build a thing"):
"""Build a minimal HermesCLI stub with an active goal wired in."""
from cli import HermesCLI
from hermes_cli.goals import GoalManager
cli = HermesCLI.__new__(HermesCLI)
# State the hook + helpers touch directly.
cli._pending_input = queue.Queue()
cli._last_turn_interrupted = False
cli.conversation_history = []
# `_get_goal_manager()` reads `self.session_id` directly, not
# `self.agent.session_id`. Match the production lookup.
cli.session_id = session_id
cli.agent = MagicMock()
cli.agent.session_id = session_id
mgr = GoalManager(session_id=session_id, default_max_turns=5)
mgr.set(goal_text)
cli._goal_manager = mgr
return cli, mgr
# ──────────────────────────────────────────────────────────────────────
# Tests
# ──────────────────────────────────────────────────────────────────────
class TestInterruptAutoPause:
def test_interrupted_turn_is_resumable(self, hermes_home):
"""After auto-pause from Ctrl+C, /goal resume puts it back to active."""
sid = f"sid-resume-{uuid.uuid4().hex}"
cli, mgr = _make_cli_with_goal(sid)
cli._last_turn_interrupted = True
cli.conversation_history = [
{"role": "assistant", "content": "partial"},
]
with patch("hermes_cli.goals.judge_goal"):
cli._maybe_continue_goal_after_turn()
assert mgr.state.status == "paused"
mgr.resume()
assert mgr.state.status == "active"
class TestHealthyTurnStillRuns:
def test_clean_response_enqueues_continuation_when_judge_says_continue(
self, hermes_home,
):
"""Sanity check: the hook still works in the happy path."""
sid = f"sid-healthy-{uuid.uuid4().hex}"
cli, mgr = _make_cli_with_goal(sid)
cli._last_turn_interrupted = False
cli.conversation_history = [
{"role": "user", "content": "go"},
{"role": "assistant", "content": "did some work, more to do"},
]
# Force the judge to say "continue" without touching the network.
with patch(
"hermes_cli.goals.judge_goal",
return_value=("continue", "needs more steps", False, None, False),
):
cli._maybe_continue_goal_after_turn()
# Continuation prompt must be queued.
assert not cli._pending_input.empty()
queued = cli._pending_input.get_nowait()
assert "Continuing toward your standing goal" in queued
assert mgr.state.status == "active"
def test_clean_response_marks_done_when_judge_says_done(self, hermes_home):
sid = f"sid-done-{uuid.uuid4().hex}"
cli, mgr = _make_cli_with_goal(sid)
cli._last_turn_interrupted = False
cli.conversation_history = [
{"role": "assistant", "content": "all finished, here's the result"},
]
with patch(
"hermes_cli.goals.judge_goal",
return_value=("done", "goal satisfied", False, None, False),
):
cli._maybe_continue_goal_after_turn()
assert cli._pending_input.empty()
assert mgr.state.status == "done"
class TestInterruptFlagLifecycle:
def test_chat_resets_flag_at_entry(self, hermes_home):
"""chat() must reset _last_turn_interrupted at the top of each turn.
This guards against stale flag state: if turn N was interrupted and
turn N+1 runs clean, the hook must not see True from N.
"""
# We can't run chat() end-to-end here, but we can assert the reset
# is the first thing after the secret-capture registration by
# inspecting the source shape.
from cli import HermesCLI
import inspect
src = inspect.getsource(HermesCLI.chat)
# Look for an explicit reset near the top of chat().
head = src.split("if not self._ensure_runtime_credentials", 1)[0]
assert "self._last_turn_interrupted = False" in head, (
"chat() must reset _last_turn_interrupted before run_conversation "
"runs — otherwise a prior turn's interrupt state leaks into the "
"next turn's goal hook decision."
)
+69
View File
@@ -0,0 +1,69 @@
"""`/goal <text>` kicks the loop with a short pointer when the user's last message already carries the goal.
In one run `/goal <2,000-char handoff note>` was issued 16 minutes after the same note had been pasted as
a user message; the kickoff re-sent it and the agent spent 11 API calls / 6 min deciding it was a replay.
"""
import queue
from hermes_cli.cli_commands_mixin import CLICommandsMixin
from hermes_cli.goals import GOAL_ALREADY_SEEN_KICK
def _cli(history):
cli = CLICommandsMixin.__new__(CLICommandsMixin)
cli.conversation_history = history
cli._pending_input = queue.Queue()
from hermes_cli.goals import GoalManager
cli._get_goal_manager = lambda: GoalManager("kick-test")
return cli
def _kick(cli, text):
cli._handle_goal_command("/goal " + text)
return cli._pending_input.get_nowait()
HANDOFF = "HANDOFF: resume round 3 integration.\n" + "\n".join(f" - step {i}: merge r3-{i} and run its targeted suite, then the full suite" for i in range(8))
def test_goal_that_the_user_just_pasted_kicks_with_a_pointer_not_the_text():
cli = _cli([{"role": "user", "content": HANDOFF + "\n\nGo."},
{"role": "assistant", "content": "ok"}])
assert _kick(cli, HANDOFF) == GOAL_ALREADY_SEEN_KICK
# block-style content is handled too
cli = _cli([{"role": "user", "content": [{"type": "text", "text": HANDOFF}]}])
assert _kick(cli, HANDOFF) == GOAL_ALREADY_SEEN_KICK
def test_a_new_goal_or_a_goal_from_an_older_turn_is_kicked_verbatim():
assert _kick(_cli([]), "Ship the release") == "Ship the release"
cli = _cli([{"role": "user", "content": "Something unrelated"}])
assert _kick(cli, HANDOFF) == " ".join(HANDOFF.split())
# only the LAST user message counts: the agent has moved on since an older paste
cli = _cli([{"role": "user", "content": HANDOFF}, {"role": "assistant", "content": "done"},
{"role": "user", "content": "now something else"}])
assert _kick(cli, HANDOFF) == " ".join(HANDOFF.split())
def test_a_short_goal_that_selects_one_option_from_the_last_message_is_kicked_verbatim():
"""Independent-review witness: after a message offering API or UI work, `/goal ship the API` and
`/goal ship the UI` produced identical kickoffs. A goal that is a fragment of the last message
carries the selection; only a near-whole re-paste is replaced by the pointer."""
offer = "I can either ship the API or ship the UI next; which do you want? " * 8
cli = _cli([{"role": "user", "content": offer}])
assert _kick(cli, "ship the API") == "ship the API"
assert _kick(cli, "ship the UI") == "ship the UI"
# a long goal that is only a minority of a much longer message is also kept verbatim
long_goal = "x" * 500
cli = _cli([{"role": "user", "content": long_goal + " " + "y" * 2000}])
assert _kick(cli, long_goal) == long_goal
def test_gateway_and_tui_surfaces_use_the_same_rule(tmp_path, monkeypatch):
"""Independent review: other surfaces still duplicated the full goal. One shared function now."""
from hermes_cli import goals
long_goal = "HANDOFF " + "step; " * 120
assert goals.goal_kick_prompt(long_goal, long_goal) == goals.GOAL_ALREADY_SEEN_KICK
assert goals.goal_kick_prompt("ship the API", "ship the API or ship the UI? " * 8) == "ship the API"
# DB-backed lookup fails safe to "" (goal kicked verbatim) when no session/db
assert goals.last_user_message_from_db(None) == ""
+58
View File
@@ -0,0 +1,58 @@
"""A parked /goal resumes from the idle hook once its barrier lifts, without waiting for another turn."""
import queue
import time
from unittest.mock import patch
import pytest
from hermes_cli import goals
from hermes_cli.cli_loops_mixin import CLILoopsMixin
@pytest.fixture
def hermes_home(tmp_path, monkeypatch):
from pathlib import Path
home = tmp_path / ".hermes"; home.mkdir()
monkeypatch.setattr(Path, "home", lambda: tmp_path)
monkeypatch.setenv("HERMES_HOME", str(home))
goals._DB_CACHE.clear()
yield home
goals._DB_CACHE.clear()
class _Cli(CLILoopsMixin):
def __init__(self, mgr):
self._pending_input = queue.Queue()
self._mgr = mgr
def _get_goal_manager(self):
return self._mgr
def test_idle_hook_queues_the_continuation_when_a_timed_barrier_has_elapsed(hermes_home):
mgr = goals.GoalManager(session_id="resume-idle")
mgr.set("finish the thing")
mgr.wait_for_seconds(1, reason="cooldown")
cli = _Cli(mgr)
with patch("cli._cprint"), patch("cli._DIM", ""), patch("cli._RST", ""):
cli._maybe_resume_parked_goal()
assert cli._pending_input.empty() # still parked
mgr.state.waiting_until = time.time() - 1
mgr._save()
cli._last_goal_barrier_check = 0.0
cli._maybe_resume_parked_goal()
assert not cli._pending_input.empty() # continuation queued
assert "finish the thing" in cli._pending_input.get()
assert mgr.state.waiting_until == 0.0 # barrier cleared
def test_idle_hook_is_a_no_op_for_an_unparked_or_inactive_goal(hermes_home):
mgr = goals.GoalManager(session_id="resume-noop")
mgr.set("g")
cli = _Cli(mgr)
cli._maybe_resume_parked_goal()
assert cli._pending_input.empty()
mgr.clear()
cli._last_goal_barrier_check = 0.0
cli._maybe_resume_parked_goal()
assert cli._pending_input.empty()
+89
View File
@@ -0,0 +1,89 @@
from pathlib import Path
from unittest.mock import patch
from cli import (
HermesCLI,
_collect_query_images,
_format_image_attachment_badges,
_termux_example_image_path,
)
def _make_cli():
cli_obj = HermesCLI.__new__(HermesCLI)
cli_obj._attached_images = []
return cli_obj
def _make_image(path: Path) -> Path:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(b"\x89PNG\r\n\x1a\n")
return path
class TestImageCommand:
def test_handle_image_command_attaches_local_image(self, tmp_path):
img = _make_image(tmp_path / "photo.png")
cli_obj = _make_cli()
with patch("cli._cprint"):
cli_obj._handle_image_command(f"/image {img}")
assert cli_obj._attached_images == [img]
def test_handle_image_command_rejects_non_image_file(self, tmp_path):
file_path = tmp_path / "notes.txt"
file_path.write_text("hello\n", encoding="utf-8")
cli_obj = _make_cli()
with patch("cli._cprint") as mock_print:
cli_obj._handle_image_command(f"/image {file_path}")
assert cli_obj._attached_images == []
rendered = " ".join(str(arg) for call in mock_print.call_args_list for arg in call.args)
assert "Not a supported image file" in rendered
class TestCollectQueryImages:
def test_collect_query_images_accepts_explicit_image_arg(self, tmp_path):
img = _make_image(tmp_path / "diagram.png")
message, images = _collect_query_images("describe this", str(img))
assert message == "describe this"
assert images == [img]
def test_collect_query_images_supports_tilde_paths(self, tmp_path, monkeypatch):
home = tmp_path / "home"
img = _make_image(home / "storage" / "shared" / "Pictures" / "cat.png")
monkeypatch.setenv("HOME", str(home))
# ntpath.expanduser ignores HOME (Python 3.8+) — it wants USERPROFILE.
monkeypatch.setenv("USERPROFILE", str(home))
message, images = _collect_query_images("describe this", "~/storage/shared/Pictures/cat.png")
assert message == "describe this"
assert images == [img]
class TestTermuxImageHints:
def test_termux_example_image_path_prefers_real_shared_storage_root(self, monkeypatch):
existing = {"/sdcard", "/storage/emulated/0"}
monkeypatch.setattr("cli.os.path.isdir", lambda path: path in existing)
hint = _termux_example_image_path()
assert hint == "/sdcard/Pictures/cat.png"
class TestImageBadgeFormatting:
def test_compact_badges_use_filename_on_narrow_terminals(self, tmp_path):
img = _make_image(tmp_path / "Screenshot 2026-04-09 at 11.22.33 AM.png")
badges = _format_image_attachment_badges([img], image_counter=1, width=40)
assert badges.startswith("[📎 ")
assert "Image #1" not in badges
+716
View File
@@ -0,0 +1,716 @@
"""Tests for HermesCLI initialization -- catches configuration bugs
that only manifest at runtime (not in mocked unit tests)."""
import os
import sys
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
import pytest
def _make_cli(env_overrides=None, config_overrides=None, **kwargs):
"""Create a HermesCLI instance with minimal mocking."""
import importlib
_clean_config = {
"model": {
"default": "anthropic/claude-opus-4.6",
"base_url": "https://openrouter.ai/api/v1",
"provider": "auto",
},
"display": {"compact": False, "tool_progress": "all"},
"agent": {},
"terminal": {"env_type": "local"},
}
if config_overrides:
_clean_config.update(config_overrides)
clean_env = {"LLM_MODEL": "", "HERMES_MAX_ITERATIONS": ""}
if env_overrides:
clean_env.update(env_overrides)
prompt_toolkit_stubs = {
"prompt_toolkit": MagicMock(),
"prompt_toolkit.history": MagicMock(),
"prompt_toolkit.styles": MagicMock(),
"prompt_toolkit.patch_stdout": MagicMock(),
"prompt_toolkit.application": MagicMock(),
"prompt_toolkit.layout": MagicMock(),
"prompt_toolkit.layout.processors": MagicMock(),
"prompt_toolkit.filters": MagicMock(),
"prompt_toolkit.layout.dimension": MagicMock(),
"prompt_toolkit.layout.menus": MagicMock(),
"prompt_toolkit.widgets": MagicMock(),
"prompt_toolkit.key_binding": MagicMock(),
"prompt_toolkit.completion": MagicMock(),
"prompt_toolkit.formatted_text": MagicMock(),
"prompt_toolkit.auto_suggest": MagicMock(),
}
try:
with patch.dict(sys.modules, prompt_toolkit_stubs), \
patch.dict("os.environ", clean_env, clear=False):
import cli as _cli_mod
_cli_mod = importlib.reload(_cli_mod)
with patch.object(_cli_mod, "get_tool_definitions", return_value=[]), \
patch.dict(_cli_mod.__dict__, {"CLI_CONFIG": _clean_config}):
return _cli_mod.HermesCLI(**kwargs)
finally:
# The reload above re-executed cli.py while prompt_toolkit was stubbed
# with MagicMocks, permanently rebinding cli's module globals
# (``_pt_print``, ``_PT_ANSI``, …) to those mocks. ``patch.dict``
# restores ``sys.modules`` on exit, but NOT the names the reloaded
# module already bound — so ``sys.modules["cli"]`` is left with a
# mock ``_pt_print``, and ``cli._cprint`` then silently no-ops for
# every later test (one half of the order-dependent
# ``test_resume_quiet_stderr`` full-suite failure; the other half is
# the prompt_toolkit output cache reset in this dir's conftest).
# Reload once more with the real modules visible so cli's globals
# rebind cleanly.
import cli as _cli_restore
importlib.reload(_cli_restore)
class TestMaxTurnsResolution:
"""max_turns must always resolve to a positive integer, never None."""
def test_default_max_turns_is_unlimited(self):
# Default is now unlimited (max_turns caused more problems than it
# solved). Still a positive int (the sys.maxsize sentinel), so loop
# conditions like `count < max_iterations` keep working.
import sys
cli = _make_cli()
assert isinstance(cli.max_turns, int)
assert cli.max_turns == sys.maxsize
def test_explicit_max_turns_honored(self):
cli = _make_cli(max_turns=25)
assert cli.max_turns == 25
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
class TestVerboseAndToolProgress:
def test_default_verbose_is_bool(self):
cli = _make_cli()
assert isinstance(cli.verbose, bool)
def test_tool_progress_mode_is_string(self):
cli = _make_cli()
assert isinstance(cli.tool_progress_mode, str)
assert cli.tool_progress_mode in {"off", "new", "all", "verbose"}
class TestFallbackChainInit:
def test_merges_new_and_legacy_fallback_config(self):
cli = _make_cli(config_overrides={
"fallback_providers": [
{"provider": "openrouter", "model": "anthropic/claude-sonnet-4.6"},
],
"fallback_model": {"provider": "nous", "model": "Hermes-4"},
})
assert cli._fallback_model == [
{"provider": "openrouter", "model": "anthropic/claude-sonnet-4.6"},
{"provider": "nous", "model": "Hermes-4"},
]
class TestBusyInputMode:
def test_default_busy_input_mode_is_interrupt(self):
cli = _make_cli()
assert cli.busy_input_mode == "interrupt"
def test_busy_input_mode_queue_is_honored(self):
cli = _make_cli(config_overrides={"display": {"busy_input_mode": "queue"}})
assert cli.busy_input_mode == "queue"
def test_queue_command_works_while_busy(self):
"""When agent is running, /queue should still put the prompt in _pending_input."""
cli = _make_cli()
cli._agent_running = True
cli.process_command("/queue follow up")
assert cli._pending_input.get_nowait() == "follow up"
def test_interrupt_mode_routes_busy_enter_to_interrupt(self):
"""In interrupt mode (default), Enter while busy goes to _interrupt_queue."""
cli = _make_cli()
cli._agent_running = True
text = "redirect"
if cli.busy_input_mode == "queue":
cli._pending_input.put(text)
else:
cli._interrupt_queue.put(text)
assert cli._interrupt_queue.get_nowait() == "redirect"
assert cli._pending_input.empty()
class TestPromptToolkitTerminalCompatibility:
def test_lf_enter_binding_respects_multiline_shortcuts(self):
"""Ctrl+J is reserved by default, with legacy LF-submit available as an opt-out.
Some thin POSIX PTYs deliver plain Enter as LF/c-j instead of CR/enter.
The default keeps c-j free for multiline input; disabling multiline
shortcuts restores c-j → submit on bare local POSIX terminals. Windows,
WSL, SSH sessions, Windows Terminal, and Ghostty always reserve c-j for
the Ctrl+Enter/Ctrl+J newline binding. See issue #22379.
The native-Windows arm of this behaviour is
``test_windows_leaves_ctrl_j_unbound`` below — it has to run on a real
Windows host, because ``_bind_prompt_submit_keys`` delegates to
``_preserve_ctrl_enter_newline()``, which short-circuits on
``sys.platform == "win32"``. Faking that here would assert the literal
in the ``if`` and nothing about how prompt_toolkit actually delivers
keys on a Windows console.
"""
import os as _os
from unittest.mock import patch as _patch
from prompt_toolkit.key_binding import KeyBindings
from cli import _bind_prompt_submit_keys
def submit_handler(event):
return None
# Default: Enter submits while c-j stays free for the newline binding.
# (Runs on the POSIX CI job; the native-Windows arm is the marked test
# below, so no sys.platform fake is needed here.)
with _patch.dict(_os.environ, {}, clear=True), \
_patch("builtins.open", side_effect=OSError("no /proc")):
kb = KeyBindings()
_bind_prompt_submit_keys(kb, submit_handler)
bindings = {tuple(key.value for key in binding.keys): binding.handler for binding in kb.bindings}
assert bindings[("c-m",)] is submit_handler
assert ("c-j",) not in bindings
# Legacy opt-out: bare POSIX LF/c-j submits for thin PTYs.
kb = KeyBindings()
_bind_prompt_submit_keys(
kb,
submit_handler,
multiline_shortcuts_enabled=False,
)
bindings = {tuple(key.value for key in binding.keys): binding.handler for binding in kb.bindings}
assert bindings[("c-m",)] is submit_handler
assert bindings[("c-j",)] is submit_handler
# POSIX over SSH: c-j stays free so Ctrl+Enter (sent as LF by
# Windows Terminal / Kitty / mintty over SSH) inserts a newline.
with _patch.dict(_os.environ, {"SSH_CONNECTION": "1.2.3.4 5 6.7.8.9 22"}, clear=True), \
_patch("builtins.open", side_effect=OSError("no /proc")):
kb = KeyBindings()
_bind_prompt_submit_keys(kb, submit_handler)
bindings = {tuple(key.value for key in binding.keys): binding.handler for binding in kb.bindings}
assert bindings[("c-m",)] is submit_handler
assert ("c-j",) not in bindings
# Ghostty through tmux: TERM_PROGRAM is tmux, but Ghostty exports a
# stable env marker. Keep c-j free so Ctrl+J inserts a newline.
with _patch.dict(_os.environ, {"TERM": "tmux-256color", "TERM_PROGRAM": "tmux", "GHOSTTY_RESOURCES_DIR": "/usr/share/ghostty"}, clear=True), \
_patch("builtins.open", side_effect=OSError("no /proc")):
kb = KeyBindings()
_bind_prompt_submit_keys(kb, submit_handler)
bindings = {tuple(key.value for key in binding.keys): binding.handler for binding in kb.bindings}
assert bindings[("c-m",)] is submit_handler
assert ("c-j",) not in bindings
@pytest.mark.windows_only
def test_windows_leaves_ctrl_j_unbound(self):
"""On native Windows only enter submits; c-j is free for the newline
binding added separately in the prompt setup."""
from prompt_toolkit.key_binding import KeyBindings
from cli import _bind_prompt_submit_keys
def submit_handler(event):
return None
kb = KeyBindings()
_bind_prompt_submit_keys(kb, submit_handler)
bindings = {tuple(key.value for key in binding.keys): binding.handler for binding in kb.bindings}
assert bindings[("c-m",)] is submit_handler
assert ("c-j",) not in bindings
def test_cpr_warning_callback_is_disabled(self):
from cli import _disable_prompt_toolkit_cpr_warning
renderer = SimpleNamespace(cpr_not_supported_callback=lambda: None)
app = SimpleNamespace(renderer=renderer)
_disable_prompt_toolkit_cpr_warning(app)
assert renderer.cpr_not_supported_callback is None
def test_cpr_gating_posix_suppresses_without_ssh(self, monkeypatch):
"""POSIX suppresses CPR without SSH.
The native-Windows arm (``_terminal_may_leak_cpr() is False``, plus
the ``PROMPT_TOOLKIT_NO_CPR`` override that outranks it) lives in
``tests/cli/test_cpr_local_leak.py`` under ``windows_only``, where it
runs against a real Windows console.
"""
from cli import _terminal_may_leak_cpr
for var in ("SSH_CONNECTION", "SSH_CLIENT", "SSH_TTY", "PROMPT_TOOLKIT_NO_CPR"):
monkeypatch.delenv(var, raising=False)
assert _terminal_may_leak_cpr() is True
monkeypatch.setenv("PROMPT_TOOLKIT_NO_CPR", "1")
assert _terminal_may_leak_cpr() is True
class TestSingleQueryState:
def test_voice_and_interrupt_state_initialized_before_run(self):
"""Single-query mode calls chat() without going through run()."""
cli = _make_cli()
assert cli._voice_tts is False
assert cli._voice_mode is False
assert cli._voice_tts_done.is_set()
assert hasattr(cli, "_interrupt_queue")
assert hasattr(cli, "_pending_input")
class TestHistoryDisplay:
def test_history_numbers_only_visible_messages_and_summarizes_tools(self, capsys):
cli = _make_cli()
cli.conversation_history = [
{"role": "system", "content": "system prompt"},
{"role": "user", "content": "Hello"},
{
"role": "assistant",
"content": None,
"tool_calls": [{"id": "call_1"}, {"id": "call_2"}],
},
{"role": "tool", "content": "tool output 1"},
{"role": "tool", "content": "tool output 2"},
{"role": "assistant", "content": "All set."},
{"role": "user", "content": "A" * 250},
]
cli.show_history()
output = capsys.readouterr().out
assert "[You #1]" in output
assert "[Hermes #2]" in output
assert "(requested 2 tool calls)" in output
assert "[Tools]" in output
assert "(2 tool messages hidden)" in output
assert "[Hermes #3]" in output
assert "[You #4]" in output
assert "[You #5]" not in output
assert "A" * 250 in output
assert "A" * 250 + "..." not in output
def test_resume_without_target_lists_recent_sessions(self, capsys):
cli = _make_cli()
cli.session_id = "current"
cli._session_db = MagicMock()
cli._session_db.list_sessions_rich.return_value = [
{
"id": "current",
"title": "Current",
"preview": "Current preview",
"last_active": 0,
},
{
"id": "20260401_201329_d85961",
"title": "Checking Running Hermes Agent",
"preview": "check running gateways for hermes agent",
"last_active": 0,
},
]
cli._handle_resume_command("/resume")
output = capsys.readouterr().out
assert "Recent sessions" in output
assert "Checking Running Hermes Agent" in output
assert "Use /resume" in output
assert "session title" in output
def test_sessions_command_no_args_lists_recent_sessions(self, capsys):
"""/sessions with no args prints the recent-sessions table (TUI parity).
Regression test: `sessions` was registered in the central command
registry and surfaced by /help and tab-completion, but the classic
CLI dispatcher had no elif branch for it, so the canonical name fell
through and printed `Unknown command: sessions`.
"""
cli = _make_cli()
cli.session_id = "current"
cli._session_db = MagicMock()
cli._session_db.list_sessions_rich.return_value = [
{
"id": "20260401_201329_d85961",
"title": "Checking Running Hermes Agent",
"preview": "check running gateways for hermes agent",
"last_active": 0,
},
]
# Drive it through the public dispatcher to also lock in the
# process_command wiring, not just the handler in isolation.
cli.process_command("/sessions")
output = capsys.readouterr().out
assert "Unknown command" not in output
assert "Recent sessions" in output
assert "Checking Running Hermes Agent" in output
assert "20260401_201329_d85961" in output
def test_sessions_with_target_delegates_to_resume(self):
"""/sessions <id_or_title> behaves identically to /resume <id_or_title>.
We intercept `_handle_resume_command` rather than the full resume
machinery (which would otherwise require simulating an entire session
switch). The contract under test is the dispatch wiring.
"""
cli = _make_cli()
with patch.object(cli, "_handle_resume_command") as mock_resume:
cli.process_command("/sessions Checking Running Hermes Agent")
mock_resume.assert_called_once_with(
"/resume Checking Running Hermes Agent"
)
class TestNestedDictModelDefaultPairing:
"""A dict-valued ``model.default`` must keep its nested provider paired.
``model.default: {provider: ..., model: ...}`` canonicalizes to the string
model AND the nested provider, so ``HermesCLI`` routes the model through
that provider instead of discarding it and falling back to the outer
merged ``model.provider`` (``"auto"`` — authoritative at runtime
resolution, which would route the model through the wrong active
provider).
"""
def test_nested_dict_default_keeps_provider_paired(self):
cli = _make_cli(config_overrides={
"model": {
"default": {"provider": "nous", "model": "nested-default-model"},
"provider": "auto",
},
})
assert cli.model == "nested-default-model"
assert cli.requested_provider == "nous"
assert cli.provider == "nous"
def test_nested_dict_model_alias_keeps_provider_paired(self):
cli = _make_cli(config_overrides={
"model": {
"model": {"provider": "openai", "model": "nested-alias-model"},
"provider": "auto",
},
})
assert cli.model == "nested-alias-model"
assert cli.requested_provider == "openai"
assert cli.provider == "openai"
def test_flat_string_default_still_uses_outer_provider(self):
cli = _make_cli(config_overrides={
"model": {
"default": "flat-default-model",
"provider": "auto",
},
})
assert cli.model == "flat-default-model"
assert cli.requested_provider == "auto"
assert cli.provider == "auto"
def test_nested_provider_does_not_override_explicit_provider_arg(self):
cli = _make_cli(
config_overrides={
"model": {
"default": {"provider": "nous", "model": "nested-default-model"},
"provider": "auto",
},
},
provider="anthropic",
)
assert cli.model == "nested-default-model"
assert cli.requested_provider == "anthropic"
assert cli.provider == "anthropic"
def test_whoami_command_is_dispatched_and_prints_cli_access(self, capsys):
"""/whoami is advertised in classic CLI help and must not fall through.
Regression test: the command existed in the shared registry, so it
appeared in /help and completion, but classic CLI dispatch lacked a
matching branch and printed `Unknown command: /whoami`.
"""
cli = _make_cli()
cli.process_command("/whoami")
output = capsys.readouterr().out
assert "Unknown command" not in output
assert "cli (local terminal)" in output
assert "Tier:" in output
assert "unrestricted" in output
assert "Slash commands: all available" in output
def test_provider_prefixed_startup_model_overrides_stale_provider(self):
cli = _make_cli(
config_overrides={
"model": {
"default": "anthropic/claude-opus-4.6",
"provider": "anthropic",
},
"providers": {
"nous": {
"base_url": "https://inference-api.nousresearch.com/v1",
},
},
},
model="nous/deepseek-v4-pro",
)
assert cli.model == "deepseek-v4-pro"
assert cli.requested_provider == "nous"
class TestRootLevelProviderOverride:
"""Root-level provider/base_url in config.yaml must NOT override model.provider."""
def test_model_provider_wins_over_root_provider(self, tmp_path, monkeypatch):
"""model.provider takes priority — root-level provider is only a fallback."""
import yaml
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
config_path = hermes_home / "config.yaml"
config_path.write_text(yaml.safe_dump({
"provider": "opencode-go", # stale root-level key
"model": {
"default": "google/gemini-3-flash-preview",
"provider": "openrouter", # correct canonical key
},
}))
import cli
monkeypatch.setattr(cli, "_hermes_home", hermes_home)
cfg = cli.load_cli_config()
assert cfg["model"]["provider"] == "openrouter"
def test_root_provider_used_as_fallback_when_model_provider_missing(self, tmp_path, monkeypatch):
"""Legacy root-level provider still populates model.provider in the CLI loader."""
import yaml
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
config_path = hermes_home / "config.yaml"
config_path.write_text(yaml.safe_dump({
"provider": "opencode-go", # stale root key
"model": {
"default": "google/gemini-3-flash-preview",
# no explicit model.provider — defaults provide "auto"
},
}))
import cli
monkeypatch.setattr(cli, "_hermes_home", hermes_home)
cfg = cli.load_cli_config()
assert cfg["model"]["provider"] == "opencode-go"
def test_root_base_url_used_as_fallback_when_model_base_url_missing(self, tmp_path, monkeypatch):
"""Legacy root-level base_url still populates model.base_url in the CLI loader."""
import yaml
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
config_path = hermes_home / "config.yaml"
config_path.write_text(yaml.safe_dump({
"base_url": "https://example.com/v1",
"model": {
"default": "google/gemini-3-flash-preview",
},
}))
import cli
monkeypatch.setattr(cli, "_hermes_home", hermes_home)
cfg = cli.load_cli_config()
assert cfg["model"]["base_url"] == "https://example.com/v1"
def test_terminal_vercel_runtime_bridged_to_env(self, tmp_path, monkeypatch):
"""Classic CLI must expose terminal.vercel_runtime to terminal_tool.py."""
import yaml
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
monkeypatch.delenv("TERMINAL_VERCEL_RUNTIME", raising=False)
config_path = hermes_home / "config.yaml"
config_path.write_text(yaml.safe_dump({
"terminal": {
"backend": "vercel_sandbox",
"vercel_runtime": "python3.13",
},
}))
import cli
monkeypatch.setattr(cli, "_hermes_home", hermes_home)
cfg = cli.load_cli_config()
assert cfg["terminal"]["vercel_runtime"] == "python3.13"
assert os.environ["TERMINAL_VERCEL_RUNTIME"] == "python3.13"
def test_normalize_root_model_keys_moves_to_model(self):
"""_normalize_root_model_keys migrates root keys into model section."""
from hermes_cli.config import _normalize_root_model_keys
config = {
"provider": "opencode-go",
"base_url": "https://example.com/v1",
"model": {
"default": "some-model",
},
}
result = _normalize_root_model_keys(config)
# Root keys removed
assert "provider" not in result
assert "base_url" not in result
# Migrated into model section
assert result["model"]["provider"] == "opencode-go"
assert result["model"]["base_url"] == "https://example.com/v1"
def test_normalize_root_model_keys_does_not_override_existing(self):
"""Existing model.provider is never overridden by root-level key."""
from hermes_cli.config import _normalize_root_model_keys
config = {
"provider": "stale-provider",
"model": {
"default": "some-model",
"provider": "correct-provider",
},
}
result = _normalize_root_model_keys(config)
assert result["model"]["provider"] == "correct-provider"
assert "provider" not in result # root key still cleaned up
# --- 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_alias_to_default(self):
"""model.model becomes model.default."""
from hermes_cli.config import _normalize_root_model_keys
result = _normalize_root_model_keys({"model": {"model": "via-model-key"}})
assert result["model"]["default"] == "via-model-key"
assert "model" not in result["model"]
def test_normalize_model_wins_over_name(self):
"""Precedence: model > name when both are aliases and default is empty."""
from hermes_cli.config import _normalize_root_model_keys
result = _normalize_root_model_keys({"model": {"model": "m-key", "name": "n-key"}})
assert result["model"]["default"] == "m-key"
assert "model" not in result["model"] and "name" not in result["model"]
# --- dict-valued model.default flattening (PR #83902 follow-up) --------
# ``model.default: {provider: ..., model: ...}`` must flatten into a string
# ``model.default`` plus ``model.provider`` at the load chokepoint so every
# reader (doctor, status, fallback picker, prompt-size, context-switch
# guard) sees plain strings instead of a nested dict that crashes
# ``.strip()``/``.lower()`` or routes the model through the wrong provider.
def test_nested_dict_default_flattens_model_and_provider(self):
"""dict model.default -> string default + provider, no outer provider set."""
from hermes_cli.config import _normalize_root_model_keys
result = _normalize_root_model_keys({
"model": {
"default": {"provider": "nous", "model": "nested-default-model"},
},
})
assert result["model"]["default"] == "nested-default-model"
assert result["model"]["provider"] == "nous"
def test_nested_dict_default_provider_wins_over_auto(self):
"""Nested provider replaces the merged default "auto"."""
from hermes_cli.config import _normalize_root_model_keys
result = _normalize_root_model_keys({
"model": {
"default": {"provider": "nous", "model": "nested-default-model"},
"provider": "auto",
},
})
assert result["model"]["default"] == "nested-default-model"
assert result["model"]["provider"] == "nous"
def test_nested_dict_default_never_overrides_explicit_provider(self):
"""An explicitly configured model.provider beats the nested provider."""
from hermes_cli.config import _normalize_root_model_keys
result = _normalize_root_model_keys({
"model": {
"default": {"provider": "nous", "model": "nested-default-model"},
"provider": "anthropic",
},
})
assert result["model"]["default"] == "nested-default-model"
assert result["model"]["provider"] == "anthropic"
def test_nested_dict_model_alias_flattens_to_default(self):
"""dict model.model alias also flattens (default > model > name)."""
from hermes_cli.config import _normalize_root_model_keys
result = _normalize_root_model_keys({
"model": {
"model": {"provider": "openai", "model": "nested-alias-model"},
},
})
assert result["model"]["default"] == "nested-alias-model"
assert result["model"]["provider"] == "openai"
assert "model" not in result["model"]
def test_flat_string_default_untouched(self):
"""Plain string defaults keep existing behavior exactly."""
from hermes_cli.config import _normalize_root_model_keys
result = _normalize_root_model_keys({
"model": {"default": "flat-default-model", "provider": "auto"},
})
assert result["model"]["default"] == "flat-default-model"
assert result["model"]["provider"] == "auto"
+55
View File
@@ -0,0 +1,55 @@
from unittest.mock import MagicMock, patch
from types import SimpleNamespace
from cli import HermesCLI
from hermes_cli.main_agent_cmds import cmd_insights
class _InsightsEngineStub:
calls = []
def __init__(self, db):
self.db = db
def generate(self, *, days=30, source=None):
self.calls.append({"days": days, "source": source})
return {"days": days, "source": source}
def format_terminal(self, report):
return f"days={report['days']} source={report['source']}"
def _run_show_insights(command: str):
cli_obj = HermesCLI.__new__(HermesCLI)
db = MagicMock()
_InsightsEngineStub.calls = []
with patch("hermes_state.SessionDB", return_value=db), \
patch("agent.insights.InsightsEngine", _InsightsEngineStub):
cli_obj._show_insights(command)
return _InsightsEngineStub.calls, db
def test_cli_insights_accepts_positional_days(capsys):
calls, db = _run_show_insights("/insights 7")
assert calls == [{"days": 7, "source": None}]
db.close.assert_called_once()
assert "days=7 source=None" in capsys.readouterr().out
def test_cli_insights_keeps_days_flag_and_source(capsys):
calls, db = _run_show_insights("/insights --days 14 --source discord")
assert calls == [{"days": 14, "source": "discord"}]
db.close.assert_called_once()
assert "days=14 source=discord" in capsys.readouterr().out
def test_subcommand_insights_closes_database_when_generation_fails(capsys):
db = MagicMock()
with patch("hermes_state.SessionDB", return_value=db), \
patch("agent.insights.InsightsEngine", side_effect=RuntimeError("boom")):
cmd_insights(SimpleNamespace(days=30, source=None))
db.close.assert_called_once()
assert "Error generating insights: boom" in capsys.readouterr().out
+538
View File
@@ -0,0 +1,538 @@
"""Regression tests for the CLI interrupt-acknowledgement race.
Symptom (user report, July 2026): interrupting an active turn is
unreliable — the interrupt message is sometimes "vacuumed into the void".
Root cause: ``HermesCLI.chat()`` fires ``agent.interrupt(msg)`` from its
monitor loop, but only re-queued the message when the turn RESULT carried
``interrupted=True``. Two races defeat that:
1. The agent thread passes its last ``_interrupt_requested`` check (or
finishes entirely) just before the interrupt lands — the turn
completes "normally", ``finalize_turn()`` never acknowledges the
interrupt, and the user's message was silently dropped.
2. Worse, when the interrupt lands *after* ``finalize_turn()``'s
``clear_interrupt()``, the stale ``_interrupt_requested`` flag
survives on the agent and instantly aborts the NEXT turn at its
first loop check.
The fix: when ``chat()`` consumed an ``interrupt_msg`` but the result
doesn't acknowledge the interrupt, re-queue the message as the next turn
and clear the stale agent flag (only when the agent thread has exited).
"""
from __future__ import annotations
import importlib
import queue
import sys
import threading
import time
import types
from unittest.mock import MagicMock, patch
def _make_cli():
"""Build a HermesCLI with prompt_toolkit stubbed (same pattern as
test_cli_interrupt_drain_regression.py)."""
_clean_config = {
"model": {
"default": "anthropic/claude-opus-4.6",
"base_url": "https://openrouter.ai/api/v1",
"provider": "auto",
},
"display": {"compact": False, "tool_progress": "all"},
"agent": {},
"terminal": {"env_type": "local"},
}
clean_env = {"LLM_MODEL": "", "HERMES_MAX_ITERATIONS": ""}
prompt_toolkit_stubs = {
"prompt_toolkit": MagicMock(),
"prompt_toolkit.history": MagicMock(),
"prompt_toolkit.styles": MagicMock(),
"prompt_toolkit.patch_stdout": MagicMock(),
"prompt_toolkit.application": MagicMock(),
"prompt_toolkit.layout": MagicMock(),
"prompt_toolkit.layout.processors": MagicMock(),
"prompt_toolkit.filters": MagicMock(),
"prompt_toolkit.layout.dimension": MagicMock(),
"prompt_toolkit.layout.menus": MagicMock(),
"prompt_toolkit.widgets": MagicMock(),
"prompt_toolkit.key_binding": MagicMock(),
"prompt_toolkit.completion": MagicMock(),
"prompt_toolkit.formatted_text": MagicMock(),
"prompt_toolkit.auto_suggest": MagicMock(),
}
with patch.dict(sys.modules, prompt_toolkit_stubs), patch.dict(
"os.environ", clean_env, clear=False
):
import cli as _cli_mod
_cli_mod = importlib.reload(_cli_mod)
with patch.object(_cli_mod, "get_tool_definitions", return_value=[]), patch.dict(
_cli_mod.__dict__, {"CLI_CONFIG": _clean_config}
):
return _cli_mod.HermesCLI()
class _StubAgent:
"""Agent whose turn completes WITHOUT acknowledging the interrupt."""
def __init__(self, session_id, turn_seconds=0.5):
self.session_id = session_id
self.turn_seconds = turn_seconds
self._interrupt_requested = False
self._interrupt_message = None
self._active_children = []
self.interrupt_calls = []
self.clear_calls = 0
self.max_iterations = 90
self.model = "test/model"
self.platform = "cli"
def run_conversation(self, **kwargs):
# Simulate a turn that finishes normally — it never observed the
# interrupt flag (raced past its last check).
time.sleep(self.turn_seconds)
return {
"final_response": "turn finished normally",
"messages": [
{"role": "user", "content": "original"},
{"role": "assistant", "content": "turn finished normally"},
],
"api_calls": 1,
"completed": True,
# NOTE: no "interrupted" key — the race means finalize_turn
# never saw the flag (or cleared it before it was re-set).
"partial": True, # skip auto-title thread in the test
# Skip the Rich Panel rendering path (crashes under the
# prompt_toolkit/skin mocks; irrelevant to this regression).
"response_previewed": True,
}
def interrupt(self, message=None):
self.interrupt_calls.append(message)
self._interrupt_requested = True
self._interrupt_message = message
def clear_interrupt(self):
self.clear_calls += 1
self._interrupt_requested = False
self._interrupt_message = None
def test_unacknowledged_interrupt_message_is_requeued_not_dropped():
cli = _make_cli()
agent = _StubAgent(cli.session_id)
cli.agent = agent
cli._interrupt_queue = queue.Queue()
cli._pending_input = queue.Queue()
cli._interrupt_queue.put("urgent new message")
with patch.object(cli, "_ensure_runtime_credentials", return_value=True), \
patch.object(cli, "_resolve_turn_agent_config", return_value={
"signature": cli._active_agent_route_signature,
"model": None, "runtime": None, "request_overrides": None,
}), \
patch.object(cli, "_init_agent", return_value=True):
cli.chat("original")
# The interrupt fired against the agent...
assert agent.interrupt_calls == ["urgent new message"]
# ...the turn result never acknowledged it, so the message must be
# re-queued as the next turn instead of dropped.
queued = []
while not cli._pending_input.empty():
queued.append(cli._pending_input.get_nowait())
assert any("urgent new message" in str(q) for q in queued), (
f"interrupt message was dropped; pending_input={queued!r}"
)
# ...and the stale flag must be cleared so the NEXT turn doesn't
# instantly self-abort at its first _interrupt_requested check.
assert agent._interrupt_requested is False
assert agent.clear_calls >= 1
def test_chat_persists_clean_input_when_a_queued_note_changes_api_message():
"""Queued notes remain API-local and preserve close-handoff marker identity."""
cli = _make_cli()
class _NoteAgent(_StubAgent):
def __init__(self, session_id):
super().__init__(session_id, turn_seconds=0)
self.captured = None
def run_conversation(self, **kwargs):
self.captured = kwargs
return {
"final_response": "done",
"messages": [{"role": "assistant", "content": "done"}],
"api_calls": 1,
"completed": True,
"partial": True,
"response_previewed": True,
}
agent = _NoteAgent(cli.session_id)
cli.agent = agent
cli._interrupt_queue = queue.Queue()
cli._pending_input = queue.Queue()
cli._pending_model_switch_note = "[MODEL SWITCH NOTE]"
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("clean prompt")
assert agent.captured is not None
assert agent.captured["user_message"] == "[MODEL SWITCH NOTE]\n\nclean prompt"
assert agent.captured["persist_user_message"] == "clean prompt"
def test_chat_preserves_clean_multimodal_input_when_note_changes_api_message():
"""A queued note forwards original native parts as the persistence override."""
cli = _make_cli()
class _NoteAgent(_StubAgent):
def __init__(self, session_id):
super().__init__(session_id, turn_seconds=0)
self.captured = None
def run_conversation(self, **kwargs):
self.captured = kwargs
return {
"final_response": "done",
"messages": [{"role": "assistant", "content": "done"}],
"api_calls": 1,
"completed": True,
"partial": True,
"response_previewed": True,
}
clean_parts = [
{"type": "text", "text": "Describe this screenshot"},
{"type": "image_url", "image_url": {"url": "data:image/png;base64,AAAA"}},
]
agent = _NoteAgent(cli.session_id)
cli.agent = agent
cli._interrupt_queue = queue.Queue()
cli._pending_input = queue.Queue()
cli._pending_model_switch_note = "[MODEL SWITCH NOTE]"
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(clean_parts)
assert agent.captured is not None
assert agent.captured["persist_user_message"] == clean_parts
assert agent.captured["persist_user_message"] is not agent.captured["user_message"]
api_parts = agent.captured["user_message"]
assert api_parts[0]["text"] == "[MODEL SWITCH NOTE]\n\nDescribe this screenshot"
assert api_parts[1] == clean_parts[1]
def test_chat_multimodal_note_persists_clean_input_once(tmp_path, monkeypatch):
"""The real CLI-to-agent path stores clean image parts, never the queued note."""
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")
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.provider = "test"
agent.base_url = ""
agent.api_key = ""
agent.api_mode = "chat_completions"
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._pending_cli_user_message = None
agent._session_persist_lock = threading.RLock()
agent._persist_user_message_idx = None
agent._persist_user_message_override = None
agent._persist_user_message_timestamp = None
agent._active_children = []
agent._interrupt_requested = False
clean_parts = [
{"type": "text", "text": "Describe this screenshot"},
{"type": "image_url", "image_url": {"url": "data:image/png;base64,AAAA"}},
]
captured = {}
def _realish_run(**kwargs):
captured.update(kwargs)
# Drive production turn setup and the real SQLite persistence seam,
# then return a normal CLI result without starting a provider loop.
from agent.turn_context import build_turn_context
agent.quiet_mode = True
agent.max_iterations = 1
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._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
context = build_turn_context(
agent,
kwargs["user_message"],
None,
kwargs["conversation_history"],
kwargs["task_id"],
None,
kwargs["persist_user_message"],
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 if isinstance(value, str) else "[multimodal test message]"
),
set_session_context=lambda _session_id: None,
set_current_write_origin=lambda _origin: None,
ra=lambda: types.SimpleNamespace(_set_interrupt=lambda *_args: None),
)
agent._apply_persist_user_message_override(context.messages)
agent._persist_session(context.messages, kwargs["conversation_history"])
return {
"final_response": "done",
"messages": context.messages + [{"role": "assistant", "content": "done"}],
"api_calls": 1,
"completed": True,
"partial": True,
"response_previewed": True,
}
agent.run_conversation = _realish_run
cli.agent = agent
cli._interrupt_queue = queue.Queue()
cli._pending_input = queue.Queue()
cli._pending_model_switch_note = "[MODEL SWITCH NOTE]"
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(clean_parts)
assert captured["persist_user_message"] == clean_parts
assert captured["user_message"][0]["text"] == "[MODEL SWITCH NOTE]\n\nDescribe this screenshot"
assert [m["content"] for m in db.get_messages_as_conversation(session_id)] == [
"Describe this screenshot\n[screenshot]"
]
def test_chat_clears_previous_turn_persistence_override_before_staging():
"""A close before the next worker starts cannot reuse a stale override."""
cli = _make_cli()
class _StagingAgent(_StubAgent):
def __init__(self, session_id):
super().__init__(session_id, turn_seconds=0)
self.staged_override = None
self.staged_message = None
self._session_messages = []
self._persist_user_message_idx = 7
self._persist_user_message_override = "previous clean prompt"
self._persist_user_message_timestamp = 123.0
def run_conversation(self, **kwargs):
self.staged_override = self._persist_user_message_override
self.staged_message = self._pending_cli_user_message
return {
"final_response": "done",
"messages": [{"role": "assistant", "content": "done"}],
"api_calls": 1,
"completed": True,
"partial": True,
"response_previewed": True,
}
agent = _StagingAgent(cli.session_id)
cli.agent = agent
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):
cli.chat("new prompt")
assert agent.staged_override is None
assert agent._persist_user_message_idx is None
assert agent._persist_user_message_timestamp is None
assert agent.staged_message["role"] == "user"
assert agent.staged_message["content"] == "new prompt"
assert isinstance(agent.staged_message["timestamp"], float)
def test_close_waits_for_atomic_cli_staging_before_snapshot(tmp_path, monkeypatch):
"""Close cannot retain the mutable pre-append history as its DB baseline."""
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"
# Deliberately distinct from CLI history: this is the normal pre-worker
# state that used to let close retain the wrong mutable baseline.
agent._session_messages = list(prefix)
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._pending_cli_user_message = None
agent._session_persist_lock = threading.RLock()
agent._persist_user_message_idx = None
agent._persist_user_message_override = None
agent._persist_user_message_timestamp = None
agent._active_children = []
agent._interrupt_requested = False
staging_entered = threading.Event()
release_staging = threading.Event()
run_entered = threading.Event()
release_run = threading.Event()
class _BlockingHistory(list):
def __init__(self, values):
super().__init__(values)
self._block_next_append = True
def append(self, value):
if self._block_next_append:
self._block_next_append = False
staging_entered.set()
assert release_staging.wait(timeout=5)
return super().append(value)
def _block_run(**_kwargs):
run_entered.set()
assert release_run.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 = _BlockingHistory(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 staging_entered.wait(timeout=5)
close_started = threading.Event()
close_finished = threading.Event()
def _close():
close_started.set()
cli._persist_active_session_before_close()
close_finished.set()
close_thread = threading.Thread(target=_close)
close_thread.start()
assert close_started.wait(timeout=5)
# The close snapshot must wait for the locked pending-pointer/history
# handoff; otherwise the subsequent append poisons its DB baseline.
assert not close_finished.wait(timeout=0.1)
release_staging.set()
assert run_entered.wait(timeout=5)
assert close_finished.wait(timeout=5)
release_run.set()
chat_thread.join(timeout=10)
close_thread.join(timeout=10)
assert not chat_thread.is_alive()
assert not close_thread.is_alive()
assert [m["content"] for m in db.get_messages_as_conversation(session_id)] == [
"old prompt",
"old answer",
"new prompt",
]
@@ -0,0 +1,138 @@
"""Regression test for #20271: classic-CLI hangs when messages typed during
an agent turn never leave ``_interrupt_queue``.
Background
----------
The CLI routes user input typed while ``_agent_running`` is True into
``_interrupt_queue`` (separate from ``_pending_input``) so that the explicit
interrupt path can opt to deliver them as a single combined "interrupt"
message. The explicit drain at the top of ``process_loop`` only fires when
``busy_input_mode == "interrupt"`` AND a ``pending_message`` was
acknowledged.
The original PR #17939 paired the paste-file TOCTOU fix with a separate
drain inside ``process_loop``'s ``finally`` block: any message left in
``_interrupt_queue`` after the agent's turn ends gets re-queued onto
``_pending_input``. The drain was split off in #17666 / #18760 as "worth
its own review" and never re-landed. v0.12.0 users hit a hang when typing
during a turn that completes naturally — the message sits in
``_interrupt_queue``, the next ``Enter`` re-routes input to the same
blocked queue, and the CLI looks frozen.
This test exercises the restored ``_drain_interrupt_queue_to_pending_input``
helper that ``process_loop`` now calls every turn. The integration into
``process_loop`` itself is not threaded here (it requires a real
prompt_toolkit app); the helper is unit-testable on its own and is the
load-bearing piece.
"""
from __future__ import annotations
import importlib
import queue
import sys
from unittest.mock import MagicMock, patch
def _make_cli():
"""Build a HermesCLI instance with prompt_toolkit stubbed out.
Mirrors the helper in ``test_cli_steer_busy_path.py``.
"""
_clean_config = {
"model": {
"default": "anthropic/claude-opus-4.6",
"base_url": "https://openrouter.ai/api/v1",
"provider": "auto",
},
"display": {"compact": False, "tool_progress": "all"},
"agent": {},
"terminal": {"env_type": "local"},
}
clean_env = {"LLM_MODEL": "", "HERMES_MAX_ITERATIONS": ""}
prompt_toolkit_stubs = {
"prompt_toolkit": MagicMock(),
"prompt_toolkit.history": MagicMock(),
"prompt_toolkit.styles": MagicMock(),
"prompt_toolkit.patch_stdout": MagicMock(),
"prompt_toolkit.application": MagicMock(),
"prompt_toolkit.layout": MagicMock(),
"prompt_toolkit.layout.processors": MagicMock(),
"prompt_toolkit.filters": MagicMock(),
"prompt_toolkit.layout.dimension": MagicMock(),
"prompt_toolkit.layout.menus": MagicMock(),
"prompt_toolkit.widgets": MagicMock(),
"prompt_toolkit.key_binding": MagicMock(),
"prompt_toolkit.completion": MagicMock(),
"prompt_toolkit.formatted_text": MagicMock(),
"prompt_toolkit.auto_suggest": MagicMock(),
}
with patch.dict(sys.modules, prompt_toolkit_stubs), patch.dict(
"os.environ", clean_env, clear=False
):
import cli as _cli_mod
_cli_mod = importlib.reload(_cli_mod)
with patch.object(_cli_mod, "get_tool_definitions", return_value=[]), patch.dict(
_cli_mod.__dict__, {"CLI_CONFIG": _clean_config}
):
return _cli_mod.HermesCLI()
class TestInterruptQueueDrain:
"""``_drain_interrupt_queue_to_pending_input`` re-queues stray messages."""
def test_drains_single_pending_message_into_pending_input(self):
cli = _make_cli()
cli._interrupt_queue.put("typed during agent turn")
cli._drain_interrupt_queue_to_pending_input()
assert cli._interrupt_queue.empty()
assert cli._pending_input.qsize() == 1
assert cli._pending_input.get_nowait() == "typed during agent turn"
def test_preserves_order_when_draining_multiple_messages(self):
cli = _make_cli()
for msg in ("first", "second", "third"):
cli._interrupt_queue.put(msg)
cli._drain_interrupt_queue_to_pending_input()
assert cli._interrupt_queue.empty()
drained = []
while not cli._pending_input.empty():
drained.append(cli._pending_input.get_nowait())
assert drained == ["first", "second", "third"]
def test_noop_when_interrupt_queue_is_empty(self):
cli = _make_cli()
cli._drain_interrupt_queue_to_pending_input()
assert cli._interrupt_queue.empty()
assert cli._pending_input.empty()
def test_skips_falsy_messages(self):
cli = _make_cli()
cli._interrupt_queue.put("")
cli._interrupt_queue.put(None)
cli._interrupt_queue.put("real")
cli._drain_interrupt_queue_to_pending_input()
assert cli._interrupt_queue.empty()
assert cli._pending_input.qsize() == 1
assert cli._pending_input.get_nowait() == "real"
def test_swallows_exceptions_so_main_loop_never_breaks(self):
cli = _make_cli()
# Replace _pending_input with an object whose .put raises — simulating
# an unexpected internal error. The drain must NOT propagate.
broken = MagicMock(spec=queue.Queue)
broken.put.side_effect = RuntimeError("simulated put failure")
cli._pending_input = broken
cli._interrupt_queue.put("anything")
# Should not raise.
cli._drain_interrupt_queue_to_pending_input()
+171
View File
@@ -0,0 +1,171 @@
"""End-to-end test simulating CLI interrupt during subagent execution.
Reproduces the exact scenario:
1. Parent agent calls delegate_task
2. Child agent is running (simulated with a slow tool)
3. User "types a message" (simulated by calling parent.interrupt from another thread)
4. Child should detect the interrupt and stop
This tests the COMPLETE path including _run_single_child, _active_children
registration, interrupt propagation, and child detection.
"""
import threading
import time
import unittest
from unittest.mock import MagicMock, patch
from tools.interrupt import set_interrupt
class TestCLISubagentInterrupt(unittest.TestCase):
"""Simulate exact CLI scenario."""
def setUp(self):
set_interrupt(False)
def tearDown(self):
set_interrupt(False)
def test_full_delegate_interrupt_flow(self):
"""Full integration: parent runs delegate_task, main thread interrupts."""
from run_agent import AIAgent
interrupt_detected = threading.Event()
child_started = threading.Event()
child_api_call_count = 0
# Create a real-enough parent agent
parent = AIAgent.__new__(AIAgent)
parent._interrupt_requested = False
parent._interrupt_message = None
parent._active_children = []
parent._active_children_lock = threading.Lock()
parent.quiet_mode = True
parent.model = "test/model"
parent.base_url = "http://localhost:1"
parent.api_key = "test"
parent.provider = "test"
parent.api_mode = "chat_completions"
parent.platform = "cli"
parent.enabled_toolsets = ["terminal", "file"]
parent.providers_allowed = None
parent.providers_ignored = None
parent.providers_order = None
parent.provider_sort = None
parent.max_tokens = None
parent.reasoning_config = None
parent.prefill_messages = None
parent._session_db = None
parent._delegate_depth = 0
parent._delegate_spinner = None
parent.tool_progress_callback = None
parent._execution_thread_id = None
# We'll track what happens with _active_children
original_children = parent._active_children
# Mock the child's run_conversation to simulate a slow operation
# that checks _interrupt_requested like the real one does
def mock_child_run_conversation(user_message, **kwargs):
child_started.set()
# Find the child in parent._active_children
child = parent._active_children[-1] if parent._active_children else None
# Simulate the agent loop: poll _interrupt_requested like run_conversation does
for i in range(100): # Up to 10 seconds (100 * 0.1s)
if child and child._interrupt_requested:
interrupt_detected.set()
return {
"final_response": "Interrupted!",
"messages": [],
"api_calls": 1,
"completed": False,
"interrupted": True,
"interrupt_message": child._interrupt_message,
}
time.sleep(0.1)
return {
"final_response": "Finished without interrupt",
"messages": [],
"api_calls": 5,
"completed": True,
"interrupted": False,
}
# Patch AIAgent to use our mock
from tools.delegate_tool import _run_single_child
from agent.iteration_budget import IterationBudget
parent.iteration_budget = IterationBudget(max_total=100)
# Run delegate in a thread (simulates agent_thread)
delegate_result = [None]
delegate_error = [None]
def run_delegate():
try:
with patch('run_agent.AIAgent') as MockAgent:
mock_instance = MagicMock()
mock_instance._interrupt_requested = False
mock_instance._interrupt_message = None
mock_instance._active_children = []
mock_instance._active_children_lock = threading.Lock()
mock_instance.quiet_mode = True
mock_instance.run_conversation = mock_child_run_conversation
mock_instance.interrupt = lambda msg=None: setattr(mock_instance, '_interrupt_requested', True) or setattr(mock_instance, '_interrupt_message', msg)
mock_instance.tools = []
MockAgent.return_value = mock_instance
# Register child manually (normally done by _build_child_agent)
parent._active_children.append(mock_instance)
result = _run_single_child(
task_index=0,
goal="Do something slow",
child=mock_instance,
parent_agent=parent,
)
delegate_result[0] = result
except Exception as e:
delegate_error[0] = e
agent_thread = threading.Thread(target=run_delegate, daemon=True)
agent_thread.start()
# Wait for child to start
assert child_started.wait(timeout=5), "Child never started!"
# Now simulate user interrupt (from main/process thread)
time.sleep(0.2) # Give child a moment to be in its loop
print(f"Parent has {len(parent._active_children)} active children")
assert len(parent._active_children) >= 1, f"Expected child in _active_children, got {len(parent._active_children)}"
# This is what the CLI does:
parent.interrupt("Hey stop that")
print(f"Parent._interrupt_requested: {parent._interrupt_requested}")
for i, child in enumerate(parent._active_children):
print(f"Child {i}._interrupt_requested: {child._interrupt_requested}")
# Wait for child to detect interrupt
detected = interrupt_detected.wait(timeout=10.0)
# Wait for delegate to finish
agent_thread.join(timeout=15)
assert not agent_thread.is_alive(), "delegate thread did not finish"
if delegate_error[0]:
raise delegate_error[0]
assert detected, "Child never detected the interrupt!"
result = delegate_result[0]
assert result is not None, "Delegate returned no result"
assert result["status"] == "interrupted", f"Expected 'interrupted', got '{result['status']}'"
print(f"✓ Interrupt detected! Result: {result}")
if __name__ == "__main__":
unittest.main()
+433
View File
@@ -0,0 +1,433 @@
"""Tests for the light-mode terminal detection + color remap in cli.py.
Covers the env-override path and the SkinConfig.get_color() wrapper that
the resize / light-mode salvage installs at module import time. We don't
try to fake an OSC 11 reply — the env-override branch short-circuits
before the terminal query, which is the path most users hit.
"""
from __future__ import annotations
import pytest
@pytest.fixture
def cli_mod(monkeypatch):
"""Import cli with the light-mode cache cleared each test."""
import cli as _cli
# The module-level _install_skin_light_mode_hook() and import-time
# _detect_light_mode() prime ran once at first import. We just reset
# the detection cache so the per-test env override takes effect.
monkeypatch.setattr(_cli, "_LIGHT_MODE_CACHE", None)
return _cli
class TestLightModeDetection:
def test_hermes_light_env_true_forces_light(self, cli_mod, monkeypatch):
monkeypatch.setenv("HERMES_LIGHT", "1")
assert cli_mod._detect_light_mode() is True
def test_hermes_light_env_false_forces_dark(self, cli_mod, monkeypatch):
monkeypatch.setenv("HERMES_LIGHT", "0")
# Also blank out other signals so nothing else flips it light.
monkeypatch.delenv("HERMES_TUI_LIGHT", raising=False)
monkeypatch.delenv("HERMES_TUI_THEME", raising=False)
monkeypatch.delenv("HERMES_TUI_BACKGROUND", raising=False)
monkeypatch.delenv("COLORFGBG", raising=False)
assert cli_mod._detect_light_mode() is False
def test_theme_hint_light(self, cli_mod, monkeypatch):
monkeypatch.delenv("HERMES_LIGHT", raising=False)
monkeypatch.delenv("HERMES_TUI_LIGHT", raising=False)
monkeypatch.setenv("HERMES_TUI_THEME", "light")
assert cli_mod._detect_light_mode() is True
def test_background_hex_hint_light(self, cli_mod, monkeypatch):
monkeypatch.delenv("HERMES_LIGHT", raising=False)
monkeypatch.delenv("HERMES_TUI_LIGHT", raising=False)
monkeypatch.delenv("HERMES_TUI_THEME", raising=False)
monkeypatch.setenv("HERMES_TUI_BACKGROUND", "#FFFFFF")
assert cli_mod._detect_light_mode() is True
def test_background_hex_hint_dark(self, cli_mod, monkeypatch):
monkeypatch.delenv("HERMES_LIGHT", raising=False)
monkeypatch.delenv("HERMES_TUI_LIGHT", raising=False)
monkeypatch.delenv("HERMES_TUI_THEME", raising=False)
monkeypatch.setenv("HERMES_TUI_BACKGROUND", "#1a1a2e")
monkeypatch.delenv("COLORFGBG", raising=False)
assert cli_mod._detect_light_mode() is False
def test_colorfgbg_light_bg_slot(self, cli_mod, monkeypatch):
monkeypatch.delenv("HERMES_LIGHT", raising=False)
monkeypatch.delenv("HERMES_TUI_LIGHT", raising=False)
monkeypatch.delenv("HERMES_TUI_THEME", raising=False)
monkeypatch.delenv("HERMES_TUI_BACKGROUND", raising=False)
monkeypatch.setenv("COLORFGBG", "0;15") # bg slot 15 = light
assert cli_mod._detect_light_mode() is True
def test_cache_is_sticky(self, cli_mod, monkeypatch):
monkeypatch.setenv("HERMES_LIGHT", "1")
assert cli_mod._detect_light_mode() is True
# Even if the env flips, the cached result wins until reset.
monkeypatch.setenv("HERMES_LIGHT", "0")
assert cli_mod._detect_light_mode() is True
class TestOsc11Probe:
"""The OSC 11 background probe must never run where its reply can leak
into prompt_toolkit's input (a late BEL-terminated reply reads as Ctrl+G
= open-editor, trapping the user in a stray editor). Guard the cases we
refuse to probe in.
"""
@pytest.mark.parametrize("var", ("SSH_CONNECTION", "SSH_CLIENT", "SSH_TTY"))
def test_skips_over_ssh(self, cli_mod, monkeypatch, var):
monkeypatch.setattr(cli_mod.sys.stdin, "isatty", lambda: True, raising=False)
monkeypatch.setattr(cli_mod.sys.stdout, "isatty", lambda: True, raising=False)
for v in ("SSH_CONNECTION", "SSH_CLIENT", "SSH_TTY"):
monkeypatch.delenv(v, raising=False)
monkeypatch.setenv(var, "1.2.3.4 5555 22")
assert cli_mod._query_osc11_background() is None
def test_skips_when_not_a_tty(self, cli_mod, monkeypatch):
monkeypatch.setattr(cli_mod.sys.stdin, "isatty", lambda: False, raising=False)
assert cli_mod._query_osc11_background() is None
class TestLightModeRemap:
def test_remap_no_op_in_dark_mode(self, cli_mod, monkeypatch):
monkeypatch.setenv("HERMES_LIGHT", "0")
# Cache is None from the fixture; first call sticks at False.
assert cli_mod._maybe_remap_for_light_mode("#FFF8DC") == "#FFF8DC"
def test_remap_known_dark_color(self, cli_mod, monkeypatch):
monkeypatch.setenv("HERMES_LIGHT", "1")
# Force the detect cache to True for this test.
cli_mod._LIGHT_MODE_CACHE = True
assert cli_mod._maybe_remap_for_light_mode("#FFF8DC") == "#1A1A1A"
assert cli_mod._maybe_remap_for_light_mode("#FFD700") == "#9A6B00"
def test_remap_case_insensitive(self, cli_mod, monkeypatch):
cli_mod._LIGHT_MODE_CACHE = True
# Lowercase input should still remap.
assert cli_mod._maybe_remap_for_light_mode("#fff8dc") == "#1A1A1A"
def test_remap_unknown_color_passthrough(self, cli_mod, monkeypatch):
cli_mod._LIGHT_MODE_CACHE = True
# A color not in the remap table is returned unchanged.
assert cli_mod._maybe_remap_for_light_mode("#ABCDEF") == "#ABCDEF"
def test_remap_skips_statusbar_paired_colors(self, cli_mod, monkeypatch):
"""Colors that live on a dark bg (status bar fg) MUST NOT be
remapped — otherwise they go dark-on-dark and disappear.
Regression guard for the patch-11 fix (intentional table omission).
"""
cli_mod._LIGHT_MODE_CACHE = True
for fg in ("#C0C0C0", "#888888", "#555555", "#8B8682"):
assert cli_mod._maybe_remap_for_light_mode(fg) == fg, (
f"{fg} is a status-bar fg paired with dark bg; remapping it "
"would produce dark-on-dark"
)
class TestSkinConfigHook:
"""Exercise the installed color hook, including self-painted badge colors."""
@pytest.mark.parametrize("skin_name", ["default", "sisyphus"])
def test_badge_preserves_its_paired_colors_in_light_mode(
self, cli_mod, monkeypatch, skin_name
):
from hermes_cli.skin_engine import (
get_active_skin, get_prompt_toolkit_style_overrides, set_active_skin,
)
monkeypatch.setenv("HERMES_LIGHT", "1")
previous = get_active_skin().name
try:
set_active_skin(skin_name)
skin = get_active_skin()
background = skin.colors.get("status_bar_strong", skin.colors.get("banner_title", "#FFD700"))
foreground = skin.colors.get("status_bar_bg", "#1a1a2e")
assert get_prompt_toolkit_style_overrides()["status-bar-session-title"] == (
f"bg:{background} {foreground} bold"
)
finally:
set_active_skin(previous)
def test_hook_installed(self, cli_mod):
from hermes_cli.skin_engine import SkinConfig
assert getattr(SkinConfig, "_hermes_light_mode_hook_installed", False) is True
def test_hook_is_idempotent(self, cli_mod):
# Calling the installer twice must not double-wrap (the marker
# attribute is the guard).
from hermes_cli.skin_engine import SkinConfig
before = SkinConfig.get_color
cli_mod._install_skin_light_mode_hook()
after = SkinConfig.get_color
assert before is after
def test_skin_color_remaps_through_wrapper_in_light_mode(
self, cli_mod, monkeypatch
):
from hermes_cli.skin_engine import SkinConfig
cli_mod._LIGHT_MODE_CACHE = True
skin = SkinConfig(
name="test",
colors={"banner_text": "#FFF8DC", "response_border": "#FFD700"},
)
# The wrapper kicks in at get_color, not at construction time.
assert skin.get_color("banner_text") == "#1A1A1A"
assert skin.get_color("response_border") == "#9A6B00"
def test_skin_color_passthrough_in_dark_mode(self, cli_mod, monkeypatch):
from hermes_cli.skin_engine import SkinConfig
cli_mod._LIGHT_MODE_CACHE = False
skin = SkinConfig(name="test", colors={"banner_text": "#FFF8DC"})
assert skin.get_color("banner_text") == "#FFF8DC"
class TestOsc11DrainGuard:
"""Regression: a late-arriving OSC 11 reply must not leak into
prompt_toolkit's input buffer (#40250).
Two layers guard against this: the DA1 fence keeps the main read loop
listening until the terminal proves it has processed our query, and
the drain loop in the ``finally`` block reads (and discards) any
stragglers that slip past TCSAFLUSH.
"""
def test_late_reply_is_consumed_not_leaked(self, cli_mod, monkeypatch):
"""Simulate a terminal that sends the OSC 11 reply 150ms after the
query. With the DA1 fence the main loop is still listening, so the
reply is consumed AND used; nothing remains for prompt_toolkit."""
import os, termios, tty as _tty
# Create a pipe pair to fake stdin
read_fd, write_fd = os.pipe()
# Set up fake termios on the read end
# We'll monkeypatch tcgetattr/tcsetattr to no-op
fake_attrs = [0, 0, 0, 0, 0, 0, [b'\x00'] * 32]
monkeypatch.setattr(termios, "tcgetattr", lambda fd: fake_attrs)
monkeypatch.setattr(termios, "tcsetattr", lambda fd, when, attrs: None)
monkeypatch.setattr(_tty, "setcbreak", lambda fd: None)
# Make stdin.isatty / stdout.isatty return True
monkeypatch.setattr(cli_mod.sys.stdin, "isatty", lambda: True, raising=False)
monkeypatch.setattr(cli_mod.sys.stdout, "isatty", lambda: True, raising=False)
monkeypatch.setattr(cli_mod.sys.stdin, "fileno", lambda: read_fd, raising=False)
# Clear SSH env vars
for v in ("SSH_CONNECTION", "SSH_CLIENT", "SSH_TTY"):
monkeypatch.delenv(v, raising=False)
# Write a delayed OSC 11 reply (then the DA1 fence reply) — the
# fenced main loop must still be listening and consume both.
import threading
def delayed_write():
import time
time.sleep(0.15)
os.write(write_fd, b"\x1b]11;rgb:0c0c/0c0c/0c0c\x1b\\\x1b[?62;22c")
t = threading.Thread(target=delayed_write, daemon=True)
t.start()
# The late reply is consumed by the fenced read loop and used.
result = cli_mod._query_osc11_background()
assert result == "#0C0C0C"
# Verify the pipe is drained — a non-blocking read should return empty
import select
r, _, _ = select.select([read_fd], [], [], 0)
assert not r, "late OSC 11 bytes must be consumed, not left to leak"
os.close(read_fd)
os.close(write_fd)
def test_post_deadline_straggler_is_drained(self, cli_mod, monkeypatch):
"""Bytes that arrive after the main loop has already finished (DA1
answered instantly, reply straggles in during teardown) are eaten
by the post-flush drain window instead of leaking (#40250)."""
import os, termios, tty as _tty
read_fd, write_fd = os.pipe()
fake_attrs = [0, 0, 0, 0, 0, 0, [b'\x00'] * 32]
monkeypatch.setattr(termios, "tcgetattr", lambda fd: fake_attrs)
monkeypatch.setattr(termios, "tcsetattr", lambda fd, when, attrs: None)
monkeypatch.setattr(_tty, "setcbreak", lambda fd: None)
monkeypatch.setattr(cli_mod.sys.stdin, "isatty", lambda: True, raising=False)
monkeypatch.setattr(cli_mod.sys.stdout, "isatty", lambda: True, raising=False)
monkeypatch.setattr(cli_mod.sys.stdin, "fileno", lambda: read_fd, raising=False)
for v in ("SSH_CONNECTION", "SSH_CLIENT", "SSH_TTY"):
monkeypatch.delenv(v, raising=False)
# DA1 answered immediately (herdr-style: OSC 11 swallowed) — main
# loop exits fast — then a straggler payload lands during teardown.
os.write(write_fd, b"\x1b[?62;22c")
import threading
def straggler():
import time
time.sleep(0.02) # inside the 50ms drain window
os.write(write_fd, b"\x1b]11;rgb:0c0c/0c0c/0c0c\x1b\\")
t = threading.Thread(target=straggler, daemon=True)
t.start()
result = cli_mod._query_osc11_background()
assert result is None # OSC 11 was swallowed; only DA1 answered
import select
r, _, _ = select.select([read_fd], [], [], 0)
assert not r, "drain loop should have consumed the straggler bytes"
os.close(read_fd)
os.close(write_fd)
# ────────────────────────────────────────────────────────────────────────
# OSC 11 query — DA1 fence behavior.
#
# The query is fenced with a DA1 sentinel so a terminal manager that
# swallows OSC 11 (herdr) or relays it slowly (SSH bridges, some tmux
# setups) can never leave reply bytes in the tty buffer for
# prompt_toolkit to read as typed input. These tests run the real
# function in a child on a real PTY and play the terminal's role from
# the parent side.
import os as _os
import sys as _sys
_CHILD_SRC = r"""
import sys, os
sys.path.insert(0, os.environ["HERMES_REPO"])
import cli
bg = cli._query_osc11_background()
print("RESULT:" + repr(bg), flush=True)
# Drain anything left in the tty buffer — must be empty (no leak).
import termios, tty, select, time
fd = sys.stdin.fileno()
old = termios.tcgetattr(fd)
tty.setcbreak(fd)
buf = b""
deadline = time.monotonic() + 0.6
while time.monotonic() < deadline:
r, _, _ = select.select([fd], [], [], 0.1)
if r:
buf += os.read(fd, 256)
termios.tcsetattr(fd, termios.TCSADRAIN, old)
print("LEFTOVER:" + repr(buf), flush=True)
"""
def _run_osc11_child(reply_fn, repo_root, timeout=8.0):
"""Fork a PTY child running _query_osc11_background().
reply_fn(query_age_seconds) -> bytes to write once, or None to wait.
Returns (result_line, leftover_line).
"""
import pty
import time as _time
env = dict(_os.environ, HERMES_REPO=str(repo_root))
for var in ("SSH_CONNECTION", "SSH_CLIENT", "SSH_TTY"):
env.pop(var, None)
pid, master = pty.fork()
if pid == 0: # child
_os.execvpe(_sys.executable, [_sys.executable, "-c", _CHILD_SRC], env)
_os.set_blocking(master, False)
out = b""
answered = 0
query_at = None
t0 = _time.monotonic()
try:
while _time.monotonic() - t0 < timeout:
try:
chunk = _os.read(master, 1024)
if chunk:
out += chunk
except (BlockingIOError, OSError):
pass
# importing cli primes _detect_light_mode() which issues its own
# OSC 11 query before the explicit call — answer each query.
if query_at is None and out.count(b"\x1b]11;?") > answered:
query_at = _time.monotonic()
if query_at is not None:
payload = reply_fn(_time.monotonic() - query_at)
if payload is not None:
_os.write(master, payload)
answered += 1
query_at = None
if b"LEFTOVER:" in out and out.rstrip().endswith(b"'"):
break
_time.sleep(0.005)
finally:
try:
_os.close(master)
except OSError:
pass
try:
_os.waitpid(pid, 0)
except ChildProcessError:
pass
text = out.decode("utf-8", "replace")
print("child raw output:", repr(text)) # aids debugging on failure
result = leftover = None
for line in text.splitlines():
# The PTY echoes the query bytes onto the same line as the first
# print, so match anywhere in the line rather than at the start.
if "RESULT:" in line and result is None:
result = line.split("RESULT:", 1)[1]
elif "LEFTOVER:" in line and leftover is None:
leftover = line.split("LEFTOVER:", 1)[1]
return result, leftover
@pytest.fixture
def repo_root():
import pathlib
return pathlib.Path(__file__).resolve().parents[2]
@pytest.mark.skipif(_sys.platform == "win32", reason="POSIX PTY test")
class TestOsc11Da1Fence:
def test_herdr_style_da1_only_returns_none_without_leak(self, repo_root):
"""Terminal answers DA1 instantly but swallows OSC 11 (herdr)."""
result, leftover = _run_osc11_child(
lambda age: b"\x1b[?62;22c", repo_root
)
assert result == "None"
assert leftover == "b''"
def test_slow_inorder_reply_is_consumed_not_leaked(self, repo_root):
"""OSC 11 reply arrives at +300ms (past the old 100ms budget),
DA1 right behind it. The fence keeps us listening, so the color
is detected and nothing leaks into the tty buffer."""
result, leftover = _run_osc11_child(
lambda age: (
b"\x1b]11;rgb:1e1e/1e1e/2e2e\x1b\\\x1b[?62;22c"
if age > 0.3 else None
),
repo_root,
)
assert result == "'#1E1E2E'"
assert leftover == "b''"
def test_mute_terminal_times_out_clean(self, repo_root):
"""Terminal that answers nothing: give up at the safety-net
deadline with no leftovers."""
result, leftover = _run_osc11_child(lambda age: None, repo_root)
assert result == "None"
assert leftover == "b''"
+72
View File
@@ -0,0 +1,72 @@
"""Regression tests for loading feedback on slow slash commands."""
from unittest.mock import patch
from cli import HermesCLI
class TestCLILoadingIndicator:
def _make_cli(self):
cli_obj = HermesCLI.__new__(HermesCLI)
cli_obj._app = None
cli_obj._last_invalidate = 0.0
cli_obj._command_running = False
cli_obj._command_status = ""
return cli_obj
def test_skills_command_sets_busy_state_and_prints_status(self, capsys):
cli_obj = self._make_cli()
seen = {}
def fake_handle(cmd: str):
seen["cmd"] = cmd
seen["running"] = cli_obj._command_running
seen["status"] = cli_obj._command_status
print("skills done")
with patch.object(cli_obj, "_handle_skills_command", side_effect=fake_handle), \
patch.object(cli_obj, "_invalidate") as invalidate_mock:
assert cli_obj.process_command("/skills search kubernetes")
output = capsys.readouterr().out
assert "⏳ Searching skills..." in output
assert "skills done" in output
assert seen == {
"cmd": "/skills search kubernetes",
"running": True,
"status": "Searching skills...",
}
assert cli_obj._command_running is False
assert cli_obj._command_status == ""
assert invalidate_mock.call_count == 2
def test_reload_mcp_sets_busy_state_and_prints_status(self, capsys):
cli_obj = self._make_cli()
seen = {}
def fake_reload():
seen["running"] = cli_obj._command_running
seen["status"] = cli_obj._command_status
print("reload done")
# /reload-mcp now wraps the actual reload in a prompt-cache-invalidation
# confirmation prompt (commit 4d7fc0f37). This test exercises the
# loading-indicator path, not the confirmation UX, so pre-approve the
# reload via config so the handler goes straight into _reload_mcp().
fake_cfg = {"approvals": {"mcp_reload_confirm": False}}
with patch.object(cli_obj, "_reload_mcp", side_effect=fake_reload), \
patch.object(cli_obj, "_invalidate") as invalidate_mock, \
patch("cli.load_cli_config", return_value=fake_cfg):
assert cli_obj.process_command("/reload-mcp")
output = capsys.readouterr().out
assert "⏳ Reloading MCP servers..." in output
assert "reload done" in output
assert seen == {
"running": True,
"status": "Reloading MCP servers...",
}
assert cli_obj._command_running is False
assert cli_obj._command_status == ""
assert invalidate_mock.call_count == 2
+104
View File
@@ -0,0 +1,104 @@
from io import StringIO
from rich.console import Console
from rich.markdown import Markdown
from cli import _render_final_assistant_content
def _render_to_text(renderable) -> str:
buf = StringIO()
Console(file=buf, width=80, force_terminal=False, color_system=None).print(renderable)
return buf.getvalue()
def test_final_assistant_content_uses_markdown_renderable():
renderable = _render_final_assistant_content("# Title\n\n- one\n- two")
assert isinstance(renderable, Markdown)
output = _render_to_text(renderable)
assert "Title" in output
assert "one" in output
assert "two" in output
def test_final_assistant_content_keeps_non_path_markdown_escapes():
renderable = _render_final_assistant_content(r"1\. Not an ordered list")
output = _render_to_text(renderable)
assert "1. Not an ordered list" in output
assert r"1\." not in output
def test_strip_mode_preserves_lists():
renderable = _render_final_assistant_content(
"**Formatting**\n- Ran prettier\n- Files changed\n- Verified clean",
mode="strip",
)
output = _render_to_text(renderable)
assert "- Ran prettier" in output
assert "- Files changed" in output
assert "- Verified clean" in output
assert "**" not in output
def test_strip_mode_preserves_blockquotes():
renderable = _render_final_assistant_content(
"> This is quoted text\n> Another quoted line",
mode="strip",
)
output = _render_to_text(renderable)
assert "> This is quoted" in output
assert "> Another quoted" in output
def test_strip_mode_preserves_cron_asterisks_in_plain_text():
renderable = _render_final_assistant_content("* * * * *", mode="strip")
output = _render_to_text(renderable)
assert "* * * * *" in output
# Still treat the canonical 3-asterisk Markdown horizontal rule as decoration.
renderable = _render_final_assistant_content("* * *", mode="strip")
output = _render_to_text(renderable)
assert "* * *" not in output
def test_strip_mode_preserves_intraword_underscores_in_snake_case_identifiers():
renderable = _render_final_assistant_content(
"Let me look at test_case_with_underscores and SOME_CONST "
"then /tmp/snake_case_dir/file_with_name.py",
mode="strip",
)
output = _render_to_text(renderable)
assert "test_case_with_underscores" in output
assert "SOME_CONST" in output
assert "snake_case_dir" in output
assert "file_with_name" in output
def test_strip_mode_still_strips_boundary_underscore_emphasis():
renderable = _render_final_assistant_content(
"say _hi_ and __bold__ now",
mode="strip",
)
output = _render_to_text(renderable)
assert "say hi and bold now" in output
+193
View File
@@ -0,0 +1,193 @@
"""Tests for automatic MCP reload when config.yaml mcp_servers section changes."""
import time
from pathlib import Path
from unittest.mock import MagicMock, patch
def _make_cli(tmp_path, mcp_servers=None, extra_config=None):
"""Create a minimal HermesCLI instance with mocked config."""
import cli as cli_mod
obj = object.__new__(cli_mod.HermesCLI)
cfg = {"mcp_servers": mcp_servers or {}}
if extra_config:
cfg.update(extra_config)
obj.config = cfg
obj._agent_running = False
obj._last_config_check = 0.0
obj._config_mcp_servers = mcp_servers or {}
cfg_file = tmp_path / "config.yaml"
cfg_file.write_text("mcp_servers: {}\n")
obj._config_mtime = cfg_file.stat().st_mtime
obj._reload_mcp = MagicMock()
obj._busy_command = MagicMock()
obj._busy_command.return_value.__enter__ = MagicMock(return_value=None)
obj._busy_command.return_value.__exit__ = MagicMock(return_value=False)
obj._slow_command_status = MagicMock(return_value="reloading...")
return obj, cfg_file
class TestMCPConfigWatch:
def test_new_mcp_server_triggers_reload(self, tmp_path):
"""Adding a new MCP server to config triggers auto-reload."""
import yaml
obj, cfg_file = _make_cli(tmp_path, mcp_servers={})
# Simulate user adding a new MCP server to config.yaml
cfg_file.write_text(yaml.dump({"mcp_servers": {"github": {"url": "https://mcp.github.com"}}}))
obj._config_mtime = 0.0 # force stale mtime
with patch("hermes_cli.config.get_config_path", return_value=cfg_file):
obj._check_config_mcp_changes()
obj._reload_mcp.assert_called_once()
def test_removed_mcp_server_triggers_reload(self, tmp_path):
"""Removing an MCP server from config triggers auto-reload."""
import yaml
obj, cfg_file = _make_cli(tmp_path, mcp_servers={"github": {"url": "https://mcp.github.com"}})
# Simulate user removing the server
cfg_file.write_text(yaml.dump({"mcp_servers": {}}))
obj._config_mtime = 0.0
with patch("hermes_cli.config.get_config_path", return_value=cfg_file):
obj._check_config_mcp_changes()
obj._reload_mcp.assert_called_once()
def test_optout_disables_auto_reload(self, tmp_path, capsys):
"""When mcp.auto_reload_on_config_change is False, a changed
mcp_servers section must NOT trigger an automatic reload — but the
change is still detected and the user is told how to apply it.
This protects the provider prompt cache: every automatic reload
rebuilds the agent tool surface and invalidates cached prefixes.
The toggle is the top-level ``mcp:`` section in config.yaml, and the
watcher reads it from the same freshly-parsed file it diffs — so
flipping the toggle and editing mcp_servers in one edit behaves
correctly.
"""
import yaml
obj, cfg_file = _make_cli(
tmp_path,
mcp_servers={},
)
# Simulate a changed mcp_servers section with auto-reload opted out.
cfg_file.write_text(yaml.dump({
"mcp": {"auto_reload_on_config_change": False},
"mcp_servers": {"github": {"url": "https://mcp.github.com"}},
}))
obj._config_mtime = 0.0 # force stale mtime
with patch("hermes_cli.config.get_config_path", return_value=cfg_file):
obj._check_config_mcp_changes()
obj._reload_mcp.assert_not_called()
out = capsys.readouterr().out
assert "reload skipped" in out
assert "/reload-mcp" in out
assert "prompt cache" in out
def test_optout_updates_snapshot_so_reload_mcp_applies_cleanly(self, tmp_path):
"""After an opted-out change, the watcher must not re-notify every
tick: the snapshot is updated so the same content compares equal on
the next pass."""
import yaml
obj, cfg_file = _make_cli(tmp_path, mcp_servers={})
cfg_file.write_text(yaml.dump({
"mcp": {"auto_reload_on_config_change": False},
"mcp_servers": {"github": {"url": "https://mcp.github.com"}},
}))
obj._config_mtime = 0.0
with patch("hermes_cli.config.get_config_path", return_value=cfg_file):
obj._check_config_mcp_changes()
# Second pass: same file content, new mtime — no reload, no change.
obj._last_config_check = 0.0
obj._config_mtime = 0.0
obj._check_config_mcp_changes()
obj._reload_mcp.assert_not_called()
assert obj._config_mcp_servers == {"github": {"url": "https://mcp.github.com"}}
def test_optout_path_is_top_level_mcp_not_auxiliary(self, tmp_path):
"""Regression guard: the opt-out toggle is the top-level
``mcp.auto_reload_on_config_change`` key, NOT ``auxiliary.mcp``
(which holds side-LLM task provider settings).
A config that sets ONLY ``auxiliary.mcp.auto_reload_on_config_change:
false`` must NOT disable the reload."""
import yaml
obj, cfg_file = _make_cli(
tmp_path,
mcp_servers={},
)
cfg_file.write_text(yaml.dump({
"auxiliary": {"mcp": {"auto_reload_on_config_change": False}},
"mcp_servers": {"github": {"url": "https://mcp.github.com"}},
}))
obj._config_mtime = 0.0
with patch("hermes_cli.config.get_config_path", return_value=cfg_file):
obj._check_config_mcp_changes()
# Reload happened because the aux-task path is not the toggle.
obj._reload_mcp.assert_called()
def test_env_var_templates_do_not_false_positive_on_unrelated_saves(
self, tmp_path, monkeypatch, capsys
):
"""Regression for the '/reasoning triggers MCP reload' bug (#55701).
Init snapshots mcp_servers from the loaded config, which has been
through _expand_env_vars() — so ``${MCP_GH_API_KEY}`` is stored
expanded. The watcher re-parses the RAW yaml. Without expanding the
watcher side too, the comparison is always unequal whenever any
template is in use, so EVERY config.yaml rewrite (e.g.
save_config_value('agent.reasoning_effort', ...) from /reasoning)
fired a full MCP reconnect.
"""
import yaml
monkeypatch.setenv("MCP_GH_API_KEY", "sekrit-token")
raw_servers = {
"github": {
"url": "https://mcp.github.com",
"headers": {"Authorization": "Bearer ${MCP_GH_API_KEY}"},
}
}
expanded_servers = {
"github": {
"url": "https://mcp.github.com",
"headers": {"Authorization": "Bearer sekrit-token"},
}
}
# Init snapshot holds the EXPANDED form (as load_cli_config produces).
obj, cfg_file = _make_cli(tmp_path, mcp_servers=expanded_servers)
# Unrelated-key save: mcp_servers content identical (raw templates),
# only reasoning_effort changed — mtime moves.
cfg_file.write_text(yaml.dump({
"agent": {"reasoning_effort": "high"},
"mcp_servers": raw_servers,
}))
obj._config_mtime = 0.0
with patch("hermes_cli.config.get_config_path", return_value=cfg_file):
obj._check_config_mcp_changes()
obj._reload_mcp.assert_not_called()
assert "MCP server config changed" not in capsys.readouterr().out
+299
View File
@@ -0,0 +1,299 @@
"""Regression tests for CLI fresh-session commands."""
from __future__ import annotations
import importlib
import os
import sys
from datetime import datetime, timedelta
from unittest.mock import MagicMock, patch
import pytest
from hermes_state import SessionDB
from tools.todo_tool import TodoStore
class _FakeCompressor:
"""Minimal stand-in for ContextCompressor."""
def __init__(self):
self.last_prompt_tokens = 500
self.last_completion_tokens = 200
self.last_total_tokens = 700
self.compression_count = 3
self._context_probed = True
class _FakeAgent:
def __init__(self, session_id: str, session_start):
self.session_id = session_id
self.session_start = session_start
self.model = "anthropic/claude-opus-4.6"
self._last_flushed_db_idx = 7
self._todo_store = TodoStore()
self._todo_store.write(
[{"id": "t1", "content": "unfinished task", "status": "in_progress"}]
)
self.commit_memory_session = MagicMock()
self._invalidate_system_prompt = MagicMock()
# Token counters (non-zero to verify reset)
self.session_total_tokens = 1000
self.session_input_tokens = 600
self.session_output_tokens = 400
self.session_prompt_tokens = 550
self.session_completion_tokens = 350
self.session_cache_read_tokens = 100
self.session_cache_write_tokens = 50
self.session_reasoning_tokens = 80
self.session_api_calls = 5
self.session_estimated_cost_usd = 0.42
self.session_cost_status = "estimated"
self.session_cost_source = "openrouter"
self.context_compressor = _FakeCompressor()
def reset_session_state(self):
"""Mirror the real AIAgent.reset_session_state()."""
self.session_total_tokens = 0
self.session_input_tokens = 0
self.session_output_tokens = 0
self.session_prompt_tokens = 0
self.session_completion_tokens = 0
self.session_cache_read_tokens = 0
self.session_cache_write_tokens = 0
self.session_reasoning_tokens = 0
self.session_api_calls = 0
self.session_estimated_cost_usd = 0.0
self.session_cost_status = "unknown"
self.session_cost_source = "none"
if hasattr(self, "context_compressor") and self.context_compressor:
self.context_compressor.last_prompt_tokens = 0
self.context_compressor.last_completion_tokens = 0
self.context_compressor.last_total_tokens = 0
self.context_compressor.compression_count = 0
self.context_compressor._context_probed = False
def _make_cli(env_overrides=None, config_overrides=None, **kwargs):
"""Create a HermesCLI instance with minimal mocking."""
_clean_config = {
"model": {
"default": "anthropic/claude-opus-4.6",
"base_url": "https://openrouter.ai/api/v1",
"provider": "auto",
},
"display": {"compact": False, "tool_progress": "all"},
"agent": {},
"terminal": {"env_type": "local"},
}
if config_overrides:
_clean_config.update(config_overrides)
clean_env = {"LLM_MODEL": "", "HERMES_MAX_ITERATIONS": ""}
if env_overrides:
clean_env.update(env_overrides)
prompt_toolkit_stubs = {
"prompt_toolkit": MagicMock(),
"prompt_toolkit.history": MagicMock(),
"prompt_toolkit.styles": MagicMock(),
"prompt_toolkit.patch_stdout": MagicMock(),
"prompt_toolkit.application": MagicMock(),
"prompt_toolkit.layout": MagicMock(),
"prompt_toolkit.layout.processors": MagicMock(),
"prompt_toolkit.filters": MagicMock(),
"prompt_toolkit.layout.dimension": MagicMock(),
"prompt_toolkit.layout.menus": MagicMock(),
"prompt_toolkit.widgets": MagicMock(),
"prompt_toolkit.key_binding": MagicMock(),
"prompt_toolkit.completion": MagicMock(),
"prompt_toolkit.formatted_text": MagicMock(),
"prompt_toolkit.auto_suggest": MagicMock(),
}
with patch.dict(sys.modules, prompt_toolkit_stubs), patch.dict(
"os.environ", clean_env, clear=False
):
import cli as _cli_mod
_cli_mod = importlib.reload(_cli_mod)
with patch.object(_cli_mod, "get_tool_definitions", return_value=[]), patch.dict(
_cli_mod.__dict__, {"CLI_CONFIG": _clean_config}
):
return _cli_mod.HermesCLI(**kwargs)
def _prepare_cli_with_active_session(tmp_path):
cli = _make_cli()
cli._session_db = SessionDB(db_path=tmp_path / "state.db")
cli._session_db.create_session(session_id=cli.session_id, source="cli", model=cli.model)
cli.agent = _FakeAgent(cli.session_id, cli.session_start)
cli.conversation_history = [{"role": "user", "content": "hello"}]
old_session_start = cli.session_start - timedelta(seconds=1)
cli.session_start = old_session_start
cli.agent.session_start = old_session_start
# Bypass the destructive-slash confirmation gate — these tests focus on
# the new-session mechanics, not the confirm prompt itself (covered in
# tests/cli/test_destructive_slash_confirm.py).
cli._confirm_destructive_slash = lambda *_a, **_kw: "once"
return cli
@pytest.fixture(autouse=True)
def _reset_session_id_context():
from gateway.session_context import _UNSET, _VAR_MAP
yield
os.environ.pop("HERMES_SESSION_ID", None)
_VAR_MAP["HERMES_SESSION_ID"].set(_UNSET)
def test_new_command_creates_real_fresh_session_and_resets_agent_state(tmp_path):
cli = _prepare_cli_with_active_session(tmp_path)
old_session_id = cli.session_id
old_session_start = cli.session_start
cli.process_command("/new")
assert cli.session_id != old_session_id
old_session = cli._session_db.get_session(old_session_id)
assert old_session is not None
assert old_session["end_reason"] == "new_session"
new_session = cli._session_db.get_session(cli.session_id)
assert new_session is not None
cli._session_db.append_message(cli.session_id, role="user", content="next turn")
assert cli.agent.session_id == cli.session_id
assert cli.agent._last_flushed_db_idx == 0
assert cli.agent._todo_store.read() == []
assert cli.session_start > old_session_start
assert cli.agent.session_start == cli.session_start
cli.agent._invalidate_system_prompt.assert_called_once()
def test_new_session_delivers_context_engine_boundary_synchronously(tmp_path):
"""The context-engine on_session_end must fire during /new itself.
It is cheap local state work and ordering-sensitive: it must land before
reset_session_state() rebinds the engine to the new session. The LLM-bound
provider extraction is what gets deferred, not this."""
cli = _prepare_cli_with_active_session(tmp_path)
old_session_id = cli.session_id
engine_calls = []
cli.agent.context_compressor.on_session_end = (
lambda sid, msgs: engine_calls.append((sid, list(msgs)))
)
cli.process_command("/new")
assert engine_calls == [(old_session_id, [{"role": "user", "content": "hello"}])]
def test_run_cleanup_flushes_pending_memory_manager_work(tmp_path):
"""A '/new then quit' must not drop the queued old-session extraction.
_run_cleanup gives the manager's serialized worker a bounded drain via
flush_pending() before shutdown_all()'s short-fuse drain runs."""
import cli as _cli_mod
agent = MagicMock()
mm = MagicMock()
mm.flush_pending.return_value = True
agent._memory_manager = mm
agent._session_messages = []
old_ref = _cli_mod._active_agent_ref
_cli_mod._active_agent_ref = agent
_cli_mod._cleanup_done = False
try:
_cli_mod._run_cleanup(notify_session_finalize=False)
finally:
_cli_mod._cleanup_done = True
_cli_mod._active_agent_ref = old_ref
mm.flush_pending.assert_called_once_with(timeout=10)
def test_clear_command_starts_new_session_before_redrawing(tmp_path):
cli = _prepare_cli_with_active_session(tmp_path)
cli.console = MagicMock()
cli.show_banner = MagicMock()
old_session_id = cli.session_id
cli.process_command("/clear")
assert cli.session_id != old_session_id
assert cli._session_db.get_session(old_session_id)["end_reason"] == "new_session"
assert cli._session_db.get_session(cli.session_id) is not None
cli.console.clear.assert_called_once()
cli.show_banner.assert_called_once()
assert cli.conversation_history == []
def test_new_session_resets_token_counters(tmp_path):
"""Regression test for #2099: /new must zero all token counters."""
cli = _prepare_cli_with_active_session(tmp_path)
# Verify counters are non-zero before reset
agent = cli.agent
assert agent.session_total_tokens > 0
assert agent.session_api_calls > 0
assert agent.context_compressor.compression_count > 0
cli.process_command("/new")
# All agent token counters must be zero
assert agent.session_total_tokens == 0
assert agent.session_input_tokens == 0
assert agent.session_output_tokens == 0
assert agent.session_prompt_tokens == 0
assert agent.session_completion_tokens == 0
assert agent.session_cache_read_tokens == 0
assert agent.session_cache_write_tokens == 0
assert agent.session_reasoning_tokens == 0
assert agent.session_api_calls == 0
assert agent.session_estimated_cost_usd == 0.0
assert agent.session_cost_status == "unknown"
assert agent.session_cost_source == "none"
# Context compressor counters must also be zero
comp = agent.context_compressor
assert comp.last_prompt_tokens == 0
assert comp.last_completion_tokens == 0
assert comp.last_total_tokens == 0
assert comp.compression_count == 0
assert comp._context_probed is False
def test_new_session_with_title(capsys):
"""new_session(title=...) creates a session and sets the title."""
cli = _make_cli()
cli._session_db = MagicMock()
cli.agent = _FakeAgent("old_session_id", datetime.now())
cli.conversation_history = []
cli.new_session(title="My Test Session")
# Assert set_session_title was called with the new session ID and sanitized title
cli._session_db.set_session_title.assert_called_once()
call_args = cli._session_db.set_session_title.call_args
assert call_args[0][0] == cli.session_id
assert call_args[0][1] == "My Test Session"
captured = capsys.readouterr()
assert "My Test Session" in captured.out
+265
View File
@@ -0,0 +1,265 @@
"""The base-CLI petdex pane: reactive half-block sprite above the prompt.
Mirrors the TUI's PetPane. The methods are tested in isolation via __new__ so
we don't pay the full HermesCLI.__init__ cost; a synthetic spritesheet exercises
the real engine decode + half-block fragment building.
"""
from __future__ import annotations
import threading
import pytest
from agent.pet import store
from agent.pet.constants import FRAME_H, FRAME_W
from agent.pet.render import PetRenderer
from cli import HermesCLI
@pytest.fixture
def boba_like(tmp_path, monkeypatch):
"""Install a synthetic pet into a temp HERMES_HOME and return its slug."""
from PIL import Image
home = tmp_path / ".hermes"
home.mkdir()
monkeypatch.setenv("HERMES_HOME", str(home))
cols, rows = 8, 9
sheet = Image.new("RGBA", (FRAME_W * cols, FRAME_H * rows), (0, 0, 0, 0))
for r in range(rows):
color = (20 + r * 25, 60, 120, 255)
for c in range(cols):
block = Image.new("RGBA", (FRAME_W, FRAME_H), color)
sheet.paste(block, (c * FRAME_W, r * FRAME_H))
pet_dir = store.pets_dir() / "boba"
pet_dir.mkdir(parents=True, exist_ok=True)
sheet.save(pet_dir / "spritesheet.webp")
(pet_dir / "pet.json").write_text(
'{"id":"boba","displayName":"Boba","description":"d","spritesheetPath":"spritesheet.webp"}'
)
return "boba"
def _make_cli():
cli_obj = HermesCLI.__new__(HermesCLI)
cli_obj._app = None
cli_obj._pet_lock = threading.Lock()
cli_obj._pet_enabled = False
cli_obj._pet_renderer = None
cli_obj._pet_slug = ""
cli_obj._pet_cols = 18
cli_obj._pet_scale = 0.7
cli_obj._pet_frames_cache = {}
cli_obj._pet_kitty_cache = {}
cli_obj._pet_kitty_image_id = 0
cli_obj._pet_kitty_pending = ""
cli_obj._pet_frame_idx = 0
cli_obj._agent_running = False
# Transient-beat + reasoning state (set by HermesCLI.__init__ in production).
cli_obj._pet_event = ""
cli_obj._pet_event_until = 0.0
cli_obj._pet_reasoning = False
# Blocking-modal state — a live one maps the pet to `waiting`.
cli_obj._approval_state = None
cli_obj._clarify_state = None
cli_obj._sudo_state = None
cli_obj._secret_state = None
cli_obj._slash_confirm_state = None
return cli_obj
def test_pet_state_tracks_agent_running():
cli_obj = _make_cli()
assert cli_obj._derive_pet_state() == "idle"
cli_obj._agent_running = True
assert cli_obj._derive_pet_state() == "run"
def test_pet_state_waits_on_a_blocking_modal():
# A live clarify/approval pauses the agent on the user → `waiting`, even
# while the turn is technically still running.
cli_obj = _make_cli()
cli_obj._agent_running = True
cli_obj._clarify_state = {"question": "?"}
assert cli_obj._derive_pet_state() == "waiting"
def test_pet_pane_collapsed_when_disabled():
# No renderer resolved → the window reports zero height and no fragments,
# so it's invisible for users without a pet.
cli_obj = _make_cli()
assert cli_obj._pet_widget_height() == 0
assert cli_obj._pet_fragments() == []
def test_pet_fragments_render_half_blocks(boba_like):
cli_obj = _make_cli()
cli_obj._pet_renderer = PetRenderer(
str(store.load_pet("boba").spritesheet), mode="unicode", scale=0.4, unicode_cols=14
)
cli_obj._pet_cols = 14
cli_obj._pet_enabled = True
height = cli_obj._pet_widget_height()
assert height > 0
frags = cli_obj._pet_fragments()
assert frags, "expected fragments for an enabled pet"
# Each fragment is a (style, text) pair; glyphs are half-blocks or blanks.
glyphs = {text for _, text in frags}
assert glyphs <= {"▀", "▄", " ", "\n"}
# Opaque cells carry a truecolor foreground style.
assert any(text == "▀" and "fg:#" in style for style, text in frags)
# Row count in the fragment stream matches the reported window height.
assert sum(1 for _, text in frags if text == "\n") == height - 1
def test_pet_resolve_config_enables_and_disables(boba_like):
from hermes_cli.config import load_config, save_config
cli_obj = _make_cli()
cfg = load_config()
cfg.setdefault("display", {}).setdefault("pet", {})
cfg["display"]["pet"].update({"enabled": True, "slug": "boba"})
save_config(cfg)
cli_obj._pet_resolve_config()
assert cli_obj._pet_enabled is True
assert cli_obj._pet_renderer is not None
assert cli_obj._pet_slug == "boba"
cfg["display"]["pet"]["enabled"] = False
save_config(cfg)
cli_obj._pet_resolve_config()
assert cli_obj._pet_enabled is False
assert cli_obj._pet_renderer is None
def test_pet_fragments_render_kitty_placeholders(boba_like):
from agent.pet import render
cli_obj = _make_cli()
pet = store.load_pet("boba")
assert pet is not None
cli_obj._pet_renderer = PetRenderer(str(pet.spritesheet), mode="kitty", scale=0.4)
cli_obj._pet_slug = "boba"
cli_obj._pet_kitty_image_id = render.kitty_image_id("boba")
cli_obj._pet_enabled = True
frags = cli_obj._pet_fragments()
assert frags
assert any("\U0010eeee" in text for _, text in frags)
payload = cli_obj._pet_kitty_payload_for("idle")
assert payload is not None
color = render.kitty_color_hex(payload["image_id"])
assert all(f"fg:{color}" in style for style, text in frags if text != "\n")
assert cli_obj._pet_widget_height() > 0
cli_obj._pet_queue_kitty_frame("idle")
assert cli_obj._pet_kitty_pending.startswith("\x1b_G")
class Output:
def __init__(self):
self.raw = ""
self.flushed = False
def write_raw(self, text):
self.raw += text
def flush(self):
self.flushed = True
class App:
output = Output()
app = App()
cli_obj._pet_flush_kitty_frame(app)
assert app.output.raw.startswith("\x1b_G")
assert app.output.flushed is True
assert cli_obj._pet_kitty_pending == ""
def test_pet_off_clears_pending_kitty_frame(boba_like):
from hermes_cli.config import load_config, save_config
cli_obj = _make_cli()
cli_obj._pet_kitty_pending = "stale-apc"
cfg = load_config()
cfg.setdefault("display", {}).setdefault("pet", {}).update(
{"enabled": True, "slug": "boba", "render_mode": "off"}
)
save_config(cfg)
cli_obj._pet_resolve_config()
assert cli_obj._pet_enabled is False
assert cli_obj._pet_renderer is None
assert cli_obj._pet_kitty_pending == ""
def test_pet_resolve_wezterm_stays_unicode(boba_like, monkeypatch):
from hermes_cli.config import load_config, save_config
monkeypatch.delenv("KITTY_WINDOW_ID", raising=False)
monkeypatch.setenv("TERM", "xterm-256color")
monkeypatch.setenv("TERM_PROGRAM", "WezTerm")
cli_obj = _make_cli()
cfg = load_config()
cfg.setdefault("display", {}).setdefault("pet", {}).update(
{"enabled": True, "slug": "boba", "render_mode": "auto"}
)
save_config(cfg)
cli_obj._pet_resolve_config()
assert cli_obj._pet_renderer is not None
assert cli_obj._pet_renderer.mode == "unicode"
def test_pet_resolve_ghostty_uses_kitty(boba_like, monkeypatch):
from hermes_cli.config import load_config, save_config
monkeypatch.delenv("WEZTERM_PANE", raising=False)
monkeypatch.delenv("KITTY_WINDOW_ID", raising=False)
monkeypatch.setenv("TERM", "xterm-ghostty")
monkeypatch.setenv("TERM_PROGRAM", "ghostty")
cli_obj = _make_cli()
cfg = load_config()
cfg.setdefault("display", {}).setdefault("pet", {}).update(
{"enabled": True, "slug": "boba", "render_mode": "auto"}
)
save_config(cfg)
cli_obj._pet_resolve_config()
assert cli_obj._pet_renderer is not None
assert cli_obj._pet_renderer.mode == "kitty"
def test_force_full_redraw_requeues_kitty_frame(boba_like, monkeypatch):
from agent.pet import render
cli_obj = _make_cli()
pet = store.load_pet("boba")
assert pet is not None
cli_obj._pet_renderer = PetRenderer(str(pet.spritesheet), mode="kitty", scale=0.4)
cli_obj._pet_enabled = True
cli_obj._pet_kitty_image_id = render.kitty_image_id("boba")
cli_obj._terminal_io_broken = False
cli_obj._clear_prompt_toolkit_screen = lambda *a, **k: None
cli_obj._redraw_rebuilds_scrollback = lambda: False
class App:
def invalidate(self):
self.invalidated = True
cli_obj._app = App()
monkeypatch.setattr("cli._replay_output_history", lambda: None)
cli_obj._force_full_redraw()
assert cli_obj._pet_kitty_pending.startswith("\x1b_G")
+102
View File
@@ -0,0 +1,102 @@
"""Tests for slash command prefix matching in HermesCLI.process_command."""
from unittest.mock import MagicMock, patch
from cli import HermesCLI
def _make_cli():
cli_obj = HermesCLI.__new__(HermesCLI)
cli_obj.config = {}
cli_obj.console = MagicMock()
cli_obj.agent = None
cli_obj.conversation_history = []
cli_obj.session_id = None
cli_obj._pending_input = MagicMock()
return cli_obj
class TestSlashCommandPrefixMatching:
def test_unique_prefix_dispatches_command(self):
"""/con should dispatch to /config when it uniquely matches."""
cli_obj = _make_cli()
with patch.object(cli_obj, 'show_config') as mock_config:
cli_obj.process_command("/con")
mock_config.assert_called_once()
def test_ambiguous_prefix_shows_suggestions(self):
"""/re matches multiple commands — should show ambiguous message."""
cli_obj = _make_cli()
with patch("cli._cprint") as mock_cprint:
cli_obj.process_command("/re")
printed = " ".join(str(c) for c in mock_cprint.call_args_list)
assert "Ambiguous" in printed or "Did you mean" in printed
def test_skill_command_prefix_matches(self):
"""A prefix that uniquely matches a skill command should dispatch it."""
cli_obj = _make_cli()
fake_skill = {"/test-skill-xyz": {"name": "Test Skill", "description": "test"}}
printed = []
cli_obj.console.print = lambda *a, **kw: printed.append(str(a))
import cli as cli_mod
with patch.object(cli_mod, '_skill_commands', fake_skill):
cli_obj.process_command("/test-skill-xy")
# Should NOT show "Unknown command" — should have dispatched or attempted skill
unknown = any("Unknown command" in p for p in printed)
assert not unknown, f"Expected skill prefix to match, got: {printed}"
def test_ambiguous_between_builtin_and_skill(self):
"""Ambiguous prefix spanning builtin + skill commands shows suggestions."""
cli_obj = _make_cli()
# /help-extra is a fake skill that shares /hel prefix with /help
fake_skill = {"/help-extra": {"name": "Help Extra", "description": "test"}}
import cli as cli_mod
with patch.object(cli_mod, '_skill_commands', fake_skill), patch.object(cli_obj, 'show_help') as mock_help:
cli_obj.process_command("/help")
# /help is an exact match so should work normally, not show ambiguous
mock_help.assert_called_once()
printed = " ".join(str(c) for c in cli_obj.console.print.call_args_list)
assert "Ambiguous" not in printed
def test_shortest_match_preferred_over_longer_skill(self):
"""/qui should dispatch to /quit (5 chars) not report ambiguous with /quint-pipeline (15 chars)."""
cli_obj = _make_cli()
fake_skill = {"/quint-pipeline": {"name": "Quint Pipeline", "description": "test"}}
import cli as cli_mod
with patch.object(cli_mod, '_skill_commands', fake_skill):
# /quit is caught by the exact "/quit" branch → process_command returns False
result = cli_obj.process_command("/qui")
# Returns False because /quit was dispatched (exits chat loop)
assert result is False
printed = " ".join(str(c) for c in cli_obj.console.print.call_args_list)
assert "Ambiguous" not in printed
def test_tied_shortest_matches_still_ambiguous(self):
"""/re matches /reset and /retry (both 6 chars) — no unique shortest, stays ambiguous."""
cli_obj = _make_cli()
printed = []
import cli as cli_mod
with patch.object(cli_mod, '_cprint', side_effect=lambda t: printed.append(t)):
cli_obj.process_command("/re")
combined = " ".join(printed)
assert "Ambiguous" in combined or "Did you mean" in combined
def test_exact_typed_name_dispatches_over_longer_match(self):
"""/help typed with /help-extra skill installed → exact match wins."""
cli_obj = _make_cli()
fake_skill = {"/help-extra": {"name": "Help Extra", "description": ""}}
import cli as cli_mod
with patch.object(cli_mod, '_skill_commands', fake_skill), \
patch.object(cli_obj, 'show_help') as mock_help:
cli_obj.process_command("/help")
mock_help.assert_called_once()
printed = " ".join(str(c) for c in cli_obj.console.print.call_args_list)
assert "Ambiguous" not in printed
+155
View File
@@ -0,0 +1,155 @@
from __future__ import annotations
import importlib
import os
import sys
from unittest.mock import MagicMock, patch
import pytest
def _make_real_cli(**kwargs):
clean_config = {
"model": {
"default": "anthropic/claude-opus-4.6",
"base_url": "https://openrouter.ai/api/v1",
"provider": "auto",
},
"display": {"compact": False, "tool_progress": "all"},
"agent": {},
"terminal": {"env_type": "local"},
}
clean_env = {"LLM_MODEL": "", "HERMES_MAX_ITERATIONS": ""}
prompt_toolkit_stubs = {
"prompt_toolkit": MagicMock(),
"prompt_toolkit.history": MagicMock(),
"prompt_toolkit.styles": MagicMock(),
"prompt_toolkit.patch_stdout": MagicMock(),
"prompt_toolkit.application": MagicMock(),
"prompt_toolkit.layout": MagicMock(),
"prompt_toolkit.layout.processors": MagicMock(),
"prompt_toolkit.filters": MagicMock(),
"prompt_toolkit.layout.dimension": MagicMock(),
"prompt_toolkit.layout.menus": MagicMock(),
"prompt_toolkit.widgets": MagicMock(),
"prompt_toolkit.key_binding": MagicMock(),
"prompt_toolkit.completion": MagicMock(),
"prompt_toolkit.formatted_text": MagicMock(),
}
with patch.dict(sys.modules, prompt_toolkit_stubs), patch.dict(
"os.environ", clean_env, clear=False
):
import cli as cli_mod
cli_mod = importlib.reload(cli_mod)
with patch.object(cli_mod, "get_tool_definitions", return_value=[]), patch.dict(
cli_mod.__dict__, {"CLI_CONFIG": clean_config}
):
return cli_mod.HermesCLI(**kwargs)
class _DummyCLI:
def __init__(self, **kwargs):
self.kwargs = kwargs
self.session_id = "session-123"
self.system_prompt = "base prompt"
self.preloaded_skills = []
def show_banner(self):
return None
def show_tools(self):
return None
def show_toolsets(self):
return None
def run(self):
return None
def _real_finalize(cli_obj):
"""Call the real HermesCLI.finalize_preloaded_skills on a dummy object."""
return _REAL_FINALIZE(cli_obj)
def _capture_real_finalize():
import cli as cli_mod
return cli_mod.HermesCLI.__dict__["finalize_preloaded_skills"]
_REAL_FINALIZE = _capture_real_finalize()
def test_main_applies_preloaded_skills_to_system_prompt(monkeypatch):
import cli as cli_mod
created = {}
def fake_cli(**kwargs):
created["cli"] = _DummyCLI(**kwargs)
return created["cli"]
monkeypatch.setattr(cli_mod, "HermesCLI", fake_cli)
monkeypatch.setattr(
cli_mod,
"build_preloaded_skills_prompt",
lambda skills, task_id=None: ("skill prompt", ["hermes-agent-dev", "github-auth"], []),
)
with pytest.raises(SystemExit):
cli_mod.main(skills="hermes-agent-dev,github-auth", list_tools=True)
cli_obj = created["cli"]
# The preload now runs in a background thread and is folded in at agent
# init via finalize_preloaded_skills() (startup-latency change). Drive
# the finalize explicitly — the same call _init_agent makes.
_real_finalize(cli_obj)
assert cli_obj.system_prompt == "base prompt\n\nskill prompt"
assert cli_obj.preloaded_skills == ["hermes-agent-dev", "github-auth"]
def test_main_raises_for_unknown_preloaded_skill(monkeypatch):
import cli as cli_mod
created = {}
def fake_cli(**kwargs):
created["cli"] = _DummyCLI(**kwargs)
return created["cli"]
monkeypatch.setattr(cli_mod, "HermesCLI", fake_cli)
monkeypatch.setattr(
cli_mod,
"build_preloaded_skills_prompt",
lambda skills, task_id=None: ("", [], ["missing-skill"]),
)
with pytest.raises(SystemExit):
cli_mod.main(skills="missing-skill", list_tools=True)
# The all-skills-unknown hard failure now surfaces when the preload is
# finalized (agent init), preserving the fail-loud contract.
with pytest.raises(ValueError, match=r"Unknown skill\(s\): missing-skill"):
_real_finalize(created["cli"])
def test_show_banner_does_not_print_skills():
"""show_banner() no longer prints the activated skills line — it moved to run()."""
cli_obj = _make_real_cli(compact=False)
cli_obj.preloaded_skills = ["hermes-agent-dev", "github-auth"]
cli_obj.console = MagicMock()
with patch("hermes_cli.banner.build_welcome_banner") as mock_banner, patch(
"shutil.get_terminal_size", return_value=os.terminal_size((120, 40))
):
cli_obj.show_banner()
print_calls = [
call.args[0]
for call in cli_obj.console.print.call_args_list
if call.args and isinstance(call.args[0], str)
]
startup_lines = [line for line in print_calls if "Activated skills:" in line]
assert len(startup_lines) == 0
assert mock_banner.call_count == 1
+685
View File
@@ -0,0 +1,685 @@
import importlib
import sys
import types
from contextlib import nullcontext
from types import SimpleNamespace
import pytest
from hermes_cli.auth import AuthError
from hermes_cli import main as hermes_main
import hermes_cli.main_provider_setup as hermes_cli_main_provider_setup
from hermes_cli import model_setup_flows
# ---------------------------------------------------------------------------
# Module isolation: _import_cli() wipes tools.* / cli / run_agent from
# sys.modules so it can re-import cli fresh. Without cleanup the wiped
# modules leak into subsequent tests, breaking
# mock patches that target "tools.file_tools._get_file_ops" etc.
# ---------------------------------------------------------------------------
def _reset_modules(prefixes: tuple[str, ...]):
for name in list(sys.modules):
if any(name == p or name.startswith(p + ".") for p in prefixes):
sys.modules.pop(name, None)
@pytest.fixture(autouse=True)
def _restore_cli_and_tool_modules():
"""Save and restore tools/cli/run_agent modules around every test."""
prefixes = ("tools", "cli", "run_agent")
original_modules = {
name: module
for name, module in sys.modules.items()
if any(name == p or name.startswith(p + ".") for p in prefixes)
}
try:
yield
finally:
_reset_modules(prefixes)
sys.modules.update(original_modules)
def _install_prompt_toolkit_stubs():
class _Dummy:
def __init__(self, *args, **kwargs):
pass
class _Condition:
def __init__(self, func):
self.func = func
def __bool__(self):
return bool(self.func())
class _ANSI(str):
pass
root = types.ModuleType("prompt_toolkit")
history = types.ModuleType("prompt_toolkit.history")
styles = types.ModuleType("prompt_toolkit.styles")
patch_stdout = types.ModuleType("prompt_toolkit.patch_stdout")
application = types.ModuleType("prompt_toolkit.application")
layout = types.ModuleType("prompt_toolkit.layout")
processors = types.ModuleType("prompt_toolkit.layout.processors")
filters = types.ModuleType("prompt_toolkit.filters")
dimension = types.ModuleType("prompt_toolkit.layout.dimension")
menus = types.ModuleType("prompt_toolkit.layout.menus")
widgets = types.ModuleType("prompt_toolkit.widgets")
key_binding = types.ModuleType("prompt_toolkit.key_binding")
completion = types.ModuleType("prompt_toolkit.completion")
formatted_text = types.ModuleType("prompt_toolkit.formatted_text")
history.FileHistory = _Dummy
styles.Style = _Dummy
patch_stdout.patch_stdout = lambda *args, **kwargs: nullcontext()
application.Application = _Dummy
layout.Layout = _Dummy
layout.HSplit = _Dummy
layout.Window = _Dummy
layout.FormattedTextControl = _Dummy
layout.ConditionalContainer = _Dummy
processors.Processor = _Dummy
processors.Transformation = _Dummy
processors.PasswordProcessor = _Dummy
processors.ConditionalProcessor = _Dummy
filters.Condition = _Condition
dimension.Dimension = _Dummy
menus.CompletionsMenu = _Dummy
widgets.TextArea = _Dummy
key_binding.KeyBindings = _Dummy
completion.Completer = _Dummy
completion.Completion = _Dummy
formatted_text.ANSI = _ANSI
root.print_formatted_text = lambda *args, **kwargs: None
sys.modules.setdefault("prompt_toolkit", root)
sys.modules.setdefault("prompt_toolkit.history", history)
sys.modules.setdefault("prompt_toolkit.styles", styles)
sys.modules.setdefault("prompt_toolkit.patch_stdout", patch_stdout)
sys.modules.setdefault("prompt_toolkit.application", application)
sys.modules.setdefault("prompt_toolkit.layout", layout)
sys.modules.setdefault("prompt_toolkit.layout.processors", processors)
sys.modules.setdefault("prompt_toolkit.filters", filters)
sys.modules.setdefault("prompt_toolkit.layout.dimension", dimension)
sys.modules.setdefault("prompt_toolkit.layout.menus", menus)
sys.modules.setdefault("prompt_toolkit.widgets", widgets)
sys.modules.setdefault("prompt_toolkit.key_binding", key_binding)
sys.modules.setdefault("prompt_toolkit.completion", completion)
sys.modules.setdefault("prompt_toolkit.formatted_text", formatted_text)
def _import_cli():
for name in list(sys.modules):
if name == "cli" or name == "run_agent" or name == "tools" or name.startswith("tools."):
sys.modules.pop(name, None)
if "firecrawl" not in sys.modules:
sys.modules["firecrawl"] = types.SimpleNamespace(Firecrawl=object)
try:
importlib.import_module("prompt_toolkit")
except ModuleNotFoundError:
_install_prompt_toolkit_stubs()
return importlib.import_module("cli")
def test_provider_flag_uses_named_custom_default_model(monkeypatch):
"""`--provider <custom>` without `-m` uses that entry's default_model (#86978)."""
cli = _import_cli()
monkeypatch.setitem(
cli.CLI_CONFIG,
"model",
{"default": "tencent/hy3:free", "provider": "nous"},
)
config = {
"model": {"default": "tencent/hy3:free", "provider": "nous"},
"providers": {
"gmk-lan": {
"name": "GMK Local",
"base_url": "http://gmk.lan:9931/v1",
"api_key": "not-needed",
"default_model": "/models/gemma.gguf",
}
},
}
monkeypatch.setattr("hermes_cli.config.load_config", lambda: config)
monkeypatch.setattr("hermes_cli.runtime_provider.load_config", lambda: config)
shell = cli.HermesCLI(provider="gmk-lan", compact=True, max_turns=1)
assert shell.model == "/models/gemma.gguf"
assert shell.requested_provider == "gmk-lan"
def test_explicit_model_wins_over_provider_default_model(monkeypatch):
"""`-m` still wins when `--provider` also names a custom default_model."""
cli = _import_cli()
monkeypatch.setitem(
cli.CLI_CONFIG,
"model",
{"default": "tencent/hy3:free", "provider": "nous"},
)
config = {
"model": {"default": "tencent/hy3:free", "provider": "nous"},
"providers": {
"gmk-lan": {
"name": "GMK Local",
"base_url": "http://gmk.lan:9931/v1",
"api_key": "not-needed",
"default_model": "/models/gemma.gguf",
}
},
}
monkeypatch.setattr("hermes_cli.config.load_config", lambda: config)
monkeypatch.setattr("hermes_cli.runtime_provider.load_config", lambda: config)
shell = cli.HermesCLI(
provider="gmk-lan",
model="explicit-id",
compact=True,
max_turns=1,
)
assert shell.model == "explicit-id"
def test_provider_flag_logs_when_custom_default_model_cannot_resolve(monkeypatch, caplog):
"""A named --provider that fails to resolve must not fail silently."""
cli = _import_cli()
monkeypatch.setitem(
cli.CLI_CONFIG,
"model",
{"default": "tencent/hy3:free", "provider": "nous"},
)
def _boom(_name):
raise RuntimeError("catalog unavailable")
monkeypatch.setattr(
"hermes_cli.runtime_provider._get_named_custom_provider",
_boom,
)
with caplog.at_level("WARNING"):
shell = cli.HermesCLI(provider="gmk-lan", compact=True, max_turns=1)
assert shell.model == "tencent/hy3:free"
assert any(
"gmk-lan" in rec.getMessage() and "catalog unavailable" in rec.getMessage()
for rec in caplog.records
)
def test_hermes_cli_init_does_not_eagerly_resolve_runtime_provider(monkeypatch):
cli = _import_cli()
calls = {"count": 0}
def _unexpected_runtime_resolve(**kwargs):
calls["count"] += 1
raise AssertionError("resolve_runtime_provider should not be called in HermesCLI.__init__")
monkeypatch.setattr("hermes_cli.runtime_provider.resolve_runtime_provider", _unexpected_runtime_resolve)
monkeypatch.setattr("hermes_cli.runtime_provider.format_runtime_provider_error", lambda exc: str(exc))
shell = cli.HermesCLI(model="gpt-5", compact=True, max_turns=1)
assert shell is not None
assert calls["count"] == 0
def test_runtime_resolution_failure_is_not_sticky(monkeypatch):
cli = _import_cli()
calls = {"count": 0}
def _runtime_resolve(**kwargs):
calls["count"] += 1
if calls["count"] == 1:
raise RuntimeError("temporary auth failure")
return {
"provider": "openrouter",
"api_mode": "chat_completions",
"base_url": "https://openrouter.ai/api/v1",
"api_key": "test-key",
"source": "env/config",
}
class _DummyAgent:
def __init__(self, *args, **kwargs):
self.kwargs = kwargs
monkeypatch.setattr("hermes_cli.runtime_provider.resolve_runtime_provider", _runtime_resolve)
monkeypatch.setattr("hermes_cli.runtime_provider.format_runtime_provider_error", lambda exc: str(exc))
monkeypatch.setattr("run_agent.AIAgent", _DummyAgent)
shell = cli.HermesCLI(model="gpt-5", compact=True, max_turns=1)
assert shell._init_agent() is False
assert shell._init_agent() is True
assert calls["count"] == 2
assert shell.agent is not None
def test_cli_turn_routing_uses_primary_when_disabled(monkeypatch):
cli = _import_cli()
shell = cli.HermesCLI(model="gpt-5", compact=True, max_turns=1)
shell.provider = "openrouter"
shell.api_mode = "chat_completions"
shell.base_url = "https://openrouter.ai/api/v1"
shell.api_key = "sk-primary"
result = shell._resolve_turn_agent_config("what time is it in tokyo?")
assert result["model"] == "gpt-5"
assert result["runtime"]["provider"] == "openrouter"
def test_model_flow_nous_does_not_restore_stale_custom_api_key(tmp_path, monkeypatch):
import yaml
config_home = tmp_path / "hermes"
config_home.mkdir()
monkeypatch.setenv("HERMES_HOME", str(config_home))
config_path = config_home / "config.yaml"
config_path.write_text(
yaml.safe_dump(
{
"model": {
"provider": "custom",
"default": "glm-5.2",
"base_url": "https://api.neuralwatt.com/v1",
"api_key": "${NEURALWATT_API_KEY}",
"api_mode": "chat_completions",
}
},
sort_keys=False,
)
)
stale_config = yaml.safe_load(config_path.read_text()) or {}
selected_model = "deepseek/deepseek-v4-flash"
monkeypatch.setattr(
"hermes_cli.auth.get_provider_auth_state",
lambda provider: {
"access_token": "nous-token",
"portal_base_url": "https://portal.example.com",
},
)
monkeypatch.setattr(
"hermes_cli.auth.resolve_nous_runtime_credentials",
lambda *args, **kwargs: {
"base_url": "https://inference-api.nousresearch.com/v1",
"api_key": "nous-key",
},
)
monkeypatch.setattr(
"hermes_cli.models.get_curated_nous_model_ids",
lambda: [selected_model],
)
monkeypatch.setattr("hermes_cli.models_pricing.get_pricing_for_provider", lambda provider: {})
monkeypatch.setattr("hermes_cli.models.check_nous_free_tier", lambda **kwargs: False)
monkeypatch.setattr(
"hermes_cli.models.union_with_portal_paid_recommendations",
lambda model_ids, pricing, portal_url: (model_ids, pricing),
)
monkeypatch.setattr(
"hermes_cli.auth._prompt_model_selection",
lambda *args, **kwargs: selected_model,
)
monkeypatch.setattr(
"hermes_cli.nous_subscription.prompt_enable_tool_gateway",
lambda config: None,
)
hermes_main._model_flow_nous(stale_config, current_model="glm-5.2")
config = yaml.safe_load(config_path.read_text()) or {}
model = config.get("model")
assert model["provider"] == "nous"
assert model["default"] == selected_model
assert model["base_url"] == "https://inference-api.nousresearch.com/v1"
assert "api_key" not in model
assert "api_mode" not in model
def _seed_stale_custom_model(tmp_path, monkeypatch):
import yaml
config_home = tmp_path / "hermes"
config_home.mkdir()
monkeypatch.setenv("HERMES_HOME", str(config_home))
config_path = config_home / "config.yaml"
config_path.write_text(
yaml.safe_dump(
{
"model": {
"provider": "custom",
"default": "glm-5.2",
"base_url": "https://api.neuralwatt.com/v1",
"api_key": "${NEURALWATT_API_KEY}",
"api": "legacy-stale-key",
"api_mode": "anthropic_messages",
}
},
sort_keys=False,
)
)
(config_home / ".env").write_text("")
return config_path
def test_codex_provider_uses_config_model(monkeypatch):
"""Model comes from config.yaml, not LLM_MODEL env var.
Config.yaml is the single source of truth to avoid multi-agent conflicts."""
cli = _import_cli()
# LLM_MODEL env var should be IGNORED (even if set)
monkeypatch.setenv("LLM_MODEL", "should-be-ignored")
monkeypatch.delenv("OPENAI_MODEL", raising=False)
# Set model via config
monkeypatch.setitem(cli.CLI_CONFIG, "model", {
"default": "gpt-5.2-codex",
"provider": "openai-codex",
"base_url": "https://chatgpt.com/backend-api/codex",
})
def _runtime_resolve(**kwargs):
return {
"provider": "openai-codex",
"api_mode": "codex_responses",
"base_url": "https://chatgpt.com/backend-api/codex",
"api_key": "fake-codex-token",
"source": "env/config",
}
monkeypatch.setattr("hermes_cli.runtime_provider.resolve_runtime_provider", _runtime_resolve)
monkeypatch.setattr("hermes_cli.runtime_provider.format_runtime_provider_error", lambda exc: str(exc))
# Prevent live API call from overriding the config model
monkeypatch.setattr(
"hermes_cli.codex_models.get_codex_model_ids",
lambda access_token=None: ["gpt-5.2-codex"],
)
shell = cli.HermesCLI(compact=True, max_turns=1)
assert shell._ensure_runtime_credentials() is True
assert shell.provider == "openai-codex"
# Model from config (may be normalized by codex provider logic)
assert "codex" in shell.model.lower()
# LLM_MODEL env var is NOT used
assert shell.model != "should-be-ignored"
def test_model_flow_custom_saves_verified_v1_base_url(monkeypatch, capsys):
monkeypatch.setattr(
"hermes_cli.config.get_env_value",
lambda key: "" if key in {"OPENAI_BASE_URL", "OPENAI_API_KEY"} else "",
)
saved_env = {}
monkeypatch.setattr("hermes_cli.config.save_env_value", lambda key, value: saved_env.__setitem__(key, value))
monkeypatch.setattr("hermes_cli.auth._save_model_choice", lambda model: saved_env.__setitem__("MODEL", model))
monkeypatch.setattr("hermes_cli.auth.deactivate_provider", lambda: None)
monkeypatch.setattr("hermes_cli.main_provider_setup._save_custom_provider", lambda *args, **kwargs: None)
monkeypatch.setattr(
"hermes_cli.models.probe_api_models",
lambda api_key, base_url: {
"models": ["llm"],
"probed_url": "http://localhost:8000/v1/models",
"resolved_base_url": "http://localhost:8000/v1",
"suggested_base_url": "http://localhost:8000/v1",
"used_fallback": True,
},
)
monkeypatch.setattr(
"hermes_cli.config.load_config",
lambda: {"model": {"default": "", "provider": "custom", "base_url": ""}},
)
monkeypatch.setattr("hermes_cli.config.save_config", lambda cfg: None)
# After the probe detects a single model ("llm"), the flow asks
# "Use this model? [Y/n]:" — confirm with Enter, then context length,
# then display name. The api_mode prompt also runs before model selection.
answers = iter(["http://localhost:8000", "local-key", "", "", "", "", ""])
monkeypatch.setattr("builtins.input", lambda _prompt="": next(answers))
monkeypatch.setattr("hermes_cli.secret_prompt.masked_secret_prompt", lambda _prompt="": next(answers))
hermes_main._model_flow_custom({})
output = capsys.readouterr().out
assert "Saving the working base URL instead" in output
assert "Detected model: llm" in output
# OPENAI_BASE_URL is no longer saved to .env — config.yaml is authoritative
assert "OPENAI_BASE_URL" not in saved_env
assert saved_env["MODEL"] == "llm"
def test_model_flow_custom_persists_selected_api_mode(monkeypatch):
saved_cfg = {"model": {"default": "", "provider": "custom", "base_url": ""}}
captured_provider = {}
monkeypatch.setattr(
"hermes_cli.config.get_env_value",
lambda key: "" if key in {"OPENAI_BASE_URL", "OPENAI_API_KEY"} else "",
)
monkeypatch.setattr("hermes_cli.auth._save_model_choice", lambda model: None)
monkeypatch.setattr("hermes_cli.auth.deactivate_provider", lambda: None)
monkeypatch.setattr(
"hermes_cli.models.probe_api_models",
lambda api_key, base_url: {
"models": [],
"probed_url": f"{base_url.rstrip('/')}/models",
"resolved_base_url": None,
"suggested_base_url": None,
"used_fallback": False,
},
)
saved_env = {}
monkeypatch.setattr("hermes_cli.config.load_config", lambda: saved_cfg)
monkeypatch.setattr("hermes_cli.config.save_config", lambda cfg: saved_cfg.update(cfg))
monkeypatch.setattr(
"hermes_cli.config.save_env_value",
lambda key, value: saved_env.__setitem__(key, value),
)
monkeypatch.setattr(
"hermes_cli.main_provider_setup._save_custom_provider",
lambda base_url, api_key="", model="", context_length=None, name=None, api_mode=None, key_env="": captured_provider.update(
{
"base_url": base_url,
"api_key": api_key,
"model": model,
"context_length": context_length,
"name": name,
"api_mode": api_mode,
"key_env": key_env,
}
),
)
answers = iter(
[
"https://codex.example.com/v1",
"3",
"chosen-model",
"",
"",
]
)
monkeypatch.setattr("builtins.input", lambda _prompt="": next(answers))
monkeypatch.setattr("hermes_cli.secret_prompt.masked_secret_prompt", lambda _prompt="": "test-key")
hermes_main._model_flow_custom({"model": {"provider": "custom"}})
assert saved_cfg["model"]["provider"] == "custom"
assert saved_cfg["model"]["base_url"] == "https://codex.example.com/v1"
assert saved_cfg["model"]["api_mode"] == "codex_responses"
assert captured_provider["api_mode"] == "codex_responses"
# The key itself goes to .env; config.yaml only references it (#69449).
key_env = captured_provider["key_env"]
assert saved_cfg["model"]["api_key"] == f"${{{key_env}}}"
assert saved_env[key_env] == "test-key"
def test_cmd_model_forwards_nous_login_tls_options(monkeypatch):
monkeypatch.setattr(hermes_main, "_require_tty", lambda *a: None)
monkeypatch.setattr(
"hermes_cli.config.load_config",
lambda: {"model": {"default": "gpt-5", "provider": "nous"}},
)
monkeypatch.setattr("hermes_cli.config.save_config", lambda cfg: None)
monkeypatch.setattr("hermes_cli.config.get_env_value", lambda key: "")
monkeypatch.setattr("hermes_cli.config.save_env_value", lambda key, value: None)
monkeypatch.setattr("hermes_cli.auth.resolve_provider", lambda requested, **kwargs: "nous")
monkeypatch.setattr("hermes_cli.auth.get_provider_auth_state", lambda provider_id: None)
monkeypatch.setattr(hermes_main, "_prompt_provider_choice", lambda choices, **kwargs: 0)
monkeypatch.setattr(hermes_cli_main_provider_setup, "_prompt_provider_choice", lambda choices, **kwargs: 0)
captured = {}
def _fake_login(login_args, provider_config):
captured["portal_url"] = login_args.portal_url
captured["inference_url"] = login_args.inference_url
captured["client_id"] = login_args.client_id
captured["scope"] = login_args.scope
captured["no_browser"] = login_args.no_browser
captured["timeout"] = login_args.timeout
captured["ca_bundle"] = login_args.ca_bundle
captured["insecure"] = login_args.insecure
monkeypatch.setattr("hermes_cli.auth._login_nous", _fake_login)
hermes_main.cmd_model(
SimpleNamespace(
portal_url="https://portal.nousresearch.com",
inference_url="https://inference.nousresearch.com/v1",
client_id="hermes-local",
scope="openid profile",
no_browser=True,
timeout=7.5,
ca_bundle="/tmp/local-ca.pem",
insecure=True,
)
)
assert captured == {
"portal_url": "https://portal.nousresearch.com",
"inference_url": "https://inference.nousresearch.com/v1",
"client_id": "hermes-local",
"scope": "openid profile",
"no_browser": True,
"timeout": 7.5,
"ca_bundle": "/tmp/local-ca.pem",
"insecure": True,
}
# ---------------------------------------------------------------------------
# _auto_provider_name — unit tests
# ---------------------------------------------------------------------------
def test_auto_provider_name_localhost():
from hermes_cli.main_provider_setup import _auto_provider_name
assert _auto_provider_name("http://localhost:11434/v1") == "Local (localhost:11434)"
assert _auto_provider_name("http://127.0.0.1:1234/v1") == "Local (127.0.0.1:1234)"
def test_save_custom_provider_uses_provided_name(monkeypatch, tmp_path):
"""When a display name is passed, it should appear in the saved entry."""
import yaml
from hermes_cli.main_provider_setup import _save_custom_provider
cfg_path = tmp_path / "config.yaml"
cfg_path.write_text(yaml.dump({}))
monkeypatch.setattr(
"hermes_cli.config.load_config", lambda: yaml.safe_load(cfg_path.read_text()) or {},
)
saved = {}
def _save(cfg):
saved.update(cfg)
monkeypatch.setattr("hermes_cli.config.save_config", _save)
_save_custom_provider("http://localhost:11434/v1", name="Ollama")
entries = saved.get("custom_providers", [])
assert len(entries) == 1
assert entries[0]["name"] == "Ollama"
def test_save_custom_provider_references_the_key_instead_of_inlining_it(monkeypatch, tmp_path):
"""With key_env set the entry must not carry the secret (#69449)."""
import yaml
from hermes_cli.main_provider_setup import _save_custom_provider
cfg_path = tmp_path / "config.yaml"
cfg_path.write_text(yaml.dump({}))
monkeypatch.setattr(
"hermes_cli.config.load_config", lambda: yaml.safe_load(cfg_path.read_text()) or {},
)
saved = {}
monkeypatch.setattr("hermes_cli.config.save_config", lambda cfg: saved.update(cfg))
_save_custom_provider(
"http://localhost:11434/v1",
api_key="sk-secret",
name="Ollama",
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
assert "sk-secret" not in yaml.safe_dump(saved)
def test_custom_endpoint_key_env_is_a_valid_posix_name_for_ip_endpoints():
"""Every IP-based local endpoint slugs to a digit-leading name.
``save_env_value`` rejects names that don't match
``[A-Za-z_][A-Za-z0-9_]*``, so deriving ``127_0_0_1_8080_API_KEY`` would
raise on exactly the local-proxy setups this is meant to protect. The
fixed prefix makes the result valid by construction.
"""
import re
from hermes_cli.config import _ENV_VAR_NAME_RE, custom_endpoint_key_env
for identity in ("127.0.0.1_8080", "0.0.0.0", "10.0.0.7:11434", "", "-–-"):
assert _ENV_VAR_NAME_RE.match(custom_endpoint_key_env(identity)), identity
+22
View File
@@ -0,0 +1,22 @@
"""Regression tests for collapsed paste references passed to /queue."""
from queue import Queue
from unittest.mock import patch
from cli import HermesCLI
def test_queue_expands_collapsed_paste_reference(tmp_path):
pasted = "first\nmiddle\nlast"
paste_file = tmp_path / "paste.txt"
paste_file.write_text(pasted, encoding="utf-8")
placeholder = f"[Pasted text #1: 3 lines → {paste_file}]"
cli_obj = HermesCLI.__new__(HermesCLI)
cli_obj._agent_running = False
cli_obj._pending_input = Queue()
cli_obj._pending_resume_sessions = None
with patch("cli._cprint"):
assert cli_obj.process_command(f"/queue {placeholder}") is True
assert cli_obj._pending_input.get_nowait() == pasted
+99
View File
@@ -0,0 +1,99 @@
"""Tests for the ``/reload-skills`` CLI slash command (``HermesCLI._reload_skills``).
The CLI handler prints the diff (name + description) for the user and —
when any skills were added or removed — queues a one-shot note on
``self._pending_skills_reload_note``. The note is prepended to the NEXT
user message (see cli.py ~L8770, same pattern as
``_pending_model_switch_note``) and cleared after use, so no phantom user
turn is persisted to ``conversation_history``.
"""
from unittest.mock import patch
def _make_cli():
"""Build a minimal HermesCLI shell exposing ``_reload_skills``."""
import cli as cli_mod
obj = object.__new__(cli_mod.HermesCLI)
obj._command_running = False
obj.conversation_history = []
obj.agent = None
return obj
class TestReloadSkillsCLI:
def test_reports_added_and_removed_and_queues_note(self, capsys):
cli = _make_cli()
with patch(
"agent.skill_commands.reload_skills",
return_value={
"added": [
{"name": "alpha", "description": "Run alpha to do xyz"},
{"name": "beta", "description": "Run beta to do abc"},
],
"removed": [
{"name": "gamma", "description": "Old removed skill"},
],
"unchanged": ["delta"],
"total": 3,
"commands": 3,
},
):
cli._reload_skills()
out = capsys.readouterr().out
assert "Added Skills:" in out
assert "- alpha: Run alpha to do xyz" in out
assert "- beta: Run beta to do abc" in out
assert "Removed Skills:" in out
assert "- gamma: Old removed skill" in out
assert "3 skill(s) available" in out
# Must NOT pollute conversation_history — alternation-safe.
assert cli.conversation_history == []
# One-shot note queued with system-prompt-style formatting.
note = getattr(cli, "_pending_skills_reload_note", None)
assert note is not None
assert note.startswith("[USER INITIATED SKILLS RELOAD:")
assert note.endswith("Use skills_list to see the updated catalog.]")
assert "Added Skills:" in note
assert " - alpha: Run alpha to do xyz" in note
assert " - beta: Run beta to do abc" in note
assert "Removed Skills:" in note
assert " - gamma: Old removed skill" in note
def test_reports_no_changes_and_queues_nothing(self, capsys):
cli = _make_cli()
with patch(
"agent.skill_commands.reload_skills",
return_value={
"added": [],
"removed": [],
"unchanged": ["alpha"],
"total": 1,
"commands": 1,
},
):
cli._reload_skills()
out = capsys.readouterr().out
assert "No new skills detected" in out
assert "1 skill(s) available" in out
assert cli.conversation_history == []
assert getattr(cli, "_pending_skills_reload_note", None) is None
def test_handles_reload_failure_gracefully(self, capsys):
cli = _make_cli()
with patch(
"agent.skill_commands.reload_skills",
side_effect=RuntimeError("boom"),
):
cli._reload_skills()
out = capsys.readouterr().out
assert "Skills reload failed" in out
assert "boom" in out
assert cli.conversation_history == []
assert getattr(cli, "_pending_skills_reload_note", None) is None
+266
View File
@@ -0,0 +1,266 @@
import os
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
from cli import HermesCLI
def _make_cli():
cli_obj = HermesCLI.__new__(HermesCLI)
cli_obj.session_id = "current_session"
cli_obj._resumed = False
cli_obj._pending_title = None
cli_obj.conversation_history = []
cli_obj.agent = None
cli_obj._session_db = MagicMock()
cli_obj._pending_resume_sessions = None
# _handle_resume_command now triggers _display_resumed_history (#31695),
# which reads self.resume_display. "minimal" short-circuits the recap so
# the test only exercises session-switch behavior.
cli_obj.resume_display = "minimal"
return cli_obj
class TestCliResumeCommand:
def test_show_recent_sessions_includes_indexes_and_resume_hint(self, capsys):
cli_obj = _make_cli()
cli_obj._list_recent_sessions = MagicMock(return_value=[
{"id": "sess_002", "title": "Coding", "preview": "build feature", "last_active": None},
{"id": "sess_001", "title": "Research", "preview": "read docs", "last_active": None},
])
shown = cli_obj._show_recent_sessions(reason="resume")
output = capsys.readouterr().out
assert shown is True
assert "1" in output
assert "2" in output
assert "Coding" in output
assert "Research" in output
assert "/resume 2" in output
assert "/resume <session title>" in output
def test_show_recent_sessions_uses_prompt_toolkit_safe_print(self):
cli_obj = _make_cli()
cli_obj._list_recent_sessions = MagicMock(return_value=[
{"id": "sess_002", "title": "Coding", "preview": "build feature", "last_active": None},
])
running_app = SimpleNamespace(_is_running=True)
with (
patch("prompt_toolkit.application.get_app_or_none", return_value=running_app),
patch("cli._cprint") as mock_cprint,
):
shown = cli_obj._show_recent_sessions(reason="sessions")
assert shown is True
printed = "\n".join(call.args[0] for call in mock_cprint.call_args_list)
assert "Recent sessions" in printed
assert "Coding" in printed
def test_handle_resume_by_index_switches_to_numbered_session(self):
cli_obj = _make_cli()
cli_obj._list_recent_sessions = MagicMock(return_value=[
{"id": "sess_002", "title": "Coding"},
{"id": "sess_001", "title": "Research"},
])
cli_obj._session_db.get_session.return_value = {"id": "sess_001", "title": "Research"}
cli_obj._session_db.get_resume_conversations.return_value = [
{"role": "user", "content": "hello"},
{"role": "assistant", "content": "hi"},
], [
{"role": "user", "content": "hello"},
{"role": "assistant", "content": "hi"},
]
# resolve_resume_session_id passes the id through when no compression chain.
cli_obj._session_db.resolve_resume_session_id.return_value = "sess_001"
with (
patch("hermes_cli.main._resolve_session_by_name_or_id", return_value=None),
patch("cli._cprint") as mock_cprint,
):
cli_obj._handle_resume_command("/resume 2")
printed = " ".join(str(call) for call in mock_cprint.call_args_list)
assert cli_obj.session_id == "sess_001"
assert "Resumed session sess_001" in printed
assert "Research" in printed
def test_handle_resume_by_index_out_of_range(self):
cli_obj = _make_cli()
cli_obj._list_recent_sessions = MagicMock(return_value=[
{"id": "sess_002", "title": "Coding"},
])
with patch("cli._cprint") as mock_cprint:
cli_obj._handle_resume_command("/resume 9")
printed = " ".join(str(call) for call in mock_cprint.call_args_list)
assert "out of range" in printed.lower()
assert "/resume" in printed
assert cli_obj.session_id == "current_session"
class TestCliResumeRestoresCwd:
"""Mid-chat /resume must retarget the working directory to where the
session was started — the same contract as a startup ``hermes -c`` /
``--resume``.
Regression coverage for #38562: ``_restore_session_cwd()`` was wired into
the startup resume paths but not into ``_handle_resume_command()``, so an
interactive ``/resume`` (and ``/sessions <id>``, which delegates here) left
the process + ``TERMINAL_CWD`` pointing at whatever directory the user had
cd'd into — so the terminal/code-exec tools and relative paths ran in the
wrong repo.
"""
def _resumable_cli(self, session_meta):
cli_obj = _make_cli()
cli_obj._session_db.get_session.return_value = session_meta
cli_obj._session_db.get_resume_conversations.return_value = [
{"role": "user", "content": "hello"},
], [
{"role": "user", "content": "hello"},
]
cli_obj._session_db.resolve_resume_session_id.return_value = session_meta["id"]
return cli_obj
def test_handle_resume_restores_recorded_cwd(self, tmp_path):
recorded = str(tmp_path)
cli_obj = self._resumable_cli({"id": "sess_dir", "title": "Dir", "cwd": recorded})
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,
patch.dict(os.environ, {}, clear=False),
):
cli_obj._handle_resume_command("/resume Dir")
# Assert inside the patch.dict scope — it restores os.environ on exit.
assert os.environ.get("TERMINAL_CWD") == recorded
mock_chdir.assert_called_once_with(recorded)
def test_sessions_command_restores_recorded_cwd(self, tmp_path):
# /sessions <id> delegates to the resume flow, so it restores cwd too.
recorded = str(tmp_path)
cli_obj = self._resumable_cli({"id": "sess_dir", "title": "Dir", "cwd": recorded})
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,
patch.dict(os.environ, {}, clear=False),
):
cli_obj._handle_sessions_command("/sessions Dir")
# Assert inside the patch.dict scope — it restores os.environ on exit.
assert os.environ.get("TERMINAL_CWD") == recorded
mock_chdir.assert_called_once_with(recorded)
class TestPendingResumeNumberedSelection:
"""Bare `/resume` arms a one-shot prompt so the next bare number resumes.
Regression coverage for #34584: previously, running `/resume` (no args)
printed the recent-sessions list but left no selection state armed, so
typing just `3` on the next line was sent to the agent as chat instead of
resuming session #3.
"""
def test_bare_resume_arms_pending_selection(self):
cli_obj = _make_cli()
sessions = [
{"id": "sess_002", "title": "Coding"},
{"id": "sess_001", "title": "Research"},
]
cli_obj._list_recent_sessions = MagicMock(return_value=sessions)
cli_obj._show_recent_sessions = MagicMock(return_value=True)
with patch("cli._cprint"):
cli_obj._handle_resume_command("/resume")
assert cli_obj._pending_resume_sessions == sessions
def test_pending_number_resumes_selected_session(self):
cli_obj = _make_cli()
sessions = [
{"id": "sess_002", "title": "Coding"},
{"id": "sess_001", "title": "Research"},
]
cli_obj._pending_resume_sessions = sessions
# _handle_resume_command("/resume 2") re-resolves the index via
# _list_recent_sessions, so it must return the same list.
cli_obj._list_recent_sessions = MagicMock(return_value=sessions)
cli_obj._session_db.get_session.return_value = {"id": "sess_001", "title": "Research"}
cli_obj._session_db.get_resume_conversations.return_value = [
{"role": "user", "content": "hello"},
], [
{"role": "user", "content": "hello"},
]
cli_obj._session_db.resolve_resume_session_id.return_value = "sess_001"
with (
patch("hermes_cli.main._resolve_session_by_name_or_id", return_value=None),
patch("cli._cprint"),
):
consumed = cli_obj._consume_pending_resume_selection("2")
assert consumed is True
assert cli_obj.session_id == "sess_001"
# One-shot: prompt is disarmed after consuming.
assert cli_obj._pending_resume_sessions is None
def test_pending_disarmed_by_other_command(self):
cli_obj = _make_cli()
cli_obj._pending_resume_sessions = [{"id": "sess_002", "title": "Coding"}]
# Stub out the help handler so process_command("/help") is cheap.
cli_obj.show_help = MagicMock()
cli_obj.process_command("/help")
# A non-resume command disarms the one-shot prompt (#34584).
assert cli_obj._pending_resume_sessions is None
class TestResumeFlushesBeforeEndSession:
"""Regression for #47202: /resume must flush un-persisted messages to
the session DB before ending the old session, just like /new and
compress_context() already do."""
def test_resume_flushes_when_agent_present(self):
cli_obj = _make_cli()
cli_obj.conversation_history = [
{"role": "user", "content": "hello"},
{"role": "assistant", "content": "hi"},
]
agent = MagicMock()
cli_obj.agent = agent
cli_obj._session_db.get_session.return_value = {"id": "target", "title": "T"}
cli_obj._session_db.get_resume_conversations.return_value = ([], [])
cli_obj._session_db.resolve_resume_session_id.return_value = "target"
with (
patch("hermes_cli.main._resolve_session_by_name_or_id", return_value="target"),
patch("cli._cprint"),
):
cli_obj._handle_resume_command("/resume target")
agent._flush_messages_to_session_db.assert_called_once_with(
[{"role": "user", "content": "hello"}, {"role": "assistant", "content": "hi"}],
conversation_history=[{"role": "user", "content": "hello"}, {"role": "assistant", "content": "hi"}],
)
cli_obj._session_db.end_session.assert_called_once()
+378
View File
@@ -0,0 +1,378 @@
"""Regression tests for CLI /retry and carrier-aware rewind semantics."""
from types import SimpleNamespace
from unittest.mock import MagicMock
import pytest
from agent.context_compressor import (
HISTORICAL_TASK_HEADING,
SUMMARY_PREFIX,
_SUMMARY_END_MARKER,
)
from hermes_state import SessionDB
from tests.cli.test_cli_init import _make_cli
def _composite_carrier(ask="REAL ASK"):
return {
"role": "user",
"content": (
f"{SUMMARY_PREFIX}\n{HISTORICAL_TASK_HEADING}\nold task\n\n"
f"{_SUMMARY_END_MARKER}\n\n{ask}"
),
}
def _message_rows(db, session_id):
rows = db._conn.execute(
"SELECT id, content, active FROM messages "
"WHERE session_id = ? ORDER BY id",
(session_id,),
).fetchall()
return [tuple(row) for row in rows]
def test_retry_last_truncates_history_before_requeueing_message():
cli = _make_cli()
cli._session_db = None
cli.conversation_history = [
{"role": "user", "content": "first"},
{"role": "assistant", "content": "one"},
{"role": "user", "content": "retry me"},
{"role": "assistant", "content": "old answer"},
]
retry_msg = cli.retry_last()
assert retry_msg == "retry me"
assert cli.conversation_history == [
{"role": "user", "content": "first"},
{"role": "assistant", "content": "one"},
]
cli.conversation_history.append({"role": "user", "content": retry_msg})
cli.conversation_history.append({"role": "assistant", "content": "new answer"})
assert [m["content"] for m in cli.conversation_history if m["role"] == "user"] == [
"first",
"retry me",
]
def test_process_command_retry_requeues_original_message_not_retry_command():
cli = _make_cli()
cli._session_db = None
queued = []
class _Queue:
def put(self, value):
queued.append(value)
cli._pending_input = _Queue()
cli.conversation_history = [
{"role": "user", "content": "retry me"},
{"role": "assistant", "content": "old answer"},
]
cli.process_command("/retry")
assert queued == ["retry me"]
assert cli.conversation_history == []
def test_retry_fails_closed_when_warm_and_durable_targets_differ(tmp_path):
cli = _make_cli()
cli._session_db.close()
db = SessionDB(db_path=tmp_path / "state.db")
cli._session_db = db
cli.session_id = "cli-target-mismatch"
db.create_session(cli.session_id, source="cli")
db.append_message(cli.session_id, "user", "DURABLE ASK")
db.append_message(cli.session_id, "assistant", "old answer")
history = [
{"role": "user", "content": "WARM ASK"},
{"role": "assistant", "content": "old answer"},
]
cli.conversation_history = history
cli._pending_input = MagicMock()
before_rows = _message_rows(db, cli.session_id)
cli.process_command("/retry")
cli._pending_input.put.assert_not_called()
assert cli.conversation_history is history
assert _message_rows(db, cli.session_id) == before_rows
db.close()
def test_retry_fails_closed_when_transcript_changes_after_snapshot(
tmp_path, monkeypatch
):
cli = _make_cli()
cli._session_db.close()
db = SessionDB(db_path=tmp_path / "state.db")
sibling = SessionDB(db_path=db.db_path)
cli._session_db = db
cli.session_id = "cli-cas-race"
db.create_session(cli.session_id, source="cli")
db.append_message(cli.session_id, "user", "RETRY ME")
db.append_message(cli.session_id, "assistant", "failed answer")
history = db.get_messages_as_conversation(cli.session_id)
cli.conversation_history = history
original_rewind = db.rewind_to_message
def _append_then_rewind(*args, **kwargs):
sibling.append_message(cli.session_id, "assistant", "concurrent tail")
return original_rewind(*args, **kwargs)
monkeypatch.setattr(db, "rewind_to_message", _append_then_rewind)
assert cli.retry_last() is None
assert cli.conversation_history is history
rows = db._conn.execute(
"SELECT content, active FROM messages "
"WHERE session_id = ? ORDER BY id",
(cli.session_id,),
).fetchall()
assert [tuple(row) for row in rows] == [
("RETRY ME", 1),
("failed answer", 1),
("concurrent tail", 1),
]
sibling.close()
db.close()
@pytest.mark.parametrize("command", ["retry", "undo"])
def test_rewind_matches_warm_raw_carrier_to_durable_sanitized_sidecar(
tmp_path, command
):
from agent.memory_manager import sanitize_context
cli = _make_cli()
cli._session_db.close()
db = SessionDB(db_path=tmp_path / "state.db")
cli._session_db = db
cli.session_id = f"cli-sanitized-{command}"
db.create_session(cli.session_id, source="cli")
raw_carrier = _composite_carrier(
" REAL ASK\n\n<memory-context>\nprivate\n</memory-context> "
)["content"]
db.append_message(
cli.session_id,
"user",
sanitize_context(raw_carrier).strip(),
api_content=raw_carrier,
)
db.append_message(cli.session_id, "assistant", "failed answer")
durable = db.get_messages_as_conversation(cli.session_id)
cli.conversation_history = [
{"role": "user", "content": raw_carrier},
durable[1],
]
cli._pending_input = MagicMock()
cli._prefill_input_buffer = MagicMock()
if command == "retry":
cli.process_command("/retry")
cli._pending_input.put.assert_called_once_with("REAL ASK")
else:
cli.undo_last()
cli._prefill_input_buffer.assert_called_once_with("REAL ASK")
assert len(cli.conversation_history) == 1
scaffold = cli.conversation_history[0]
assert scaffold["display_kind"] == "hidden"
assert "REAL ASK" not in scaffold["content"]
active = db.get_messages_as_conversation(cli.session_id, include_row_ids=True)
assert active[0]["_row_id"] == scaffold["_row_id"]
assert active[0]["content"] == scaffold["content"]
db.close()
@pytest.mark.parametrize("command", ["retry", "undo"])
@pytest.mark.parametrize("prefix_kind", ["buried_ephemeral", "old_media"])
def test_rewind_keeps_the_richer_warm_prefix_after_validating_the_target(
tmp_path, command, prefix_kind
):
cli = _make_cli()
cli._session_db.close()
db = SessionDB(db_path=tmp_path / "state.db")
cli._session_db = db
cli.session_id = f"cli-projection-{prefix_kind}-{command}"
db.create_session(cli.session_id, source="cli")
if prefix_kind == "buried_ephemeral":
history = [
{"role": "user", "content": "OLDER ASK"},
{"role": "assistant", "content": "candidate answer"},
{
"role": "user",
"content": "[System: verify before stopping]",
"_verification_stop_synthetic": True,
},
{"role": "assistant", "content": "verified answer"},
{"role": "user", "content": "PLAIN TARGET"},
{"role": "assistant", "content": "failed answer"},
]
durable_prefix = [
("user", "OLDER ASK"),
("assistant", "candidate answer"),
("assistant", "verified answer"),
]
expected_prefix = ["OLDER ASK", "candidate answer", "verified answer"]
expected_active = [1, 1, 1, 0, 0]
else:
media_content = [
{"type": "text", "text": "OLDER ASK"},
{"type": "image_url", "image_url": {"url": "data:image/png,AA"}},
]
history = [
{"role": "user", "content": media_content},
{"role": "assistant", "content": "older answer"},
{"role": "user", "content": "PLAIN TARGET"},
{"role": "assistant", "content": "failed answer"},
]
durable_prefix = [
("user", "OLDER ASK\n[screenshot]"),
("assistant", "older answer"),
]
expected_prefix = [media_content, "older answer"]
expected_active = [1, 1, 0, 0]
for role, content in durable_prefix:
db.append_message(cli.session_id, role, content)
db.append_message(cli.session_id, "user", "PLAIN TARGET")
db.append_message(cli.session_id, "assistant", "failed answer")
cli.conversation_history = history
cli._pending_input = MagicMock()
cli._prefill_input_buffer = MagicMock()
if command == "retry":
cli.process_command("/retry")
cli._pending_input.put.assert_called_once_with("PLAIN TARGET")
else:
cli.undo_last()
cli._prefill_input_buffer.assert_called_once_with("PLAIN TARGET")
assert [message.get("content") for message in cli.conversation_history] == (
expected_prefix
)
assert [row[2] for row in _message_rows(db, cli.session_id)] == expected_active
db.close()
def test_retry_last_durably_preserves_composite_carrier_scaffold(tmp_path):
cli = _make_cli()
cli._session_db.close()
db = SessionDB(db_path=tmp_path / "state.db")
cli._session_db = db
cli.session_id = "cli-carrier-retry"
db.create_session(cli.session_id, source="cli")
db.append_message(cli.session_id, "user", _composite_carrier()["content"])
db.append_message(cli.session_id, "assistant", "failed answer")
cli.conversation_history = db.get_messages_as_conversation(cli.session_id)
old_history = cli.conversation_history
cli.agent = SimpleNamespace(
_session_messages=old_history,
_last_flushed_db_idx=len(old_history),
_db_flush_scan_prefix=list(old_history),
)
retry_msg = cli.retry_last()
assert retry_msg == "REAL ASK"
assert len(cli.conversation_history) == 1
scaffold = cli.conversation_history[0]
assert scaffold["display_kind"] == "hidden"
assert "REAL ASK" not in scaffold["content"]
assert scaffold["_db_persisted"] is True
active = db.get_messages_as_conversation(cli.session_id, include_row_ids=True)
assert len(active) == 1
assert active[0]["content"] == scaffold["content"]
assert active[0]["_row_id"] == scaffold["_row_id"]
assert cli.agent._session_messages is cli.conversation_history
assert cli.agent._last_flushed_db_idx == 1
assert cli.agent._db_flush_scan_prefix == cli.conversation_history
db.close()
def test_retry_last_rejects_media_before_db_or_memory_mutation():
cli = _make_cli()
db = MagicMock()
cli._session_db = db
history = [
{
"role": "user",
"content": [
{"type": "text", "text": "look again"},
{"type": "image_url", "image_url": {"url": "data:image/png;base64,AA"}},
],
},
{"role": "assistant", "content": "old answer"},
]
cli.conversation_history = history
assert cli.retry_last() is None
assert cli.conversation_history is history
db.get_messages_as_conversation.assert_not_called()
db.rewind_to_message.assert_not_called()
def test_retry_last_db_failure_leaves_warm_history_unchanged():
cli = _make_cli()
db = MagicMock()
db.get_messages_as_conversation.side_effect = OSError("db unavailable")
cli._session_db = db
history = [
{"role": "user", "content": "retry me"},
{"role": "assistant", "content": "old answer"},
]
cli.conversation_history = history
assert cli.retry_last() is None
assert cli.conversation_history is history
def test_undo_last_prefills_live_text_and_retains_durable_scaffold(tmp_path):
cli = _make_cli()
cli._session_db.close()
db = SessionDB(db_path=tmp_path / "state.db")
cli._session_db = db
cli.session_id = "cli-carrier-undo"
db.create_session(cli.session_id, source="cli")
db.append_message(cli.session_id, "user", "older ask")
db.append_message(cli.session_id, "assistant", "older answer")
db.append_message(cli.session_id, "user", _composite_carrier()["content"])
db.append_message(cli.session_id, "assistant", "failed answer")
cli.conversation_history = db.get_messages_as_conversation(cli.session_id)
cli._prefill_input_buffer = MagicMock()
cli.agent = SimpleNamespace(
_session_messages=cli.conversation_history,
_last_flushed_db_idx=len(cli.conversation_history),
_db_flush_scan_prefix=list(cli.conversation_history),
_invalidate_system_prompt=MagicMock(),
_memory_manager=None,
)
cli.undo_last()
cli._prefill_input_buffer.assert_called_once_with("REAL ASK")
assert [m.get("content") for m in cli.conversation_history[:2]] == [
"older ask",
"older answer",
]
scaffold = cli.conversation_history[2]
assert scaffold["display_kind"] == "hidden"
assert "REAL ASK" not in scaffold["content"]
assert scaffold["_db_persisted"] is True
active = db.get_messages_as_conversation(cli.session_id, include_row_ids=True)
assert active[2]["_row_id"] == scaffold["_row_id"]
assert active[2]["content"] == scaffold["content"]
assert cli.agent._session_messages is cli.conversation_history
assert cli.agent._last_flushed_db_idx == 3
db.close()
+127
View File
@@ -0,0 +1,127 @@
"""Tests for save_config_value() in cli.py — atomic write behavior."""
from pathlib import Path
from unittest.mock import MagicMock
import yaml
import pytest
class TestSaveConfigValueAtomic:
"""save_config_value() must use atomic round-trip YAML updates."""
@pytest.fixture
def config_env(self, tmp_path, monkeypatch):
"""Isolated config environment with a writable config.yaml."""
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
config_path = hermes_home / "config.yaml"
config_path.write_text(yaml.dump({
"model": {"default": "test-model", "provider": "openrouter"},
"display": {"skin": "default"},
}))
# save_config_value resolves the target live via get_hermes_home(), so
# point HERMES_HOME at the temp dir (the _hermes_home import-time
# constant is no longer consulted).
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
monkeypatch.setattr("cli._hermes_home", hermes_home)
return config_path
def test_calls_roundtrip_yaml_update(self, config_env, monkeypatch):
"""save_config_value must preserve user-edited YAML structure."""
mock_update = MagicMock()
monkeypatch.setattr("utils.atomic_roundtrip_yaml_update", mock_update)
from cli import save_config_value
save_config_value("display.skin", "mono")
mock_update.assert_called_once_with(config_env, "display.skin", "mono")
def test_creates_nested_keys(self, config_env):
"""Dot-separated paths create intermediate dicts as needed."""
from cli import save_config_value
save_config_value("auxiliary.compression.model", "google/gemini-3-flash-preview")
result = yaml.safe_load(config_env.read_text())
assert result["auxiliary"]["compression"]["model"] == "google/gemini-3-flash-preview"
def test_model_write_runs_shared_cron_drift_warning(self, config_env, monkeypatch):
warning = MagicMock()
monkeypatch.setattr(
"hermes_cli.config.warn_unpinned_cron_jobs_after_model_config_change",
warning,
)
from cli import save_config_value
assert save_config_value("model.default", "new-model") is True
warning.assert_called_once_with("model.default", "new-model")
def test_file_not_truncated_on_error(self, config_env, monkeypatch):
"""If atomic_yaml_write raises, the original file is untouched."""
original_content = config_env.read_text()
def exploding_write(*args, **kwargs):
raise OSError("disk full")
monkeypatch.setattr("utils.atomic_roundtrip_yaml_update", exploding_write)
from cli import save_config_value
result = save_config_value("display.skin", "broken")
assert result is False
assert config_env.read_text() == original_content
class TestSaveConfigValueTargetsUserConfig:
"""Regression: persisted runtime settings must land in HERMES_HOME/config.yaml
(which config readers actually read), never the repo's cli-config.yaml.
This was the "wake-word ear reverts to disabled after restart" bug: on an
install whose HERMES_HOME/config.yaml did not exist yet, save_config_value
fell back to the checked-in cli-config.yaml. The toggle reported success, but
startup read HERMES_HOME/config.yaml and never saw the setting."""
def test_creates_user_config_when_absent(self, tmp_path, monkeypatch):
# Fresh HERMES_HOME with NO config.yaml (managed/desktop first launch).
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
from cli import save_config_value
assert save_config_value("wake_word.enabled", True) is True
config_path = hermes_home / "config.yaml"
assert config_path.exists(), "user config.yaml must be created, not skipped"
result = yaml.safe_load(config_path.read_text())
assert result["wake_word"]["enabled"] is True
def test_does_not_write_repo_cli_config(self, tmp_path, monkeypatch):
# Even when the repo's cli-config.yaml exists, the write goes to the
# user config, so a runtime setting is never buried in the shipped file.
import cli as cli_module
repo_cli_config = Path(cli_module.__file__).parent / "cli-config.yaml"
before = repo_cli_config.read_text() if repo_cli_config.exists() else None
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
from cli import save_config_value
save_config_value("wake_word.enabled", True)
# The repo template is untouched…
after = repo_cli_config.read_text() if repo_cli_config.exists() else None
assert after == before
# …and the value landed in the user config.
result = yaml.safe_load((hermes_home / "config.yaml").read_text())
assert result["wake_word"]["enabled"] is True
+147
View File
@@ -0,0 +1,147 @@
import queue
import threading
import time
from unittest.mock import patch
import cli as cli_module
import tools.skills_tool as skills_tool_module
from cli import HermesCLI
from hermes_cli.callbacks import prompt_for_secret
from tools.skills_tool import set_secret_capture_callback
class _FakeBuffer:
def __init__(self):
self.reset_called = False
def reset(self):
self.reset_called = True
class _FakeApp:
def __init__(self):
self.invalidated = False
self.current_buffer = _FakeBuffer()
def invalidate(self):
self.invalidated = True
def _make_cli_stub(with_app=False):
cli = HermesCLI.__new__(HermesCLI)
cli._app = _FakeApp() if with_app else None
cli._last_invalidate = 0.0
cli._secret_state = None
cli._secret_deadline = 0
return cli
def test_secret_capture_callback_can_be_completed_from_cli_state_machine():
cli = _make_cli_stub(with_app=True)
results = []
with patch("hermes_cli.callbacks.save_env_value_secure") as save_secret:
save_secret.return_value = {
"success": True,
"stored_as": "TENOR_API_KEY",
"validated": False,
}
thread = threading.Thread(
target=lambda: results.append(
cli._secret_capture_callback("TENOR_API_KEY", "Tenor API key")
)
)
thread.start()
deadline = time.time() + 2
while cli._secret_state is None and time.time() < deadline:
time.sleep(0.01)
assert cli._secret_state is not None
cli._submit_secret_response("super-secret-value")
thread.join(timeout=2)
assert results[0]["success"] is True
assert results[0]["stored_as"] == "TENOR_API_KEY"
assert results[0]["skipped"] is False
def test_cancel_secret_capture_marks_setup_skipped():
cli = _make_cli_stub()
cli._secret_state = {
"response_queue": queue.Queue(),
"var_name": "TENOR_API_KEY",
"prompt": "Tenor API key",
"metadata": {},
}
cli._secret_deadline = 123
cli._cancel_secret_capture()
assert cli._secret_state is None
assert cli._secret_deadline == 0
def test_secret_capture_uses_masked_prompt_without_tui():
cli = _make_cli_stub()
with patch("hermes_cli.callbacks.masked_secret_prompt", return_value="secret-value"), patch(
"hermes_cli.callbacks.save_env_value_secure"
) as save_secret:
save_secret.return_value = {
"success": True,
"stored_as": "TENOR_API_KEY",
"validated": False,
}
result = prompt_for_secret(cli, "TENOR_API_KEY", "Tenor API key")
assert result["success"] is True
assert result["stored_as"] == "TENOR_API_KEY"
assert result["skipped"] is False
def test_secret_capture_timeout_clears_hidden_input_buffer():
cli = _make_cli_stub(with_app=True)
cleared = {"value": False}
def clear_buffer():
cleared["value"] = True
cli._clear_secret_input_buffer = clear_buffer
with patch("hermes_cli.callbacks.queue.Queue.get", side_effect=queue.Empty), patch(
"hermes_cli.callbacks._time.monotonic",
side_effect=[0, 121],
):
result = prompt_for_secret(cli, "TENOR_API_KEY", "Tenor API key")
assert result["success"] is True
assert result["skipped"] is True
assert result["reason"] == "timeout"
assert cleared["value"] is True
def test_cli_chat_registers_secret_capture_callback():
clean_config = {
"model": {
"default": "anthropic/claude-opus-4.6",
"base_url": "https://openrouter.ai/api/v1",
"provider": "auto",
},
"display": {"compact": False, "tool_progress": "all"},
"agent": {},
"terminal": {"env_type": "local"},
}
with patch("cli.get_tool_definitions", return_value=[]), patch.dict(
"os.environ", {"LLM_MODEL": "", "HERMES_MAX_ITERATIONS": ""}, clear=False
), patch.dict(cli_module.__dict__, {"CLI_CONFIG": clean_config}):
cli_obj = HermesCLI()
with patch.object(cli_obj, "_ensure_runtime_credentials", return_value=False):
cli_obj.chat("hello")
try:
assert skills_tool_module._secret_capture_callback == cli_obj._secret_capture_callback
finally:
set_secret_capture_callback(None)
+74
View File
@@ -0,0 +1,74 @@
"""Verify Shift+Enter byte sequences parse to the same key tuple Alt+Enter
produces, so the existing Alt+Enter newline handler in `cli.py` fires for
terminals that emit a distinct Shift+Enter under the Kitty keyboard protocol
or xterm modifyOtherKeys mode.
"""
from __future__ import annotations
import pytest
from prompt_toolkit.input.ansi_escape_sequences import ANSI_SEQUENCES
from prompt_toolkit.input.vt100_parser import Vt100Parser
from prompt_toolkit.keys import Keys
from hermes_cli.pt_input_extras import install_shift_enter_alias
SHIFT_ENTER_SEQUENCES = (
"\x1b[13;2u", # Kitty / CSI-u, modifier=2 (Shift)
"\x1b[27;2;13~", # xterm modifyOtherKeys=2
"\x1b[27;2;13u",
)
@pytest.fixture(autouse=True)
def _ensure_alias_installed():
"""Make every test idempotent — install the alias once per test run."""
install_shift_enter_alias()
def _parse(byte_seq: str):
out = []
parser = Vt100Parser(out.append)
for ch in byte_seq:
parser.feed(ch)
parser.flush()
return [kp.key for kp in out]
def test_install_registers_all_three_sequences():
for seq in SHIFT_ENTER_SEQUENCES:
assert seq in ANSI_SEQUENCES, f"missing mapping for {seq!r}"
assert ANSI_SEQUENCES[seq] == (Keys.Escape, Keys.ControlM)
def test_install_returns_zero_when_already_correct():
"""Idempotency — running install twice should not report a second change."""
install_shift_enter_alias()
assert install_shift_enter_alias() == 0
def test_csi_u_shift_enter_parses_as_alt_enter():
"""Kitty keyboard protocol Shift+Enter must parse to the same key tuple
Alt+Enter produces, so the existing handler is reused."""
alt_enter = _parse("\x1b\r")
shift_enter = _parse("\x1b[13;2u")
assert shift_enter == alt_enter, (
f"Shift+Enter via CSI-u should parse identically to Alt+Enter; "
f"got {shift_enter!r} vs {alt_enter!r}"
)
def test_plain_enter_remains_distinct_from_alt_enter():
"""Plain Enter must keep emitting a single key (submit), not a two-key
Alt+Enter tuple — otherwise we would have broken submit."""
enter = _parse("\r")
alt_enter = _parse("\x1b\r")
assert enter != alt_enter
assert len(enter) == 1
assert len(alt_enter) == 2
@@ -0,0 +1,330 @@
"""Regression tests for #15165 (CLI sibling site) — CLI exit cleanup must
forward the agent's conversation transcript to ``shutdown_memory_provider``
so memory providers' ``on_session_end`` hooks see the real messages.
Before the fix, ``_run_cleanup`` called
``shutdown_memory_provider(getattr(agent, 'conversation_history', None) or [])``.
``AIAgent`` has no ``conversation_history`` attribute — so the ``or []``
branch always fired and providers got an empty list on CLI exit. This
mirrors the gateway bug fixed in the same commit (gateway/run.py uses
``_session_messages``, which IS set on ``AIAgent``).
The fix reads ``_session_messages`` (same attribute the gateway path uses)
with an ``isinstance(..., list)`` guard so MagicMock-based agents in
other tests keep their existing no-arg behaviour.
"""
from __future__ import annotations
import threading
from typing import Any
from unittest.mock import MagicMock, patch
@patch("hermes_cli.plugins.invoke_hook")
def test_cleanup_forwards_session_messages(mock_invoke_hook):
"""_run_cleanup forwards a populated ``_session_messages`` list."""
import cli as cli_mod
transcript = [
{"role": "user", "content": "remember my dog is named Biscuit"},
{"role": "assistant", "content": "Got it — Biscuit."},
]
agent = MagicMock()
agent.session_id = "cli-session-id"
agent._session_messages = transcript
cli_mod._active_agent_ref = agent
cli_mod._cleanup_done = False
try:
cli_mod._run_cleanup()
finally:
cli_mod._active_agent_ref = None
cli_mod._cleanup_done = False
agent.shutdown_memory_provider.assert_called_once_with(transcript)
@patch("hermes_cli.plugins.invoke_hook")
def test_cleanup_provider_exception_is_swallowed(mock_invoke_hook):
"""A raising ``shutdown_memory_provider`` must not crash CLI exit."""
import cli as cli_mod
agent = MagicMock()
agent.session_id = "cli-session-id"
agent._session_messages = [{"role": "user", "content": "x"}]
agent.shutdown_memory_provider.side_effect = RuntimeError("boom")
cli_mod._active_agent_ref = agent
cli_mod._cleanup_done = False
try:
cli_mod._run_cleanup() # must not raise
finally:
cli_mod._active_agent_ref = None
cli_mod._cleanup_done = False
agent.shutdown_memory_provider.assert_called_once()
def _real_agent(db, session_id, session_messages):
"""Build the real persistence seam without the heavyweight LLM client."""
from run_agent import AIAgent
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 = 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._pending_cli_user_message = None
agent._session_persist_lock = threading.RLock()
return agent
def test_cli_close_preflush_resumed_prefix_is_not_duplicated(tmp_path, monkeypatch):
"""A signal during the turn-start flush preserves the old DB prefix once.
The pause is after ``_persist_session`` records its live snapshot but before
its normal DB flush. The close helper must retain the distinct CLI baseline.
"""
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-preflush-resume"
db.create_session(session_id=session_id, source="cli")
loaded = [
{"role": "user", "content": "old prompt"},
{"role": "assistant", "content": "old answer"},
]
for message in loaded:
db.append_message(
session_id=session_id,
role=message["role"],
content=message["content"],
)
live_messages = list(loaded) + [{"role": "user", "content": "new prompt"}]
agent = _real_agent(db, session_id, [])
entered_flush = threading.Event()
release_flush = threading.Event()
flush_calls = 0
def _pause_before_flush(
messages: list[dict[str, Any]],
conversation_history: list[dict[str, Any]] | None = None,
) -> None:
nonlocal flush_calls
flush_calls += 1
if flush_calls == 1:
# The worker has assigned its snapshot and is now paused before its
# regular DB write. The concurrent close call must stay live.
agent._session_messages = messages
entered_flush.set()
assert release_flush.wait(timeout=5)
from run_agent import AIAgent
# Runtime accepts None; the stub keeps that optional contract explicit.
return AIAgent._flush_messages_to_session_db(
agent,
messages,
conversation_history if conversation_history is not None else [],
)
agent._flush_messages_to_session_db = _pause_before_flush
worker = threading.Thread(
target=lambda: agent._persist_session(live_messages, loaded),
daemon=True,
)
worker.start()
assert entered_flush.wait(timeout=5)
cli = object.__new__(cli_mod.HermesCLI)
cli.conversation_history = list(loaded) + [{"role": "user", "content": "ui prompt"}]
cli.session_id = session_id
cli.agent = agent
close_started = threading.Event()
close_finished = threading.Event()
def _close_while_worker_flushes():
close_started.set()
cli._persist_active_session_before_close()
close_finished.set()
close_worker = threading.Thread(target=_close_while_worker_flushes, daemon=True)
close_worker.start()
assert close_started.wait(timeout=5)
# The per-agent persistence lock holds the close flush until the normal
# turn-start write has stamped its durable markers.
assert not close_finished.wait(timeout=0.1)
release_flush.set()
worker.join(timeout=5)
close_worker.join(timeout=5)
assert not worker.is_alive()
assert not close_worker.is_alive()
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_hands_staged_user_marker_to_turn_start(tmp_path, monkeypatch):
"""A close before turn setup does not duplicate the CLI-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-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"}
# `chat()` copies a completed agent transcript before it stages the next
# user input, so close initially sees the prior agent snapshot only.
cli_history = list(prefix) + [staged]
agent._pending_cli_user_message = staged
cli = object.__new__(cli_mod.HermesCLI)
cli.conversation_history = cli_history
cli.session_id = session_id
cli.agent = agent
# Close appends only the pending UI dict, while treating the durable prefix
# as its baseline. Turn setup then reuses the marked dict without re-writing.
cli._persist_active_session_before_close()
assert staged["_db_persisted"] is True
worker_messages = list(prefix) + [staged]
agent._persist_session(worker_messages, prefix)
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_uses_clean_override_for_shortened_pending_snapshot(tmp_path, monkeypatch):
"""Close retains the clean user text when its snapshot omits the prefix."""
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-shortened-noted-pending"
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": "[MODEL NOTE]\n\nnew prompt"}
agent._pending_cli_user_message = staged
# The normal worker index is relative to the full resumed history, while a
# close before its first persistence flush sees only this staged dict.
agent._persist_user_message_idx = len(prefix)
agent._persist_user_message_override = "new prompt"
agent._persist_user_message_timestamp = None
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 [m["content"] for m in db.get_messages_as_conversation(session_id)] == [
"old prompt",
"old answer",
"new prompt",
]
assert staged["_db_persisted"] is True
def test_cli_close_builds_prompt_before_creating_first_session_row(tmp_path, monkeypatch):
"""First-turn close persistence must not leave a NULL prompt snapshot."""
monkeypatch.setenv("HERMES_HOME", str(tmp_path / ".hermes"))
import agent.conversation_loop as loop_mod
import cli as cli_mod
from hermes_state import SessionDB
db = SessionDB(db_path=tmp_path / "state.db")
session_id = "cli-close-first-turn"
agent = _real_agent(db, session_id, [])
agent._session_db_created = False
agent._cached_system_prompt = None
staged = {"role": "user", "content": "first prompt"}
agent._pending_cli_user_message = staged
def _build_prompt(target, _system_message, _history):
target._cached_system_prompt = "close-built-system-prompt"
monkeypatch.setattr(loop_mod, "_restore_or_build_system_prompt", _build_prompt)
cli = object.__new__(cli_mod.HermesCLI)
cli.conversation_history = [staged]
cli.session_id = session_id
cli.agent = agent
cli._persist_active_session_before_close()
session = db.get_session(session_id)
assert session is not None
assert session["system_prompt"] == "close-built-system-prompt"
assert [m["content"] for m in db.get_messages_as_conversation(session_id)] == [
"first prompt"
]
+89
View File
@@ -0,0 +1,89 @@
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
from cli import HermesCLI, _rich_text_from_ansi
from hermes_cli.skin_engine import get_active_skin, set_active_skin
def _make_cli_stub():
cli = HermesCLI.__new__(HermesCLI)
cli._sudo_state = None
cli._secret_state = None
cli._approval_state = None
cli._clarify_state = None
cli._clarify_freetext = False
cli._command_running = False
cli._agent_running = False
cli._voice_recording = False
cli._voice_processing = False
cli._voice_mode = False
cli._command_spinner_frame = lambda: "⟳"
cli._tui_style_base = {
"prompt": "#fff",
"input-area": "#fff",
"input-rule": "#aaa",
"prompt-working": "#888 italic",
}
cli._app = SimpleNamespace(style=None)
cli._invalidate = MagicMock()
return cli
class TestCliSkinPromptIntegration:
def test_ares_prompt_fragments_use_skin_symbol(self):
cli = _make_cli_stub()
set_active_skin("ares")
assert cli._get_tui_prompt_fragments() == [("class:prompt", "⚔ ")]
def test_secret_prompt_fragments_preserve_secret_state(self):
cli = _make_cli_stub()
cli._secret_state = {"response_queue": object()}
set_active_skin("ares")
assert cli._get_tui_prompt_fragments() == [("class:sudo-prompt", "🔑 ⚔ ")]
def test_narrow_terminals_compact_voice_recording_prompt_fragments(self):
cli = _make_cli_stub()
cli._voice_recording = True
cli._voice_recorder = SimpleNamespace(current_rms=3000)
with patch.object(HermesCLI, "_get_tui_terminal_width", return_value=50):
frags = cli._get_tui_prompt_fragments()
assert frags[0][0] == "class:voice-recording"
assert frags[0][1].startswith("●")
assert "❯" not in frags[0][1]
def test_apply_tui_skin_style_updates_running_app(self):
cli = _make_cli_stub()
set_active_skin("ares")
assert cli._apply_tui_skin_style() is True
assert cli._app.style is not None
cli._invalidate.assert_called_once_with(min_interval=0.0)
def test_handle_skin_command_refreshes_live_tui(self, capsys):
cli = _make_cli_stub()
with patch("cli.save_config_value", return_value=True):
cli._handle_skin_command("/skin ares")
output = capsys.readouterr().out
assert "Skin set to: ares (saved)" in output
assert "Prompt + TUI colors updated." in output
assert cli._app.style is not None
class TestAnsiRichTextHelper:
def test_preserves_literal_brackets(self):
text = _rich_text_from_ansi("[notatag] literal")
assert text.plain == "[notatag] literal"
def test_strips_ansi_but_keeps_plain_text(self):
text = _rich_text_from_ansi("\x1b[31mred\x1b[0m")
assert text.plain == "red"
+717
View File
@@ -0,0 +1,717 @@
import time
from copy import deepcopy
from datetime import datetime, timedelta
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
import cli as cli_mod
from cli import HermesCLI
def _make_cli(model: str = "anthropic/claude-sonnet-4-20250514"):
cli_obj = HermesCLI.__new__(HermesCLI)
cli_obj.model = model
cli_obj.session_start = datetime.now() - timedelta(minutes=14, seconds=32)
cli_obj.conversation_history = [{"role": "user", "content": "hi"}]
cli_obj.agent = None
return cli_obj
def _attach_agent(
cli_obj,
*,
input_tokens: int | None = None,
output_tokens: int | None = None,
cache_read_tokens: int = 0,
cache_write_tokens: int = 0,
prompt_tokens: int,
completion_tokens: int,
total_tokens: int,
api_calls: int,
context_tokens: int,
context_length: int,
compressions: int = 0,
):
cli_obj.agent = SimpleNamespace(
model=cli_obj.model,
provider="anthropic" if cli_obj.model.startswith("anthropic/") else None,
base_url="",
session_input_tokens=input_tokens if input_tokens is not None else prompt_tokens,
session_output_tokens=output_tokens if output_tokens is not None else completion_tokens,
session_cache_read_tokens=cache_read_tokens,
session_cache_write_tokens=cache_write_tokens,
session_prompt_tokens=prompt_tokens,
session_completion_tokens=completion_tokens,
session_total_tokens=total_tokens,
session_api_calls=api_calls,
get_rate_limit_state=lambda: None,
context_compressor=SimpleNamespace(
last_prompt_tokens=context_tokens,
context_length=context_length,
compression_count=compressions,
),
)
return cli_obj
class TestCLIStatusBar:
def test_session_title_is_right_aligned_after_it_is_queued(self):
cli_obj = _make_cli()
cli_obj._pending_title = "weekly-digest"
text = cli_obj._build_status_bar_text(width=80)
assert text.endswith(" weekly-digest ")
assert cli_obj._status_bar_display_width(text) == 80
def test_snapshot_refreshes_persisted_session_title(self):
cli_obj = _make_cli()
cli_obj.session_id = "session-1"
cli_obj._session_db = SimpleNamespace( # type: ignore[assignment]
get_session_title=lambda sid: "user-profiles" if sid == "session-1" else None
)
snapshot = cli_obj._get_status_bar_snapshot()
assert snapshot["session_title"] == "user-profiles"
def test_status_bar_config_helper_treats_persisted_off_as_hidden(self):
for value in (False, "off", "false", "hidden", "no", "0"):
assert cli_mod._status_bar_visible_from_display_config({"tui_statusbar": value}) is False
for value in (True, "top", "bottom", "on", None):
assert cli_mod._status_bar_visible_from_display_config({"tui_statusbar": value}) is True
def test_status_bar_initial_visibility_honors_tui_statusbar_config(self, monkeypatch):
config = deepcopy(cli_mod.CLI_CONFIG)
config.setdefault("display", {})["tui_statusbar"] = False
config["display"].pop("statusbar", None)
monkeypatch.setattr(cli_mod, "CLI_CONFIG", config)
cli_obj = HermesCLI(model="test-model", toolsets=[], provider="auto")
assert cli_obj._status_bar_visible is False
def test_context_style_thresholds(self):
cli_obj = _make_cli()
assert cli_obj._status_bar_context_style(None) == "class:status-bar-dim"
assert cli_obj._status_bar_context_style(10) == "class:status-bar-good"
assert cli_obj._status_bar_context_style(50) == "class:status-bar-warn"
assert cli_obj._status_bar_context_style(81) == "class:status-bar-bad"
assert cli_obj._status_bar_context_style(95) == "class:status-bar-critical"
def test_build_status_bar_text_for_wide_terminal(self):
cli_obj = _attach_agent(
_make_cli(),
prompt_tokens=10_230,
completion_tokens=2_220,
total_tokens=12_450,
api_calls=7,
context_tokens=12_450,
context_length=200_000,
)
text = cli_obj._build_status_bar_text(width=120)
assert "claude-sonnet-4-20250514" in text
assert "12.4K/200K" in text
assert "6%" in text
assert "$0.06" not in text # cost hidden by default
assert "15m" in text
def test_input_height_counts_prompt_only_on_first_wrapped_row(self):
# Regression for prompt_toolkit classic CLI resize glitches: the prompt
# is inserted by BeforeInput only on logical line 0. At three terminal
# cells, "⚔ " leaves one cell for the first input character, but
# wrapped continuation rows use the full three cells. Estimating every
# wrapped row as one-cell wide over-allocates the TextArea and can leave
# stale prompt/input cells visible after resize.
assert cli_mod._estimate_tui_input_height(["abcdef"], "⚔ ", 3) == 3
def test_compression_count_shown_in_wide_status_bar(self):
cli_obj = _attach_agent(
_make_cli(),
prompt_tokens=10_230,
completion_tokens=2_220,
total_tokens=12_450,
api_calls=7,
context_tokens=12_450,
context_length=200_000,
compressions=3,
)
text = cli_obj._build_status_bar_text(width=120)
assert "🗜️ 3" in text
def test_minimal_tui_chrome_threshold(self):
cli_obj = _make_cli()
assert cli_obj._use_minimal_tui_chrome(width=63) is True
assert cli_obj._use_minimal_tui_chrome(width=64) is False
def test_scheduled_unsuppress_debounces_resize_storm(self):
"""A fresh resize cancels the pending unsuppress and restarts it."""
cli_obj = _make_cli()
cli_obj._status_bar_unsuppress_timer = None
cli_obj._status_bar_suppressed_after_resize = True
app = MagicMock()
app.loop = None
# First schedule (long delay) then a second should cancel the first.
cli_obj._schedule_status_bar_unsuppress(app, delay=5.0)
first_timer = cli_obj._status_bar_unsuppress_timer
assert first_timer is not None
cli_obj._schedule_status_bar_unsuppress(app, delay=0.01)
assert first_timer is not cli_obj._status_bar_unsuppress_timer
assert not first_timer.is_alive() or first_timer.finished.is_set()
time.sleep(0.1)
assert cli_obj._status_bar_suppressed_after_resize is False
def test_spinner_height_uses_display_width_for_wide_characters(self):
cli_obj = _make_cli()
cli_obj._spinner_text = "你" * 40
cli_obj._tool_start_time = 0
assert cli_obj._spinner_widget_height(width=64) == 2
def test_voice_status_bar_compacts_on_narrow_terminals(self):
cli_obj = _make_cli()
cli_obj._voice_mode = True
cli_obj._voice_recording = False
cli_obj._voice_processing = False
cli_obj._voice_tts = True
cli_obj._voice_continuous = True
fragments = cli_obj._get_voice_status_fragments(width=50)
assert fragments == [("class:voice-status", " 🎤 Ctrl+B ")]
# Round-13 Copilot review regressions on #19835. The label in voice
# status bar / recording hint / placeholder must render the
# configured ``voice.record_key`` — not hardcoded Ctrl+B. Pinning
# the cache (``set_voice_record_key_cache``) keeps display in sync
# with the prompt_toolkit binding without re-reading config on
# every render.
def test_voice_status_bar_renders_configured_ctrl_letter(self):
cli_obj = _make_cli()
cli_obj._voice_mode = True
cli_obj._voice_recording = False
cli_obj._voice_processing = False
cli_obj._voice_tts = False
cli_obj._voice_continuous = False
cli_obj.set_voice_record_key_cache("ctrl+o")
wide = cli_obj._get_voice_status_fragments(width=120)
assert any("Ctrl+O to record" in text for _cls, text in wide)
compact = cli_obj._get_voice_status_fragments(width=50)
assert compact == [("class:voice-status", " 🎤 Ctrl+O ")]
class TestCLIUsageReport:
def test_show_usage_omits_cost_reporting(self, capsys):
cli_obj = _attach_agent(
_make_cli(),
prompt_tokens=10_230,
completion_tokens=2_220,
total_tokens=12_450,
api_calls=7,
context_tokens=12_450,
context_length=200_000,
compressions=1,
)
cli_obj.verbose = False
cli_obj._show_usage()
output = capsys.readouterr().out
# Token counts and session metadata still shown.
assert "Model:" in output
assert "Input tokens:" in output
assert "Output tokens:" in output
assert "Total tokens:" in output
assert "Session duration:" in output
assert "Compressions:" in output
# Cost and cache-hit reporting is removed everywhere.
assert "Total cost:" not in output
assert "Cost status:" not in output
assert "Cost source:" not in output
assert "Cache read tokens:" not in output
assert "Cache write tokens:" not in output
class TestStatusBarWidthSource:
"""Ensure status bar fragments don't overflow the terminal width."""
def _make_wide_cli(self):
cli_obj = _attach_agent(
_make_cli(),
prompt_tokens=100_000,
completion_tokens=5_000,
total_tokens=105_000,
api_calls=20,
context_tokens=100_000,
context_length=200_000,
)
cli_obj._status_bar_visible = True
return cli_obj
def test_fragments_fit_within_announced_width(self):
"""Total fragment text length must not exceed the width used to build them."""
from unittest.mock import MagicMock, patch
cli_obj = self._make_wide_cli()
for width in (40, 52, 76, 80, 120, 200):
mock_app = MagicMock()
mock_app.output.get_size.return_value = MagicMock(columns=width)
with patch("prompt_toolkit.application.get_app", return_value=mock_app):
frags = cli_obj._get_status_bar_fragments()
total_text = "".join(text for _, text in frags)
display_width = cli_obj._status_bar_display_width(total_text)
assert display_width <= width + 4, ( # +4 for minor padding chars
f"At width={width}, fragment total {display_width} cells overflows "
f"({total_text!r})"
)
def test_fragments_put_session_title_at_far_right(self):
cli_obj = self._make_wide_cli()
cli_obj._pending_title = "weekly-digest"
mock_app = MagicMock()
mock_app.output.get_size.return_value = MagicMock(columns=100)
with patch("prompt_toolkit.application.get_app", return_value=mock_app):
frags = cli_obj._get_status_bar_fragments()
text = "".join(value for _, value in frags)
assert text.endswith(" weekly-digest ")
assert cli_obj._status_bar_display_width(text) == 100
def test_fragments_use_pt_width_over_shutil(self):
"""When prompt_toolkit reports a width, shutil.get_terminal_size must not be used."""
from unittest.mock import MagicMock, patch
cli_obj = self._make_wide_cli()
mock_app = MagicMock()
mock_app.output.get_size.return_value = MagicMock(columns=120)
with patch("prompt_toolkit.application.get_app", return_value=mock_app) as mock_get_app, \
patch("shutil.get_terminal_size") as mock_shutil:
cli_obj._get_status_bar_fragments()
mock_shutil.assert_not_called()
def test_build_status_bar_text_uses_pt_width(self):
"""_build_status_bar_text() must also prefer prompt_toolkit width."""
from unittest.mock import MagicMock, patch
cli_obj = self._make_wide_cli()
mock_app = MagicMock()
mock_app.output.get_size.return_value = MagicMock(columns=80)
with patch("prompt_toolkit.application.get_app", return_value=mock_app), \
patch("shutil.get_terminal_size") as mock_shutil:
text = cli_obj._build_status_bar_text() # no explicit width
mock_shutil.assert_not_called()
assert isinstance(text, str)
assert len(text) > 0
class TestIdleSinceLastTurn:
"""Time-since-last-final-agent-response read-out on the status bar."""
def test_hidden_before_first_turn(self):
assert HermesCLI._format_idle_since(None, turn_live=False) == ""
def test_hidden_while_turn_is_live(self):
assert HermesCLI._format_idle_since(time.time() - 30, turn_live=True) == ""
def test_shows_compact_idle_time_after_turn(self):
label = HermesCLI._format_idle_since(time.time() - 42, turn_live=False)
assert label.startswith("✓ ")
assert label == "✓ 42s"
def test_snapshot_carries_idle_since(self):
cli_obj = _make_cli()
cli_obj._last_turn_finished_at = time.time() - 10
cli_obj._prompt_start_time = None
cli_obj._prompt_duration = 5.0
snapshot = cli_obj._get_status_bar_snapshot()
assert snapshot["idle_since"].startswith("✓ ")
class TestStatusBarFieldConfig:
"""Tests for display.status_bar.fields config customization (#41909)."""
def _cli_with_fields(self, fields, width=120):
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,
)
with patch.object(cli_mod, "CLI_CONFIG", {"display": {"status_bar": {"fields": fields}}}):
text = cli_obj._build_status_bar_text(width=width)
return text
def test_default_fields_show_all(self):
"""With no config, all default fields appear."""
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,
)
with patch.object(cli_mod, "CLI_CONFIG", {}):
text = cli_obj._build_status_bar_text(width=120)
assert "claude-sonnet-4-20250514" in text
assert "12.4K/200K" in text
assert "🗜️" in text
assert "15m" in text
def test_only_model_and_duration(self):
text = self._cli_with_fields(["model", "duration"])
assert "claude-sonnet-4-20250514" in text
assert "15m" in text
assert "12.4K/200K" not in text
assert "🗜️" not in text
assert "%" not in text
def test_only_model(self):
text = self._cli_with_fields(["model"])
assert "claude-sonnet-4-20250514" in text
assert "15m" not in text
assert "12.4K/200K" not in text
def test_context_pct_only(self):
text = self._cli_with_fields(["context_pct"])
assert "%" in text
assert "claude-sonnet-4-20250514" not in text
def test_compressions_only(self):
text = self._cli_with_fields(["compressions"])
assert "🗜️ 7" in text
assert "claude-sonnet-4-20250514" not in text
def test_total_tokens_when_explicitly_requested(self):
text = self._cli_with_fields(["model", "total_tokens"])
assert "Σ12.4K" in text
assert "claude-sonnet-4-20250514" in text
def test_total_tokens_hidden_by_default(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,
)
with patch.object(cli_mod, "CLI_CONFIG", {}):
text = cli_obj._build_status_bar_text(width=120)
assert "Σ" not in text
def test_narrow_terminal_drops_context_detail(self):
"""Narrow terminal (<76) ignores context_detail even if configured."""
text = self._cli_with_fields(["model", "context_detail", "duration"], width=60)
assert "claude-sonnet-4-20250514" in text
assert "15m" in text
assert "12.4K/200K" not in text
def test_field_config_never_empties_the_bar(self):
"""A fields list matching nothing still anchors on the model name."""
text = self._cli_with_fields(["nonexistent_field"])
assert "claude-sonnet-4-20250514" in text
def test_fragments_respect_field_config(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
with patch.object(cli_mod, "CLI_CONFIG", {"display": {"status_bar": {"fields": ["model", "duration"]}}}), \
patch.object(cli_obj, "_get_tui_terminal_width", return_value=120):
frags = cli_obj._get_status_bar_fragments()
frag_texts = [text for _, text in frags]
assert any("claude-sonnet-4-20250514" in t for t in frag_texts)
assert any("15m" in t for t in frag_texts)
assert not any("🗜️" in t for t in frag_texts)
assert not any("12.4K" in t for t in frag_texts)
def test_field_order_is_fixed(self):
"""Config controls visibility, not ordering — model stays first."""
text = self._cli_with_fields(["duration", "model", "compressions"])
model_pos = text.find("claude-sonnet-4-20250514")
comp_pos = text.find("🗜️")
dur_pos = text.find("15m")
assert 0 <= model_pos < comp_pos < dur_pos
def test_empty_fields_list_uses_defaults(self):
text = self._cli_with_fields([])
assert "claude-sonnet-4-20250514" in text
assert "12.4K/200K" in text
assert "🗜️" in text
def test_field_set_is_cached_per_instance(self):
cli_obj = _make_cli()
with patch.object(cli_mod, "CLI_CONFIG", {"display": {"status_bar": {"fields": ["model"]}}}):
first = cli_obj._get_status_bar_field_set()
# Cache holds even if config object changes afterwards (per-session semantics).
with patch.object(cli_mod, "CLI_CONFIG", {"display": {"status_bar": {"fields": ["duration"]}}}):
second = cli_obj._get_status_bar_field_set()
assert first == second == frozenset({"model"})
class TestCacheHitRate:
def test_cache_hit_rate_shown_in_wide_terminal(self):
cli_obj = _attach_agent(
_make_cli(),
prompt_tokens=10_000,
completion_tokens=2_000,
total_tokens=12_000,
api_calls=5,
context_tokens=12_000,
context_length=200_000,
cache_read_tokens=7600,
cache_write_tokens=0,
)
text = cli_obj._build_status_bar_text(width=120)
assert "◎ 76.0%" in text
def test_cache_hit_rate_shown_in_narrow_terminal(self):
cli_obj = _attach_agent(
_make_cli(),
prompt_tokens=10_000,
completion_tokens=2_000,
total_tokens=12_000,
api_calls=5,
context_tokens=12_000,
context_length=200_000,
cache_read_tokens=5000,
cache_write_tokens=0,
)
text = cli_obj._build_status_bar_text(width=60)
assert "◎ 50%" in text
def test_cache_hit_rate_hidden_when_zero(self):
cli_obj = _attach_agent(
_make_cli(),
prompt_tokens=10_000,
completion_tokens=2_000,
total_tokens=12_000,
api_calls=5,
context_tokens=12_000,
context_length=200_000,
cache_read_tokens=0,
cache_write_tokens=0,
)
text = cli_obj._build_status_bar_text(width=120)
assert "◎" not in text
def test_cache_hit_rate_hidden_when_no_data(self):
cli_obj = _attach_agent(
_make_cli(),
prompt_tokens=10_000,
completion_tokens=2_000,
total_tokens=12_000,
api_calls=5,
context_tokens=12_000,
context_length=200_000,
)
text = cli_obj._build_status_bar_text(width=120)
assert "◎" not in text
def test_cache_hit_rate_one_decimal(self):
cli_obj = _attach_agent(
_make_cli(),
prompt_tokens=10_000,
completion_tokens=2_000,
total_tokens=12_000,
api_calls=5,
context_tokens=12_000,
context_length=200_000,
cache_read_tokens=7620,
cache_write_tokens=0,
)
text = cli_obj._build_status_bar_text(width=120)
assert "◎ 76.2%" in text
def test_cache_hit_rate_with_anthropic_style_cache(self):
"""Anthropic has both cache_read and cache_write"""
cli_obj = _attach_agent(
_make_cli(),
prompt_tokens=10_000,
completion_tokens=2_000,
total_tokens=12_000,
api_calls=5,
context_tokens=12_000,
context_length=200_000,
cache_read_tokens=5000,
cache_write_tokens=2000,
)
text = cli_obj._build_status_bar_text(width=120)
# cache_read / prompt_tokens = 5000 / 10000 = 50%
assert "◎ 50.0%" in text
class TestRollingLatencyVelocity:
def _with_history(self, cli_obj, latencies, outputs):
from collections import deque
cli_obj.agent._api_latency_history = deque(latencies, maxlen=10)
cli_obj.agent._api_output_history = deque(outputs, maxlen=10)
return cli_obj
def test_latency_and_tps_shown_in_wide_terminal(self):
cli_obj = _attach_agent(
_make_cli(),
prompt_tokens=10_000, completion_tokens=2_000, total_tokens=12_000,
api_calls=5, context_tokens=12_000, context_length=200_000,
)
self._with_history(cli_obj, [2.0, 4.0], [120, 180])
text = cli_obj._build_status_bar_text(width=140)
assert "\u25f7 3.0s" in text # mean latency (2+4)/2
assert "\u2191 50 t/s" in text # true throughput 300/6.0
def test_latency_hidden_without_history(self):
cli_obj = _attach_agent(
_make_cli(),
prompt_tokens=10_000, completion_tokens=2_000, total_tokens=12_000,
api_calls=5, context_tokens=12_000, context_length=200_000,
)
text = cli_obj._build_status_bar_text(width=140)
assert "\u25f7" not in text
assert "t/s" not in text
def test_latency_and_tps_respect_field_filter(self):
cli_obj = _attach_agent(
_make_cli(),
prompt_tokens=10_000, completion_tokens=2_000, total_tokens=12_000,
api_calls=5, context_tokens=12_000, context_length=200_000,
)
self._with_history(cli_obj, [2.0], [100])
with patch.object(cli_mod, "CLI_CONFIG", {"display": {"status_bar": {"fields": ["model", "duration"]}}}):
text = cli_obj._build_status_bar_text(width=140)
assert "\u25f7" not in text
assert "t/s" not in text
def test_negative_latency_guard(self):
cli_obj = _attach_agent(
_make_cli(),
prompt_tokens=10_000, completion_tokens=2_000, total_tokens=12_000,
api_calls=5, context_tokens=12_000, context_length=200_000,
)
self._with_history(cli_obj, [-0.8], [100])
snapshot = cli_obj._get_status_bar_snapshot()
assert snapshot["avg_latency"] is None
assert snapshot["avg_velocity"] is None
class TestCacheHitBaselineReset:
def test_baseline_resets_on_model_switch(self):
cli_obj = _attach_agent(
_make_cli(),
prompt_tokens=10_000, completion_tokens=2_000, total_tokens=12_000,
api_calls=5, context_tokens=12_000, context_length=200_000,
cache_read_tokens=9_000,
)
first = cli_obj._get_status_bar_snapshot()
assert first["cache_hit_pct"] == 90.0
# Switch model. The bar repaints every frame, so the switch is
# observed (and the baseline reset) before new tokens accrue.
cli_obj.model = "openai/gpt-5"
cli_obj.agent.model = "openai/gpt-5"
reset_snap = cli_obj._get_status_bar_snapshot()
assert reset_snap["cache_hit_pct"] is None # new regime, no data yet
cli_obj.agent.session_prompt_tokens = 12_000
cli_obj.agent.session_cache_read_tokens = 9_500
second = cli_obj._get_status_bar_snapshot()
# Delta since switch: 500/2000 = 25%, not the lifetime 79%.
assert second["cache_hit_pct"] == 25.0
def test_baseline_resets_on_compression(self):
cli_obj = _attach_agent(
_make_cli(),
prompt_tokens=10_000, completion_tokens=2_000, total_tokens=12_000,
api_calls=5, context_tokens=12_000, context_length=200_000,
cache_read_tokens=8_000,
)
cli_obj._get_status_bar_snapshot()
cli_obj.agent.context_compressor.compression_count = 1
cli_obj._get_status_bar_snapshot() # repaint observes the compression
cli_obj.agent.session_prompt_tokens = 14_000
cli_obj.agent.session_cache_read_tokens = 8_400
snap = cli_obj._get_status_bar_snapshot()
assert snap["cache_hit_pct"] == 10.0 # 400/4000 post-compression
def test_title_field_filter_hides_session_badge(self):
cli_obj = _make_cli()
cli_obj._pending_title = "weekly-digest"
with patch.object(cli_mod, "CLI_CONFIG", {"display": {"status_bar": {"fields": ["model", "duration"]}}}):
text = cli_obj._build_status_bar_text(width=80)
assert "weekly-digest" not in text
+77
View File
@@ -0,0 +1,77 @@
"""Status-bar goal segment (⊙ goal N/M) — active-goal-only rendering.
The segment mirrors the desktop composer goal indicator: it appears only
while a /goal is ACTIVE, shows turns used vs the turn budget, and stays out
of the bar entirely for paused/done/absent goals (those already print their
own glyph lines in the conversation thread).
"""
from datetime import datetime, timedelta
from types import SimpleNamespace
from cli import HermesCLI
def _make_cli(model: str = "anthropic/claude-sonnet-4-20250514"):
cli_obj = HermesCLI.__new__(HermesCLI)
cli_obj.model = model
cli_obj.session_start = datetime.now() - timedelta(minutes=14, seconds=32)
cli_obj.conversation_history = [{"role": "user", "content": "hi"}]
cli_obj.agent = None
return cli_obj
def _attach_goal(cli_obj, *, active: bool, turns_used: int = 3, max_turns: int = 20):
"""Bind a fake GoalManager the way _get_goal_manager caches one."""
cli_obj.session_id = "sess-goal-test"
cli_obj._goal_manager = SimpleNamespace(
session_id="sess-goal-test",
is_active=lambda: active,
state=SimpleNamespace(turns_used=turns_used, max_turns=max_turns),
)
return cli_obj
class TestStatusBarGoalSegment:
def test_goal_segment_composition(self):
cli_obj = _attach_goal(_make_cli(), active=True, turns_used=3, max_turns=20)
snapshot = cli_obj._get_status_bar_snapshot()
assert snapshot["goal_active"] is True
assert snapshot["goal_turns_used"] == 3
assert snapshot["goal_max_turns"] == 20
assert cli_obj._status_bar_goal_segment(snapshot) == "⊙ goal 3/20"
def test_goal_segment_absent_when_paused(self):
# Paused goals must NOT occupy the status bar (active-only contract).
cli_obj = _attach_goal(_make_cli(), active=False)
snapshot = cli_obj._get_status_bar_snapshot()
assert snapshot["goal_active"] is False
assert cli_obj._status_bar_goal_segment(snapshot) == ""
def test_active_goal_rendered_in_wide_status_bar(self):
cli_obj = _attach_goal(_make_cli(), active=True, turns_used=5, max_turns=20)
text = cli_obj._build_status_bar_text(width=120)
assert "⊙ goal 5/20" in text
def test_active_goal_rendered_in_medium_status_bar(self):
cli_obj = _attach_goal(_make_cli(), active=True, turns_used=1, max_turns=20)
text = cli_obj._build_status_bar_text(width=60)
assert "⊙ goal 1/20" in text
def test_active_goal_rendered_in_narrow_status_bar(self):
cli_obj = _attach_goal(_make_cli(), active=True, turns_used=2, max_turns=20)
text = cli_obj._build_status_bar_text(width=50)
assert "⊙ goal" in text
+133
View File
@@ -0,0 +1,133 @@
"""Tests for CLI /status command behavior."""
from datetime import datetime
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
from cli import HermesCLI
from hermes_cli.commands import resolve_command
def _make_cli():
cli_obj = HermesCLI.__new__(HermesCLI)
cli_obj.config = {}
cli_obj.console = MagicMock()
cli_obj.agent = None
cli_obj.conversation_history = []
cli_obj.session_id = "session-123"
cli_obj._pending_input = MagicMock()
cli_obj._status_bar_visible = True
cli_obj.model = "openai/gpt-5.4"
cli_obj.provider = "openai"
cli_obj.session_start = datetime(2026, 4, 9, 19, 24)
cli_obj._agent_running = False
cli_obj._session_db = MagicMock()
cli_obj._session_db.get_session.return_value = None
return cli_obj
def test_status_command_is_available_in_cli_registry():
cmd = resolve_command("status")
assert cmd is not None
assert cmd.gateway_only is False
def test_egress_command_is_available_in_cli_registry():
cmd = resolve_command("egress")
assert cmd is not None
assert cmd.gateway_only is False
assert "status" in cmd.subcommands
def test_process_command_egress_prints_proxy_status(monkeypatch):
cli_obj = _make_cli()
monkeypatch.setattr(
"hermes_cli.proxy_cli.format_status_text",
lambda: "Egress proxy status\nEnabled: no",
)
assert cli_obj.process_command("/egress") is True
cli_obj.console.print.assert_called()
printed = "\n".join(str(call.args[0]) for call in cli_obj.console.print.call_args_list)
assert "Egress proxy status" in printed
def test_status_prefix_prefers_status_command_over_statusbar_toggle():
cli_obj = _make_cli()
with patch.object(cli_obj, "_show_session_status") as mock_status:
assert cli_obj.process_command("/sta") is True
mock_status.assert_called_once_with()
assert cli_obj._status_bar_visible is True
def test_show_session_status_prints_gateway_style_summary():
cli_obj = _make_cli()
cli_obj.agent = SimpleNamespace(
session_total_tokens=321,
session_api_calls=4,
)
cli_obj._session_db.get_session.return_value = {
"title": "My titled session",
"started_at": 1775791440,
}
with patch("hermes_constants.display_hermes_home", return_value="~/.hermes"):
cli_obj._show_session_status()
printed = "\n".join(str(call.args[0]) for call in cli_obj.console.print.call_args_list)
assert "Hermes CLI Status" in printed
assert "Session ID: session-123" in printed
assert "Path: ~/.hermes" in printed
assert "Title: My titled session" in printed
assert "Model: openai/gpt-5.4 (openai)" in printed
assert "Tokens: 321" in printed
assert "Agent Running: No" in printed
_, kwargs = cli_obj.console.print.call_args
assert kwargs.get("highlight") is False
assert kwargs.get("markup") is False
def test_show_session_status_includes_reasoning_approvals_context():
"""C-02: /status surfaces reasoning level, approval mode, and context %."""
cli_obj = _make_cli()
cli_obj.agent = SimpleNamespace(session_total_tokens=1000, session_api_calls=2,
reasoning_config={"enabled": True, "effort": "high"})
cli_obj.reasoning_config = {"enabled": True, "effort": "high"}
cli_obj.show_reasoning = True
cli_obj.session_key = ""
cli_obj._session_db.get_session.return_value = {"started_at": 1775791440}
cli_obj._get_status_bar_snapshot = lambda: {
"context_tokens": 50000, "context_length": 200000, "context_percent": 25,
}
with patch("hermes_constants.display_hermes_home", return_value="~/.hermes"), \
patch("tools.approval_context._get_approval_mode", return_value="manual"), \
patch("tools.approval.is_approval_bypass_active_for_session", return_value=False):
cli_obj._show_session_status()
printed = "\n".join(str(call.args[0]) for call in cli_obj.console.print.call_args_list)
assert "Reasoning: high (display: on)" in printed
assert "Approvals: manual" in printed
assert "Context: 75% left · 50,000 / 200,000 tokens used" in printed
def test_profile_command_reports_custom_root_profile(monkeypatch, tmp_path, capsys):
"""Profile detection works for custom-root deployments (not under ~/.hermes)."""
cli_obj = _make_cli()
profile_home = tmp_path / "profiles" / "coder"
monkeypatch.setenv("HERMES_HOME", str(profile_home))
monkeypatch.setattr(Path, "home", lambda: tmp_path / "unrelated-home")
cli_obj._handle_profile_command()
out = capsys.readouterr().out
assert "Profile: coder" in out
assert f"Home: {profile_home}" in out
+146
View File
@@ -0,0 +1,146 @@
"""Regression tests for classic-CLI mid-run /steer dispatch.
Background
----------
/steer sent while the agent is running used to be queued through
``self._pending_input`` alongside ordinary user input. ``process_loop``
pulls from that queue and calls ``process_command()`` — but while the
agent is running, ``process_loop`` is blocked inside ``self.chat()``.
By the time the queued /steer was pulled, ``_agent_running`` had
already flipped back to False, so ``process_command()`` took the idle
fallback (``"No agent running; queued as next turn"``) and delivered
the steer as an ordinary next-turn message.
The fix dispatches /steer inline on the UI thread when the agent is
running — matching the existing pattern for /model — so the steer
reaches ``agent.steer()`` (thread-safe) without touching the queue.
These tests exercise the detector + inline dispatch without starting a
prompt_toolkit app.
"""
from __future__ import annotations
import importlib
import sys
from unittest.mock import MagicMock, patch
def _make_cli():
"""Create a HermesCLI instance with prompt_toolkit stubbed out."""
_clean_config = {
"model": {
"default": "anthropic/claude-opus-4.6",
"base_url": "https://openrouter.ai/api/v1",
"provider": "auto",
},
"display": {"compact": False, "tool_progress": "all"},
"agent": {},
"terminal": {"env_type": "local"},
}
clean_env = {"LLM_MODEL": "", "HERMES_MAX_ITERATIONS": ""}
prompt_toolkit_stubs = {
"prompt_toolkit": MagicMock(),
"prompt_toolkit.history": MagicMock(),
"prompt_toolkit.styles": MagicMock(),
"prompt_toolkit.patch_stdout": MagicMock(),
"prompt_toolkit.application": MagicMock(),
"prompt_toolkit.layout": MagicMock(),
"prompt_toolkit.layout.processors": MagicMock(),
"prompt_toolkit.filters": MagicMock(),
"prompt_toolkit.layout.dimension": MagicMock(),
"prompt_toolkit.layout.menus": MagicMock(),
"prompt_toolkit.widgets": MagicMock(),
"prompt_toolkit.key_binding": MagicMock(),
"prompt_toolkit.completion": MagicMock(),
"prompt_toolkit.formatted_text": MagicMock(),
"prompt_toolkit.auto_suggest": MagicMock(),
}
with patch.dict(sys.modules, prompt_toolkit_stubs), patch.dict(
"os.environ", clean_env, clear=False
):
import cli as _cli_mod
_cli_mod = importlib.reload(_cli_mod)
with patch.object(_cli_mod, "get_tool_definitions", return_value=[]), patch.dict(
_cli_mod.__dict__, {"CLI_CONFIG": _clean_config}
):
return _cli_mod.HermesCLI()
class TestSteerInlineDetector:
"""_should_handle_steer_command_inline gates the busy-path fast dispatch."""
def test_detects_steer_when_agent_running(self):
cli = _make_cli()
cli._agent_running = True
assert cli._should_handle_steer_command_inline("/steer focus on error handling") is True
def test_ignores_steer_when_agent_idle(self):
"""Idle-path /steer should fall through to the normal process_loop
dispatch so the queue-style fallback message is emitted."""
cli = _make_cli()
cli._agent_running = False
assert cli._should_handle_steer_command_inline("/steer do something") is False
def test_ignores_non_slash_input(self):
cli = _make_cli()
cli._agent_running = True
assert cli._should_handle_steer_command_inline("steer without slash") is False
assert cli._should_handle_steer_command_inline("") is False
def test_ignores_other_slash_commands(self):
cli = _make_cli()
cli._agent_running = True
assert cli._should_handle_steer_command_inline("/queue hello") is False
assert cli._should_handle_steer_command_inline("/stop") is False
assert cli._should_handle_steer_command_inline("/help") is False
def test_ignores_steer_with_attached_images(self):
"""Image payloads take the normal path; steer doesn't accept images."""
cli = _make_cli()
cli._agent_running = True
assert cli._should_handle_steer_command_inline("/steer text", has_images=True) is False
class TestSteerBusyPathDispatch:
"""When the detector fires, process_command('/steer ...') must call
agent.steer() directly rather than the idle-path fallback."""
def test_process_command_routes_to_agent_steer(self):
"""With _agent_running=True and agent.steer present, /steer reaches
agent.steer(payload), NOT _pending_input."""
cli = _make_cli()
cli._agent_running = True
cli.agent = MagicMock()
cli.agent.steer = MagicMock(return_value=True)
# Make sure the idle-path fallback would be observable if taken
cli._pending_input = MagicMock()
cli.process_command("/steer focus on errors")
cli.agent.steer.assert_called_once_with("focus on errors")
cli._pending_input.put.assert_not_called()
def test_idle_path_queues_as_next_turn(self):
"""Control — when the agent is NOT running, /steer correctly falls
back to next-turn queue semantics. Demonstrates why the fix was
needed: the queue path only works when you can actually drain it."""
cli = _make_cli()
cli._agent_running = False
cli.agent = MagicMock()
cli.agent.steer = MagicMock(return_value=True)
cli._pending_input = MagicMock()
cli.process_command("/steer would-be-next-turn")
# Idle path does NOT call agent.steer
cli.agent.steer.assert_not_called()
# It puts the payload in the queue as a normal next-turn message
cli._pending_input.put.assert_called_once_with("would-be-next-turn")
if __name__ == "__main__": # pragma: no cover
import pytest
pytest.main([__file__, "-v"])
@@ -0,0 +1,47 @@
"""Tests for defensive terminal control-response stripping in the CLI.
Covers Cursor Position Report (CPR / DSR) responses that occasionally
leak into the input buffer after terminal resize storms or multiplexer
tab switches — see issue #14692.
"""
from cli import _strip_leaked_terminal_responses_with_meta
def _strip_leaked_terminal_responses(text: str) -> str:
cleaned, _ = _strip_leaked_terminal_responses_with_meta(text)
return cleaned
class TestStripLeakedTerminalResponses:
def test_strips_canonical_dsr_response(self):
# Reports from issue #14692
text = "\x1b[53;1R"
assert _strip_leaked_terminal_responses(text) == ""
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_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_with_large_coordinates(self):
text = "abc\x1b[<10000;12345;98765Mdef"
assert _strip_leaked_terminal_responses(text) == "abcdef"
def test_strips_multiple_concatenated_sgr_mouse_reports(self):
text = "<65;1;49M<35;1;42Mhello<64;1;40m"
assert _strip_leaked_terminal_responses(text) == "hello"
+49
View File
@@ -0,0 +1,49 @@
"""Regression tests for terminal navigation/focus escape sequences.
Ghostty/macOS window and tab navigation can deliver terminal focus reports
(CSI I / CSI O) to the running TUI. These must be consumed by the input parser,
not inserted into the prompt buffer and cleaned up later.
"""
from prompt_toolkit.input.vt100_parser import Vt100Parser
from prompt_toolkit.keys import Keys
from hermes_cli.pt_input_extras import install_ignored_terminal_sequences
def _parse_keys(data: str):
events = []
parser = Vt100Parser(events.append)
parser.feed_and_flush(data)
return [(event.key, event.data) for event in events]
def test_focus_events_are_parser_level_ignored_before_prompt_buffer():
install_ignored_terminal_sequences()
assert _parse_keys("\x1b[O\x1b[Ihello") == [
(Keys.Ignore, "\x1b[O"),
(Keys.Ignore, "\x1b[I"),
("h", "h"),
("e", "e"),
("l", "l"),
("l", "l"),
("o", "o"),
]
def test_regular_escape_shortcuts_still_parse_normally():
install_ignored_terminal_sequences()
assert _parse_keys("\x1bg") == [(Keys.Escape, "\x1b"), ("g", "g")]
def test_install_is_idempotent_and_setdefault_safe():
"""Second call should return 0 (no new mappings); existing user
registrations must not be overwritten."""
first = install_ignored_terminal_sequences()
second = install_ignored_terminal_sequences()
# At most first should be 2 (both CSI I + CSI O), second always 0
# since the entries are now present.
assert second == 0
assert first in (0, 1, 2) # 0 if a prior test in same process already installed
+101
View File
@@ -0,0 +1,101 @@
"""Tests for /tools slash command handler in the interactive CLI."""
from unittest.mock import MagicMock, patch
from cli import HermesCLI
def _make_cli(enabled_toolsets=None):
"""Build a minimal HermesCLI stub without running __init__."""
cli_obj = HermesCLI.__new__(HermesCLI)
cli_obj.enabled_toolsets = set(enabled_toolsets or ["web", "memory"])
cli_obj._command_running = False
cli_obj.console = MagicMock()
return cli_obj
# ── /tools (no subcommand) ──────────────────────────────────────────────────
class TestToolsSlashNoSubcommand:
def test_bare_tools_shows_tool_list(self):
cli_obj = _make_cli()
with patch.object(cli_obj, "show_tools") as mock_show:
cli_obj._handle_tools_command("/tools")
mock_show.assert_called_once()
# ── /tools list ─────────────────────────────────────────────────────────────
class TestToolsSlashList:
def test_list_calls_backend(self, capsys):
cli_obj = _make_cli()
with patch("hermes_cli.tools_config.load_config",
return_value={"platform_toolsets": {"cli": ["web"]}}), \
patch("hermes_cli.tools_config.save_config"):
cli_obj._handle_tools_command("/tools list")
out = capsys.readouterr().out
assert "web" in out
# ── /tools disable (session reset) ──────────────────────────────────────────
class TestToolsSlashDisableWithReset:
def test_disable_applies_directly_and_resets_session(self):
"""Disable applies immediately (no confirmation prompt) and resets session."""
cli_obj = _make_cli(["web", "memory"])
with patch("hermes_cli.tools_config.load_config",
return_value={"platform_toolsets": {"cli": ["web", "memory"]}}), \
patch("hermes_cli.tools_config.save_config"), \
patch("hermes_cli.tools_config._get_platform_tools", return_value={"memory"}), \
patch("hermes_cli.config.load_config", return_value={}), \
patch.object(cli_obj, "new_session") as mock_reset:
cli_obj._handle_tools_command("/tools disable web")
mock_reset.assert_called_once()
assert "web" not in cli_obj.enabled_toolsets
def test_disable_always_resets_session(self):
"""Even without a confirmation prompt, disable always resets the session."""
cli_obj = _make_cli(["web", "memory"])
with patch("hermes_cli.tools_config.load_config",
return_value={"platform_toolsets": {"cli": ["web", "memory"]}}), \
patch("hermes_cli.tools_config.save_config"), \
patch("hermes_cli.tools_config._get_platform_tools", return_value={"memory"}), \
patch("hermes_cli.config.load_config", return_value={}), \
patch.object(cli_obj, "new_session") as mock_reset:
cli_obj._handle_tools_command("/tools disable web")
mock_reset.assert_called_once()
# ── /tools enable (session reset) ───────────────────────────────────────────
class TestToolsSlashEnableWithReset:
def test_enable_applies_directly_and_resets_session(self):
"""Enable applies immediately (no confirmation prompt) and resets session."""
cli_obj = _make_cli(["memory"])
with patch("hermes_cli.tools_config.load_config",
return_value={"platform_toolsets": {"cli": ["memory"]}}), \
patch("hermes_cli.tools_config.save_config"), \
patch("hermes_cli.tools_config._get_platform_tools", return_value={"memory", "web"}), \
patch("hermes_cli.config.load_config", return_value={}), \
patch.object(cli_obj, "new_session") as mock_reset:
cli_obj._handle_tools_command("/tools enable web")
mock_reset.assert_called_once()
assert "web" in cli_obj.enabled_toolsets
def test_enable_missing_name_prints_usage(self, capsys):
cli_obj = _make_cli()
cli_obj._handle_tools_command("/tools enable")
out = capsys.readouterr().out
assert "Usage" in out
@@ -0,0 +1,90 @@
import importlib
import sys
from unittest.mock import MagicMock, patch
_cli_mod = None
def _make_cli(user_message_preview=None):
global _cli_mod
clean_config = {
"model": {
"default": "anthropic/claude-opus-4.6",
"base_url": "https://openrouter.ai/api/v1",
"provider": "auto",
},
"display": {
"compact": False,
"tool_progress": "all",
"user_message_preview": user_message_preview or {"first_lines": 2, "last_lines": 2},
},
"agent": {},
"terminal": {"env_type": "local"},
}
clean_env = {"LLM_MODEL": "", "HERMES_MAX_ITERATIONS": ""}
prompt_toolkit_stubs = {
"prompt_toolkit": MagicMock(),
"prompt_toolkit.history": MagicMock(),
"prompt_toolkit.styles": MagicMock(),
"prompt_toolkit.patch_stdout": MagicMock(),
"prompt_toolkit.application": MagicMock(),
"prompt_toolkit.layout": MagicMock(),
"prompt_toolkit.layout.processors": MagicMock(),
"prompt_toolkit.filters": MagicMock(),
"prompt_toolkit.layout.dimension": MagicMock(),
"prompt_toolkit.layout.menus": MagicMock(),
"prompt_toolkit.widgets": MagicMock(),
"prompt_toolkit.key_binding": MagicMock(),
"prompt_toolkit.completion": MagicMock(),
"prompt_toolkit.formatted_text": MagicMock(),
"prompt_toolkit.auto_suggest": MagicMock(),
}
with patch.dict(sys.modules, prompt_toolkit_stubs), patch.dict("os.environ", clean_env, clear=False):
import cli as mod
mod = importlib.reload(mod)
_cli_mod = mod
with patch.object(mod, "get_tool_definitions", return_value=[]), patch.dict(mod.__dict__, {"CLI_CONFIG": clean_config}):
return mod.HermesCLI()
class TestSubmittedUserMessagePreview:
def test_default_preview_shows_first_two_lines_and_last_two_lines(self):
cli = _make_cli()
rendered = cli._format_submitted_user_message_preview(
"line1\nline2\nline3\nline4\nline5\nline6"
)
assert "line1" in rendered
assert "line2" in rendered
assert "line5" in rendered
assert "line6" in rendered
assert "line3" not in rendered
assert "line4" not in rendered
assert "(+2 more lines)" in rendered
def test_preview_can_hide_last_lines(self):
cli = _make_cli({"first_lines": 2, "last_lines": 0})
rendered = cli._format_submitted_user_message_preview(
"line1\nline2\nline3\nline4\nline5\nline6"
)
assert "line1" in rendered
assert "line2" in rendered
assert "line5" not in rendered
assert "line6" not in rendered
assert "(+4 more lines)" in rendered
def test_invalid_first_lines_value_falls_back_to_one(self):
cli = _make_cli({"first_lines": 0, "last_lines": 2})
rendered = cli._format_submitted_user_message_preview("line1\nline2\nline3\nline4")
assert "line1" in rendered
assert "line3" in rendered
assert "line4" in rendered
assert "(+1 more line)" in rendered
@@ -0,0 +1,252 @@
"""Regression tests: YOLO mode persists across ``hermes --resume``.
Pre-fix bug: the ``/yolo`` toggle (and the process-start ``--yolo`` flag)
lived only in the in-memory ``tools.approval._session_yolo`` set / the
frozen env var. Resuming a session in a fresh process silently reverted the
bypass — dangerous commands started prompting again even though the user had
YOLO on for that session.
The fix persists a ``yolo_mode`` flag inside the session row's
``model_config`` JSON:
- ``SessionDB.set_session_yolo`` merges the flag (preserving lineage markers
like ``_branched_from``), written by the CLI ``/yolo`` toggle.
- ``AIAgent._ensure_db_session`` carries a live session bypass (or a frozen
``--yolo`` launch, via agent_init) into the creation-time model_config.
- ``HermesCLI._restore_session_yolo`` reads the flag on every resume path
and re-enables the in-memory bypass.
"""
import json
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
import pytest
import tools.approval as approval_module
from tools import approval_context
from cli import HermesCLI
from hermes_state import SessionDB
SESSION_ID = "yolo_persist_session"
@pytest.fixture(autouse=True)
def _hermetic_yolo(monkeypatch):
monkeypatch.delenv("HERMES_YOLO_MODE", raising=False)
monkeypatch.setattr(approval_module, "_YOLO_MODE_FROZEN", False)
approval_module.clear_session(SESSION_ID)
yield
approval_module.clear_session(SESSION_ID)
@pytest.fixture
def db(tmp_path):
d = SessionDB(db_path=tmp_path / "state.db")
yield d
try:
d.close()
except Exception:
pass
class TestSessionDbYoloFlag:
def test_set_and_read_round_trip(self, db):
db.create_session(session_id=SESSION_ID, source="cli", model="m")
db.set_session_yolo(SESSION_ID, True)
meta = db.get_session(SESSION_ID)
assert SessionDB.session_yolo_enabled(meta) is True
db.set_session_yolo(SESSION_ID, False)
meta = db.get_session(SESSION_ID)
assert SessionDB.session_yolo_enabled(meta) is False
def test_merge_preserves_existing_model_config_keys(self, db):
db.create_session(
session_id=SESSION_ID,
source="cli",
model="m",
model_config={"max_iterations": 42, "_branched_from": "parent_x"},
)
db.set_session_yolo(SESSION_ID, True)
meta = db.get_session(SESSION_ID)
config = json.loads(meta["model_config"])
assert config["yolo_mode"] is True
assert config["max_iterations"] == 42
assert config["_branched_from"] == "parent_x"
def test_missing_row_is_noop(self, db):
# Row doesn't exist yet (lazy creation) — must not raise or create.
db.set_session_yolo("does_not_exist", True)
assert db.get_session("does_not_exist") is None
def test_creation_time_model_config_flag_reads_back(self, db):
db.create_session(
session_id=SESSION_ID,
source="cli",
model="m",
model_config={"yolo_mode": True},
)
meta = db.get_session(SESSION_ID)
assert SessionDB.session_yolo_enabled(meta) is True
def test_reader_is_false_on_garbage(self):
assert SessionDB.session_yolo_enabled(None) is False
assert SessionDB.session_yolo_enabled({}) is False
assert SessionDB.session_yolo_enabled({"model_config": None}) is False
assert SessionDB.session_yolo_enabled({"model_config": "not json {"}) is False
assert SessionDB.session_yolo_enabled({"model_config": "[1,2]"}) is False
assert (
SessionDB.session_yolo_enabled({"model_config": '{"yolo_mode": false}'})
is False
)
def _stand_in(session_id=SESSION_ID, session_db=None):
return SimpleNamespace(
session_id=session_id,
_session_db=session_db,
_console_print=lambda *a, **k: None,
)
class TestRestoreSessionYolo:
def test_restore_enables_bypass_when_flag_set(self):
stand_in = _stand_in()
meta = {"id": SESSION_ID, "model_config": '{"yolo_mode": true}'}
assert approval_module.is_session_yolo_enabled(SESSION_ID) is False
HermesCLI._restore_session_yolo(stand_in, meta)
assert approval_module.is_session_yolo_enabled(SESSION_ID) is True
def test_restore_noop_when_flag_absent(self):
stand_in = _stand_in()
meta = {"id": SESSION_ID, "model_config": '{"max_iterations": 10}'}
HermesCLI._restore_session_yolo(stand_in, meta)
assert approval_module.is_session_yolo_enabled(SESSION_ID) is False
def test_restore_noop_when_meta_empty(self):
stand_in = _stand_in()
HermesCLI._restore_session_yolo(stand_in, {})
HermesCLI._restore_session_yolo(stand_in, None)
assert approval_module.is_session_yolo_enabled(SESSION_ID) is False
def test_restore_idempotent_when_already_enabled(self):
stand_in = _stand_in()
approval_module.enable_session_yolo(SESSION_ID)
meta = {"id": SESSION_ID, "model_config": '{"yolo_mode": true}'}
# Should not raise or print duplicate banners; state stays enabled.
HermesCLI._restore_session_yolo(stand_in, meta)
assert approval_module.is_session_yolo_enabled(SESSION_ID) is True
def test_restore_skipped_under_frozen_process_yolo(self):
stand_in = _stand_in()
meta = {"id": SESSION_ID, "model_config": '{"yolo_mode": true}'}
with patch.object(approval_module, "_YOLO_MODE_FROZEN", True):
HermesCLI._restore_session_yolo(stand_in, meta)
# Frozen bypass already covers everything — the session set is
# untouched (avoids persisting a session-scoped bypass the user
# only asked for at process scope).
assert approval_module.is_session_yolo_enabled(SESSION_ID) is False
class TestToggleYoloPersists:
def test_toggle_writes_flag_through_session_db(self):
db = MagicMock()
stand_in = SimpleNamespace(session_id=SESSION_ID, _session_db=db)
# Bind the real persist helper so the toggle's getattr finds it.
stand_in._persist_session_yolo = (
lambda key, enabled: HermesCLI._persist_session_yolo(
stand_in, key, enabled
)
)
with patch("cli._cprint"):
HermesCLI._toggle_yolo(stand_in) # ON
db.set_session_yolo.assert_called_once_with(SESSION_ID, True)
with patch("cli._cprint"):
HermesCLI._toggle_yolo(stand_in) # OFF
db.set_session_yolo.assert_called_with(SESSION_ID, False)
def test_toggle_survives_missing_session_db(self):
stand_in = SimpleNamespace(session_id=SESSION_ID, _session_db=None)
stand_in._persist_session_yolo = (
lambda key, enabled: HermesCLI._persist_session_yolo(
stand_in, key, enabled
)
)
with patch("cli._cprint"):
HermesCLI._toggle_yolo(stand_in) # must not raise
assert approval_module.is_session_yolo_enabled(SESSION_ID) is True
def test_toggle_still_works_without_persist_helper(self):
# Back-compat with the minimal stand-in used by older tests.
stand_in = SimpleNamespace(session_id=SESSION_ID)
with patch("cli._cprint"):
HermesCLI._toggle_yolo(stand_in)
assert approval_module.is_session_yolo_enabled(SESSION_ID) is True
class TestEndToEndPersistAndRestore:
def test_full_round_trip_through_real_db(self, db):
"""Toggle ON in 'process 1', restore in 'process 2' (fresh in-memory
approval state), and verify a dangerous command auto-approves."""
db.create_session(session_id=SESSION_ID, source="cli", model="m")
# Process 1: user toggles /yolo ON — persisted to the row.
cli_one = SimpleNamespace(session_id=SESSION_ID, _session_db=db)
cli_one._persist_session_yolo = (
lambda key, enabled: HermesCLI._persist_session_yolo(
cli_one, key, enabled
)
)
with patch("cli._cprint"):
HermesCLI._toggle_yolo(cli_one)
assert approval_module.is_session_yolo_enabled(SESSION_ID) is True
# Simulate process exit: in-memory approval state is gone.
approval_module.clear_session(SESSION_ID)
assert approval_module.is_session_yolo_enabled(SESSION_ID) is False
# Process 2: --resume reads the row and restores the bypass.
meta = db.get_session(SESSION_ID)
cli_two = _stand_in(session_db=db)
HermesCLI._restore_session_yolo(cli_two, meta)
assert approval_module.is_session_yolo_enabled(SESSION_ID) is True
token = approval_context.set_current_session_key(SESSION_ID)
try:
result = approval_module.check_all_command_guards(
"rm -rf /tmp/scratch-xyzzy", "local",
)
assert result["approved"] is True
finally:
approval_context.reset_current_session_key(token)
def test_toggle_off_round_trip(self, db):
"""OFF must persist too — a resumed session must not resurrect a
bypass the user explicitly turned off."""
db.create_session(
session_id=SESSION_ID,
source="cli",
model="m",
model_config={"yolo_mode": True},
)
cli_one = SimpleNamespace(session_id=SESSION_ID, _session_db=db)
cli_one._persist_session_yolo = (
lambda key, enabled: HermesCLI._persist_session_yolo(
cli_one, key, enabled
)
)
approval_module.enable_session_yolo(SESSION_ID)
with patch("cli._cprint"):
HermesCLI._toggle_yolo(cli_one) # OFF
approval_module.clear_session(SESSION_ID)
meta = db.get_session(SESSION_ID)
cli_two = _stand_in(session_db=db)
HermesCLI._restore_session_yolo(cli_two, meta)
assert approval_module.is_session_yolo_enabled(SESSION_ID) is False
+219
View File
@@ -0,0 +1,219 @@
"""Regression tests for the CLI ``/yolo`` in-chat toggle.
Pre-fix bug (issue #33925): ``cli.HermesCLI._toggle_yolo`` mutated only
``os.environ["HERMES_YOLO_MODE"]``. That env var is captured once at
module-import time into ``tools.approval._YOLO_MODE_FROZEN`` (security
hardening: stops prompt-injected skills from flipping the bypass mid-run),
so the post-startup toggle was a silent no-op. ``/yolo`` advertised "YOLO ON"
in the status bar while every dangerous command still hit the approval
prompt. Only ``hermes --yolo`` (process-start env), ``HERMES_YOLO_MODE=1``,
and ``hermes config set approvals.mode off`` actually bypassed.
The fix routes the CLI toggle through ``enable_session_yolo`` /
``disable_session_yolo`` (matching the gateway and TUI ``/yolo`` paths) and
binds ``self.session_id`` as the active approval session key around each
``run_conversation`` call so ``is_current_session_yolo_enabled()`` resolves
against the same key the toggle writes under.
We test ``_toggle_yolo`` and ``_is_session_yolo_active`` as unbound methods
against a minimal stand-in object that exposes only the attribute they
read (``session_id``). This avoids the heavy ``HermesCLI`` construction
path used in ``test_cli_init.py``, which is incompatible with this test
file's path layout — ``HermesCLI.__init__`` imports a lot of optional
state we don't need here.
"""
import os
from types import SimpleNamespace
from unittest.mock import patch
import pytest
import tools.approval as approval_module
from tools import approval_context
from cli import HermesCLI
SESSION_KEY = "test-cli-yolo-session"
@pytest.fixture(autouse=True)
def _clear_approval_state(monkeypatch):
"""Clear the YOLO bypass + env var around every test so cases are independent."""
monkeypatch.delenv("HERMES_YOLO_MODE", raising=False)
# The value is intentionally frozen at tools.approval import time. Local
# Hermes-driven test runs may inherit HERMES_YOLO_MODE=1 from the parent
# agent process, so make the default test state hermetic; the one test that
# covers startup-frozen YOLO explicitly patches it back to True.
monkeypatch.setattr(approval_module, "_YOLO_MODE_FROZEN", False)
approval_module.clear_session(SESSION_KEY)
approval_module.clear_session("default")
yield
approval_module.clear_session(SESSION_KEY)
approval_module.clear_session("default")
def _make_stand_in(session_id: str = SESSION_KEY) -> SimpleNamespace:
"""Minimal stand-in exposing only ``session_id``.
``_toggle_yolo`` and ``_is_session_yolo_active`` are both pure methods
that only read ``self.session_id`` — no other CLI state is touched.
Calling them as unbound functions against this stand-in is equivalent
to invoking them on a fully-constructed ``HermesCLI`` for the
behaviour under test, and avoids the brittle prompt_toolkit / config
stubbing required to instantiate ``HermesCLI`` from this test file.
"""
return SimpleNamespace(session_id=session_id)
class TestToggleYoloIsSessionScoped:
"""The CLI /yolo handler must mutate the session-yolo set, not the env var.
The env var path is dead-on-arrival because ``_YOLO_MODE_FROZEN`` is
captured once at module import, long before the CLI's ``/yolo`` command
can run.
"""
def test_toggle_yolo_enables_session_bypass(self):
stand_in = _make_stand_in()
assert approval_module.is_session_yolo_enabled(SESSION_KEY) is False
with patch("cli._cprint"):
HermesCLI._toggle_yolo(stand_in)
assert approval_module.is_session_yolo_enabled(SESSION_KEY) is True
def test_toggle_yolo_disables_session_bypass_on_second_call(self):
stand_in = _make_stand_in()
with patch("cli._cprint"):
HermesCLI._toggle_yolo(stand_in) # ON
assert approval_module.is_session_yolo_enabled(SESSION_KEY) is True
HermesCLI._toggle_yolo(stand_in) # OFF
assert approval_module.is_session_yolo_enabled(SESSION_KEY) is False
def test_two_independent_sessions_are_isolated(self):
"""``/yolo`` toggled in one session must not bypass approvals in
another session — mirrors the gateway-side invariant."""
cli_a = _make_stand_in(session_id="session-yolo-a")
cli_b = _make_stand_in(session_id="session-yolo-b")
try:
with patch("cli._cprint"):
HermesCLI._toggle_yolo(cli_a)
assert approval_module.is_session_yolo_enabled("session-yolo-a") is True
assert approval_module.is_session_yolo_enabled("session-yolo-b") is False
finally:
approval_module.clear_session("session-yolo-a")
approval_module.clear_session("session-yolo-b")
class TestIsSessionYoloActiveHelper:
"""The status-bar helper must read the live session-yolo state, not the
env var (which is the bug class this PR fixes)."""
def test_helper_reflects_toggle(self):
stand_in = _make_stand_in()
assert HermesCLI._is_session_yolo_active(stand_in) is False
with patch("cli._cprint"):
HermesCLI._toggle_yolo(stand_in)
assert HermesCLI._is_session_yolo_active(stand_in) is True
with patch("cli._cprint"):
HermesCLI._toggle_yolo(stand_in)
assert HermesCLI._is_session_yolo_active(stand_in) is False
def test_helper_honors_frozen_yolo_mode(self):
"""``hermes --yolo`` sets ``HERMES_YOLO_MODE`` before tool imports, so
``_YOLO_MODE_FROZEN`` ends up True. The status bar should still
reflect YOLO on in that case even when the session toggle is off."""
stand_in = _make_stand_in()
with patch.object(approval_module, "_YOLO_MODE_FROZEN", True):
assert HermesCLI._is_session_yolo_active(stand_in) is True
def test_toggle_under_frozen_yolo_reports_locked_and_stays_on(self):
"""With process-level YOLO frozen ON, /yolo must NOT claim approvals
are back. Pre-fix, the second toggle printed "YOLO mode OFF —
dangerous commands will require approval" while the frozen flag kept
auto-approving everything — a false safety claim."""
stand_in = _make_stand_in()
printed = []
with patch.object(approval_module, "_YOLO_MODE_FROZEN", True):
with patch("cli._cprint", side_effect=lambda msg: printed.append(msg)):
HermesCLI._toggle_yolo(stand_in)
HermesCLI._toggle_yolo(stand_in)
# Still effectively ON, and no session-level state was flipped.
assert HermesCLI._is_session_yolo_active(stand_in) is True
assert not approval_module.is_session_yolo_enabled(SESSION_KEY)
joined = "\n".join(printed)
assert "locked ON" in joined
assert "will require approval" not in joined
class TestToggleYoloEndToEnd:
"""End-to-end: a dangerous command must auto-approve through the same
``check_all_command_guards`` path the terminal tool uses."""
def test_toggle_yolo_bypasses_dangerous_command_check(self):
stand_in = _make_stand_in()
token = approval_context.set_current_session_key(SESSION_KEY)
try:
with patch("cli._cprint"):
HermesCLI._toggle_yolo(stand_in) # YOLO ON
result = approval_module.check_all_command_guards(
"rm -rf /tmp/scratch-xyzzy", "local",
)
assert result["approved"] is True, (
f"YOLO toggle should auto-approve dangerous commands, got: {result}"
)
finally:
approval_context.reset_current_session_key(token)
class TestSessionRotationTransfersYolo:
"""When the CLI's ``session_id`` rotates mid-run (``/branch``, auto
compression continuation), YOLO state keyed under the old id must move
to the new id. Otherwise the user's ``/yolo ON`` silently reverts on
the next turn — the same UX failure mode this PR set out to fix.
Mirrors ``tui_gateway/server.py`` ~line 1297-1305."""
def test_transfer_moves_yolo_to_new_session(self):
stand_in = _make_stand_in(session_id="old-id")
try:
approval_module.enable_session_yolo("old-id")
assert approval_module.is_session_yolo_enabled("old-id") is True
HermesCLI._transfer_session_yolo(stand_in, "old-id", "new-id")
assert approval_module.is_session_yolo_enabled("new-id") is True
assert approval_module.is_session_yolo_enabled("old-id") is False
finally:
approval_module.clear_session("old-id")
approval_module.clear_session("new-id")
def test_transfer_handles_empty_inputs_safely(self):
stand_in = _make_stand_in(session_id="x")
# Both directions of empty input should be safe no-ops; nothing
# to transfer from "" / to "".
HermesCLI._transfer_session_yolo(stand_in, "", "new")
HermesCLI._transfer_session_yolo(stand_in, "old", "")
# Neither key should have been touched.
assert approval_module.is_session_yolo_enabled("new") is False
assert approval_module.is_session_yolo_enabled("old") is False
+101
View File
@@ -0,0 +1,101 @@
"""Tests for /help declutter + Ctrl+P command palette (C-04 / C-05)."""
from types import SimpleNamespace
from cli import HermesCLI
from hermes_cli.commands import HELP_SESSION_SUBGROUPS
def _bare_cli():
cli = HermesCLI.__new__(HermesCLI)
return cli
class TestHelpSubgroups:
def test_session_subgroups_defined(self):
# The oversized Session category is split into readable sub-headers.
assert "Context" in HELP_SESSION_SUBGROUPS
assert "Background & Automation" in HELP_SESSION_SUBGROUPS
# Representative members land in the right buckets.
assert "compress" in HELP_SESSION_SUBGROUPS["Context"]
assert "goal" in HELP_SESSION_SUBGROUPS["Background & Automation"]
class TestCommandPaletteFilter:
def _state(self, entries, query):
cli = _bare_cli()
cli._command_palette_state = {
"entries": entries, "filter": query, "selected": 0, "_scroll_offset": 0,
}
return cli
ENTRIES = [
("/model", "Model", "Switch model"),
("/compress", "Context", "Compress the conversation"),
("/goal", "Background", "Set an autonomous goal"),
("/status", "Context", "Show session status"),
]
def test_empty_query_returns_all(self):
cli = self._state(self.ENTRIES, "")
assert cli._command_palette_visible_entries() == self.ENTRIES
def test_filter_by_command_name(self):
cli = self._state(self.ENTRIES, "model")
rows = cli._command_palette_visible_entries()
assert [r[0] for r in rows] == ["/model"]
def test_filter_by_description(self):
cli = self._state(self.ENTRIES, "autonomous")
rows = cli._command_palette_visible_entries()
assert [r[0] for r in rows] == ["/goal"]
def test_subsequence_match(self):
# "cmp" is a subsequence of "/compress"
cli = self._state(self.ENTRIES, "cmprs")
rows = cli._command_palette_visible_entries()
assert "/compress" in [r[0] for r in rows]
def test_no_match(self):
cli = self._state(self.ENTRIES, "zzznope")
assert cli._command_palette_visible_entries() == []
def test_command_name_matches_rank_above_description(self):
# "status" matches /status by name (rank<=2). A row that only matches
# in its description must rank strictly after any name match.
entries = [
("/goal", "Background", "check the status of things"), # desc-only match
("/status", "Context", "Show session status"), # name match
]
cli = self._state(entries, "status")
rows = cli._command_palette_visible_entries()
assert rows[0][0] == "/status" # name match ranks first
def test_query_does_not_overmatch_via_description(self):
# A short query must not match a row solely because its letters appear
# as a scattered subsequence of the description (the "steer" bug).
entries = [
("/new", "Session", "Start a totally separate resettable session"),
]
cli = self._state(entries, "steer")
# "steer" is NOT a subsequence of "/new" and not a substring of the
# description, so the row is dropped.
assert cli._command_palette_visible_entries() == []
class TestCommandPaletteSelectionInserts:
def test_selection_prefills_exact_command(self):
cli = _bare_cli()
cli._command_palette_state = {
"entries": [("/model", "Model", "Switch model")],
"filter": "", "selected": 0, "_scroll_offset": 0,
}
# Fake prompt_toolkit app + buffer.
buf = SimpleNamespace(text="", cursor_position=0)
cli._app = SimpleNamespace(current_buffer=buf)
cli._invalidate = lambda **k: None
cli._restore_modal_input_snapshot = lambda: None
cli._handle_command_palette_selection()
# Exact command string inserted (with trailing space), never executed.
assert buf.text == "/model "
assert cli._command_palette_state is None # palette closed
+24
View File
@@ -0,0 +1,24 @@
"""Completion backlogs preserve results without multiplying autonomous turns."""
from evals.completion_backlog_probe import probe
def test_ready_completions_share_one_turn_across_interactive_routes(tmp_path):
for surface in ("cli", "poller", "post-turn"):
for scenario in ("backlog", "single", "mixed"):
result = probe(surface, scenario, tmp_path / surface / scenario)
assert result["wire_turns"] == (5 if scenario == "mixed" else 1), result
assert result["payload_order"] == sorted(result["payload_order"]), result
if scenario == "mixed":
assert result["delegation_delivered_once"], result
assert result["all_payloads_preserved"], result
if scenario == "single":
assert result["single_exact"], result
def test_consumed_or_foreign_completions_never_start_a_turn(tmp_path):
for surface in ("cli", "poller", "post-turn"):
for scenario in ("consumed", "foreign"):
result = probe(surface, scenario, tmp_path / surface / scenario)
assert result["wire_turns"] == 0, result
if scenario == "foreign":
assert result["queue_remaining"] == result["children"], result
+109
View File
@@ -0,0 +1,109 @@
"""Tests for /compress --preview/--dry-run/--aggressive flags and the
/compact alias (PR #3243 salvage).
Covers the pure helpers in ``hermes_cli.partial_compress`` plus alias
resolution in the command registry. The CLI and gateway surfaces both
route through these helpers, so the flag semantics are pinned here once.
"""
from hermes_cli.commands import COMMANDS, resolve_command
from hermes_cli.partial_compress import (
DEFAULT_KEEP_LAST,
extract_compress_flags,
parse_partial_compress_args,
summarize_compress_preview,
)
def _history(n_pairs: int) -> list[dict[str, str]]:
h: list[dict[str, str]] = []
for i in range(n_pairs):
h.append({"role": "user", "content": f"u{i}"})
h.append({"role": "assistant", "content": f"a{i}"})
return h
# ── /compact alias resolution ─────────────────────────────────────────
def test_compact_resolves_to_compress():
cmd = resolve_command("compact")
assert cmd is not None
assert cmd.name == "compress"
assert "compact" in cmd.aliases
def test_compact_listed_in_flat_commands():
assert "/compact" in COMMANDS
assert "alias for /compress" in COMMANDS["/compact"]
# ── extract_compress_flags ────────────────────────────────────────────
def test_dry_run_is_preview():
for form in ("--dry-run", "--dryrun", "--DRY-RUN"):
_, preview, _ = extract_compress_flags(form)
assert preview is True, form
def test_flags_coexist_with_focus_topic():
rest, preview, _ = extract_compress_flags("database schema --dry-run")
assert rest == "database schema"
assert preview is True
partial, _, focus = parse_partial_compress_args(rest)
assert partial is False and focus == "database schema"
# ── summarize_compress_preview ────────────────────────────────────────
def test_preview_full_compress_counts():
hist = _history(5)
report = summarize_compress_preview(hist, False, DEFAULT_KEEP_LAST, None, 1234)
assert report["head_count"] == 10
assert report["tail_count"] == 0
assert report["total"] == 10
assert report["partial"] is False
joined = "\n".join(report["lines"])
assert "no changes made" in joined.lower()
assert "10 of 10" in joined
assert "1,234" in joined
def test_preview_partial_boundary_counts():
hist = _history(5)
report = summarize_compress_preview(hist, True, 2, None, 999)
# Keeping last 2 exchanges = 4 tail messages, 6 head messages.
assert report["head_count"] == 6
assert report["tail_count"] == 4
assert report["partial"] is True
joined = "\n".join(report["lines"])
assert "last 2 exchange" in joined
def test_preview_is_side_effect_free():
hist = _history(4)
before = [dict(m) for m in hist]
summarize_compress_preview(hist, True, 1, None, 10)
assert hist == before
+118
View File
@@ -0,0 +1,118 @@
"""Tests for /compress <focus> — guided compression with focus topic.
Inspired by Claude Code's /compact <focus> feature.
"""
from unittest.mock import MagicMock, patch
from tests.cli.test_cli_init import _make_cli
def _make_history() -> list[dict[str, str]]:
return [
{"role": "user", "content": "one"},
{"role": "assistant", "content": "two"},
{"role": "user", "content": "three"},
{"role": "assistant", "content": "four"},
]
def test_focus_topic_extracted_and_passed(capsys):
"""Focus topic is extracted from the command and passed to _compress_context."""
shell = _make_cli()
history = _make_history()
compressed = [history[0], history[-1]]
shell.conversation_history = history
shell.agent = MagicMock()
shell.agent.compression_enabled = True
shell.agent._cached_system_prompt = ""
shell.agent._compress_context.return_value = (compressed, "")
def _estimate(messages):
if messages is history:
return 100
return 50
with patch("agent.model_metadata.estimate_messages_tokens_rough", side_effect=_estimate):
shell._manual_compress("/compress database schema")
output = capsys.readouterr().out
assert 'focus: "database schema"' in output
# Verify focus_topic was passed through
shell.agent._compress_context.assert_called_once()
call_kwargs = shell.agent._compress_context.call_args
assert call_kwargs.kwargs.get("focus_topic") == "database schema"
def test_no_focus_topic_when_bare_command(capsys):
"""When no focus topic is provided, None is passed."""
shell = _make_cli()
history = _make_history()
shell.conversation_history = history
shell.agent = MagicMock()
shell.agent.compression_enabled = True
shell.agent._cached_system_prompt = ""
shell.agent._compress_context.return_value = (list(history), "")
with patch("agent.model_metadata.estimate_messages_tokens_rough", return_value=100):
shell._manual_compress("/compress")
shell.agent._compress_context.assert_called_once()
call_kwargs = shell.agent._compress_context.call_args
assert call_kwargs.kwargs.get("focus_topic") is None
def test_empty_focus_after_command_treated_as_none(capsys):
"""Trailing whitespace after /compress does not produce a focus topic."""
shell = _make_cli()
history = _make_history()
shell.conversation_history = history
shell.agent = MagicMock()
shell.agent.compression_enabled = True
shell.agent._cached_system_prompt = ""
shell.agent._compress_context.return_value = (list(history), "")
with patch("agent.model_metadata.estimate_messages_tokens_rough", return_value=100):
shell._manual_compress("/compress ")
shell.agent._compress_context.assert_called_once()
call_kwargs = shell.agent._compress_context.call_args
assert call_kwargs.kwargs.get("focus_topic") is None
def test_focus_topic_printed_in_compression_banner(capsys):
"""The focus topic shows in the compression progress banner."""
shell = _make_cli()
history = _make_history()
compressed = [history[0], history[-1]]
shell.conversation_history = history
shell.agent = MagicMock()
shell.agent.compression_enabled = True
shell.agent._cached_system_prompt = ""
shell.agent._compress_context.return_value = (compressed, "")
with patch("agent.model_metadata.estimate_messages_tokens_rough", return_value=100):
shell._manual_compress("/compress API endpoints")
output = capsys.readouterr().out
assert 'focus: "API endpoints"' in output
def test_no_focus_prints_standard_banner(capsys):
"""Without focus, the standard banner (no focus: line) is printed."""
shell = _make_cli()
history = _make_history()
compressed = [history[0], history[-1]]
shell.conversation_history = history
shell.agent = MagicMock()
shell.agent.compression_enabled = True
shell.agent._cached_system_prompt = ""
shell.agent._compress_context.return_value = (compressed, "")
with patch("agent.model_metadata.estimate_messages_tokens_rough", return_value=100):
shell._manual_compress("/compress")
output = capsys.readouterr().out
assert "focus:" not in output
assert "Compressing" in output
+120
View File
@@ -0,0 +1,120 @@
"""Tests for /compress here [N] — boundary-aware partial compression.
Verifies the CLI handler (_manual_compress) splits the history, compresses
only the head, and re-appends the verbatim tail. Inspired by Claude Code's
Rewind "Summarize up to here" action (v2.1.139, May 2026).
"""
from unittest.mock import MagicMock, patch
from tests.cli.test_cli_init import _make_cli
def _make_history() -> list[dict[str, str]]:
# 8 messages = 4 exchanges.
h: list[dict[str, str]] = []
for i in range(4):
h.append({"role": "user", "content": f"u{i}"})
h.append({"role": "assistant", "content": f"a{i}"})
return h
def _wire_agent(shell, compressed_head):
shell.agent = MagicMock()
shell.agent.compression_enabled = True
shell.agent._cached_system_prompt = ""
shell.agent.session_id = None
shell.agent.tools = None
shell.agent._compress_context.return_value = (compressed_head, "")
shell.agent._compression_skipped_due_to_lock = False
def test_compress_here_compresses_head_only(capsys):
"""/compress here 2 passes only the head to _compress_context."""
shell = _make_cli()
history = _make_history()
shell.conversation_history = history
# Pretend compression collapses the head into a single summary message.
summary = [{"role": "user", "content": "[summary of earlier turns]"}]
_wire_agent(shell, summary)
with patch("agent.model_metadata.estimate_request_tokens_rough", return_value=100):
shell._manual_compress("/compress here 2")
# _compress_context should have been called with the HEAD only
# (everything before the last 2 user-starts = first 4 messages).
shell.agent._compress_context.assert_called_once()
call = shell.agent._compress_context.call_args
passed_head = call.args[0]
assert passed_head == history[:4]
# focus_topic must be None in partial mode (modes are exclusive).
assert call.kwargs.get("focus_topic") is None
def test_compress_here_reappends_verbatim_tail(capsys):
"""The most recent exchanges are preserved verbatim after the summary."""
shell = _make_cli()
history = _make_history()
shell.conversation_history = history
# Head compresses to an assistant-role summary so the seam
# (assistant -> user tail) is already valid — tail rides along whole.
summary = [{"role": "assistant", "content": "[summary]"}]
_wire_agent(shell, summary)
with patch("agent.model_metadata.estimate_request_tokens_rough", return_value=100):
shell._manual_compress("/compress here 2")
# Result = compressed head + verbatim tail (last 2 exchanges).
assert shell.conversation_history == summary + history[4:]
# Tail boundary keeps role alternation valid (tail starts on user).
assert history[4]["role"] == "user"
# No consecutive same-role user/assistant messages anywhere.
roles = [m["role"] for m in shell.conversation_history
if m["role"] in ("user", "assistant")]
assert all(roles[i] != roles[i + 1] for i in range(len(roles) - 1))
def test_compress_here_banner_mentions_summarizing_up_to_here(capsys):
shell = _make_cli()
history = _make_history()
shell.conversation_history = history
_wire_agent(shell, [{"role": "user", "content": "[summary]"}])
with patch("agent.model_metadata.estimate_request_tokens_rough", return_value=100):
shell._manual_compress("/compress here")
out = capsys.readouterr().out
assert "Summarizing up to here" in out
assert "verbatim" in out
def test_bare_compress_still_full(capsys):
"""/compress with no args compresses the whole history (full mode)."""
shell = _make_cli()
history = _make_history()
shell.conversation_history = history
_wire_agent(shell, list(history))
with patch("agent.model_metadata.estimate_request_tokens_rough", return_value=100):
shell._manual_compress("/compress")
call = shell.agent._compress_context.call_args
# Full mode passes the entire history as the head.
assert call.args[0] == history
out = capsys.readouterr().out
assert "Summarizing up to here" not in out
def test_focus_still_works(capsys):
"""/compress <focus> keeps the existing focus behavior."""
shell = _make_cli()
history = _make_history()
shell.conversation_history = history
_wire_agent(shell, list(history))
with patch("agent.model_metadata.estimate_request_tokens_rough", return_value=100):
shell._manual_compress("/compress database schema")
call = shell.agent._compress_context.call_args
assert call.args[0] == history
assert call.kwargs.get("focus_topic") == "database schema"
+149
View File
@@ -0,0 +1,149 @@
"""Type-ahead queue-drain proof for /compress (issue #61042, PR #68284).
PR #68284 made the classic prompt_toolkit CLI keep the composer editable
while ``/compress`` runs (``_busy_command(..., blocks_input=False)``), with
the docstring claim that "the queued input is processed against the
compacted history after the command completes."
These tests pin that claim end-to-end for the classic CLI:
1. ``test_type_ahead_queued_during_compress_becomes_next_prompt`` — runs
``_manual_compress`` on a worker thread (matching the real topology:
slash commands execute on ``process_loop``, which is blocked inside
``process_command`` for the duration), submits a type-ahead payload into
``_pending_input`` mid-compression (what ``handle_enter``'s idle branch
does — ``_agent_running`` is False while a slash command runs), and
asserts that after compression commits the compacted history the queued
text is still the next item ``process_loop`` will drain. Nothing inside
the compression path may consume or drop it.
2. ``test_handle_enter_never_gates_on_command_running`` — structural
invariant: ``handle_enter`` must not consult ``_command_running`` /
``_command_blocks_input``. The composer's read-only state is enforced
solely by the TextArea's ``read_only=Condition(_command_blocks_input)``;
if a future edit added an early-return in ``handle_enter`` while a
command runs, type-ahead submissions would be silently dropped and the
drain contract above would break without any other test noticing.
The Ink TUI (ui-tui) needs no equivalent fix: its ``/compress`` is an async
``session.compress`` RPC and the composer is never made read-only while it
runs; the gateway already resolves the concurrent-mutation race via the
``history_version`` guard in ``_compress_session_history``.
"""
from __future__ import annotations
import ast
import queue as queue_mod
import threading
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
from tests.cli.test_cli_init import _make_cli
def _make_history() -> list[dict[str, str]]:
return [
{"role": "user", "content": "one"},
{"role": "assistant", "content": "two"},
{"role": "user", "content": "three"},
{"role": "assistant", "content": "four"},
]
def test_type_ahead_queued_during_compress_becomes_next_prompt():
"""Text queued while /compress runs survives compaction and is the next prompt."""
shell = _make_cli()
history = _make_history()
compressed = [
{"role": "user", "content": "[summary]"},
history[-1],
]
shell.conversation_history = history
shell.agent = MagicMock()
shell.agent.compression_enabled = True
shell.agent._cached_system_prompt = ""
shell.agent.tools = None
shell.agent.session_id = shell.session_id
compress_started = threading.Event()
release_compress = threading.Event()
mid_compress = {}
def _compress(*_args, **_kwargs):
compress_started.set()
# Hold the "compression in flight" window open until the test has
# simulated the user's type-ahead submission.
assert release_compress.wait(timeout=10), "test deadlock: never released"
return (list(compressed), "")
shell.agent._compress_context.side_effect = _compress
with patch("agent.model_metadata.estimate_request_tokens_rough", return_value=100):
worker = threading.Thread(target=shell._manual_compress, daemon=True)
worker.start()
assert compress_started.wait(timeout=10), "compression never started"
# Mid-compression: composer stays editable (spinner up, input open).
mid_compress["running"] = shell._command_running
mid_compress["blocks_input"] = shell._command_blocks_input
# The user types a follow-up and presses Enter. handle_enter's normal
# routing puts it on _pending_input (agent idle — slash commands run
# on process_loop, not the agent). process_loop is blocked inside
# process_command → _manual_compress, so the payload must sit queued.
shell._pending_input.put("follow-up prompt drafted during compaction")
release_compress.set()
worker.join(timeout=10)
assert not worker.is_alive(), "_manual_compress did not finish"
assert mid_compress == {"running": True, "blocks_input": False}
# Compaction committed the compressed transcript...
assert shell.conversation_history == compressed
# ...and busy state was fully unwound so the next turn renders normally.
assert shell._command_running is False
assert shell._command_blocks_input is False
# The queued type-ahead is exactly the next item process_loop drains —
# i.e. it becomes the next prompt, processed against the compacted
# history. Compression must not consume, reorder, or drop it.
assert (
shell._pending_input.get_nowait()
== "follow-up prompt drafted during compaction"
)
with pytest.raises(queue_mod.Empty):
shell._pending_input.get_nowait()
def test_handle_enter_never_gates_on_command_running():
"""handle_enter must not consult the busy-command flags (drop-proof routing).
Enter-key routing while a slash command runs must keep flowing into
``_pending_input``; read-only enforcement belongs exclusively to the
TextArea's ``read_only=Condition(...)``. A ``_command_running`` /
``_command_blocks_input`` check inside ``handle_enter`` would let a
future edit silently drop type-ahead submissions during /compress.
"""
cli_path = Path(__file__).resolve().parents[2] / "hermes_cli" / "cli_tui_mixin.py"
tree = ast.parse(cli_path.read_text(encoding="utf-8"))
handlers = ("_tui_handle_enter", "_tui_enter_inline_command", "_tui_enter_overlay",
"_tui_enter_clarify_freetext", "_tui_enter_clarify_choice", "_tui_enter_while_busy")
targets = [n for n in ast.walk(tree) if isinstance(n, ast.FunctionDef) and n.name in handlers]
assert {n.name for n in targets} == set(handlers), "Enter handlers not found in cli_tui_mixin.py"
offenders = [
node.attr
for target in targets
for node in ast.walk(target)
if isinstance(node, ast.Attribute)
and node.attr in {"_command_running", "_command_blocks_input"}
]
assert not offenders, (
"handle_enter references busy-command state — Enter routing while a "
f"slash command runs risks dropping type-ahead input: {offenders}"
)
+168
View File
@@ -0,0 +1,168 @@
"""Local CPR leak reproduction + classic-CLI Application output selection.
* Deterministic local-PTY proof that delayed CPR replies leak as
``ESC[row;colR`` / ``^[[row;colR`` when ``enable_cpr=True`` (no SSH).
* Integration-level assertion that, with no SSH env vars, classic CLI
output selection wires a CPR-disabled Output into Application on POSIX.
* Native Windows keeps prompt_toolkit's default output selection.
"""
from __future__ import annotations
import os
import select
import sys
import threading
import time
import pytest
from cli import (
_build_cpr_disabled_output,
_select_classic_cli_pt_output,
_terminal_may_leak_cpr,
)
@pytest.fixture(autouse=True)
def _clear_cpr_env(monkeypatch):
for var in ("SSH_CONNECTION", "SSH_CLIENT", "SSH_TTY", "PROMPT_TOOLKIT_NO_CPR"):
monkeypatch.delenv(var, raising=False)
class TestClassicCliOutputSelection:
@pytest.mark.windows_only
def test_windows_preserves_default_output_selection(self):
assert _terminal_may_leak_cpr() is False
assert _select_classic_cli_pt_output(sys.stdout) is None
@pytest.mark.windows_only
def test_windows_honors_explicit_no_cpr(self, monkeypatch):
monkeypatch.setenv("PROMPT_TOOLKIT_NO_CPR", "1")
assert _terminal_may_leak_cpr() is True
out = _select_classic_cli_pt_output(sys.stdout)
# Build may return None if stdout is not a real tty in CI; if it
# succeeds it must be CPR-disabled.
assert out is None or out.enable_cpr is False
def _openpty_or_skip():
import pty
try:
return pty.openpty()
except OSError as exc:
pytest.skip(f"no PTY devices available: {exc}")
@pytest.mark.skipif(sys.platform == "win32", reason="POSIX PTY harness")
class TestDelayedCprLocalPtyLeak:
def test_delayed_cpr_reply_leaks_when_enable_cpr_true(self):
"""Local (no SSH) delayed ESC[6n reply lands as ESC[39;1R on stdin."""
import tty
from prompt_toolkit.data_structures import Size
from prompt_toolkit.output.vt100 import Vt100_Output
master, slave = _openpty_or_skip()
try:
tty.setraw(slave)
slave_w = os.fdopen(os.dup(slave), "w", buffering=1)
stop = threading.Event()
queries = 0
def terminal() -> None:
nonlocal queries
buf = b""
while not stop.is_set():
try:
r, _, _ = select.select([master], [], [], 0.05)
except OSError:
break
if not r:
continue
try:
chunk = os.read(master, 4096)
except OSError:
break
if not chunk:
break
buf += chunk
while True:
idx = buf.find(b"\x1b[6n")
if idx < 0:
buf = buf[-8:] if len(buf) > 8 else buf
break
buf = buf[idx + 4 :]
queries += 1
time.sleep(0.12)
try:
os.write(master, b"\x1b[39;1R")
except OSError:
pass
threading.Thread(target=terminal, daemon=True).start()
out = Vt100_Output(
slave_w, lambda: Size(rows=40, columns=80), enable_cpr=True
)
out.ask_for_cpr()
out.flush()
for i in range(4):
slave_w.write(f"\rgpt-5.6-sol Q {i}\n")
slave_w.flush()
time.sleep(0.02)
time.sleep(0.3)
data = b""
while True:
r, _, _ = select.select([slave], [], [], 0.05)
if not r:
break
data += os.read(slave, 4096)
stop.set()
slave_w.close()
assert queries >= 1
assert b"\x1b[39;1R" in data
finally:
try:
os.close(slave)
except OSError:
pass
try:
os.close(master)
except OSError:
pass
def test_cpr_disabled_output_sends_no_query(self):
"""Hermes CPR-disabled builder must not emit ESC[6n."""
master, slave = _openpty_or_skip()
try:
slave_w = os.fdopen(slave, "w", buffering=1)
out = _build_cpr_disabled_output(slave_w)
assert out is not None
assert out.enable_cpr is False
seen = b""
def reader() -> None:
nonlocal seen
r, _, _ = select.select([master], [], [], 0.25)
if r:
seen = os.read(master, 4096)
threading.Thread(target=reader, daemon=True).start()
slave_w.write("status ok\n")
slave_w.flush()
# Do not call ask_for_cpr — renderer skips it when NOT_SUPPORTED.
time.sleep(0.3)
slave_w.close()
assert b"\x1b[6n" not in seen
finally:
try:
os.close(master)
except OSError:
pass
+193
View File
@@ -0,0 +1,193 @@
"""Tests for cli._cprint's bg-thread cooperation with prompt_toolkit.
Background: when a prompt_toolkit Application is running, a bg thread that
calls ``_pt_print`` directly can race with the input-area redraw and the
printed line can end up visually buried behind the prompt. ``_cprint`` now
routes cross-thread prints through ``run_in_terminal`` via
``loop.call_soon_threadsafe`` so the self-improvement background review's
``💾 Self-improvement review: …`` summary actually surfaces to the user.
These tests verify the routing logic without spinning up a real PT app.
"""
from __future__ import annotations
import sys
import types
from types import SimpleNamespace
import pytest
import cli
@pytest.fixture(autouse=True)
def reset_output_history():
cli._configure_output_history(False, 200)
yield
cli._configure_output_history(True, 200)
def test_cprint_no_app_direct_print(monkeypatch):
"""No active app → direct _pt_print, no run_in_terminal involvement."""
calls = []
monkeypatch.setattr(cli, "_pt_print", lambda x: calls.append(("pt_print", x)))
monkeypatch.setattr(cli, "_PT_ANSI", lambda t: ("ANSI", t))
# Patch the prompt_toolkit import the function performs internally.
fake_pt_app = types.ModuleType("prompt_toolkit.application")
fake_pt_app.get_app_or_none = lambda: None
fake_pt_app.run_in_terminal = lambda *a, **kw: calls.append(("run_in_terminal",))
monkeypatch.setitem(sys.modules, "prompt_toolkit.application", fake_pt_app)
cli._cprint("hello")
assert calls == [("pt_print", ("ANSI", "hello"))]
def test_cprint_app_not_running_direct_print(monkeypatch):
"""App exists but not running (e.g. teardown) → direct print."""
calls = []
monkeypatch.setattr(cli, "_pt_print", lambda x: calls.append(("pt_print", x)))
monkeypatch.setattr(cli, "_PT_ANSI", lambda t: t)
fake_app = SimpleNamespace(_is_running=False, loop=None)
fake_pt_app = types.ModuleType("prompt_toolkit.application")
fake_pt_app.get_app_or_none = lambda: fake_app
fake_pt_app.run_in_terminal = lambda *a, **kw: calls.append(("run_in_terminal",))
monkeypatch.setitem(sys.modules, "prompt_toolkit.application", fake_pt_app)
cli._cprint("x")
assert calls == [("pt_print", "x")]
def test_cprint_same_thread_as_app_loop_direct_print(monkeypatch):
"""App running on same thread → direct print (no scheduling)."""
direct_prints = []
monkeypatch.setattr(cli, "_pt_print", lambda x: direct_prints.append(x))
monkeypatch.setattr(cli, "_PT_ANSI", lambda t: t)
class FakeLoop:
def is_running(self):
return True
def call_soon_threadsafe(self, cb, *args):
raise AssertionError(
"call_soon_threadsafe must not be used on the app's own thread"
)
fake_loop = FakeLoop()
fake_asyncio = types.ModuleType("asyncio")
class _Policy:
def get_event_loop(self):
return fake_loop # same as app loop
fake_asyncio.get_event_loop_policy = lambda: _Policy()
monkeypatch.setitem(sys.modules, "asyncio", fake_asyncio)
fake_app = SimpleNamespace(_is_running=True, loop=fake_loop)
fake_pt_app = types.ModuleType("prompt_toolkit.application")
fake_pt_app.get_app_or_none = lambda: fake_app
fake_pt_app.run_in_terminal = lambda *a, **kw: None
monkeypatch.setitem(sys.modules, "prompt_toolkit.application", fake_pt_app)
cli._cprint("x")
assert direct_prints == ["x"]
def test_cprint_swallows_prompt_toolkit_import_error(monkeypatch):
"""If prompt_toolkit.application itself fails to import, fall back."""
direct_prints = []
monkeypatch.setattr(cli, "_pt_print", lambda x: direct_prints.append(x))
monkeypatch.setattr(cli, "_PT_ANSI", lambda t: t)
# Drop cached prompt_toolkit.application AND install a meta-path finder
# that raises ImportError on re-import.
monkeypatch.delitem(sys.modules, "prompt_toolkit.application", raising=False)
class _BlockFinder:
def find_module(self, name, path=None):
if name == "prompt_toolkit.application":
return self
return None
def load_module(self, name):
raise ImportError("blocked for test")
def find_spec(self, name, path=None, target=None):
if name == "prompt_toolkit.application":
# Returning a bogus spec that will fail on load works too,
# but raising here keeps the test simple.
raise ImportError("blocked for test")
return None
blocker = _BlockFinder()
sys.meta_path.insert(0, blocker)
try:
cli._cprint("fallback2")
finally:
sys.meta_path.remove(blocker)
assert direct_prints == ["fallback2"]
def test_replay_output_history_rerenders_callable_entries(monkeypatch):
cli._configure_output_history(True, 10)
widths_seen = []
printed = []
def _render_current_width():
widths_seen.append("called")
return ["top border", "body"]
cli._record_output_history_entry(_render_current_width)
monkeypatch.setattr(cli, "_pt_print", lambda value: printed.append(value))
monkeypatch.setattr(cli, "_PT_ANSI", lambda text: text)
cli._replay_output_history()
assert widths_seen == ["called"]
assert printed == ["top border\nbody"]
assert list(cli._OUTPUT_HISTORY) == [_render_current_width]
def test_chat_console_records_rich_ansi_for_resize_replay(monkeypatch):
cli._configure_output_history(True, 10)
monkeypatch.setattr(cli, "_pt_print", lambda *_args, **_kwargs: None)
cli.ChatConsole().print("[bold red]Hello[/]")
assert cli._OUTPUT_HISTORY
assert any("\x1b[" in line for line in cli._OUTPUT_HISTORY)
def test_suspend_output_history_blocks_recording():
cli._configure_output_history(True, 10)
with cli._suspend_output_history():
cli._record_output_history("hidden")
cli._record_output_history_entry("also hidden")
assert list(cli._OUTPUT_HISTORY) == []
def test_clear_output_history_removes_replayable_lines():
cli._configure_output_history(True, 10)
cli._record_output_history("before clear")
cli._clear_output_history()
assert list(cli._OUTPUT_HISTORY) == []
+248
View File
@@ -0,0 +1,248 @@
"""Regression tests for issue #22379 — Ctrl+Enter newline over SSH/WSL.
prompt_toolkit treats c-j (LF) as Enter on POSIX so thin PTYs (docker exec,
some BSD ssh) that send LF for plain Enter still work. But Windows Terminal
(native, WSL, and SSH-forwarded sessions) sends Ctrl+Enter as bare LF — same
byte. Without environment-aware gating, binding c-j to submit means
Ctrl+Enter submits instead of inserting a newline.
These tests pin the gating predicate and the resulting binding behavior.
``_preserve_ctrl_enter_newline()`` short-circuits to True on native Windows
before it ever looks at the environment, so the env-driven cases below are
POSIX assertions and run on the Linux job. The native-Windows short-circuit
is marked ``windows_only`` and asserted on the real host — patching
``sys.platform`` to ``"win32"`` here would only re-assert the literal in the
``if``, on an interpreter where none of the Windows terminal behaviour it
exists for is present.
"""
from __future__ import annotations
import os
from unittest.mock import patch
import pytest
import sys
class FakeKeyBindings:
def __init__(self):
self.bound = []
def add(self, *keys, **_kwargs):
def _decorator(handler):
self.bound.append(keys)
return handler
return _decorator
def _bind_submit_keys_for_local_linux(cli_mod, *, multiline_shortcuts_enabled):
with patch.object(sys, "platform", "linux"):
with patch.dict(os.environ, {}, clear=True):
with patch("builtins.open", side_effect=OSError("no /proc")):
kb = FakeKeyBindings()
cli_mod._bind_prompt_submit_keys(
kb,
lambda _event: None,
multiline_shortcuts_enabled=multiline_shortcuts_enabled,
)
return kb
@pytest.mark.windows_only
def test_native_windows_preserves_newline():
import cli as cli_mod
assert cli_mod._preserve_ctrl_enter_newline() is True
def test_ssh_tty_alone_preserves_newline():
import cli as cli_mod
# Strip out anything that might leak truth
with patch.dict(os.environ, {"SSH_TTY": "/dev/pts/0"}, clear=True):
assert cli_mod._preserve_ctrl_enter_newline() is True
def test_windows_terminal_session_preserves_newline():
import cli as cli_mod
with patch.dict(os.environ, {"WT_SESSION": "abc-def"}, clear=True):
assert cli_mod._preserve_ctrl_enter_newline() is True
def test_ghostty_tmux_session_preserves_ctrl_j_newline():
"""Ghostty-inherited env survives tmux even when TERM_PROGRAM becomes tmux."""
import cli as cli_mod
with patch.dict(
os.environ,
{"TERM": "tmux-256color", "TERM_PROGRAM": "tmux", "GHOSTTY_RESOURCES_DIR": "/usr/share/ghostty"},
clear=True,
):
assert cli_mod._preserve_ctrl_enter_newline() is True
def test_cli_multiline_shortcuts_default_on():
"""Hermes should default to the common harness behavior: Ctrl+J newline.
Claude Code documents Ctrl+J as a no-setup newline shortcut, OpenCode's
default input_newline includes ctrl+j, and Codex exposes Ctrl+J/keymap
newline behavior. Keep Hermes aligned unless the user opts out.
"""
import cli as cli_mod
assert cli_mod._cli_multiline_shortcuts_enabled({"display": {}}) is True
def test_cli_multiline_shortcuts_can_be_disabled():
import cli as cli_mod
assert cli_mod._cli_multiline_shortcuts_enabled(
{"display": {"cli_multiline_shortcuts": False}}
) is False
def test_ctrl_j_is_not_submit_when_multiline_shortcuts_enabled():
"""With the default setting, c-j is reserved for the newline handler.
This fixes local terminals like iTerm2 where Ctrl+J reaches prompt_toolkit
as c-j but the legacy POSIX fallback bound it to submit.
"""
import cli as cli_mod
kb = _bind_submit_keys_for_local_linux(
cli_mod,
multiline_shortcuts_enabled=True,
)
assert ("enter",) in kb.bound
assert ("c-j",) not in kb.bound
def test_ctrl_j_legacy_submit_when_multiline_shortcuts_disabled():
"""Users can opt out to preserve Enter-as-LF submit fallback on odd PTYs."""
import cli as cli_mod
kb = _bind_submit_keys_for_local_linux(
cli_mod,
multiline_shortcuts_enabled=False,
)
assert ("enter",) in kb.bound
assert ("c-j",) in kb.bound
def test_backslash_enter_continuation_replaces_marker_with_newline():
import cli as cli_mod
assert cli_mod._apply_backslash_line_continuation("first line\\") == "first line\n"
assert cli_mod._apply_backslash_line_continuation("first line\\ ") == "first line\n"
def test_iterm_is_allowlisted_for_extended_enter_keys():
"""iTerm2 needs the app to request extended keys before Shift+Enter is distinct."""
import cli as cli_mod
assert cli_mod._terminal_supports_extended_enter_keys({"TERM_PROGRAM": "iTerm.app"}) is True
def test_unknown_terminal_does_not_enable_extended_enter_keys():
import cli as cli_mod
assert cli_mod._terminal_supports_extended_enter_keys({"TERM_PROGRAM": "unknown"}) is False
# ---------------------------------------------------------------------------
# Ghostty: must push ONLY modifyOtherKeys, not the Kitty keyboard protocol —
# see cli._is_ghostty_terminal for the full rationale (#87630).
# ---------------------------------------------------------------------------
class _FakeOutput:
"""Minimal output object with write_raw + flush for _enable_extended_enter_keys."""
def __init__(self):
self.written = b""
def write_raw(self, data):
self.written += data.encode() if isinstance(data, str) else data
def flush(self):
pass
def test_ghostty_uses_modify_other_keys_only():
"""Ghostty must NOT push the Kitty keyboard protocol (CSI >1u)."""
import cli as cli_mod
out = _FakeOutput()
result = cli_mod._enable_extended_enter_keys(
output=out,
env={"TERM_PROGRAM": "ghostty", "TERM": "xterm-ghostty"},
)
assert result is True
# Must contain modifyOtherKeys push ...
assert b"\x1b[>4;2m" in out.written
# ... but NOT the Kitty protocol push.
assert b"\x1b[>1u" not in out.written
def test_ghostty_via_term_var_uses_modify_other_keys_only():
"""xterm-ghostty TERM (without TERM_PROGRAM) also skips Kitty protocol."""
import cli as cli_mod
out = _FakeOutput()
result = cli_mod._enable_extended_enter_keys(
output=out,
env={"TERM": "xterm-ghostty"},
)
assert result is True
assert b"\x1b[>4;2m" in out.written
assert b"\x1b[>1u" not in out.written
def test_non_ghostty_terminals_still_push_kitty_protocol():
"""iTerm2 and others still get the full dual-protocol push."""
import cli as cli_mod
out = _FakeOutput()
result = cli_mod._enable_extended_enter_keys(
output=out,
env={"TERM_PROGRAM": "iTerm.app", "TERM": "xterm-256color"},
)
assert result is True
assert b"\x1b[>1u" in out.written
assert b"\x1b[>4;2m" in out.written
@pytest.mark.linux_only
def test_proc_version_microsoft_marker_preserves_newline():
"""WSL detection via /proc when env vars are scrubbed (sudo etc.).
``linux_only``: the fallback reads ``/proc/version`` — a Linux-only
interface, and the WSL kernels it sniffs for are Linux kernels.
"""
import cli as cli_mod
from io import StringIO
with patch.dict(os.environ, {}, clear=True):
real_open = open
def _fake_open(path, *args, **kwargs):
if "/proc/version" in str(path) or "/proc/sys/kernel/osrelease" in str(path):
return StringIO("Linux version 5.15.167.4-microsoft-standard-WSL2")
return real_open(path, *args, **kwargs)
with patch("builtins.open", side_effect=_fake_open):
assert cli_mod._preserve_ctrl_enter_newline() is True
# ---------------------------------------------------------------------------
# install_ctrl_enter_alias() — ANSI sequence mappings for enhanced terminals
# ---------------------------------------------------------------------------
def test_is_ghostty_terminal_detection_paths():
"""_is_ghostty_terminal matches exactly the two allowlist conditions."""
import cli as cli_mod
assert cli_mod._is_ghostty_terminal({"TERM_PROGRAM": "ghostty"}) is True
assert cli_mod._is_ghostty_terminal({"TERM": "xterm-ghostty"}) is True
assert cli_mod._is_ghostty_terminal({"TERM": "XTERM-GHOSTTY"}) is True
assert cli_mod._is_ghostty_terminal({"TERM_PROGRAM": "iTerm.app"}) is False
assert cli_mod._is_ghostty_terminal({}) is False
+99
View File
@@ -0,0 +1,99 @@
"""Tests for CLI/TUI CWD resolution in load_cli_config().
Rules:
- Local backend CLI/TUI: always os.getcwd(), ignoring config and inherited env.
- Non-local with placeholder: pop cwd for backend default.
- Non-local with explicit path: keep as-is.
"""
_CWD_PLACEHOLDERS = (".", "auto", "cwd")
def _resolve_cwd(terminal_config: dict, defaults: dict, env: dict):
"""Mirror the CWD resolution logic from cli.py load_cli_config()."""
effective_backend = terminal_config.get("env_type", "local")
if effective_backend == "local":
terminal_config["cwd"] = "/fake/getcwd"
defaults["terminal"]["cwd"] = terminal_config["cwd"]
elif terminal_config.get("cwd") in _CWD_PLACEHOLDERS:
terminal_config.pop("cwd", None)
# Bridge: TERMINAL_CWD always exported in CLI, skipped in gateway
_is_gateway = env.get("_HERMES_GATEWAY") == "1"
if "cwd" in terminal_config:
if _is_gateway:
pass # don't touch env
else:
env["TERMINAL_CWD"] = str(terminal_config["cwd"])
return env.get("TERMINAL_CWD", "")
class TestLocalBackendCli:
"""Local backend always uses os.getcwd()."""
def test_explicit_config_ignored(self):
env = {}
tc = {"cwd": "/explicit/path", "env_type": "local"}
d = {"terminal": {"cwd": "/explicit/path"}}
assert _resolve_cwd(tc, d, env) == "/fake/getcwd"
def test_inherited_env_overwritten(self):
env = {"TERMINAL_CWD": "/parent/hermes"}
tc = {"cwd": "/home/user", "env_type": "local"}
d = {"terminal": {"cwd": "/home/user"}}
assert _resolve_cwd(tc, d, env) == "/fake/getcwd"
def test_placeholder_resolved(self):
env = {}
tc = {"cwd": "."}
d = {"terminal": {"cwd": "."}}
assert _resolve_cwd(tc, d, env) == "/fake/getcwd"
def test_env_and_no_config_file(self):
env = {"TERMINAL_CWD": "/stale/value"}
tc = {"cwd": ".", "env_type": "local"}
d = {"terminal": {"cwd": "."}}
assert _resolve_cwd(tc, d, env) == "/fake/getcwd"
class TestNonLocalBackends:
"""Non-local backends use config or per-backend defaults."""
def test_placeholder_popped(self):
env = {}
tc = {"cwd": ".", "env_type": "docker"}
d = {"terminal": {"cwd": "."}}
assert _resolve_cwd(tc, d, env) == ""
def test_explicit_path_kept(self):
env = {}
tc = {"cwd": "/srv/app", "env_type": "ssh"}
d = {"terminal": {"cwd": "/srv/app"}}
assert _resolve_cwd(tc, d, env) == "/srv/app"
def test_auto_placeholder_popped(self):
env = {}
tc = {"cwd": "auto", "env_type": "modal"}
d = {"terminal": {"cwd": "auto"}}
assert _resolve_cwd(tc, d, env) == ""
class TestGatewayLazyImport:
"""Gateway lazy import of cli.py must not clobber TERMINAL_CWD."""
def test_gateway_cwd_preserved(self):
env = {"_HERMES_GATEWAY": "1", "TERMINAL_CWD": "/home/user/project"}
tc = {"cwd": "/home/user", "env_type": "local"}
d = {"terminal": {"cwd": "/home/user"}}
result = _resolve_cwd(tc, d, env)
assert result == "/home/user/project"
def test_cli_overwrites_stale_env(self):
env = {"TERMINAL_CWD": "/stale/from/dotenv"}
tc = {"cwd": "/home/user", "env_type": "local"}
d = {"terminal": {"cwd": "/home/user"}}
result = _resolve_cwd(tc, d, env)
assert result == "/fake/getcwd"
+193
View File
@@ -0,0 +1,193 @@
"""Tests for cli.HermesCLI._confirm_destructive_slash.
Drives the helper directly via __get__ on a SimpleNamespace stand-in so we
don't have to construct a full HermesCLI (which requires extensive setup).
"""
from __future__ import annotations
import queue
from types import SimpleNamespace
from unittest.mock import patch
def _bound(fn, instance):
"""Bind an unbound method to a stand-in instance."""
return fn.__get__(instance, type(instance))
def _make_self(prompt_response):
"""Build a minimal stand-in 'self' for _confirm_destructive_slash."""
from cli import HermesCLI
self_ = SimpleNamespace(
_app=None,
_prompt_text_input=lambda _prompt: prompt_response,
_prompt_text_input_modal=lambda **_kw: prompt_response,
)
self_._normalize_slash_confirm_choice = _bound(
HermesCLI._normalize_slash_confirm_choice, self_,
)
return self_
def test_gate_on_choice_once_returns_once():
"""When the gate is on and the user picks '1', return 'once'."""
from cli import HermesCLI
self_ = _make_self(prompt_response="1")
with patch(
"cli.load_cli_config",
return_value={"approvals": {"destructive_slash_confirm": True}},
):
result = _bound(HermesCLI._confirm_destructive_slash, self_)(
"clear", "detail",
)
assert result == "once"
def test_gate_on_choice_always_persists_and_returns_always():
"""User picks 'always' → returns 'always' AND
save_config_value('approvals.destructive_slash_confirm', False) was called."""
from cli import HermesCLI
self_ = _make_self(prompt_response="2")
saves = []
def _fake_save(key, value):
saves.append((key, value))
return True
with patch(
"cli.load_cli_config",
return_value={"approvals": {"destructive_slash_confirm": True}},
), patch("cli.save_config_value", _fake_save):
result = _bound(HermesCLI._confirm_destructive_slash, self_)(
"clear", "detail",
)
assert result == "always"
assert ("approvals.destructive_slash_confirm", False) in saves
# ---------------------------------------------------------------------------
# Inline-skip escape hatch (issue #30768)
#
# Users on platforms where the prompt_toolkit modal doesn't dispatch keys
# (currently native Windows PowerShell) need a way to bypass the confirmation
# without flipping the config gate. ``/reset now``, ``/new --yes``, ``/clear
# -y`` all skip the modal and return "once" immediately.
# ---------------------------------------------------------------------------
def test_split_destructive_skip_recognized_tokens():
"""``now``, ``--yes``, and ``-y`` are recognized as skip tokens."""
from cli import HermesCLI
assert HermesCLI._split_destructive_skip("/reset now") == ("", True)
assert HermesCLI._split_destructive_skip("/clear --yes") == ("", True)
assert HermesCLI._split_destructive_skip("/undo -y") == ("", True)
def test_split_destructive_skip_handles_empty_and_none():
"""Defensive against missing/empty input."""
from cli import HermesCLI
assert HermesCLI._split_destructive_skip(None) == ("", False)
assert HermesCLI._split_destructive_skip("") == ("", False)
assert HermesCLI._split_destructive_skip(" ") == ("", False)
def test_confirm_destructive_slash_now_skips_modal():
"""``/reset now`` skips the modal even when the gate is on."""
from cli import HermesCLI
# Build a prompt stub that fails the test if invoked — proving the modal
# was never reached.
def _explode(**_kw):
raise AssertionError("modal must not be invoked when inline-skip present")
self_ = SimpleNamespace(
_app=None,
_prompt_text_input_modal=_explode,
)
self_._normalize_slash_confirm_choice = _bound(
HermesCLI._normalize_slash_confirm_choice, self_,
)
self_._split_destructive_skip = HermesCLI._split_destructive_skip # classmethod
with patch(
"cli.load_cli_config",
return_value={"approvals": {"destructive_slash_confirm": True}},
):
result = _bound(HermesCLI._confirm_destructive_slash, self_)(
"new", "detail", cmd_original="/reset now",
)
assert result == "once"
def test_confirm_destructive_slash_yes_flag_skips_modal():
"""``--yes`` flag is equivalent to ``now``."""
from cli import HermesCLI
def _explode(**_kw):
raise AssertionError("modal must not be invoked when --yes present")
self_ = SimpleNamespace(
_app=None,
_prompt_text_input_modal=_explode,
)
self_._normalize_slash_confirm_choice = _bound(
HermesCLI._normalize_slash_confirm_choice, self_,
)
self_._split_destructive_skip = HermesCLI._split_destructive_skip
with patch(
"cli.load_cli_config",
return_value={"approvals": {"destructive_slash_confirm": True}},
):
result = _bound(HermesCLI._confirm_destructive_slash, self_)(
"new", "detail", cmd_original="/new --yes My Session",
)
assert result == "once"
def test_confirm_destructive_slash_no_skip_token_still_prompts():
"""Without a skip token the gate-on path still consults the modal."""
from cli import HermesCLI
self_ = _make_self(prompt_response="3") # cancel
self_._split_destructive_skip = HermesCLI._split_destructive_skip
with patch(
"cli.load_cli_config",
return_value={"approvals": {"destructive_slash_confirm": True}},
):
result = _bound(HermesCLI._confirm_destructive_slash, self_)(
"new", "detail", cmd_original="/new My Session",
)
# Prompt was reached and returned cancel → None.
assert result is None
@@ -0,0 +1,133 @@
"""End-to-end integration test for the destructive-slash inline-skip path.
Drives ``HermesCLI.process_command("/reset now")`` against a minimal stand-in
and verifies:
1. ``new_session`` was invoked (the command actually ran)
2. ``_prompt_text_input_modal`` was NOT invoked (modal bypassed)
3. The skip token did not leak into the session title
This is the regression test for issue #30768 — the inline-skip escape hatch
must work without ever touching the modal, on every platform.
"""
from __future__ import annotations
from types import SimpleNamespace
from unittest.mock import patch
def _make_cli_stub():
"""Build a minimal HermesCLI-shaped object that can run ``process_command``
for the destructive-slash branches without spinning up a real TUI."""
from cli import HermesCLI
new_session_calls = []
def _capture_new_session(self_, title=None, silent=False):
new_session_calls.append({"title": title, "silent": silent})
self_ = SimpleNamespace(
_app=None,
_prompt_text_input_modal=lambda **_kw: (_ for _ in ()).throw(
AssertionError("modal must not be invoked when inline-skip token present")
),
new_session=lambda **kw: _capture_new_session(self_, **kw),
# Stub out side-effects the destructive-slash branches reach for.
console=SimpleNamespace(clear=lambda: None),
compact=False,
model="stub-model",
session_id="stub-session",
enabled_toolsets=[],
_pending_title=None,
_session_db=None,
)
# Bind the methods we need under test.
self_._split_destructive_skip = HermesCLI._split_destructive_skip
self_._confirm_destructive_slash = HermesCLI._confirm_destructive_slash.__get__(
self_, type(self_)
)
self_._slash_handler = HermesCLI._slash_handler
self_._cmd_new = HermesCLI._cmd_new.__get__(self_, type(self_))
self_.process_command = HermesCLI.process_command.__get__(self_, type(self_))
return self_, new_session_calls
def test_reset_now_invokes_new_session_without_modal():
"""``/reset now`` runs ``new_session`` and never touches the modal."""
self_, calls = _make_cli_stub()
with patch(
"cli.load_cli_config",
return_value={"approvals": {"destructive_slash_confirm": True}},
):
self_.process_command("/reset now")
assert calls, "new_session was never invoked"
# The /new branch passes title=None when there's no non-skip remainder.
assert calls[0]["title"] is None
def test_new_yes_with_title_preserves_title():
"""``/new --yes My Session`` runs ``new_session(title='My Session')``."""
self_, calls = _make_cli_stub()
with patch(
"cli.load_cli_config",
return_value={"approvals": {"destructive_slash_confirm": True}},
):
self_.process_command("/new --yes My Session")
assert calls, "new_session was never invoked"
assert calls[0]["title"] == "My Session"
def test_new_without_skip_token_still_consults_modal():
"""``/new My Session`` (no skip token) must reach the modal.
Sanity check that we haven't accidentally short-circuited the normal path.
"""
from cli import HermesCLI
new_session_calls = []
modal_calls = []
def _capture_new_session(self_, title=None, silent=False):
new_session_calls.append({"title": title, "silent": silent})
def _record_modal(**kw):
modal_calls.append(kw)
# Simulate user cancelling so new_session is not called.
return "3"
self_ = SimpleNamespace(
_app=None,
_prompt_text_input_modal=_record_modal,
new_session=lambda **kw: _capture_new_session(self_, **kw),
console=SimpleNamespace(clear=lambda: None),
compact=False,
model="stub-model",
session_id="stub-session",
enabled_toolsets=[],
_pending_title=None,
_session_db=None,
)
self_._split_destructive_skip = HermesCLI._split_destructive_skip
self_._normalize_slash_confirm_choice = HermesCLI._normalize_slash_confirm_choice.__get__(
self_, type(self_)
)
self_._confirm_destructive_slash = HermesCLI._confirm_destructive_slash.__get__(
self_, type(self_)
)
self_._slash_handler = HermesCLI._slash_handler
self_._cmd_new = HermesCLI._cmd_new.__get__(self_, type(self_))
self_.process_command = HermesCLI.process_command.__get__(self_, type(self_))
with patch(
"cli.load_cli_config",
return_value={"approvals": {"destructive_slash_confirm": True}},
):
self_.process_command("/new My Session")
assert modal_calls, "modal must be reached when no skip token is present"
assert not new_session_calls, "user cancelled — new_session must not run"
+85
View File
@@ -0,0 +1,85 @@
"""Tests for `/exit --delete` and `/quit --delete` session deletion.
Ports the behavior from google-gemini/gemini-cli#19332: running `/exit` or
`/quit` with the `--delete` flag arms a one-shot `_delete_session_on_exit`
flag that the CLI shutdown path uses to remove the current session from
SQLite + on-disk transcripts before exit.
"""
from unittest.mock import MagicMock
def _make_cli():
"""Bare HermesCLI suitable for process_command() tests.
Uses ``__new__`` to skip the heavy __init__; only sets the attributes
the /exit branch touches.
"""
from cli import HermesCLI
cli = HermesCLI.__new__(HermesCLI)
cli.config = {}
cli.console = MagicMock()
cli.agent = None
cli.conversation_history = []
cli.session_id = "test-session"
cli._delete_session_on_exit = False
return cli
class TestExitDeleteFlag:
def test_exit_delete_arms_flag(self):
cli = _make_cli()
result = cli.process_command("/exit --delete")
assert result is False
assert cli._delete_session_on_exit is True
def test_quit_delete_arms_flag(self):
cli = _make_cli()
result = cli.process_command("/quit --delete")
assert result is False
assert cli._delete_session_on_exit is True
def test_exit_delete_short_form(self):
"""`-d` is a convenience alias for `--delete`."""
cli = _make_cli()
result = cli.process_command("/exit -d")
assert result is False
assert cli._delete_session_on_exit is True
def test_delete_flag_trims_whitespace(self):
cli = _make_cli()
result = cli.process_command("/exit --delete ")
assert result is False
assert cli._delete_session_on_exit is True
def test_unknown_exit_argument_prints_help(self):
cli = _make_cli()
# _cprint goes through module-level print, so capture via console.
# We can't patch _cprint directly without import juggling; the
# previous assertion already proves the unknown-arg branch is
# reached (result True + flag False).
result = cli.process_command("/exit garbage")
assert result is True
assert cli._delete_session_on_exit is False
class TestCommandRegistry:
def test_quit_command_advertises_delete_flag(self):
"""The CommandDef args_hint should surface `--delete` in /help and
CLI autocomplete."""
from hermes_cli.commands import resolve_command
cmd = resolve_command("quit")
assert cmd is not None
assert cmd.args_hint == "[--delete]"
def test_exit_alias_resolves_to_quit_with_hint(self):
from hermes_cli.commands import resolve_command
cmd = resolve_command("exit")
assert cmd is not None
assert cmd.name == "quit"
assert cmd.args_hint == "[--delete]"
@@ -0,0 +1,83 @@
"""Tests for the CLI exit summary's resume hint, including profile-flag support."""
from datetime import datetime
from unittest.mock import MagicMock, patch
from cli import HermesCLI
def _make_cli(session_id="20260524_000001_abc123"):
cli_obj = HermesCLI.__new__(HermesCLI)
cli_obj.session_id = session_id
# _print_exit_summary requires a populated conversation history (msg_count > 0)
# to print the resume hint at all. One synthetic user turn is enough.
cli_obj.conversation_history = [{"role": "user", "content": "hi"}]
cli_obj.agent = None
cli_obj._session_db = None
cli_obj.session_start = datetime.now()
return cli_obj
class TestExitSummaryResumeHint:
"""The exit-line ``Resume this session with:`` hint must include the
active profile (`-p <name>`) so session IDs round-trip across
profile boundaries — sessions live under `~/.hermes-profiles/<profile>/`,
so a hint copied without `-p` from a non-default profile won't find
the session.
"""
def test_resume_hint_no_profile_flag_on_default(self, capsys):
cli_obj = _make_cli()
with patch("hermes_cli.profiles.get_active_profile_name", return_value="default"):
cli_obj._print_exit_summary()
out = capsys.readouterr().out
# No `-p` for the default profile.
assert "hermes --resume 20260524_000001_abc123" in out
assert " -p " not in out
def test_resume_hint_no_profile_flag_on_custom(self, capsys):
cli_obj = _make_cli()
with patch("hermes_cli.profiles.get_active_profile_name", return_value="custom"):
cli_obj._print_exit_summary()
out = capsys.readouterr().out
# "custom" is the standard HERMES_HOME indicator — no -p needed.
assert "hermes --resume 20260524_000001_abc123" in out
assert " -p " not in out
def test_resume_hint_includes_profile_flag_for_named_profile(self, capsys):
cli_obj = _make_cli()
with patch("hermes_cli.profiles.get_active_profile_name", return_value="dev"):
cli_obj._print_exit_summary()
out = capsys.readouterr().out
assert "hermes --resume 20260524_000001_abc123 -p dev" in out
def test_resume_hint_includes_profile_flag_on_title_hint_too(self, capsys, tmp_path):
"""When a session title is available, the `hermes -c "title"` hint
must also include the `-p` flag for non-default profiles.
"""
cli_obj = _make_cli()
fake_db = MagicMock()
fake_db.get_session_title.return_value = "My Cool Session"
cli_obj._session_db = fake_db
with patch("hermes_cli.profiles.get_active_profile_name", return_value="dev"):
cli_obj._print_exit_summary()
out = capsys.readouterr().out
assert 'hermes -c "My Cool Session" -p dev' in out
assert "hermes --resume 20260524_000001_abc123 -p dev" in out
def test_resume_hint_falls_back_when_profile_lookup_fails(self, capsys):
"""If `get_active_profile_name` raises (e.g. profiles module
missing during ``hermes update`` mid-flight), fall back to no
flag rather than crashing the exit summary.
"""
cli_obj = _make_cli()
with patch(
"hermes_cli.profiles.get_active_profile_name",
side_effect=RuntimeError("profiles unavailable"),
):
cli_obj._print_exit_summary()
out = capsys.readouterr().out
# Resume hint still printed without -p.
assert "hermes --resume 20260524_000001_abc123" in out
assert " -p " not in out
+168
View File
@@ -0,0 +1,168 @@
"""Exit watchdog: arm on shutdown *intent* (signal), never at chat startup.
Regression coverage for the #65998 class: a ``hermes --tui`` process whose
main thread wedges before ``app.run()`` returns never executes the ``finally``
that calls ``_run_cleanup`` — the only place the exit watchdog used to be
armed — so a "dead" CLI lingered indefinitely (observed ~47 min at 4% CPU).
The fix arms the backstop from the SIGTERM/SIGHUP handlers via
``_arm_exit_watchdog_on_shutdown_signal()``. Arming at *startup* (the
rejected #65998 approach) is specifically forbidden: the watchdog thread
calls ``os._exit(0)`` unconditionally after its sleep, so a startup-armed
timer hard-kills every session that outlives the timeout.
"""
from __future__ import annotations
import os
import signal
import subprocess
import sys
import time
from unittest.mock import patch
import pytest
import cli
@pytest.fixture(autouse=True)
def _reset_arm_flag(monkeypatch):
"""Each test starts with the idempotency flag clear."""
monkeypatch.setattr(cli, "_signal_watchdog_armed", False)
class TestSignalArmLogic:
def test_arms_with_double_cleanup_timeout(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()
arm.assert_called_once_with(timeout_s=14.0, from_signal=True)
def test_bad_env_value_falls_back_to_default(self, monkeypatch):
monkeypatch.setenv("HERMES_EXIT_WATCHDOG_S", "not-a-number")
with patch.object(cli, "_arm_exit_watchdog") as arm:
cli._arm_exit_watchdog_on_shutdown_signal()
arm.assert_called_once_with(timeout_s=60.0, from_signal=True)
def test_never_raises_even_if_arm_explodes(self, monkeypatch):
monkeypatch.setenv("HERMES_EXIT_WATCHDOG_S", "7")
with patch.object(cli, "_arm_exit_watchdog", side_effect=RuntimeError("boom")):
cli._arm_exit_watchdog_on_shutdown_signal() # must not raise
_REPO_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
# A minimal stand-in for the wedged-CLI shape: signal handlers mirror the
# production wiring (arm-on-signal, then a graceful unwind that wedges), and
# the main thread parks the way a stuck app.run() does.
_WEDGE_SRC = """
import os, signal, sys, time
sys.path.insert(0, {repo!r})
import cli
def _handler(signum, frame):
# Production wiring: arm the backstop the moment shutdown intent exists,
# then attempt a graceful unwind — which, in this repro, wedges (the
# KeyboardInterrupt lands in a frame that swallows it).
cli._arm_exit_watchdog_on_shutdown_signal()
signal.signal(signal.SIGTERM, _handler)
print("READY", flush=True)
while True: # the wedge: never observes any unwind
time.sleep(0.2)
"""
_CLEANUP_OVERLAP_SRC = """
import os, signal, sys, threading, time
sys.path.insert(0, {repo!r})
import cli
def _start_cleanup():
# Simulate a slow graceful-cleanup path that starts after signal intent.
time.sleep(1.0)
cli._cleanup_in_progress = True
cli._arm_exit_watchdog(timeout_s=1.5)
time.sleep(5.0)
def _handler(signum, frame):
# Arm the broad signal watchdog, then trigger a cleanup window shortly after.
cli._arm_exit_watchdog_on_shutdown_signal()
threading.Thread(target=_start_cleanup, daemon=True).start()
signal.signal(signal.SIGTERM, _handler)
print("READY", flush=True)
while True:
time.sleep(0.2)
"""
@pytest.mark.skipif(sys.platform == "win32", reason="POSIX signals")
def test_sigterm_on_wedged_process_forces_exit_within_leash():
"""E2E: a wedged process armed via the signal path self-exits at ~2×
HERMES_EXIT_WATCHDOG_S; without the signal it would live forever."""
env = dict(os.environ, HERMES_EXIT_WATCHDOG_S="1", PYTHONPATH=_REPO_ROOT)
# _arm_exit_watchdog refuses to arm under pytest (it would kill the test
# worker); the subprocess must look like a real CLI.
env.pop("PYTEST_CURRENT_TEST", None)
src = _WEDGE_SRC.format(repo=_REPO_ROOT)
p = subprocess.Popen(
[sys.executable, "-c", src],
env=env,
stdout=subprocess.PIPE,
text=True,
)
try:
assert p.stdout is not None
assert p.stdout.readline().strip() == "READY"
# Wedged, no signal yet: must still be alive well past the leash
# (proves we did NOT arm at startup — the #65998 regression).
time.sleep(3.0)
assert p.poll() is None, "watchdog fired without shutdown intent"
p.send_signal(signal.SIGTERM)
t0 = time.time()
rc = p.wait(timeout=10)
elapsed = time.time() - t0
assert rc == 0
# Leash is 2×1s; generous CI slack.
assert elapsed < 8.0, f"exit took {elapsed:.1f}s; leash should be ~2s"
finally:
if p.poll() is None:
p.kill()
@pytest.mark.skipif(sys.platform == "win32", reason="POSIX signals")
def test_signal_watchdog_is_skipped_once_cleanup_starts():
"""If cleanup starts after signal intent, the cleanup-owned watchdog should
govern shutdown and the signal watchdog should not hard-kill midway."""
env = dict(os.environ, HERMES_EXIT_WATCHDOG_S="1", PYTHONPATH=_REPO_ROOT)
env.pop("PYTEST_CURRENT_TEST", None)
src = _CLEANUP_OVERLAP_SRC.format(repo=_REPO_ROOT)
p = subprocess.Popen(
[sys.executable, "-c", src],
env=env,
stdout=subprocess.PIPE,
text=True,
)
try:
assert p.stdout is not None
assert p.stdout.readline().strip() == "READY"
p.send_signal(signal.SIGTERM)
t0 = time.time()
rc = p.wait(timeout=10)
elapsed = time.time() - t0
assert rc == 0
# Outer watchdog is at 2s, cleanup watchdog at 2.5s (1s delay + 1.5s
# timeout). Without the overlap fix this would likely exit earlier at 2s.
assert elapsed >= 2.2, f"elapsed={elapsed:.2f}s; expected cleanup watchdog to fire"
assert elapsed <= 5.0, f"elapsed={elapsed:.2f}s; expected bounded shutdown"
finally:
if p.poll() is None:
p.kill()
+349
View File
@@ -0,0 +1,349 @@
"""Tests for the /fast CLI command and service-tier config handling."""
import unittest
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
def _import_cli():
import hermes_cli.config as config_mod
if not hasattr(config_mod, "save_env_value_secure"):
config_mod.save_env_value_secure = lambda key, value: {
"success": True,
"stored_as": key,
"validated": False,
}
import cli as cli_mod
return cli_mod
class TestParseServiceTierConfig(unittest.TestCase):
def _parse(self, raw):
cli_mod = _import_cli()
return cli_mod._parse_service_tier_config(raw)
def test_fast_maps_to_priority(self):
self.assertEqual(self._parse("fast"), "priority")
self.assertEqual(self._parse("priority"), "priority")
class TestHandleFastCommand(unittest.TestCase):
def _make_cli(self, service_tier=None):
return SimpleNamespace(
service_tier=service_tier,
provider="openai-codex",
requested_provider="openai-codex",
model="gpt-5.4",
_fast_command_available=lambda: True,
agent=MagicMock(),
)
def test_no_args_shows_status(self):
cli_mod = _import_cli()
stub = self._make_cli(service_tier=None)
with (
patch.object(cli_mod, "_cprint") as mock_cprint,
patch.object(cli_mod, "save_config_value") as mock_save,
):
cli_mod.HermesCLI._handle_fast_command(stub, "/fast")
# Bare /fast shows status, does not change config
mock_save.assert_not_called()
# Should have printed the status line
printed = " ".join(str(c) for c in mock_cprint.call_args_list)
self.assertIn("normal", printed)
def test_normal_argument_clears_service_tier(self):
cli_mod = _import_cli()
stub = self._make_cli(service_tier="priority")
with (
patch.object(cli_mod, "_cprint"),
patch.object(cli_mod, "save_config_value", return_value=True) as mock_save,
):
cli_mod.HermesCLI._handle_fast_command(stub, "/fast normal")
# Session-scoped by default: no config write.
mock_save.assert_not_called()
self.assertIsNone(stub.service_tier)
self.assertIsNone(stub.agent)
def test_unsupported_model_does_not_expose_fast(self):
cli_mod = _import_cli()
stub = SimpleNamespace(
service_tier=None,
provider="openai-codex",
requested_provider="openai-codex",
model="gpt-5.3-codex",
_fast_command_available=lambda: False,
agent=MagicMock(),
)
with (
patch.object(cli_mod, "_cprint") as mock_cprint,
patch.object(cli_mod, "save_config_value") as mock_save,
):
cli_mod.HermesCLI._handle_fast_command(stub, "/fast")
mock_save.assert_not_called()
self.assertTrue(mock_cprint.called)
class TestPriorityProcessingModels(unittest.TestCase):
"""Verify the expanded Priority Processing model registry."""
def test_all_documented_models_supported(self):
from hermes_cli.models import model_supports_fast_mode
# All OpenAI flagship models support Priority Processing — including
# future releases (gpt-5.5, 5.6...) via pattern matching.
supported = [
"gpt-5.5", "gpt-5.5-mini",
"gpt-5.4", "gpt-5.4-mini", "gpt-5.2",
"gpt-5.1", "gpt-5", "gpt-5-mini",
"gpt-4.1", "gpt-4.1-mini", "gpt-4.1-nano",
"gpt-4o", "gpt-4o-mini",
"o1", "o1-mini", "o3", "o3-mini", "o4-mini",
]
for model in supported:
assert model_supports_fast_mode(model), f"{model} should support fast mode"
def test_codex_models_excluded(self):
"""Codex models route through Responses API and don't accept service_tier."""
from hermes_cli.models import model_supports_fast_mode
for model in ["gpt-5-codex", "gpt-5.2-codex", "gpt-5.3-codex", "gpt-5.1-codex-max"]:
assert not model_supports_fast_mode(model), f"{model} is codex — should not expose /fast"
def test_grok_46_supports_priority_processing(self):
from hermes_cli.models import (
model_supports_fast_mode,
resolve_fast_mode_overrides,
)
assert model_supports_fast_mode("grok-4.6") is True
assert model_supports_fast_mode("x-ai/grok-4.6-latest") is True
assert model_supports_fast_mode("grok-4.5") is False
assert resolve_fast_mode_overrides("grok-4.6") == {"service_tier": "priority"}
def test_resolve_overrides_returns_service_tier(self):
from hermes_cli.models import resolve_fast_mode_overrides
result = resolve_fast_mode_overrides("gpt-5.4")
assert result == {"service_tier": "priority"}
result = resolve_fast_mode_overrides("gpt-4.1")
assert result == {"service_tier": "priority"}
class TestFastModeRouting(unittest.TestCase):
def test_fast_command_exposed_for_model_even_when_provider_is_auto(self):
cli_mod = _import_cli()
stub = SimpleNamespace(provider="auto", requested_provider="auto", model="gpt-5.4", agent=None)
assert cli_mod.HermesCLI._fast_command_available(stub) is True
def test_turn_route_injects_overrides_without_provider_switch(self):
"""Fast mode should add request_overrides but NOT change the provider/runtime."""
cli_mod = _import_cli()
stub = SimpleNamespace(
model="gpt-5.4",
api_key="primary-key",
base_url="https://api.openai.com/v1",
provider="openai",
api_mode="chat_completions",
acp_command=None,
acp_args=[],
_credential_pool=None,
service_tier="priority",
)
route = cli_mod.HermesCLI._resolve_turn_agent_config(stub, "hi")
# Provider should NOT have changed
assert route["runtime"]["provider"] == "openai"
assert route["runtime"]["api_mode"] == "chat_completions"
# But request_overrides should be set
assert route["request_overrides"] == {"service_tier": "priority"}
# Proxied routes (OpenRouter etc.) strip/400 on the param — never sent.
stub.base_url = "https://openrouter.ai/api/v1"
stub.provider = "openrouter"
assert cli_mod.HermesCLI._resolve_turn_agent_config(stub, "hi")["request_overrides"] is None
def test_turn_route_keeps_primary_runtime_when_model_has_no_fast_backend(self):
cli_mod = _import_cli()
stub = SimpleNamespace(
model="gpt-5.3-codex",
api_key="primary-key",
base_url="https://openrouter.ai/api/v1",
provider="openrouter",
api_mode="chat_completions",
acp_command=None,
acp_args=[],
_credential_pool=None,
service_tier="priority",
)
route = cli_mod.HermesCLI._resolve_turn_agent_config(stub, "hi")
assert route["runtime"]["provider"] == "openrouter"
assert route.get("request_overrides") is None
class TestAnthropicFastMode(unittest.TestCase):
"""Verify Anthropic Fast Mode model support and override resolution."""
def test_anthropic_opus_supported(self):
from hermes_cli.models import model_supports_fast_mode
# Per the live fast-mode docs: Opus 4.8 + Opus 5, Claude API only.
# Native Anthropic format (hyphens)
assert model_supports_fast_mode("claude-opus-4-8") is True
# OpenRouter format (dots)
assert model_supports_fast_mode("claude-opus-4.8") is True
# With vendor prefix
assert model_supports_fast_mode("anthropic/claude-opus-4-8") is True
assert model_supports_fast_mode("anthropic/claude-opus-4.8") is True
assert model_supports_fast_mode("claude-opus-5") is True
assert model_supports_fast_mode("anthropic/claude-opus-5") is True
def test_anthropic_unsupported_models_excluded(self):
"""The speed=fast parameter is gated to Opus 4.8 / Opus 5.
Per https://platform.claude.com/docs/en/build-with-claude/fast-mode:
Opus 4.6 LOST fast mode 2026-06-29 (the param is silently ignored —
standard speed at standard billing — so a toggle would do nothing);
Opus 4.7 hard-400s; Sonnet/Haiku never had it; dedicated ``…-fast``
ids select fast inference via the model field, not the parameter.
"""
from hermes_cli.models import model_supports_fast_mode
assert model_supports_fast_mode("claude-sonnet-4-6") is False
assert model_supports_fast_mode("claude-sonnet-4.6") is False
assert model_supports_fast_mode("claude-haiku-4-5") is False
assert model_supports_fast_mode("claude-opus-4-6") is False
assert model_supports_fast_mode("claude-opus-4.6") is False
assert model_supports_fast_mode("claude-opus-4-7") is False
assert model_supports_fast_mode("claude-opus-4-8-fast") is False
assert model_supports_fast_mode("anthropic/claude-opus-4.8-fast") is False
assert model_supports_fast_mode("anthropic/claude-sonnet-4.6") is False
assert model_supports_fast_mode("anthropic/claude-opus-4-7") is False
def test_resolve_overrides_returns_speed_for_anthropic(self):
from hermes_cli.models import resolve_fast_mode_overrides
result = resolve_fast_mode_overrides("claude-opus-4-8")
assert result == {"speed": "fast"}
result = resolve_fast_mode_overrides("anthropic/claude-opus-4.8")
assert result == {"speed": "fast"}
def test_fast_command_hidden_for_anthropic_sonnet(self):
"""Sonnet doesn't support fast mode (Opus 4.8/5 only) — /fast must be hidden."""
cli_mod = _import_cli()
stub = SimpleNamespace(
provider="anthropic", requested_provider="anthropic",
model="claude-sonnet-4-6", agent=None,
)
assert cli_mod.HermesCLI._fast_command_available(stub) is False
def test_turn_route_injects_speed_for_anthropic(self):
"""Anthropic models should get speed:'fast' override, not service_tier."""
cli_mod = _import_cli()
stub = SimpleNamespace(
model="claude-opus-4-8",
api_key="sk-ant-test",
base_url="https://api.anthropic.com",
provider="anthropic",
api_mode="anthropic_messages",
acp_command=None,
acp_args=[],
_credential_pool=None,
service_tier="priority",
)
route = cli_mod.HermesCLI._resolve_turn_agent_config(stub, "hi")
assert route["runtime"]["provider"] == "anthropic"
assert route["request_overrides"] == {"speed": "fast"}
class TestAnthropicFastModeAdapter(unittest.TestCase):
"""Verify build_anthropic_kwargs handles fast_mode parameter."""
def test_fast_mode_adds_speed_and_beta(self):
from agent.anthropic_adapter import build_anthropic_kwargs, _FAST_MODE_BETA
kwargs = build_anthropic_kwargs(
model="claude-opus-4-8",
messages=[{"role": "user", "content": [{"type": "text", "text": "hi"}]}],
tools=None,
max_tokens=None,
reasoning_config=None,
fast_mode=True,
)
assert kwargs.get("extra_body", {}).get("speed") == "fast"
assert "speed" not in kwargs
assert "extra_headers" in kwargs
assert _FAST_MODE_BETA in kwargs["extra_headers"].get("anthropic-beta", "")
def test_fast_mode_off_no_speed(self):
from agent.anthropic_adapter import build_anthropic_kwargs
kwargs = build_anthropic_kwargs(
model="claude-opus-4-8",
messages=[{"role": "user", "content": [{"type": "text", "text": "hi"}]}],
tools=None,
max_tokens=None,
reasoning_config=None,
fast_mode=False,
)
assert kwargs.get("extra_body", {}).get("speed") is None
assert "speed" not in kwargs
assert "extra_headers" not in kwargs
def test_fast_mode_skipped_for_third_party_endpoint(self):
from agent.anthropic_adapter import build_anthropic_kwargs
kwargs = build_anthropic_kwargs(
model="claude-opus-4-8",
messages=[{"role": "user", "content": [{"type": "text", "text": "hi"}]}],
tools=None,
max_tokens=None,
reasoning_config=None,
fast_mode=True,
base_url="https://api.minimax.io/anthropic/v1",
)
# Third-party endpoints should NOT get speed or fast-mode beta
assert kwargs.get("extra_body", {}).get("speed") is None
assert "speed" not in kwargs
assert "extra_headers" not in kwargs
class TestConfigDefault(unittest.TestCase):
def test_default_config_has_service_tier(self):
from hermes_cli.config import DEFAULT_CONFIG
agent = DEFAULT_CONFIG.get("agent", {})
self.assertIn("service_tier", agent)
self.assertEqual(agent["service_tier"], "")
+189
View File
@@ -0,0 +1,189 @@
"""Finite chat must consume parallel delegated results before the CLI exits."""
import http.server
import json
import os
from pathlib import Path
import subprocess
import sys
import threading
from types import SimpleNamespace
from typing import Any
from unittest.mock import MagicMock
import pytest
REPO_ROOT = Path(__file__).resolve().parents[2]
@pytest.mark.parametrize("mode", ["quiet", "oneshot", "redirected"])
def test_finite_chat_joins_parallel_children_before_final_response(tmp_path, mode):
"""Real parser, CLI, agent loops and delegate_task. Only inference is synthetic.
Both child HTTP requests must reach the barrier before either can finish.
The provider synthesizes a final answer only from returned tool results,
not from child requests or transcripts that the parent has not consumed.
"""
home = tmp_path / "profile"
home.mkdir()
barrier = threading.Barrier(2)
lock = threading.Lock()
children, joined_results, errors = [], [], []
workers = ("WORKER_ALPHA", "WORKER_BETA")
class Provider(http.server.BaseHTTPRequestHandler):
def do_GET(self):
self.send_error(404)
def do_POST(self):
try:
request = json.loads(self.rfile.read(int(self.headers["Content-Length"])))
messages = request.get("messages", [])
if not messages:
self.send_error(404)
return
users = [str(m.get("content", "")) for m in messages if m["role"] == "user"]
worker = next((w for w in workers if w in users[-1]), None)
results = [json.loads(m["content"]) for m in messages if m["role"] == "tool"]
message: dict[str, Any]
if worker:
with lock:
children.append(worker)
barrier.wait(timeout=20)
message = {"role": "assistant", "content": worker + "_COMPLETE"}
elif results:
with lock:
joined_results.extend(results)
summaries = json.dumps(results)
joined = all(w + "_COMPLETE" in summaries for w in workers)
message = {"role": "assistant", "content": (
"FANOUT_JOINED_AND_SYNTHESIZED" if joined else "PARENT_ENDED_BEFORE_JOIN"
)}
else:
message = {"role": "assistant", "content": None, "tool_calls": [{
"id": "call_fanout", "type": "function", "function": {
"name": "delegate_task", "arguments": json.dumps({
"tasks": [{"goal": f"Complete {w} and return its completion token."}
for w in workers],
}),
},
}]}
response = {
"id": "chatcmpl-local", "object": "chat.completion", "created": 1,
"model": "test-model", "choices": [{
"index": 0, "message": message,
"finish_reason": "tool_calls" if "tool_calls" in message else "stop",
}], "usage": {"prompt_tokens": 20, "completion_tokens": 10, "total_tokens": 30},
}
content_type = "application/json"
if request.get("stream"):
response["object"] = "chat.completion.chunk"
response["choices"][0]["delta"] = response["choices"][0].pop("message")
for index, tool in enumerate(message.get("tool_calls", [])):
tool["index"] = index
raw = ("data: " + json.dumps(response) + "\n\ndata: [DONE]\n\n").encode()
content_type = "text/event-stream"
else:
raw = json.dumps(response).encode()
self.send_response(200)
self.send_header("Content-Type", content_type)
self.send_header("Content-Length", str(len(raw)))
self.end_headers()
self.wfile.write(raw)
except Exception as exc:
with lock:
errors.append(repr(exc))
self.send_error(500)
def log_message(self, format, *args):
pass
server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), Provider)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
url = f"http://127.0.0.1:{server.server_port}/v1"
(home / "config.yaml").write_text(
f"model:\n provider: custom\n base_url: {url}\n api_mode: chat_completions\n"
"memory:\n memory_enabled: false\n user_profile_enabled: false\n"
"terminal:\n env: local\n oneshot_completion_wait_seconds: 1\n"
f"delegation:\n max_concurrent_children: 2\n base_url: {url}\n"
" api_key: local-test-only\n model: test-model\n",
encoding="utf-8",
)
query = tmp_path / "query.txt"
query.write_text("Delegate two independent tasks, then synthesize their results.", encoding="utf-8")
# Retain native Windows location variables, but never inherited credentials
# or another session's finite/approval/runtime markers.
env = {key: os.environ[key] for key in (
"PATH", "SYSTEMROOT", "WINDIR", "COMSPEC", "TEMP", "TMP", "LOCALAPPDATA", "APPDATA",
) if key in os.environ}
env.update(HOME=str(tmp_path), USERPROFILE=str(tmp_path), HERMES_HOME=str(home),
HERMES_MANAGED_DIR=str(tmp_path / "managed"), TERMINAL_CWD=str(tmp_path),
OPENAI_BASE_URL=url, OPENAI_API_KEY="local-test-only", PYTHONPATH=str(REPO_ROOT),
PYTHONDONTWRITEBYTECODE="1", LANG="C.UTF-8")
mode_flags = {"quiet": ["-Q"], "oneshot": ["--oneshot"], "redirected": []}
command = [
sys.executable, "-c", "from hermes_cli.main import main; main()", "chat",
*mode_flags[mode], "--provider", "custom", "--model", "test-model",
"--toolsets", "delegation", "--ignore-rules", "--query-file", str(query),
"--reasoning", "high", "--max-turns", "10", "--run-budget", "60",
]
try:
result = subprocess.run(command, cwd=tmp_path, env=env, stdin=subprocess.DEVNULL,
capture_output=True, text=True, encoding="utf-8", timeout=75)
finally:
server.shutdown()
server.server_close()
thread.join(timeout=5)
diagnostic = (result.stdout, result.stderr, joined_results, children, errors)
assert result.returncode == 0, diagnostic
assert "FANOUT_JOINED_AND_SYNTHESIZED" in result.stdout, diagnostic
assert "PARENT_ENDED_BEFORE_JOIN" not in result.stdout, diagnostic
assert sorted(children) == sorted(workers), diagnostic
assert not errors, diagnostic
assert len(joined_results) == 1, diagnostic
assert [r["status"] for r in joined_results[0]["results"]] == ["completed", "completed"]
assert [r["summary"] for r in joined_results[0]["results"]] == [w + "_COMPLETE" for w in workers]
@pytest.mark.parametrize("query,image", [("Delegate a task", None), (None, "image.png")])
def test_tty_seeded_chat_keeps_background_delegation(monkeypatch, query, image):
"""A TTY -q (or image seed) still owns a later-turn completion consumer."""
import cli
import tools.delegate_tool as dt
from gateway.session_context import reset_session_vars
from run_agent import AIAgent
from tests.tools.test_delegate import _make_mock_parent
reset_session_vars()
monkeypatch.delenv("HERMES_SINGLE_QUERY_SESSION", raising=False)
monkeypatch.delenv("HERMES_KANBAN_TASK", raising=False)
monkeypatch.setattr(cli.sys.stdin, "isatty", lambda: True)
monkeypatch.setattr(cli.sys.stdout, "isatty", lambda: True)
monkeypatch.setattr(cli, "_collect_query_images", lambda q, i: (q, [i] if i else []))
parent = _make_mock_parent()
parent.session_id = "interactive-parent"
monkeypatch.setattr(dt, "_build_child_agent", lambda **kw: MagicMock())
monkeypatch.setattr(dt, "_resolve_delegation_credentials", lambda *a, **kw: {
"model": "test-model", "provider": "custom", "base_url": None, "api_key": None,
"api_mode": None, "command": None, "args": None,
})
dispatched = []
def dispatch(unit, unit_id, slot_key, routing):
dispatched.append(unit)
return {"status": "dispatched", "delegation_id": "interactive-delegation"}
monkeypatch.setattr("tools.delegate_tool_dispatch._dispatch_unit", dispatch)
seeded = SimpleNamespace(run=lambda: AIAgent._dispatch_delegate_task(
parent, {"tasks": [{"goal": "independent task"}]},
))
try:
result = cli._run_single_query_mode(seeded, query, image, False, False)
assert isinstance(result, str)
payload = json.loads(result)
assert payload["status"] == "dispatched"
assert len(dispatched) == 1
assert "HERMES_SINGLE_QUERY_SESSION" not in os.environ
finally:
reset_session_vars()
+385
View File
@@ -0,0 +1,385 @@
"""Tests for ``/focus`` — the display-only reduced-output view.
Focus view composes with the existing ``/verbose`` tool-progress machinery
rather than adding a second suppression mechanism. These tests cover:
* the on/off/status toggle state machine (``resolve_focus_arg``);
* the hidden-line counter and its formatter (which must respect the
pre-focus ``/verbose`` mode so it never over-claims);
* status-bar segment composition in both CLI renderers;
* the CLI command handler's stash/restore of ``tool_progress_mode``;
* **the prompt-cache invariant** — a real fake turn is dispatched through
``agent.tool_executor`` with focus on and with focus off, and the resulting
model-facing ``messages`` lists must be byte-identical.
"""
import json
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
import pytest
from hermes_cli.focus_view import (
FOCUS_CONFIG_KEY,
FOCUS_STATUSBAR_LABEL,
FOCUS_TOOL_PROGRESS_MODE,
focus_statusbar_segment,
format_focus_status,
format_focus_toggle_message,
format_hidden_line,
normalize_tool_progress_mode,
resolve_focus_arg,
would_display_tool_line,
)
from hermes_cli.cli_commands_mixin import CLICommandsMixin
# =========================================================================
# Toggle state machine — on | off | status | bare | garbage
# =========================================================================
class TestToggleStateMachine:
def test_bare_toggles_from_off_to_on(self):
assert resolve_focus_arg("", False) == ("set", True)
@pytest.mark.parametrize("word", ["status", "show", "?", "STATUS"])
def test_status_words_never_mutate(self, word):
action, target = resolve_focus_arg(word, True)
assert action == "status"
assert target is None
@pytest.mark.parametrize("word", ["sideways", "onn", "--global", "2"])
def test_garbage_reports_usage(self, word):
assert resolve_focus_arg(word, False) == ("usage", None)
# =========================================================================
# Suppression respects the existing /verbose modes
# =========================================================================
class TestComposesWithVerboseModes:
def test_focus_on_snaps_to_the_existing_off_mode(self):
# Focus view must reuse the tool_progress "off" path, not invent a mode.
assert FOCUS_TOOL_PROGRESS_MODE == "off"
def test_new_mode_skips_consecutive_repeats_like_the_renderer(self):
assert would_display_tool_line("new", "terminal", "terminal") is False
assert would_display_tool_line("new", "read_file", "terminal") is True
# "all" always counts, even repeats.
assert would_display_tool_line("all", "terminal", "terminal") is True
# =========================================================================
# Hidden-count formatter + recovery line
# =========================================================================
class TestHiddenCountFormatter:
def test_zero_and_negative_produce_no_line(self):
assert format_hidden_line(0) is None
assert format_hidden_line(-3) is None
def test_line_always_names_the_recovery_command(self):
assert "/focus off" in format_hidden_line(2)
class _FocusHost(CLICommandsMixin):
"""Minimal host exposing only the attributes the focus helpers read."""
def __init__(self, *, enabled=False, saved="all", tool_progress="all"):
self._focus_view_enabled = enabled
self._focus_saved_tool_progress = saved
self._focus_hidden_lines = 0
self._focus_last_counted_tool = None
self.tool_progress_mode = tool_progress
self.agent = None
class TestHiddenCounterAccumulation:
def test_counts_each_suppressed_tool_line(self):
host = _FocusHost(enabled=True, saved="all")
for name in ("terminal", "read_file", "web_search"):
host._note_focus_hidden_line(name)
assert host._focus_hidden_lines == 3
def test_recovery_line_is_emitted_then_counter_resets(self):
host = _FocusHost(enabled=True, saved="all")
for name in ("terminal", "read_file"):
host._note_focus_hidden_line(name)
with patch("cli._cprint") as printer:
host._emit_focus_recovery_line()
assert printer.call_count == 1
assert "2 tool lines hidden" in printer.call_args[0][0]
assert "/focus off" in printer.call_args[0][0]
# Reset so the next turn starts from zero.
assert host._focus_hidden_lines == 0
assert host._focus_last_counted_tool is None
# =========================================================================
# CLI command handler — stash / restore / persistence
# =========================================================================
class TestFocusCommandHandler:
def test_on_stashes_the_verbose_mode_and_snaps_to_off(self):
host = _FocusHost(enabled=False, saved=None, tool_progress="verbose")
with patch("cli.save_config_value", return_value=True) as saver, \
patch("cli._cprint"):
host._handle_focus_command("/focus on")
assert host._focus_view_enabled is True
assert host.tool_progress_mode == "off"
assert host._focus_saved_tool_progress == "verbose"
saver.assert_called_once_with(FOCUS_CONFIG_KEY, True)
def test_idempotent_on_does_not_reclobber_the_stash(self):
host = _FocusHost(enabled=True, saved="verbose", tool_progress="off")
with patch("cli.save_config_value") as saver, patch("cli._cprint"):
host._handle_focus_command("/focus on")
saver.assert_not_called()
# The stash still points at the real pre-focus mode, not "off".
assert host._focus_saved_tool_progress == "verbose"
def test_live_agent_mode_is_synced(self):
host = _FocusHost(enabled=False, saved=None, tool_progress="all")
host.agent = SimpleNamespace(tool_progress_mode="all")
with patch("cli.save_config_value", return_value=True), patch("cli._cprint"):
host._handle_focus_command("/focus on")
# tool_executor gates on the AGENT copy — syncing it is what makes the
# suppression take effect this turn instead of after an agent rebuild.
assert host.agent.tool_progress_mode == "off"
# =========================================================================
# Status-bar segment composition
# =========================================================================
class TestStatusBarSegment:
def test_segment_present_only_when_enabled(self):
assert focus_statusbar_segment(True) == FOCUS_STATUSBAR_LABEL
assert focus_statusbar_segment(False) == ""
@pytest.mark.parametrize("width", [40, 60, 120])
def test_text_renderer_includes_the_badge_at_every_width_tier(self, width):
from cli import HermesCLI
host = HermesCLI.__new__(HermesCLI)
host.model = "opus"
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, "_is_session_yolo_active", return_value=False):
text = HermesCLI._build_status_bar_text(host, width=width)
assert "focus" in text
# =========================================================================
# PROMPT-CACHE INVARIANT: model-facing messages are identical either way
# =========================================================================
def _make_agent(tool_progress_mode: str):
"""Build a real AIAgent whose display mode is the only difference."""
from run_agent import AIAgent
tool_defs = [
{
"type": "function",
"function": {
"name": "web_search",
"description": "search",
"parameters": {"type": "object", "properties": {}},
},
}
]
with (
patch("model_tools.get_tool_definitions", return_value=tool_defs),
patch("model_tools.check_toolset_requirements", return_value={}),
patch("hermes_cli.config.load_config", return_value={}),
patch("agent.process_bootstrap.OpenAI"),
):
agent = AIAgent(
api_key="test-key-1234567890",
base_url="https://openrouter.ai/api/v1",
# quiet_mode False so the display gate is genuinely exercised —
# with quiet_mode True the tool_progress gate would be moot.
quiet_mode=False,
skip_context_files=True,
skip_memory=True,
tool_progress_mode=tool_progress_mode,
)
agent.client = MagicMock()
agent.tool_delay = 0
agent._flush_messages_to_session_db = MagicMock()
return agent
def _tool_call(call_id: str, query: str):
return SimpleNamespace(
id=call_id,
type="function",
function=SimpleNamespace(
name="web_search", arguments=json.dumps({"query": query})
),
)
def _run_fake_turn(tool_progress_mode: str, dispatch_mode: str = "sequential"):
"""Dispatch an identical fake turn and return the model-facing messages."""
agent = _make_agent(tool_progress_mode)
assistant_message = SimpleNamespace(
content="",
tool_calls=[
_tool_call("call-1", "alpha"),
_tool_call("call-2", "beta"),
_tool_call("call-3", "gamma"),
],
)
messages: list = [
{"role": "system", "content": "you are hermes"},
{"role": "user", "content": "find three things"},
]
def fake_dispatch(name, args, task_id, *positional, **kwargs):
return json.dumps({"ok": args["query"]})
with (
patch("model_tools.handle_function_call", side_effect=fake_dispatch),
patch.object(agent, "_invoke_tool", side_effect=fake_dispatch),
patch(
"agent.tool_executor.maybe_persist_tool_result",
side_effect=lambda **kwargs: kwargs["content"],
),
# Swallow display writes so the test doesn't spam stdout; the point is
# what lands in `messages`, not what prints.
patch("builtins.print"),
):
execute = getattr(agent, f"_execute_tool_calls_{dispatch_mode}")
execute(assistant_message, messages, "task-focus")
return messages
class TestModelFacingMessagesUnchanged:
"""Focus view is display-only: the request payload must not shift a byte."""
@pytest.mark.parametrize("dispatch_mode", ["sequential", "concurrent"])
def test_model_facing_messages_identical_with_focus_on_vs_off(self, dispatch_mode):
# Creation timestamps are durable metadata, so hold the clock steady
# while comparing otherwise identical turns.
with patch("agent.message_metadata.wall_time", return_value=1_700_000_000.0):
# Focus ON == the existing tool_progress "off" suppression path.
focus_on = _run_fake_turn(FOCUS_TOOL_PROGRESS_MODE, dispatch_mode)
# Focus OFF == the default noisy display mode.
focus_off = _run_fake_turn("all", dispatch_mode)
assert focus_on == focus_off, (
"focus view altered the model-facing messages — display-only "
"invariant violated (prompt cache would break)"
)
# Sanity: the turn really did produce tool results to compare.
assert [m["role"] for m in focus_on].count("tool") == 3
assert json.loads(focus_on[-1]["content"]) == {"ok": "gamma"}
def test_toggling_focus_does_not_touch_conversation_history(self):
host = _FocusHost(enabled=False, saved=None, tool_progress="all")
history = [
{"role": "user", "content": "hello"},
{"role": "assistant", "content": "hi"},
]
host.conversation_history = history
snapshot = json.dumps(history)
with patch("cli.save_config_value", return_value=True), patch("cli._cprint"):
host._handle_focus_command("/focus on")
host._note_focus_hidden_line("terminal")
host._emit_focus_recovery_line()
host._handle_focus_command("/focus off")
assert json.dumps(host.conversation_history) == snapshot
# =========================================================================
# Registry wiring
# =========================================================================
class TestCommandRegistration:
def test_focus_is_registered_with_the_sibling_toggle_convention(self):
from hermes_cli.commands import resolve_command
cmd = resolve_command("focus")
assert cmd is not None
assert cmd.category == "Configuration"
assert cmd.args_hint == "[on|off|status]"
assert set(cmd.subcommands) == {"on", "off", "status"}
def test_verbose_cycle_releases_focus_view(self):
# /verbose is the explicit tool-progress control; cycling it must clear
# the focus badge so the indicator can never contradict the display.
from cli import HermesCLI
host = HermesCLI.__new__(HermesCLI)
host.tool_progress_mode = "off"
host._focus_view_enabled = True
host._focus_saved_tool_progress = "all"
host._focus_hidden_lines = 3
host._focus_last_counted_tool = "terminal"
host.agent = None
with patch("cli.save_config_value", return_value=True), patch("cli._cprint"):
HermesCLI._toggle_verbose(host)
assert host._focus_view_enabled is False
assert host._focus_saved_tool_progress is None
assert host._focus_hidden_lines == 0
assert host.tool_progress_mode == "new"
+159
View File
@@ -0,0 +1,159 @@
"""Regression tests for #88234 — CLI cleanup must NOT finalize a session that
was handed off to the gateway.
The handoff flow re-binds the CLI session_id to a gateway session_key via
``switch_session``, which reopens the session row. The CLI then exits and
``_run_cleanup`` fires ``_notify_session_finalize`` on that same session_id.
The resulting ``end_session`` call sets ``end_reason`` on a row the gateway
just reopened and is actively writing to — the handoff leg vanishes from
session history and ``session_search`` cannot find it.
The fix adds a module-level ``_handed_off_session_ids`` set (mirroring the
existing ``_single_query_finalize_attempted_session_ids`` pattern).
``_handle_handoff_command`` registers the session_id when the handoff
completes, and ``_should_emit_cleanup_session_finalize`` /
``_emit_interrupted_session_end`` check the set before firing.
"""
from __future__ import annotations
from unittest.mock import MagicMock, patch
def _reset_cli_globals(cli_mod):
"""Reset the module-level globals the cleanup path checks."""
cli_mod._cleanup_done = False
cli_mod._cleanup_in_progress = False
cli_mod._single_query_finalize_attempted_session_ids.clear()
cli_mod._handed_off_session_ids.clear()
cli_mod._active_agent_ref = None
def test_handed_off_session_skips_cleanup_finalize():
"""_should_emit_cleanup_session_finalize returns False for a handed-off session."""
import cli as cli_mod
_reset_cli_globals(cli_mod)
cli_mod._handed_off_session_ids.add("handoff-session-123")
assert cli_mod._should_emit_cleanup_session_finalize("handoff-session-123") is False
def test_normal_session_still_finalizes():
"""_should_emit_cleanup_session_finalize returns True for a non-handed-off session."""
import cli as cli_mod
_reset_cli_globals(cli_mod)
cli_mod._single_query_finalize_attempted_session_ids.add("other-session")
assert cli_mod._should_emit_cleanup_session_finalize("normal-session") is True
assert cli_mod._should_emit_cleanup_session_finalize("other-session") is False
def test_interrupted_session_end_skipped_for_handed_off():
"""_emit_interrupted_session_end returns early for a handed-off session."""
import cli as cli_mod
_reset_cli_globals(cli_mod)
cli_mod._handed_off_session_ids.add("handoff-session-456")
agent = MagicMock()
agent.session_id = "handoff-session-456"
cli_mod._active_agent_ref = agent
cli_mock = MagicMock()
cli_mock.agent = agent
cli_mock.session_id = "handoff-session-456"
with patch("hermes_cli.lifecycle.invoke_hook") as mock_hook:
cli_mod._emit_interrupted_session_end(cli_mock, reason="keyboard_interrupt")
# on_session_end hook must NOT fire for a handed-off session
mock_hook.assert_not_called()
def test_interrupted_session_end_fires_for_normal():
"""_emit_interrupted_session_end fires for a normal (non-handed-off) session."""
import cli as cli_mod
_reset_cli_globals(cli_mod)
agent = MagicMock()
agent.session_id = "normal-session-789"
agent._current_task_id = ""
agent._current_turn_id = ""
agent._current_api_request_id = ""
agent.model = "test-model"
agent.platform = "cli"
cli_mod._active_agent_ref = agent
cli_mock = MagicMock()
cli_mock.agent = agent
cli_mock.session_id = "normal-session-789"
with patch("hermes_cli.lifecycle.invoke_hook") as mock_hook:
cli_mod._emit_interrupted_session_end(cli_mock, reason="keyboard_interrupt")
mock_hook.assert_called_once()
def test_cleanup_does_not_finalize_handed_off_session():
"""_run_cleanup must not call finalize_session for a handed-off session."""
import cli as cli_mod
_reset_cli_globals(cli_mod)
cli_mod._handed_off_session_ids.add("handoff-session-abc")
agent = MagicMock()
agent.session_id = "handoff-session-abc"
agent._session_messages = []
cli_mod._active_agent_ref = agent
with (
patch("hermes_cli.lifecycle.finalize_session") as mock_finalize,
patch("hermes_cli.plugins.invoke_hook"),
):
cli_mod._run_cleanup()
mock_finalize.assert_not_called()
def test_cleanup_finalizes_normal_session():
"""_run_cleanup DOES call finalize_session for a normal session."""
import cli as cli_mod
_reset_cli_globals(cli_mod)
agent = MagicMock()
agent.session_id = "normal-session-def"
agent._session_messages = []
cli_mod._active_agent_ref = agent
with (
patch("hermes_cli.lifecycle.finalize_session") as mock_finalize,
patch("hermes_cli.plugins.invoke_hook"),
):
cli_mod._run_cleanup()
mock_finalize.assert_called_once()
def test_single_query_finalize_skipped_for_handed_off():
"""_notify_single_query_session_finalize must not fire for a handed-off session."""
import cli as cli_mod
_reset_cli_globals(cli_mod)
cli_mod._handed_off_session_ids.add("handoff-session-single")
agent = MagicMock()
agent.session_id = "handoff-session-single"
agent.platform = "cli"
cli_mock = MagicMock()
cli_mock.agent = agent
cli_mock.session_id = "handoff-session-single"
with patch("hermes_cli.lifecycle.finalize_session") as mock_finalize:
cli_mod._notify_single_query_session_finalize(cli_mock)
mock_finalize.assert_not_called()
@@ -0,0 +1,259 @@
"""Regression tests: a slow gateway handoff dispatch must not be misreported
as "gateway not running" — and no waiter may stomp a claimed (running) row.
Bug shape (live-reproduced on main @1c5ee5815f): /handoff poll-waited a flat
60s for a TERMINAL state. The gateway watcher claims within seconds, but the
dispatch is a FULL synthetic agent turn (whole transcript replay + delivery)
that routinely exceeds 60s. The CLI then printed "Timed out waiting for the
gateway. Is `hermes gateway` running?" (false diagnosis), called
fail_handoff() on the RUNNING row (stomping the gateway's claim), and claimed
"Your CLI session is intact" after switch_session had already re-pointed the
session. The gateway later overwrote failed -> completed: split-brain.
Fix under test:
1. SessionDB.fail_handoff(only_states=...) — CAS: waiters can only fail
rows still in the given states.
2. CLI _handle_handoff_command: 60s deadline applies only to PENDING;
a RUNNING row gets a long (15 min) wait and is never failed by the CLI.
3. tui_gateway handoff.fail: only pending rows can be failed by Desktop.
"""
from __future__ import annotations
import time
import types
from unittest.mock import MagicMock, patch
import pytest
from hermes_state import SessionDB
@pytest.fixture()
def db(tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_HOME", str(tmp_path / ".hermes"))
d = SessionDB(db_path=tmp_path / "state.db")
yield d
d.close()
# ---------------------------------------------------------------------------
# 1. State layer: fail_handoff CAS
# ---------------------------------------------------------------------------
class TestFailHandoffCAS:
def test_cas_fails_pending_row(self, db):
db.ensure_session("s1", "cli")
assert db.request_handoff("s1", "discord")
assert db.fail_handoff("s1", "timed out", only_states=("pending",)) is True
assert db.get_handoff_state("s1")["state"] == "failed"
def test_cas_refuses_running_row(self, db):
"""A waiter timeout must NOT stomp a row the gateway has claimed."""
db.ensure_session("s2", "cli")
assert db.request_handoff("s2", "discord")
assert db.claim_handoff("s2") # gateway claimed: pending -> running
assert db.fail_handoff("s2", "timed out", only_states=("pending",)) is False
assert db.get_handoff_state("s2")["state"] == "running"
# gateway still reaches its own terminal state
db.complete_handoff("s2")
assert db.get_handoff_state("s2")["state"] == "completed"
def test_unconditional_fail_still_available_to_owner(self, db):
"""The gateway watcher (owner of a claimed row) fails unconditionally."""
db.ensure_session("s3", "cli")
assert db.request_handoff("s3", "discord")
assert db.claim_handoff("s3")
assert db.fail_handoff("s3", "dispatch raised") is True
assert db.get_handoff_state("s3")["state"] == "failed"
def test_cas_refuses_terminal_rows(self, db):
db.ensure_session("s4", "cli")
assert db.request_handoff("s4", "discord")
assert db.claim_handoff("s4")
db.complete_handoff("s4")
assert db.fail_handoff("s4", "late timeout", only_states=("pending",)) is False
assert db.get_handoff_state("s4")["state"] == "completed"
# ---------------------------------------------------------------------------
# 2. CLI wait loop
# ---------------------------------------------------------------------------
def _run_handoff(db, session_id, monkeypatch, time_budget=30.0):
"""Drive the real _handle_handoff_command against a real SessionDB.
Gateway config / platform plumbing is stubbed; the poll loop, state
transitions, and fail semantics are exercised for real. time.sleep is
compressed so a simulated 60s pending deadline elapses in well under a
second of wall clock.
"""
from hermes_cli.cli_commands_mixin import CLICommandsMixin
printed: list[str] = []
class Host(CLICommandsMixin):
def __init__(self):
self.session_id = session_id
self._session_db = db
self._agent_running = False
self._should_exit = False
host = Host.__new__(Host)
Host.__init__(host)
home = types.SimpleNamespace(chat_id="123", name="home", thread_id=None)
gw_config = MagicMock()
gw_config.platforms = {}
gw_config.get_home_channel.return_value = home
import gateway.config as gwc
platform_obj = gwc.Platform("discord")
pcfg = types.SimpleNamespace(enabled=True, extra={})
gw_config.platforms = {platform_obj: pcfg}
# Compress time: each sleep(0.5) advances a fake clock by 2.0s.
clock = {"t": time.time()}
def fake_time():
return clock["t"]
def fake_sleep(secs):
clock["t"] += max(secs * 4, 2.0)
import cli as cli_mod
with patch.object(gwc, "load_gateway_config", return_value=gw_config), \
patch("time.time", side_effect=fake_time), \
patch("time.sleep", side_effect=fake_sleep), \
patch.object(cli_mod, "_cprint", side_effect=lambda s="": printed.append(s)):
keep_going = host._handle_handoff_command("/handoff discord")
return keep_going, printed, host
class TestCLIWaitLoop:
def test_pending_timeout_still_reports_gateway_down(self, db, monkeypatch):
"""No watcher ever claims the row -> 60s pending timeout, row failed."""
db.ensure_session("cli-sess-a", "cli")
keep, printed, _ = _run_handoff(db, "cli-sess-a", monkeypatch)
out = "\n".join(printed)
assert keep is True
assert "Timed out waiting for the gateway" in out
assert db.get_handoff_state("cli-sess-a")["state"] == "failed"
def test_running_row_is_never_failed_by_cli(self, db, monkeypatch):
"""Row claimed (running) and never finishing: CLI gives up eventually
but must NOT fail the row and must NOT print the gateway-down line."""
db.ensure_session("cli-sess-b", "cli")
# Pre-claim: by the time the CLI polls, the gateway owns the row.
# request_handoff happens inside the command; claim it from a fake
# watcher the instant it lands via a get_handoff_state side hook.
real_get = db.get_handoff_state
def claiming_get(sid):
row = real_get(sid)
if row and row.get("state") == "pending":
db.claim_handoff(sid)
row = real_get(sid)
return row
db_proxy = MagicMock(wraps=db)
db_proxy.get_handoff_state.side_effect = claiming_get
db_proxy.request_handoff.side_effect = db.request_handoff
db_proxy.fail_handoff.side_effect = db.fail_handoff
keep, printed, _ = _run_handoff(db_proxy, "cli-sess-b", monkeypatch)
out = "\n".join(printed)
assert keep is True
assert "Is `hermes gateway` running?" not in out
assert "taking unusually long" in out
# The row is still owned by the gateway — untouched by the CLI.
assert db.get_handoff_state("cli-sess-b")["state"] == "running"
# Late gateway completion wins cleanly (no split-brain).
db.complete_handoff("cli-sess-b")
assert db.get_handoff_state("cli-sess-b")["state"] == "completed"
def test_slow_dispatch_beyond_60s_completes(self, db, monkeypatch):
"""The exact live-repro shape: claim @~5s, complete @~80s simulated.
Old code timed out at 60s with the false 'gateway running?' message;
new code waits through the running phase and exits on completed."""
db.ensure_session("cli-sess-c", "cli")
t0 = {"polls": 0}
real_get = db.get_handoff_state
def slow_gateway(sid):
t0["polls"] += 1
row = real_get(sid)
state = (row or {}).get("state")
if state == "pending" and t0["polls"] >= 2:
db.claim_handoff(sid)
# each poll ~2s simulated; complete after ~40 polls (~80s+)
if state == "running" and t0["polls"] >= 45:
db.complete_handoff(sid)
return real_get(sid)
db_proxy = MagicMock(wraps=db)
db_proxy.get_handoff_state.side_effect = slow_gateway
db_proxy.request_handoff.side_effect = db.request_handoff
db_proxy.fail_handoff.side_effect = db.fail_handoff
keep, printed, host = _run_handoff(db_proxy, "cli-sess-c", monkeypatch)
out = "\n".join(printed)
assert keep is False # completed -> CLI exits like /quit
assert "Handoff complete" in out
assert "Timed out waiting for the gateway" not in out
assert host._should_exit is True
assert db.get_handoff_state("cli-sess-c")["state"] == "completed"
# ---------------------------------------------------------------------------
# 3. Desktop handoff.fail RPC
# ---------------------------------------------------------------------------
class TestDesktopHandoffFail:
def _call(self, db, session_key):
"""Invoke the handoff.fail handler body with server-global stand-ins."""
import contextlib
from tui_gateway import methods_session as ms
handler = None
for name, fn in ms._registry._pending:
if name == "handoff.fail":
handler = fn
break
assert handler is not None, "handoff.fail handler not registered"
session = {"session_key": session_key}
@contextlib.contextmanager
def fake_session_db(_s):
yield db
globs = dict(handler.__globals__)
globs["_sess_nowait"] = lambda p, r: (session, None)
globs["_session_db"] = fake_session_db
globs["_ok"] = lambda rid, result: {"ok": True, **result}
globs["_db_unavailable_error"] = lambda rid, code: {"error": code}
rebound = types.FunctionType(
handler.__code__, globs, handler.__name__,
handler.__defaults__, handler.__closure__,
)
return rebound("rid", {"error": "poll timeout"})
def test_desktop_fail_refuses_running_row(self, db):
db.ensure_session("d1", "desktop")
assert db.request_handoff("d1", "discord")
assert db.claim_handoff("d1")
res = self._call(db, "d1")
assert res["failed"] is False
assert res["state"] == "running"
assert db.get_handoff_state("d1")["state"] == "running"
def test_desktop_fail_still_fails_pending_row(self, db):
db.ensure_session("d2", "desktop")
assert db.request_handoff("d2", "discord")
res = self._call(db, "d2")
assert res["failed"] is True
assert db.get_handoff_state("d2")["state"] == "failed"
+148
View File
@@ -0,0 +1,148 @@
"""Tests for the /indicator CLI command and busy-indicator style config.
The /indicator command is registered in COMMAND_REGISTRY (and advertised by
/help, tab-completion and the tips system) but used to have no dispatch branch
in HermesCLI.process_command — so typing it printed "Unknown command:
/indicator". These tests lock in the dispatch wiring and the handler behavior.
"""
import unittest
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
from cli import HermesCLI
def _import_cli():
import hermes_cli.config as config_mod
if not hasattr(config_mod, "save_env_value_secure"):
config_mod.save_env_value_secure = lambda key, value: {
"success": True,
"stored_as": key,
"validated": False,
}
import cli as cli_mod
return cli_mod
def _make_cli():
cli_obj = HermesCLI.__new__(HermesCLI)
cli_obj.config = {}
cli_obj.console = MagicMock()
cli_obj.agent = None
cli_obj.conversation_history = []
cli_obj.session_id = None
cli_obj._pending_input = MagicMock()
return cli_obj
class TestIndicatorDispatch(unittest.TestCase):
"""The command must route to its handler — not fall through to "Unknown"."""
def test_indicator_dispatches_to_handler(self):
cli_obj = _make_cli()
with patch.object(cli_obj, "_handle_indicator_command") as mock_handler:
result = cli_obj.process_command("/indicator emoji")
mock_handler.assert_called_once_with("/indicator emoji")
self.assertTrue(result)
def test_indicator_is_not_unknown_command(self):
cli_obj = _make_cli()
with (
patch("cli._cprint") as mock_cprint,
patch("cli.save_config_value", return_value=True),
):
result = cli_obj.process_command("/indicator emoji")
printed = " ".join(str(c) for c in mock_cprint.call_args_list)
self.assertNotIn("Unknown command", printed)
self.assertTrue(result)
class TestHandleIndicatorCommand(unittest.TestCase):
def _stub(self, current=None):
config = {}
if current is not None:
config["display"] = {"tui_status_indicator": current}
return SimpleNamespace(config=config)
def test_no_args_shows_status(self):
cli_mod = _import_cli()
stub = self._stub("emoji")
with (
patch.object(cli_mod, "_cprint") as mock_cprint,
patch.object(cli_mod, "save_config_value") as mock_save,
):
cli_mod.HermesCLI._handle_indicator_command(stub, "/indicator")
mock_save.assert_not_called()
printed = " ".join(str(c) for c in mock_cprint.call_args_list)
self.assertIn("emoji", printed)
def test_status_argument_shows_status(self):
cli_mod = _import_cli()
stub = self._stub() # no display config -> default kaomoji
with (
patch.object(cli_mod, "_cprint") as mock_cprint,
patch.object(cli_mod, "save_config_value") as mock_save,
):
cli_mod.HermesCLI._handle_indicator_command(stub, "/indicator status")
mock_save.assert_not_called()
printed = " ".join(str(c) for c in mock_cprint.call_args_list)
self.assertIn("kaomoji", printed)
def test_valid_style_saves_to_config_key(self):
cli_mod = _import_cli()
stub = self._stub("kaomoji")
with (
patch.object(cli_mod, "_cprint"),
patch.object(cli_mod, "save_config_value", return_value=True) as mock_save,
):
cli_mod.HermesCLI._handle_indicator_command(stub, "/indicator unicode")
# Persists to the SAME key the TUI reads, and mirrors it in memory.
mock_save.assert_called_once_with("display.tui_status_indicator", "unicode")
self.assertEqual(stub.config["display"]["tui_status_indicator"], "unicode")
def test_invalid_style_prints_usage_and_does_not_save(self):
cli_mod = _import_cli()
stub = self._stub("kaomoji")
with (
patch.object(cli_mod, "_cprint") as mock_cprint,
patch.object(cli_mod, "save_config_value") as mock_save,
):
cli_mod.HermesCLI._handle_indicator_command(stub, "/indicator rainbow")
mock_save.assert_not_called()
# The stored value must be untouched.
self.assertEqual(stub.config["display"]["tui_status_indicator"], "kaomoji")
printed = " ".join(str(c) for c in mock_cprint.call_args_list)
self.assertIn("Usage: /indicator", printed)
class TestIndicatorRegistry(unittest.TestCase):
def test_indicator_in_registry(self):
from hermes_cli.commands import COMMAND_REGISTRY
names = [c.name for c in COMMAND_REGISTRY]
self.assertIn("indicator", names)
def test_indicator_subcommands_match_handler(self):
from hermes_cli.commands import COMMAND_REGISTRY
from hermes_constants import INDICATOR_STYLES
indicator = next(c for c in COMMAND_REGISTRY if c.name == "indicator")
self.assertEqual(indicator.category, "Configuration")
# The registered styles are what the handler accepts — single source of truth.
self.assertEqual(
set(indicator.subcommands), set(INDICATOR_STYLES)
)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,326 @@
"""Regression tests for #60920/#60941: interrupt marker duplication on redraw.
The root cause: The interrupt marker ("_[Interrupted - processing new message]_")
was being appended to the response string, which got recorded in _OUTPUT_HISTORY
by the Panel rendering via _cprint → _record_output_history. When
_recover_terminal_after_interrupt called _force_full_redraw → _replay_output_history,
the marker was replayed on top of the already-visible message, causing duplicates
that accumulated on every SIGWINCH.
The fix:
1. A flag ``_show_interrupt_marker`` is set instead of mutating ``response``.
2. After the Panel rendering, the marker is printed via ``_cprint`` inside a
``_suspend_output_history()`` context so it never enters ``_OUTPUT_HISTORY``.
3. ``_recover_terminal_after_interrupt`` no longer clears ``_OUTPUT_HISTORY`` —
it doesn't need to, because the marker was never recorded.
These tests verify the contract at the module level without hitting the full
prompt_toolkit input loop.
"""
from unittest.mock import MagicMock, patch
import pytest
import cli as cli_mod
from cli import HermesCLI, _suspend_output_history
@pytest.fixture(autouse=True)
def reset_output_history():
"""Reset _OUTPUT_HISTORY before and after every test."""
cli_mod._configure_output_history(True, 200)
yield
cli_mod._configure_output_history(True, 200)
# ── Recovery path: _OUTPUT_HISTORY must NOT be cleared ──────────────
class TestRecoverTerminalPreservesHistory:
"""_recover_terminal_after_interrupt must NOT clear output history.
The old fix cleared _OUTPUT_HISTORY before the redraw to prevent the
interrupt marker from being replayed. The new fix avoids recording the
marker in the first place, so the clear is unnecessary *and* harmful —
it would discard legitimate scrollback content.
"""
def test_history_preserved_after_recovery(self, monkeypatch):
"""After recovery, _OUTPUT_HISTORY still contains earlier output."""
cli_mod._configure_output_history(True, 10)
cli_mod._record_output_history("normal response text")
cli = object.__new__(HermesCLI)
cli._force_full_redraw = MagicMock()
with patch("hermes_cli.curses_ui.flush_stdin"):
cli._recover_terminal_after_interrupt()
assert list(cli_mod._OUTPUT_HISTORY) == ["normal response text"], (
"_recover_terminal_after_interrupt must NOT clear _OUTPUT_HISTORY"
)
def test_recovery_still_calls_force_full_redraw(self, monkeypatch):
"""The recovery path still forces a redraw (original behavior preserved)."""
cli = object.__new__(HermesCLI)
cli._force_full_redraw = MagicMock()
with patch("hermes_cli.curses_ui.flush_stdin"):
cli._recover_terminal_after_interrupt()
cli._force_full_redraw.assert_called_once()
def test_normal_scrollback_survives_interrupt_cycle(self, monkeypatch):
"""Multiple lines of scrollback survive a full interrupt → recovery cycle."""
cli_mod._configure_output_history(True, 50)
for i in range(5):
cli_mod._record_output_history(f"visible line {i}")
cli = object.__new__(HermesCLI)
cli._force_full_redraw = MagicMock()
with patch("hermes_cli.curses_ui.flush_stdin"):
cli._recover_terminal_after_interrupt()
assert len(cli_mod._OUTPUT_HISTORY) == 5
assert list(cli_mod._OUTPUT_HISTORY) == [
f"visible line {i}" for i in range(5)
]
# ── Marker suppression: _suspend_output_history blocks recording ────
class TestInterruptMarkerNotRecorded:
"""The interrupt marker must never enter _OUTPUT_HISTORY.
Because it's printed inside a ``with _suspend_output_history():`` block,
the marker text stays out of the replay buffer and _replay_output_history
cannot duplicate it on redraw or resize.
"""
def test_suspend_blocks_recording_during_cprint(self, monkeypatch):
"""Text printed via _cprint while supressed is not recorded in history."""
cli_mod._configure_output_history(True, 10)
monkeypatch.setattr(cli_mod, "_pt_print", lambda x: None)
monkeypatch.setattr(cli_mod, "_PT_ANSI", lambda t: t)
# Record something before so we can distinguish "empty" from "never configured"
cli_mod._record_output_history("before marker")
with _suspend_output_history():
cli_mod._cprint("── [Interrupted — processing new message] ──")
assert list(cli_mod._OUTPUT_HISTORY) == ["before marker"], (
"_OUTPUT_HISTORY must not contain the marker text printed "
"under _suspend_output_history"
)
def test_normal_cprint_still_records(self, monkeypatch):
"""Normal _cprint calls (outside the suspend context) are still recorded.
Regression: the fix must not accidentally suppress ALL output history,
only the interrupt marker.
"""
cli_mod._configure_output_history(True, 10)
printed = []
monkeypatch.setattr(cli_mod, "_pt_print", lambda x: printed.append(x))
monkeypatch.setattr(cli_mod, "_PT_ANSI", lambda t: t)
cli_mod._cprint("normal response text")
assert "normal response text" in list(cli_mod._OUTPUT_HISTORY)
assert printed == ["normal response text"]
def test_suspend_is_idempotent_nested(self):
"""Nested _suspend_output_history() calls restore correctly."""
cli_mod._configure_output_history(True, 10)
cli_mod._record_output_history("before")
with _suspend_output_history():
cli_mod._record_output_history("inside outer")
with _suspend_output_history():
cli_mod._record_output_history("inside inner")
cli_mod._record_output_history("after")
assert list(cli_mod._OUTPUT_HISTORY) == [
"before",
"after",
]
# ── _show_interrupt_marker flag logic ──────────────────────────────
class TestShowInterruptMarkerLogic:
"""The _show_interrupt_marker flag must be set correctly.
The flag is True only when: the turn was interrupted (result.interrupted),
AND there is both a response AND a pending_message (interrupt_msg).
"""
def test_marker_shown_when_interrupted_with_response_and_message(self):
"""Happy path: interrupted turn with response and pending_message."""
result = {"interrupted": True}
response = "Some partial response"
pending_message = "interrupt message"
_show_interrupt_marker = False
_interrupted_this_turn = bool(result and result.get("interrupted"))
if _interrupted_this_turn:
pending_message = result.get("interrupt_message") or pending_message
_show_interrupt_marker = bool(response and pending_message)
assert _show_interrupt_marker is True
def test_marker_suppressed_when_no_response(self):
"""No marker when there is no response text to interrupt."""
result = {"interrupted": True}
response = ""
pending_message = "interrupt message"
_show_interrupt_marker = False
_interrupted_this_turn = bool(result and result.get("interrupted"))
if _interrupted_this_turn:
pending_message = result.get("interrupt_message") or pending_message
_show_interrupt_marker = bool(response and pending_message)
assert _show_interrupt_marker is False
def test_marker_suppressed_when_no_pending_message(self):
"""No marker when there's no interrupt message text."""
result = {"interrupted": True}
response = "Some partial response"
pending_message = None
_show_interrupt_marker = False
_interrupted_this_turn = bool(result and result.get("interrupted"))
if _interrupted_this_turn:
pending_message = result.get("interrupt_message") or pending_message
_show_interrupt_marker = bool(response and pending_message)
assert _show_interrupt_marker is False
def test_marker_suppressed_when_not_interrupted(self):
"""No marker when the turn was not interrupted."""
result = {"completed": True}
response = "Full response text"
pending_message = "interrupt message"
_show_interrupt_marker = False
_interrupted_this_turn = bool(result and result.get("interrupted"))
if _interrupted_this_turn:
pending_message = result.get("interrupt_message") or pending_message
_show_interrupt_marker = bool(response and pending_message)
assert _show_interrupt_marker is False
def test_marker_shown_with_explicit_interrupt_message(self):
"""Marker shown when result provides interrupt_message."""
result = {"interrupted": True, "interrupt_message": "User cancelled"}
response = "Partial output"
pending_message = "default interrupt msg"
_show_interrupt_marker = False
_interrupted_this_turn = bool(result and result.get("interrupted"))
if _interrupted_this_turn:
pending_message = result.get("interrupt_message") or pending_message
_show_interrupt_marker = bool(response and pending_message)
assert _show_interrupt_marker is True
assert pending_message == "User cancelled"
# ── End-to-end: _show_interrupt_marker → _cprint flow ──────────────
class TestInterruptMarkerPrintFlow:
"""End-to-end: the flag leads to a supressed _cprint of the marker."""
def test_marker_printed_via_suspend_after_panel(self, monkeypatch):
"""When _show_interrupt_marker is True, the marker is cprinted.
The marker text is printed inside _suspend_output_history so it
bypasses _OUTPUT_HISTORY.
"""
cli_mod._configure_output_history(True, 10)
printed_lines = []
monkeypatch.setattr(cli_mod, "_pt_print", lambda x: printed_lines.append(x))
monkeypatch.setattr(cli_mod, "_PT_ANSI", lambda t: t)
# Simulate the production flow
_show_interrupt_marker = True
if _show_interrupt_marker:
with _suspend_output_history():
cli_mod._cprint(
"\n── [Interrupted — processing new message] ──"
)
# Marker was printed but NOT recorded in history
assert printed_lines, "Marker must have been printed"
assert "Interrupted" in printed_lines[0]
assert list(cli_mod._OUTPUT_HISTORY) == []
def test_no_marker_printed_when_flag_false(self, monkeypatch):
"""When _show_interrupt_marker is False, nothing is printed."""
printed_lines = []
monkeypatch.setattr(cli_mod, "_pt_print", lambda x: printed_lines.append(x))
monkeypatch.setattr(cli_mod, "_PT_ANSI", lambda t: t)
_show_interrupt_marker = False
if _show_interrupt_marker:
with _suspend_output_history():
cli_mod._cprint("── [Interrupted] ──")
assert printed_lines == []
# ── _replay does not replay the marker (E2E) ───────────────────────
class TestReplayDoesNotDuplicateMarker:
"""_replay_output_history must not contain the interrupt marker.
After an interrupted turn, only the normal response is in the history.
Redrawing replays only the response — no marker duplication.
"""
def test_replay_clean_after_interrupted_turn(self, monkeypatch):
"""Simulate: normal response recorded, marker supressed → replay is clean."""
cli_mod._configure_output_history(True, 10)
# Normal response gets recorded
cli_mod._record_output_history("Assistant response text")
printed = []
monkeypatch.setattr(cli_mod, "_pt_print", lambda x: printed.append(x))
monkeypatch.setattr(cli_mod, "_PT_ANSI", lambda t: t)
# Marker gets printed with supressed history (does NOT enter _OUTPUT_HISTORY)
with _suspend_output_history():
cli_mod._cprint("── [Interrupted — processing new message] ──")
# History must contain only the normal response
assert list(cli_mod._OUTPUT_HISTORY) == ["Assistant response text"], (
"Interrupt marker must not appear in _OUTPUT_HISTORY"
)
# Replay the history — this emits the normal response via _pt_print
cli_mod._replay_output_history()
# The replayed output must contain only the response, NOT the marker
# (marker was printed once by _cprint, but replay must not repeat it)
marker_count = sum(1 for p in printed if "Interrupted" in str(p))
assert marker_count == 1, (
f"Marker must appear exactly once (from _cprint), not {marker_count} "
"(duplicated by _replay_output_history)"
)
assert "Assistant response text" in "".join(printed)
+84
View File
@@ -0,0 +1,84 @@
"""Modified kitty keypad keys must behave like their non-keypad twins (#97290)."""
import asyncio
import pytest
from prompt_toolkit.input.ansi_escape_sequences import ANSI_SEQUENCES
from prompt_toolkit.input.vt100_parser import Vt100Parser
from hermes_cli import pt_input_extras
@pytest.fixture(autouse=True)
def _aliases_installed():
pt_input_extras.install_shift_enter_alias()
pt_input_extras.install_ctrl_enter_alias()
pt_input_extras.install_modify_other_keys_aliases()
pt_input_extras.install_keypress_data_normalization()
def _parse(sequence):
presses = []
parser = Vt100Parser(presses.append)
for char in sequence:
parser.feed(char)
parser.flush()
return [press.key for press in presses]
def test_modified_keypad_mirrors_its_non_keypad_twin():
"""Every supported modifier inherits its twin, including lock bits, only in CSI-u."""
twins = {
57414: "\x1b[13;{mod}u",
**{code: "\x1b[1;{mod}" + suffix for code, suffix in
zip((57417, 57418, 57419, 57420, 57423, 57424), "DCABHF")},
**{code: f"\x1b[{number};{{mod}}~" for code, number in
((57421, 5), (57422, 6), (57425, 2), (57426, 3))},
**{57399 + digit: f"\x1b[{ord(str(digit))};{{mod}}u" for digit in range(10)},
}
for code, template in twins.items():
for base_mod in range(2, 9):
for offset in (0, 64, 128, 192):
modifier = base_mod + offset
sequence = f"\x1b[{code};{modifier}u"
twin = template.format(mod=modifier)
if twin in ANSI_SEQUENCES:
assert _parse(sequence) == _parse(twin), (code, modifier)
else:
assert sequence not in ANSI_SEQUENCES
assert f"\x1b[27;{modifier};{code}~" not in ANSI_SEQUENCES
# First-writer-wins applies to the twin too, even when this builder stages a default.
from prompt_toolkit.keys import Keys
installed = dict(ANSI_SEQUENCES)
installed["\x1b[13;3u"] = Keys.ControlA
aliases = pt_input_extras._modify_other_keys_aliases(installed, Keys)
assert aliases["\x1b[57414;3u"] == installed["\x1b[13;3u"]
@pytest.mark.parametrize("prefix", ["alpha", "alpha ", "alpha!?.,", "[Pasted text #1: 15 lines]"])
def test_keypad_alt_enter_inserts_newlines_at_the_cursor(prefix):
"""Extended Enter must reach the newline handler, never self-insert raw CSI text."""
from prompt_toolkit import Application
from prompt_toolkit.buffer import Buffer
from prompt_toolkit.document import Document
from prompt_toolkit.input import create_pipe_input
from prompt_toolkit.key_binding import KeyBindings
from prompt_toolkit.layout import BufferControl, Layout, Window
from prompt_toolkit.output import DummyOutput
from hermes_cli.cli_tui_mixin import CLITuiMixin
async def probe(sequence):
buf = Buffer(document=Document(prefix + "suffix", len(prefix)))
kb = KeyBindings()
kb.add("escape", "enter")(CLITuiMixin()._tui_insert_newline)
kb.add("c-q")(lambda event: event.app.exit(result=buf.text))
with create_pipe_input() as inp:
app = Application(layout=Layout(Window(BufferControl(buf))), key_bindings=kb,
input=inp, output=DummyOutput())
return await asyncio.wait_for(app.run_async(
pre_run=lambda: inp.send_text(sequence * 2 + "\x11")), timeout=5)
for sequence in ("\x1b[57414;3u", "\x1b[57414;131u", "\x1b[13;3u", "\x1b\r"):
assert asyncio.run(probe(sequence)) == prefix + "\n\nsuffix"
+262
View File
@@ -0,0 +1,262 @@
"""Tests for CLI manual compression messaging."""
from unittest.mock import MagicMock, patch
from tests.cli.test_cli_init import _make_cli
def _make_history() -> list[dict[str, str]]:
return [
{"role": "user", "content": "one"},
{"role": "assistant", "content": "two"},
{"role": "user", "content": "three"},
{"role": "assistant", "content": "four"},
]
def test_manual_compress_keeps_tui_composer_editable(capsys):
"""A follow-up can be drafted and queued while /compress runs."""
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
observed = {}
def compress(*_args, **_kwargs):
# The classic TUI's TextArea consults this state for its read_only
# condition. Compression must retain its status spinner without
# preventing the user from drafting the next prompt.
observed["running"] = shell._command_running
observed["blocks_input"] = getattr(shell, "_command_blocks_input", shell._command_running)
return list(history), ""
shell.agent._compress_context.side_effect = compress
with patch("agent.model_metadata.estimate_request_tokens_rough", return_value=100):
shell._manual_compress()
assert observed == {"running": True, "blocks_input": False}
def test_manual_compress_explains_when_token_estimate_rises(capsys):
shell = _make_cli()
history = _make_history()
compressed = [
history[0],
{"role": "assistant", "content": "Dense summary that still counts as more tokens."},
history[-1],
]
shell.conversation_history = history
shell.agent = MagicMock()
shell.agent.compression_enabled = True
shell.agent._cached_system_prompt = ""
shell.agent.tools = None
shell.agent.session_id = shell.session_id # no-op: no split
shell.agent._compress_context.return_value = (compressed, "")
shell.agent._compression_skipped_due_to_lock = False
def _estimate(messages, **_kwargs):
if messages == history:
return 100
if messages == compressed:
return 120
raise AssertionError(f"unexpected transcript: {messages!r}")
with patch("agent.model_metadata.estimate_request_tokens_rough", side_effect=_estimate):
shell._manual_compress()
output = capsys.readouterr().out
assert "✅ Compressed: 4 → 3 messages" in output
assert "Approx request size: ~100 → ~120 tokens" in output
assert "denser summaries" in output
def test_manual_compress_syncs_session_id_after_split():
"""Regression for cli.session_id desync after /compress.
_compress_context ends the parent session and creates a new child session,
mutating agent.session_id. Without syncing, cli.session_id still points
at the ended parent — causing /status, /resume, exit summary, and the
next end_session() call (e.g. from /resume <id>) to target the wrong row.
"""
shell = _make_cli()
history = _make_history()
old_id = shell.session_id
new_child_id = "20260101_000000_child1"
compressed = [
{"role": "user", "content": "[summary]"},
history[-1],
]
shell.conversation_history = history
shell.agent = MagicMock()
shell.agent.compression_enabled = True
shell.agent._cached_system_prompt = ""
shell.agent.tools = None
# Simulate _compress_context mutating agent.session_id as a side effect.
def _fake_compress(*args, **kwargs):
shell.agent.session_id = new_child_id
return (compressed, "")
shell.agent._compress_context.side_effect = _fake_compress
shell.agent._compression_skipped_due_to_lock = False
shell.agent.session_id = old_id # starts in sync
shell._pending_title = "stale title"
with patch("agent.model_metadata.estimate_request_tokens_rough", return_value=100):
shell._manual_compress()
# CLI session_id must now point at the continuation child, not the parent.
assert shell.session_id == new_child_id
assert shell.session_id != old_id
# Pending title must be cleared — titles belong to the parent lineage and
# get regenerated for the continuation.
assert shell._pending_title is None
def test_manual_compress_flushes_compressed_history_to_child_session_db():
"""Manual /compress must persist the handoff in the continuation DB.
_compress_context rotates the agent to a new child session and returns a
compressed transcript whose first messages include the handoff summary. The
CLI then replaces its in-memory conversation_history with that transcript.
Because the child DB starts empty, the flush must start from offset 0 rather
than treating the compressed history as already persisted.
"""
shell = _make_cli()
history = _make_history()
old_id = shell.session_id
new_child_id = "20260101_000000_child1"
compressed = [
{"role": "user", "content": "[CONTEXT COMPACTION — REFERENCE ONLY] compacted"},
history[-1],
]
shell.conversation_history = history
shell.agent = MagicMock()
shell.agent.compression_enabled = True
shell.agent._cached_system_prompt = ""
shell.agent.session_id = old_id
def _fake_compress(*args, **kwargs):
shell.agent.session_id = new_child_id
return (compressed, "")
shell.agent._compress_context.side_effect = _fake_compress
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_called_once_with(compressed, None)
def test_manual_compress_runs_when_auto_compaction_disabled(capsys):
"""compression.enabled: false disables *automatic* compaction only.
Manual /compress must still work: the context-overflow error path
(agent/conversation_loop.py) explicitly directs users to /compress when
auto-compaction is off, and the gateway's /compress handler has never
gated on the flag. Regression for the CLI refusing with "Compression is
disabled in config."
"""
shell = _make_cli()
history = _make_history()
compressed = [
{"role": "user", "content": "[summary]"},
history[-1],
]
shell.conversation_history = history
shell.agent = MagicMock()
shell.agent.compression_enabled = False
shell.agent._cached_system_prompt = ""
shell.agent.tools = None
shell.agent.session_id = shell.session_id
shell.agent._compress_context.return_value = (compressed, "")
# 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 is disabled" not in output
shell.agent._compress_context.assert_called_once()
# Manual compression bypasses the summary-failure cooldown.
assert shell.agent._compress_context.call_args.kwargs.get("force") is True
assert shell.conversation_history == compressed
def test_manual_compress_shows_lock_skip_without_confirmed_holder(capsys):
"""When _compress_context skips due to the compression lock WITHOUT a
confirmed holder (signal=True — acquisition failed but
get_compression_lock_holder returned nothing, e.g. a SQLite error made
try_acquire return False), _manual_compress must print the unconfirmed
lock-skip wording, NOT claim another compression is definitely running,
and NOT show the misleading "No changes from compression" no-op text."""
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
# Simulate _compress_context setting the lock-skip signal and
# returning unchanged messages.
def _fake_compress(*args, **kwargs):
shell.agent._compression_skipped_due_to_lock = True
return (list(history), "")
shell.agent._compress_context.side_effect = _fake_compress
with patch("agent.model_metadata.estimate_request_tokens_rough", return_value=100):
shell._manual_compress()
output = capsys.readouterr().out
assert "Compression skipped" in output
assert "could not acquire" in output
# No confirmed holder → must not assert one is running.
assert "already in progress" not in output
assert "No changes from compression" not in output
# Signal should be cleared after use.
assert shell.agent._compression_skipped_due_to_lock is None
def test_manual_compress_shows_lock_in_progress_with_holder(capsys):
"""When the lock holder is a descriptive string, include it in the
status message so the user knows which process to investigate."""
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
def _fake_compress(*args, **kwargs):
shell.agent._compression_skipped_due_to_lock = "pid=12345"
return (list(history), "")
shell.agent._compress_context.side_effect = _fake_compress
with patch("agent.model_metadata.estimate_request_tokens_rough", return_value=100):
shell._manual_compress()
output = capsys.readouterr().out
assert "Compression already in progress" in output
assert "pid=12345" in output
assert "No changes from compression" not in output
+115
View File
@@ -0,0 +1,115 @@
import queue
from unittest.mock import patch
from cli import HermesCLI
from hermes_cli.moa_config import decode_moa_turn
def _make_cli():
cli = HermesCLI.__new__(HermesCLI)
cli.config = {
"moa": {
"default_preset": "default",
"presets": {
"default": {
"reference_models": [{"provider": "openai-codex", "model": "gpt-5.5"}],
"aggregator": {"provider": "openrouter", "model": "anthropic/claude-opus-4.8"},
},
"review": {
"reference_models": [{"provider": "openrouter", "model": "deepseek/deepseek-v4-pro"}],
"aggregator": {"provider": "openrouter", "model": "anthropic/claude-opus-4.8"},
},
},
}
}
cli._pending_input = queue.Queue()
cli._pending_agent_seed = None
cli._pending_moa_config = None
cli._pending_moa_disable_after_turn = False
cli._pending_moa_restore_model = None
cli._agent_running = False
cli.agent = None
cli.provider = "openrouter"
cli.requested_provider = "openrouter"
cli.model = "anthropic/claude-opus-4.8"
cli.api_key = "test-key"
cli.base_url = "https://openrouter.ai/api/v1"
cli.api_mode = "chat_completions"
return cli
def test_moa_bare_shows_usage_no_switch():
# /moa with no prompt is usage-only now; switching to a preset for the
# session is done via the model picker, not /moa.
cli = _make_cli()
cli._pending_moa_disable_after_turn = False
with patch("cli._cprint"):
assert cli.process_command("/moa") is True
assert cli.provider != "moa"
assert cli._pending_agent_seed is None
assert cli._pending_moa_disable_after_turn is False
def test_moa_arg_is_always_one_shot_prompt():
# Any argument (even a string that matches a preset name) is treated as a
# one-shot prompt through the DEFAULT preset, then the model is restored.
cli = _make_cli()
with patch("cli._cprint"):
cli.process_command("/moa review")
assert cli._pending_agent_seed == "review"
assert cli._pending_moa_disable_after_turn is True
assert cli.provider == "moa"
assert cli.model == "default"
def test_moa_non_preset_is_one_shot_prompt():
cli = _make_cli()
with patch("cli._cprint"):
cli.process_command("/moa inspect the flaky test")
assert cli._pending_agent_seed == "inspect the flaky test"
assert cli._pending_moa_disable_after_turn is True
assert cli.provider == "moa"
assert cli.model == "default"
assert cli._pending_moa_restore_model["provider"] != "moa"
class TestNormalizeMoaModel:
"""#56828: `-Q -m moa:<preset>` must route through the MoA virtual provider.
``_normalize_moa_model`` maps the model string to (provider, preset); the
__init__ wiring then forces ``requested_provider="moa"`` so the existing
resolve_runtime_provider / agent_init MoA path runs in non-interactive mode.
"""
def test_moa_prefix_maps_to_provider_and_preset(self):
from cli import _normalize_moa_model
assert _normalize_moa_model("moa:strategy") == ("moa", "strategy")
def test_none_model_unchanged(self):
from cli import _normalize_moa_model
assert _normalize_moa_model(None) == (None, None)
def test_colon_model_that_is_not_moa_unchanged(self):
from cli import _normalize_moa_model
# A provider:model form for a real provider must not be hijacked.
assert _normalize_moa_model("openrouter:deepseek/deepseek-v4") == (
None,
"openrouter:deepseek/deepseek-v4",
)
def test_override_wins_over_explicit_provider(self):
# __init__ resolves requested_provider as
# ``_moa_provider_override or provider or ...``, so a moa: prefix must
# take precedence over an explicit --provider (the #56828 deepseek case
# where MoA was silently ignored).
from cli import _normalize_moa_model
override, model = _normalize_moa_model("moa:strategy")
requested_provider = override or "deepseek" or "auto"
assert requested_provider == "moa"
assert model == "strategy"
+62
View File
@@ -0,0 +1,62 @@
"""Tests for the /model picker fuzzy filter (C-01).
The filter narrows a provider's concrete model list as the user types, but
selection must still resolve to exactly ONE real model — never an ambiguous
or fuzzy resolution (the "claude → claude-sonnet-3" footgun). These pin the
index-preserving contract of ``_filter_model_picker_entries``.
"""
from cli import HermesCLI
MODELS = [
"anthropic/claude-opus-4.8",
"anthropic/claude-sonnet-4.6",
"anthropic/claude-haiku-4.5",
"openai/gpt-5.5",
"x-ai/grok-4.6",
"deepseek/deepseek-v4-flash",
]
def test_empty_query_returns_all_with_original_indices():
pairs = HermesCLI._filter_model_picker_entries(MODELS, "")
assert pairs == list(enumerate(MODELS))
def test_filter_narrows_and_preserves_original_index():
pairs = HermesCLI._filter_model_picker_entries(MODELS, "grok")
# Only the grok row matches, and it carries its ORIGINAL index (4) so the
# selection handler resolves the exact concrete model.
assert pairs == [(4, "x-ai/grok-4.6")]
idx, label = pairs[0]
assert MODELS[idx] == label # index maps back to the real entry
def test_subsequence_match_case_insensitive():
# "cs46" is a subsequence of "anthropic/claude-sonnet-4.6"
pairs = HermesCLI._filter_model_picker_entries(MODELS, "CS46")
assert ("anthropic/claude-sonnet-4.6") in [e for _i, e in pairs]
def test_no_match_returns_empty():
assert HermesCLI._filter_model_picker_entries(MODELS, "zzzznope") == []
def test_filter_does_not_reorder_or_pick_a_default():
# Typing "claude" narrows to the three claude rows in ORIGINAL order — it
# never silently resolves to one (the anti-ambiguity guarantee). The user
# still explicitly selects among the concrete matches.
pairs = HermesCLI._filter_model_picker_entries(MODELS, "claude")
labels = [e for _i, e in pairs]
assert labels == [
"anthropic/claude-opus-4.8",
"anthropic/claude-sonnet-4.6",
"anthropic/claude-haiku-4.5",
]
# indices are the originals, in order
assert [i for i, _e in pairs] == [0, 1, 2]
def test_whitespace_query_is_treated_as_empty():
assert HermesCLI._filter_model_picker_entries(MODELS, " ") == list(enumerate(MODELS))
+747
View File
@@ -0,0 +1,747 @@
"""Regression tests for issue #87711 — Ctrl+key / Alt+key combos broken
under modifyOtherKeys level 2.
When the CLI pushes ``ESC[>4;2m`` (modifyOtherKeys=2) to supported
terminals so Shift+Enter is distinguishable from Enter, the terminal
re-encodes EVERY Ctrl+key combo as ``ESC[27;5;<codepoint>~`` instead of
the raw control byte (``\\x01`` etc.). prompt_toolkit 3.x only ships a
mapping for ``ESC[27;5;13~`` (Ctrl+Enter = Ctrl+M); all other Ctrl+letter
combos are unmapped and leak as literal text or get swallowed.
``install_modify_other_keys_aliases()`` populates ``ANSI_SEQUENCES`` with
the full set so every Ctrl+combo continues to fire the same key binding
the raw byte would.
"""
from __future__ import annotations
import pytest
from prompt_toolkit.input.ansi_escape_sequences import ANSI_SEQUENCES
from prompt_toolkit.input.vt100_parser import Vt100Parser
from prompt_toolkit.keys import Keys
from hermes_cli.pt_input_extras import install_modify_other_keys_aliases
@pytest.fixture(autouse=True)
def _ensure_alias_installed():
"""Install the alias for each test, then restore ANSI_SEQUENCES to its
prior state so the hundreds of installed mappings don't leak into
sibling test files."""
from prompt_toolkit.input.ansi_escape_sequences import ANSI_SEQUENCES as _seq
saved = dict(_seq)
install_modify_other_keys_aliases()
yield
_seq.clear()
_seq.update(saved)
# Drop the parser's prefix cache too — it was computed against the
# augmented table and would go stale after the restore above.
from prompt_toolkit.input.vt100_parser import _IS_PREFIX_OF_LONGER_MATCH_CACHE
_IS_PREFIX_OF_LONGER_MATCH_CACHE.clear()
def _parse(byte_seq: str):
"""Feed bytes through prompt_toolkit's VT100 parser and return the
list of KeyPress objects."""
out = []
parser = Vt100Parser(out.append)
for ch in byte_seq:
parser.feed(ch)
parser.flush()
return [kp.key for kp in out]
# ---------------------------------------------------------------------------
# Ctrl+letter: a-z
# ---------------------------------------------------------------------------
CTRL_LETTERS = [chr(c) for c in range(ord('a'), ord('z') + 1)]
@pytest.mark.parametrize("letter", CTRL_LETTERS)
def test_modify_other_keys_ctrl_letter_parses_as_raw_byte(letter):
"""Ctrl+<letter> under modifyOtherKeys must parse identically to the
raw control byte that prompt_toolkit already understands."""
raw_byte = chr(ord(letter) - ord('a') + 1) # Ctrl+a = \x01, etc.
raw_result = _parse(raw_byte)
assert len(raw_result) == 1, f"raw byte {raw_byte!r} should produce 1 keypress"
# modifyOtherKeys format
mok_seq = f"\x1b[27;5;{ord(letter)}~"
mok_result = _parse(mok_seq)
assert mok_result == raw_result, (
f"modifyOtherKeys Ctrl+{letter} ({mok_seq!r}) should parse identically "
f"to raw {raw_byte!r}; got {mok_result!r} vs {raw_result!r}"
)
# CSI-u format
csiu_seq = f"\x1b[{ord(letter)};5u"
csiu_result = _parse(csiu_seq)
assert csiu_result == raw_result, (
f"CSI-u Ctrl+{letter} ({csiu_seq!r}) should parse identically "
f"to raw {raw_byte!r}; got {csiu_result!r} vs {raw_result!r}"
)
@pytest.mark.parametrize("letter", CTRL_LETTERS)
def test_modify_other_keys_ctrl_letter_single_keypress(letter):
"""Each Ctrl+letter sequence must produce exactly one keypress —
a partial match would emit Escape plus literal text."""
for seq in (f"\x1b[27;5;{ord(letter)}~", f"\x1b[{ord(letter)};5u"):
result = _parse(seq)
assert len(result) == 1, (
f"{seq!r} should produce exactly 1 keypress, got {len(result)}: {result!r}"
)
# ---------------------------------------------------------------------------
# Critical individual shortcuts
# ---------------------------------------------------------------------------
def test_ctrl_c_under_modify_other_keys():
"""Ctrl+C must produce Keys.ControlC, not literal text (#56684)."""
assert _parse("\x1b[27;5;99~") == [Keys.ControlC]
assert _parse("\x1b[99;5u") == [Keys.ControlC]
def test_ctrl_a_under_modify_other_keys():
"""Ctrl+A (line start) must still fire."""
assert _parse("\x1b[27;5;97~") == [Keys.ControlA]
assert _parse("\x1b[97;5u") == [Keys.ControlA]
def test_ctrl_e_under_modify_other_keys():
"""Ctrl+E (line end) must still fire."""
assert _parse("\x1b[27;5;101~") == [Keys.ControlE]
assert _parse("\x1b[101;5u") == [Keys.ControlE]
def test_ctrl_u_under_modify_other_keys():
"""Ctrl+U (kill line) must still fire."""
assert _parse("\x1b[27;5;117~") == [Keys.ControlU]
assert _parse("\x1b[117;5u") == [Keys.ControlU]
def test_ctrl_k_under_modify_other_keys():
"""Ctrl+K (kill to end) must still fire."""
assert _parse("\x1b[27;5;107~") == [Keys.ControlK]
assert _parse("\x1b[107;5u") == [Keys.ControlK]
def test_ctrl_r_under_modify_other_keys():
"""Ctrl+R (reverse search) must still fire."""
assert _parse("\x1b[27;5;114~") == [Keys.ControlR]
assert _parse("\x1b[114;5u") == [Keys.ControlR]
def test_ctrl_d_under_modify_other_keys():
"""Ctrl+D (EOF / delete) must still fire."""
assert _parse("\x1b[27;5;100~") == [Keys.ControlD]
assert _parse("\x1b[100;5u") == [Keys.ControlD]
def test_ctrl_w_under_modify_other_keys():
"""Ctrl+W (delete word) must still fire."""
assert _parse("\x1b[27;5;119~") == [Keys.ControlW]
assert _parse("\x1b[119;5u") == [Keys.ControlW]
def test_ctrl_z_under_modify_other_keys():
"""Ctrl+Z (suspend) must still fire."""
assert _parse("\x1b[27;5;122~") == [Keys.ControlZ]
assert _parse("\x1b[122;5u") == [Keys.ControlZ]
def test_ctrl_l_under_modify_other_keys():
"""Ctrl+L (clear screen) must still fire."""
assert _parse("\x1b[27;5;108~") == [Keys.ControlL]
assert _parse("\x1b[108;5u") == [Keys.ControlL]
# ---------------------------------------------------------------------------
# Ctrl+digit: 0-9
# ---------------------------------------------------------------------------
@pytest.mark.parametrize("digit", [str(d) for d in range(10)])
def test_modify_other_keys_ctrl_digit(digit):
"""Ctrl+digit under modifyOtherKeys must map to Keys.Control<digit>."""
codepoint = ord(digit)
expected = getattr(Keys, f"Control{digit}")
mok_seq = f"\x1b[27;5;{codepoint}~"
assert _parse(mok_seq) == [expected], (
f"modifyOtherKeys Ctrl+{digit} ({mok_seq!r}) should parse as {expected}"
)
csiu_seq = f"\x1b[{codepoint};5u"
assert _parse(csiu_seq) == [expected], (
f"CSI-u Ctrl+{digit} ({csiu_seq!r}) should parse as {expected}"
)
# ---------------------------------------------------------------------------
# Ctrl+symbol
# ---------------------------------------------------------------------------
def test_ctrl_left_bracket_under_modify_other_keys():
"""Ctrl+[ = Escape under modifyOtherKeys."""
assert _parse("\x1b[27;5;91~") == _parse("\x1b")
assert _parse("\x1b[91;5u") == _parse("\x1b")
def test_ctrl_backslash_under_modify_other_keys():
"""Ctrl+\\ = ControlBackslash under modifyOtherKeys."""
assert _parse("\x1b[27;5;92~") == [Keys.ControlBackslash]
assert _parse("\x1b[92;5u") == [Keys.ControlBackslash]
def test_ctrl_right_bracket_under_modify_other_keys():
"""Ctrl+] = ControlSquareClose under modifyOtherKeys."""
assert _parse("\x1b[27;5;93~") == [Keys.ControlSquareClose]
assert _parse("\x1b[93;5u") == [Keys.ControlSquareClose]
def test_ctrl_space_under_modify_other_keys():
"""Ctrl+Space = ControlAt (NUL) under modifyOtherKeys."""
assert _parse("\x1b[27;5;32~") == _parse("\x00")
assert _parse("\x1b[32;5u") == _parse("\x00")
# ---------------------------------------------------------------------------
# Alt+letter
# ---------------------------------------------------------------------------
@pytest.mark.parametrize("letter", [chr(c) for c in range(ord('a'), ord('z') + 1)])
def test_modify_other_keys_alt_letter_parses_as_escape_letter(letter):
"""Alt+<letter> under modifyOtherKeys must parse to the same tuple
that a bare ESC+<letter> produces."""
bare_result = _parse(f"\x1b{letter}")
assert len(bare_result) == 2, f"bare ESC+{letter} should produce 2 keypresses"
mok_seq = f"\x1b[27;3;{ord(letter)}~"
mok_result = _parse(mok_seq)
assert mok_result == bare_result, (
f"modifyOtherKeys Alt+{letter} ({mok_seq!r}) should parse identically "
f"to bare ESC+{letter}; got {mok_result!r} vs {bare_result!r}"
)
csiu_seq = f"\x1b[{ord(letter)};3u"
csiu_result = _parse(csiu_seq)
assert csiu_result == bare_result, (
f"CSI-u Alt+{letter} ({csiu_seq!r}) should parse identically "
f"to bare ESC+{letter}; got {csiu_result!r} vs {bare_result!r}"
)
# ---------------------------------------------------------------------------
# Idempotency and non-clobbering
# ---------------------------------------------------------------------------
def test_install_is_idempotent():
install_modify_other_keys_aliases()
assert install_modify_other_keys_aliases() == 0
# ---------------------------------------------------------------------------
# Shift+letter → uppercase
# ---------------------------------------------------------------------------
@pytest.mark.parametrize("letter", [chr(c) for c in range(ord('a'), ord('z') + 1)])
def test_modify_other_keys_shift_letter_produces_uppercase(letter):
"""Shift+<letter> under modifyOtherKeys must produce the uppercase
character, not leak as literal escape text — the 'caps locked' bug."""
upper = letter.upper()
# modifyOtherKeys format
mok_seq = f"\x1b[27;2;{ord(letter)}~"
assert _parse(mok_seq) == [upper], (
f"modifyOtherKeys Shift+{letter} ({mok_seq!r}) should produce '{upper}'"
)
# CSI-u format
csiu_seq = f"\x1b[{ord(letter)};2u"
assert _parse(csiu_seq) == [upper], (
f"CSI-u Shift+{letter} ({csiu_seq!r}) should produce '{upper}'"
)
def test_does_not_clobber_shift_enter_alias():
"""install_modify_other_keys_aliases must not overwrite mappings
installed by install_shift_enter_alias (modifier=2, not 5)."""
from hermes_cli.pt_input_extras import install_shift_enter_alias
install_shift_enter_alias()
assert ANSI_SEQUENCES["\x1b[27;2;13~"] == (Keys.Escape, Keys.ControlM)
assert ANSI_SEQUENCES["\x1b[13;2u"] == (Keys.Escape, Keys.ControlM)
def test_does_not_clobber_ctrl_enter_alias():
"""install_modify_other_keys_aliases must not overwrite mappings
installed by install_ctrl_enter_alias (which maps Ctrl+Enter)."""
from hermes_cli.pt_input_extras import install_ctrl_enter_alias
install_ctrl_enter_alias()
# Ctrl+Enter (modifier=5, codepoint=13) is mapped to (Escape, ControlM)
assert ANSI_SEQUENCES["\x1b[27;5;13~"] == (Keys.Escape, Keys.ControlM)
assert ANSI_SEQUENCES["\x1b[13;5u"] == (Keys.Escape, Keys.ControlM)
def test_ctrl_enter_still_works_under_modify_other_keys():
"""Ctrl+Enter must produce the Alt+Enter newline tuple, not plain Ctrl+M.
This is the install_ctrl_enter_alias behavior — our new function must
not clobber it."""
from hermes_cli.pt_input_extras import install_ctrl_enter_alias
install_ctrl_enter_alias()
install_modify_other_keys_aliases()
alt_enter = _parse("\x1b\r")
ctrl_enter_mok = _parse("\x1b[27;5;13~")
ctrl_enter_csiu = _parse("\x1b[13;5u")
assert ctrl_enter_mok == alt_enter
assert ctrl_enter_csiu == alt_enter
def test_plain_enter_remains_distinct():
"""Plain Enter must keep producing a single keypress (submit), not
the two-key Alt+Enter tuple."""
enter = _parse("\r")
alt_enter = _parse("\x1b\r")
assert enter != alt_enter
assert len(enter) == 1
assert len(alt_enter) == 2
# ---------------------------------------------------------------------------
# Kitty keyboard protocol: the Esc KEY (disambiguate mode, #56684)
# ---------------------------------------------------------------------------
def test_kitty_plain_escape_key():
"""The Esc key under kitty disambiguate mode (ESC[27u) must parse as
Keys.Escape, not leak '[27u' as literal text."""
assert _parse("\x1b[27u") == [Keys.Escape]
@pytest.mark.parametrize("modifier", range(2, 9))
def test_kitty_modified_escape_key(modifier):
"""Modified Esc (Shift+Esc etc.) still behaves as Escape."""
assert _parse(f"\x1b[27;{modifier}u") == [Keys.Escape]
# ---------------------------------------------------------------------------
# Modified Enter / Tab / Backspace / Space
# ---------------------------------------------------------------------------
def test_alt_enter_produces_newline_tuple():
"""Alt+Enter under either protocol must equal bare ESC+CR (newline)."""
alt_enter = _parse("\x1b\r")
assert _parse("\x1b[13;3u") == alt_enter
assert _parse("\x1b[27;3;13~") == alt_enter
def test_shift_tab_produces_backtab():
"""Shift+Tab must behave like the legacy ESC[Z (BackTab)."""
assert _parse("\x1b[9;2u") == [Keys.BackTab]
assert _parse("\x1b[27;2;9~") == [Keys.BackTab]
def test_ctrl_backspace_produces_backward_kill_word():
"""Ctrl+Backspace must produce the (Escape, ControlH) tuple that fires
prompt_toolkit's backward-kill-word — parity with the Ink TUI and
Desktop (#78285)."""
expected = [Keys.Escape, Keys.ControlH]
assert _parse("\x1b[127;5u") == expected
assert _parse("\x1b[27;5;127~") == expected
def test_alt_backspace_produces_backward_kill_word():
"""Alt+Backspace = ESC+DEL = backward-kill-word."""
assert _parse("\x1b[127;3u") == [Keys.Escape, Keys.ControlH]
assert _parse("\x1b[27;3;127~") == [Keys.Escape, Keys.ControlH]
def test_shift_backspace_is_plain_backspace():
assert _parse("\x1b[127;2u") == _parse("\x7f")
def test_shift_space_inserts_space():
"""Shift+Space must insert a space, not leak escape text (#86866)."""
assert _parse("\x1b[32;2u") == [" "]
assert _parse("\x1b[27;2;32~") == [" "]
def _parse_presses(byte_seq: str):
"""Feed bytes through the VT100 parser and return the full KeyPress
objects (key + data), so buffer-level data leakage is observable."""
from prompt_toolkit.input.vt100_parser import Vt100Parser as _Vt100Parser
from prompt_toolkit.key_binding.key_processor import KeyPress as _KeyPress
out = []
parser = _Vt100Parser(out.append)
for ch in byte_seq:
parser.feed(ch)
parser.flush()
assert all(isinstance(kp, _KeyPress) for kp in out)
return out
@pytest.mark.parametrize(
"seq",
["\x1b[32;2u", "\x1b[27;2;32~"], # kitty CSI-u and xterm modifyOtherKeys
)
def test_shift_space_keypress_data_is_plain_space(seq):
"""The KeyPress data for Shift+Space must be ' ', not the raw CSI
sequence — self-insert inserts event.data, so raw bytes would leak
into the buffer even though the key is correctly mapped (#88071)."""
from hermes_cli.pt_input_extras import install_keypress_data_normalization
install_keypress_data_normalization()
presses = _parse_presses(seq)
assert [kp.key for kp in presses] == [" "]
assert [kp.data for kp in presses] == [" "], (
f"{seq!r} KeyPress data must be ' ', got {[kp.data for kp in presses]!r}"
)
@pytest.mark.parametrize("seq", ["\x1b[97;2u", "\x1b[27;2;97~"])
def test_shift_letter_keypress_data_is_uppercase(seq):
"""Shift+letter (modifier 2) maps to the uppercase letter; its KeyPress
data must be that letter, not the raw escape text (#88071)."""
from hermes_cli.pt_input_extras import install_keypress_data_normalization
install_keypress_data_normalization()
presses = _parse_presses(seq)
assert [kp.key for kp in presses] == ["A"]
assert [kp.data for kp in presses] == ["A"], (
f"{seq!r} KeyPress data must be 'A', got {[kp.data for kp in presses]!r}"
)
def test_keypad_digit_keypress_data_is_digit():
"""Keypad digits (Kitty PUA) map to plain digits; their KeyPress data
must be the digit, not the raw escape text (#88071)."""
from hermes_cli.pt_input_extras import install_keypress_data_normalization
install_keypress_data_normalization()
presses = _parse_presses("\x1b[57404u")
assert [kp.key for kp in presses] == ["5"]
assert [kp.data for kp in presses] == ["5"], (
f"keypad 5 KeyPress data must be '5', got {[kp.data for kp in presses]!r}"
)
def test_plain_space_keypress_data_unchanged():
"""A plain space must keep data == ' ' — normalization must not break
the ordinary typing path."""
from hermes_cli.pt_input_extras import install_keypress_data_normalization
install_keypress_data_normalization()
presses = _parse_presses(" ")
assert [kp.key for kp in presses] == [" "]
assert [kp.data for kp in presses] == [" "]
def test_buffer_level_shift_space_no_raw_csi():
"""End-to-end: feeding Shift+Space through a real Application must put
a space in the buffer, not raw CSI bytes (#88071).
This is the regression the parser-only tests miss: Vt100Parser maps
the key to ' ' but the KeyPress data still carried the raw sequence,
and the default self-insert binding inserts event.data.
"""
import asyncio
from prompt_toolkit import Application
from prompt_toolkit.buffer import Buffer
from prompt_toolkit.input import create_pipe_input
from prompt_toolkit.layout import HSplit, Layout, Window, BufferControl
from hermes_cli.pt_input_extras import install_keypress_data_normalization
install_keypress_data_normalization()
async def _probe(payload: str) -> str:
buf = Buffer()
with create_pipe_input() as inp:
app = Application(
layout=Layout(HSplit([Window(BufferControl(buf))])), input=inp
)
run_task = asyncio.ensure_future(app.run_async())
await asyncio.sleep(0.05)
inp.send_text("ab")
inp.send_text(payload)
inp.send_text("cd")
await asyncio.sleep(0.15)
result = buf.text
app.exit()
try:
await asyncio.wait_for(run_task, 2)
except Exception:
pass
return result
for label, payload in (
("Shift+Space xterm", "\x1b[27;2;32~"),
("Shift+Space kitty", "\x1b[32;2u"),
("plain space", " "),
):
buffer = asyncio.run(_probe(payload))
assert buffer == "ab cd", (
f"{label}: buffer={buffer!r} — expected 'ab cd'; raw CSI bytes "
f"must never land in the buffer"
)
def test_buffer_level_shift_letter_no_raw_csi():
"""End-to-end: Shift+letter through a real Application must type the
capital letter, not literal ``^[[27;2;<code>~`` text (#92343).
Same KeyPress.data defect as Shift+Space (#88071), surfaced live on
Ghostty after 1a8fea3ce2 dropped it onto the modifyOtherKeys-only path:
ANSI_SEQUENCES maps the sequence to 'M', but self-insert pastes
event.data — the raw escape bytes.
"""
import asyncio
from prompt_toolkit import Application
from prompt_toolkit.buffer import Buffer
from prompt_toolkit.input import create_pipe_input
from prompt_toolkit.layout import HSplit, Layout, Window, BufferControl
from hermes_cli.pt_input_extras import install_keypress_data_normalization
install_keypress_data_normalization()
async def _probe(payload: str) -> str:
buf = Buffer()
with create_pipe_input() as inp:
app = Application(
layout=Layout(HSplit([Window(BufferControl(buf))])), input=inp
)
run_task = asyncio.ensure_future(app.run_async())
await asyncio.sleep(0.05)
inp.send_text("ab")
inp.send_text(payload)
inp.send_text("cd")
await asyncio.sleep(0.15)
result = buf.text
app.exit()
try:
await asyncio.wait_for(run_task, 2)
except Exception:
pass
return result
for label, payload, expected in (
("Shift+M xterm modifyOtherKeys", "\x1b[27;2;77~", "abMcd"),
("Shift+M kitty CSI-u (shifted cp)", "\x1b[77;2u", "abMcd"),
("Shift+L kitty CSI-u (unshifted cp)", "\x1b[108;2u", "abLcd"),
("plain letter", "M", "abMcd"),
):
buffer = asyncio.run(_probe(payload))
assert buffer == expected, (
f"{label}: buffer={buffer!r} — expected {expected!r}; raw CSI "
f"bytes must never land in the buffer"
)
def test_plain_letter_keypress_data_unchanged():
"""The normalization predicate only fires on ESC-prefixed payloads —
ordinary ASCII typing must pass through untouched."""
from hermes_cli.pt_input_extras import install_keypress_data_normalization
install_keypress_data_normalization()
presses = _parse_presses("M")
assert [(kp.key, kp.data) for kp in presses] == [("M", "M")]
# ---------------------------------------------------------------------------
# Multi-modifier combos (Ctrl+Shift / Ctrl+Alt / Shift+Alt / Ctrl+Alt+Shift)
# ---------------------------------------------------------------------------
@pytest.mark.parametrize("letter", ["c", "r", "z"])
def test_ctrl_shift_letter_behaves_as_ctrl(letter):
"""Ctrl+Shift+<letter> (modifier 6) must fire the same key as
Ctrl+<letter> — both the unshifted and shifted codepoint variants."""
expected = _parse(chr(ord(letter) & 0x1F))
for cp in (ord(letter), ord(letter) - 32):
assert _parse(f"\x1b[{cp};6u") == expected
assert _parse(f"\x1b[27;6;{cp}~") == expected
def test_ctrl_alt_letter_behaves_as_escape_ctrl():
"""Ctrl+Alt+a (modifier 7) = Escape prefix + ControlA."""
assert _parse("\x1b[97;7u") == [Keys.Escape, Keys.ControlA]
def test_ctrl_alt_shift_letter_behaves_as_escape_ctrl():
"""Ctrl+Alt+Shift+a (modifier 8) = Escape prefix + ControlA."""
assert _parse("\x1b[97;8u") == [Keys.Escape, Keys.ControlA]
def test_shift_alt_letter_behaves_as_escape_upper():
"""Shift+Alt+f (modifier 4) = Escape prefix + 'F'."""
assert _parse("\x1b[102;4u") == [Keys.Escape, "F"]
# ---------------------------------------------------------------------------
# Kitty functional keys (Private Use Area codepoints)
# ---------------------------------------------------------------------------
def test_keypad_enter_behaves_as_enter():
assert _parse("\x1b[57414u") == _parse("\r")
@pytest.mark.parametrize("digit", range(10))
def test_keypad_digits_type_digits(digit):
assert _parse(f"\x1b[{57399 + digit}u") == [str(digit)]
def test_keypad_navigation_maps_to_arrows():
assert _parse("\x1b[57417u") == [Keys.Left]
assert _parse("\x1b[57418u") == [Keys.Right]
assert _parse("\x1b[57419u") == [Keys.Up]
assert _parse("\x1b[57420u") == [Keys.Down]
def test_f13_maps_to_f13():
assert _parse("\x1b[57376u") == [Keys.F13]
@pytest.mark.parametrize("code", [57358, 57428, 57441, 57448])
def test_lock_media_modifier_events_are_consumed(code):
"""Caps Lock, media keys, and bare modifier press events must be
consumed (Keys.Ignore), never leak as literal text."""
result = _parse(f"\x1b[{code}u")
assert result == [Keys.Ignore], f"CSI {code}u leaked: {result!r}"
def test_cmd_backspace_alias_not_clobbered():
"""install_cmd_backspace_alias's super-modifier mappings must survive."""
from hermes_cli.pt_input_extras import install_cmd_backspace_alias
install_cmd_backspace_alias()
install_modify_other_keys_aliases()
assert _parse("\x1b[127;9u") == [Keys.ControlU]
# ---------------------------------------------------------------------------
# Lock-bit variants (#89651): kitty/ghostty OR the CapsLock (64) / NumLock
# (128) state into the CSI-u modifier parameter, so with a lock enabled
# every combo arrives shifted (ESC[99;133u instead of ESC[99;5u) and died
# as literal text without these aliases.
# ---------------------------------------------------------------------------
@pytest.mark.parametrize("letter", CTRL_LETTERS)
def test_ctrl_letter_with_numlock_parses_as_raw_byte(letter):
"""Ctrl+<letter> with NumLock on (modifier + 128) must parse identically
to the raw control byte — the exact garbage from #89651 ([127;133u)."""
raw_byte = chr(ord(letter) - ord('a') + 1)
raw_result = _parse(raw_byte)
numlock_seq = f"\x1b[{ord(letter)};133u" # 5 + 128
assert _parse(numlock_seq) == raw_result, (
f"NumLock Ctrl+{letter} ({numlock_seq!r}) should parse identically "
f"to raw {raw_byte!r}"
)
@pytest.mark.parametrize("letter", ["a", "c", "z"])
def test_ctrl_letter_with_capslock_parses_as_raw_byte(letter):
raw_byte = chr(ord(letter) - ord('a') + 1)
capslock_seq = f"\x1b[{ord(letter)};69u" # 5 + 64
assert _parse(capslock_seq) == _parse(raw_byte)
def test_ctrl_c_with_both_locks_parses_as_raw_byte():
"""Ctrl+C with CapsLock and NumLock both on (5 + 64 + 128 = 197)."""
assert _parse("\x1b[99;197u") == _parse("\x03")
def test_alt_letter_with_numlock_keeps_escape_prefix():
assert _parse("\x1b[97;131u") == [Keys.Escape, "a"] # 3 + 128
def test_shift_letter_with_capslock_types_uppercase():
assert _parse("\x1b[97;66u") == ["A"] # 2 + 64
def test_esc_key_with_numlock_is_escape():
assert _parse("\x1b[27;129u") == [Keys.Escape] # 1 + 128
assert _parse("\x1b[27;133u") == [Keys.Escape] # 5 + 128
def test_ctrl_backspace_with_numlock_is_backward_kill_word():
"""The exact sequence from the #89651 report ([127;133u)."""
assert _parse("\x1b[127;133u") == [Keys.Escape, Keys.ControlH]
def test_modify_other_keys_tilde_form_has_no_lock_variants():
"""The xterm modifyOtherKeys encoding never carries lock bits, so no
+64/+128 variants of the ESC[27;N;CP~ form may be installed."""
for seq in ("\x1b[27;69;99~", "\x1b[27;133;99~", "\x1b[27;197;99~"):
assert seq not in ANSI_SEQUENCES
# ---------------------------------------------------------------------------
# Follow-up widening: lock twins on the alias installers, legacy CSI-letter /
# CSI-tilde navigation, unmodified CSI-u keys, and PUA functional keys.
# ---------------------------------------------------------------------------
def test_lock_bits_on_legacy_cursor_keys_map_to_plain_keys():
"""kitty stamps lock bits onto legacy CSI-letter arrows too:
plain Down + NumLock = ESC[1;129B, CapsLock = ESC[1;65B, both = 193."""
for mod, key in ((129, Keys.Down), (65, Keys.Down), (193, Keys.Down)):
assert _parse(f"\x1b[1;{mod}B") == [key]
assert _parse("\x1b[1;130B") == [Keys.ShiftDown] # Shift + NumLock
assert _parse("\x1b[1;131D") == [Keys.Escape, Keys.Left] # Alt + NumLock
assert _parse("\x1b[1;133D") == [Keys.ControlLeft] # Ctrl + NumLock
def test_lock_bits_on_tilde_navigation_keys():
"""Delete/PageUp/etc. carry the modifier in CSI-tilde form."""
assert _parse("\x1b[3;129~") == [Keys.Delete]
assert _parse("\x1b[3;69~") == [Keys.ControlDelete] # Ctrl+Delete + Caps
assert _parse("\x1b[5;193~") == [Keys.PageUp] # both locks
def test_lock_bits_on_plain_f1_through_f4():
"""Plain F1-F4 base mappings are SS3 (ESC O P); their CSI lock twins
must still resolve (ESC[1;129P etc.)."""
base = _parse("\x1bOP")
assert _parse("\x1b[1;129P") == base
assert _parse("\x1b[1;65P") == base
def test_lock_bits_on_unmodified_csi_u_keys():
"""Tab/Enter/Space/Backspace with only a lock held (modifier 1+lock)."""
assert _parse("\x1b[9;65u") == _parse("\t")
assert _parse("\x1b[13;193u") == _parse("\r")
assert _parse("\x1b[32;129u") == _parse(" ")
assert _parse("\x1b[127;129u") == _parse("\x7f")
def test_lock_bits_on_pua_functional_keys():
"""Kitty PUA functional keys (keypad, F13+) keep working under locks —
NumLock especially matters because it gates the keypad itself."""
assert _parse("\x1b[57399;129u") == ["0"] # KP_0 + NumLock
assert _parse("\x1b[57376;129u") == [Keys.F13] # F13 + NumLock
assert _parse("\x1b[57427;129u") == [Keys.Ignore] # KP_BEGIN + NumLock
def test_lock_bits_on_shift_enter_and_ctrl_enter_aliases():
from hermes_cli.pt_input_extras import (
install_ctrl_enter_alias,
install_shift_enter_alias,
)
install_shift_enter_alias()
install_ctrl_enter_alias()
newline = _parse("\x1b\r")
assert _parse("\x1b[13;130u") == newline # Shift+Enter + NumLock
assert _parse("\x1b[13;66u") == newline # Shift+Enter + CapsLock
assert _parse("\x1b[13;133u") == newline # Ctrl+Enter + NumLock
def test_lock_bits_on_cmd_backspace_alias():
from hermes_cli.pt_input_extras import install_cmd_backspace_alias
install_cmd_backspace_alias()
assert _parse("\x1b[127;137u") == [Keys.ControlU] # Cmd+Backspace + NumLock
assert _parse("\x1b[127;73u") == [Keys.ControlU] # Cmd+Backspace + Caps
assert _parse("\x1b[3;137~") == [Keys.ControlK] # Cmd+FwdDel + NumLock
@@ -0,0 +1,202 @@
"""Regression tests for #88583 — one-shot resumed-session turns must persist.
Bot Mode's bot-to-bot send (``hermes -p <bot> chat --in ~ -c "Bot Chat"
--create-if-missing -Q -q "..."``) runs exactly one turn and exits. The
receiving agent replied, the CLI banner said it resumed the titled session,
but nothing landed in state.db when the turn's in-loop transcript flush
failed transiently (write-lock contention with a multiplex gateway sharing
state.db): the one-shot path had no end-of-run durable retry and never
finalized the session row, unlike the interactive CLI (which retries on the
next turn and ends the session with ``cli_close`` on quit).
The fix routes every one-shot exit (quiet ``-Q -q``, human ``-q``, and the
kanban SIGTERM path) through ``cli._flush_one_shot_session_store``: a final
``_persist_session`` retry (idempotent via the per-message persisted
markers), a token-count drain, and ``end_session(..., "cli_close")``.
"""
from __future__ import annotations
import os
import tempfile
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import patch
import pytest
import cli as cli_mod
@pytest.fixture(autouse=True)
def _reset_finalize_state(monkeypatch):
monkeypatch.setattr(cli_mod, "_single_query_finalize_attempted_session_ids", set())
monkeypatch.setattr(cli_mod, "_handed_off_session_ids", set())
monkeypatch.setattr(cli_mod, "_cleanup_done", False, raising=False)
def _make_agent(session_db, session_id="oneshot-88583"):
"""Real AIAgent bound to a real temp SessionDB (test_860_dedup pattern)."""
with patch.dict(os.environ, {"OPENROUTER_API_KEY": "test-key"}):
from run_agent import AIAgent
agent = AIAgent(
api_key="test-key",
base_url="https://openrouter.ai/api/v1",
model="test/model",
quiet_mode=True,
session_db=session_db,
session_id=session_id,
skip_context_files=True,
skip_memory=True,
)
agent._ensure_db_session()
return agent
def _fake_cli(agent):
return SimpleNamespace(
agent=agent,
session_id=agent.session_id,
conversation_history=[],
_session_db=agent._session_db,
_release_active_session=lambda: None,
)
class TestOneShotDurableFlush:
"""#88583: the one-shot exit path must retry persistence and finalize."""
def test_finalize_single_query_persists_unflushed_turn(self, monkeypatch):
"""A turn whose in-loop flush failed must still reach state.db.
Simulates the reported failure: run_conversation produced the turn's
messages in memory (``_session_messages``) but the transcript flush
never landed (transient write-lock loss). Without the fix,
``_finalize_single_query`` performs no durable write and the turn
evaporates — this test fails.
"""
from hermes_state import SessionDB
with tempfile.TemporaryDirectory() as tmpdir:
db = SessionDB(db_path=Path(tmpdir) / "state.db")
try:
agent = _make_agent(db)
# The turn as run_conversation left it: in memory, un-stamped,
# never written (the in-loop flush failed transiently).
agent._session_messages = [
{"role": "user", "content": "Message from 🤖 worker: hello, remember this"},
{"role": "assistant", "content": "ack — noted."},
]
assert db.get_messages(agent.session_id) == []
fake = _fake_cli(agent)
monkeypatch.setattr(cli_mod, "_run_cleanup", lambda **kw: None)
monkeypatch.setattr(
cli_mod, "_notify_single_query_session_finalize", lambda _c: None
)
cli_mod._finalize_single_query(fake)
rows = db.get_messages(agent.session_id)
assert [r["role"] for r in rows] == ["user", "assistant"], (
"one-shot exit must durably flush the turn to state.db "
f"(#88583); got rows: {rows}"
)
assert "remember this" in rows[0]["content"]
finally:
db.close()
def test_finalize_single_query_ends_session_row(self, monkeypatch):
"""The resumed/created one-shot session row is finalized on exit."""
from hermes_state import SessionDB
with tempfile.TemporaryDirectory() as tmpdir:
db = SessionDB(db_path=Path(tmpdir) / "state.db")
try:
agent = _make_agent(db)
agent._session_messages = [
{"role": "user", "content": "hi"},
{"role": "assistant", "content": "hello"},
]
fake = _fake_cli(agent)
monkeypatch.setattr(cli_mod, "_run_cleanup", lambda **kw: None)
monkeypatch.setattr(
cli_mod, "_notify_single_query_session_finalize", lambda _c: None
)
cli_mod._finalize_single_query(fake)
sess = db.get_session(agent.session_id)
assert sess["ended_at"] is not None
assert sess["end_reason"] == "cli_close"
finally:
db.close()
def test_flush_is_idempotent_for_already_persisted_turns(self, monkeypatch):
"""A turn the in-loop flush already wrote is not duplicated."""
from hermes_state import SessionDB
with tempfile.TemporaryDirectory() as tmpdir:
db = SessionDB(db_path=Path(tmpdir) / "state.db")
try:
agent = _make_agent(db)
messages = [
{"role": "user", "content": "hi"},
{"role": "assistant", "content": "hello"},
]
# Normal happy path: the in-loop flush already persisted.
agent._flush_messages_to_session_db(messages, [])
assert len(db.get_messages(agent.session_id)) == 2
agent._session_messages = messages
cli_mod._flush_one_shot_session_store(_fake_cli(agent))
rows = db.get_messages(agent.session_id)
assert len(rows) == 2, f"duplicate rows written: {rows}"
finally:
db.close()
def test_flush_skips_handed_off_sessions(self):
"""A session handed off to the gateway is owned there (#88234)."""
from hermes_state import SessionDB
with tempfile.TemporaryDirectory() as tmpdir:
db = SessionDB(db_path=Path(tmpdir) / "state.db")
try:
agent = _make_agent(db)
agent._session_messages = [
{"role": "user", "content": "hi"},
{"role": "assistant", "content": "hello"},
]
cli_mod._handed_off_session_ids.add(agent.session_id)
cli_mod._flush_one_shot_session_store(_fake_cli(agent))
assert db.get_messages(agent.session_id) == []
assert db.get_session(agent.session_id)["ended_at"] is None
finally:
db.close()
def test_flush_skips_persist_disabled_agents(self):
"""Persistence-isolated forks must never write the canonical store."""
from hermes_state import SessionDB
with tempfile.TemporaryDirectory() as tmpdir:
db = SessionDB(db_path=Path(tmpdir) / "state.db")
try:
agent = _make_agent(db)
agent._persist_disabled = True
agent._session_messages = [
{"role": "user", "content": "curator harness turn"},
]
cli_mod._flush_one_shot_session_store(_fake_cli(agent))
assert db.get_messages(agent.session_id) == []
finally:
db.close()
def test_flush_survives_missing_agent(self):
cli_mod._flush_one_shot_session_store(SimpleNamespace(agent=None))
cli_mod._flush_one_shot_session_store(SimpleNamespace())
+147
View File
@@ -0,0 +1,147 @@
"""Tests for hermes_cli.partial_compress — the pure split/parse helpers
behind ``/compress here [N]`` (boundary-aware "summarize up to here").
Inspired by Claude Code's Rewind "Summarize up to here" action.
"""
from hermes_cli.partial_compress import (
DEFAULT_KEEP_LAST,
MAX_KEEP_LAST,
parse_partial_compress_args,
rejoin_compressed_head_and_tail,
split_history_for_partial_compress,
)
def _history(n_pairs: int) -> list[dict[str, str]]:
"""Build n_pairs of (user, assistant) exchanges."""
h: list[dict[str, str]] = []
for i in range(n_pairs):
h.append({"role": "user", "content": f"u{i}"})
h.append({"role": "assistant", "content": f"a{i}"})
return h
# ── parse_partial_compress_args ──────────────────────────────────────
def test_here_with_count():
partial, keep, focus = parse_partial_compress_args("here 4")
assert partial is True
assert keep == 4
assert focus is None
def test_keep_flag_forms():
for arg in ("--keep 5", "-k 5", "--keep=5"):
partial, keep, focus = parse_partial_compress_args(arg)
assert partial is True, arg
assert keep == 5, arg
assert focus is None, arg
def test_here_count_clamped_low_and_high():
_, keep_low, _ = parse_partial_compress_args("here 0")
assert keep_low == 1
_, keep_high, _ = parse_partial_compress_args(f"here {MAX_KEEP_LAST + 50}")
assert keep_high == MAX_KEEP_LAST
# ── split_history_for_partial_compress ───────────────────────────────
def test_split_tail_always_starts_on_user():
# Tool messages interleaved — tail must still snap to a user turn.
h = [
{"role": "user", "content": "u0"},
{"role": "assistant", "content": "a0"},
{"role": "user", "content": "u1"},
{"role": "assistant", "content": "a1"},
{"role": "tool", "content": "t1"},
{"role": "assistant", "content": "a1b"},
{"role": "user", "content": "u2"},
{"role": "assistant", "content": "a2"},
]
head, tail = split_history_for_partial_compress(h, keep_last=1)
assert tail[0]["role"] == "user"
assert tail[0]["content"] == "u2"
assert head + tail == h
def test_split_rejoin_preserves_all_messages():
h = _history(6)
head, tail = split_history_for_partial_compress(h, keep_last=3)
assert head + tail == h
# ── rejoin_compressed_head_and_tail (seam-alternation guard) ─────────
def _roles(msgs):
return [m["role"] for m in msgs if m["role"] in ("user", "assistant")]
def _no_consecutive_dupes(msgs):
r = _roles(msgs)
return all(r[i] != r[i + 1] for i in range(len(r) - 1))
def test_rejoin_valid_seam_assistant_then_user():
# Normal case: head ends on assistant, tail starts on user → valid.
head = [{"role": "user", "content": "[summary]"},
{"role": "assistant", "content": "ack"}]
tail = [{"role": "user", "content": "next"},
{"role": "assistant", "content": "reply"}]
out = rejoin_compressed_head_and_tail(head, tail)
assert out == head + tail
assert _no_consecutive_dupes(out)
def test_rejoin_user_user_seam_merges():
# Degenerate head ending on a user summary; tail starts on user.
head = [{"role": "user", "content": "[summary of head]"}]
tail = [{"role": "user", "content": "latest question"},
{"role": "assistant", "content": "answer"}]
out = rejoin_compressed_head_and_tail(head, tail)
assert _no_consecutive_dupes(out), out
# The two user messages were merged into one.
assert out[0]["content"] == "[summary of head]\n\nlatest question"
assert out[1] == {"role": "assistant", "content": "answer"}
def test_rejoin_assistant_assistant_seam_merges():
head = [{"role": "user", "content": "q"},
{"role": "assistant", "content": "head end"}]
tail = [{"role": "assistant", "content": "tail start"},
{"role": "user", "content": "u"}]
out = rejoin_compressed_head_and_tail(head, tail)
assert _no_consecutive_dupes(out), out
assert out[-2]["content"] == "head end\n\ntail start"
def test_rejoin_tool_seam_left_alone():
# tool->tool is the one legal repetition; don't merge.
head = [{"role": "user", "content": "q"}, {"role": "tool", "content": "t1"}]
tail = [{"role": "user", "content": "u"}]
out = rejoin_compressed_head_and_tail(head, tail)
assert out == head + tail
+228
View File
@@ -0,0 +1,228 @@
"""Tests for /personality none — clearing personality overlay.
Updated for the single-owner unification (hermes_cli.personality): built-ins
always exist, resolution reads config (agent.personalities overlays), and
persistence flows exclusively through persist_personality().
"""
import os
import pytest
from unittest.mock import MagicMock, patch
import yaml
# ── CLI tests ──────────────────────────────────────────────────────────────
class TestCLIPersonalityNone:
def _make_cli(self, personalities=None):
from cli import HermesCLI
from hermes_cli.personality import available_personalities
cli = HermesCLI.__new__(HermesCLI)
user = personalities or {
"helpful": "You are helpful.",
"concise": "You are concise.",
}
cli.config = {"agent": {"personalities": user}}
cli.personalities = available_personalities(cli.config)
cli.system_prompt = "You are kawaii~"
cli.agent = MagicMock()
cli.console = MagicMock()
return cli
def test_set_persists_display_personality_not_system_prompt(self):
cli = self._make_cli()
saves = []
def _persist(name):
saves.append(("display.personality", name))
return True
with patch("hermes_cli.personality.persist_personality", side_effect=_persist):
cli._handle_personality_command("/personality helpful")
assert cli.system_prompt == "You are helpful."
assert ("display.personality", "helpful") in saves
assert not any(k == "agent.system_prompt" for k, _ in saves)
def test_neutral_restores_manual_system_prompt_without_wiping_config(self):
cli = self._make_cli()
saves = []
def _persist(name):
saves.append(("display.personality", name))
return True
with (
patch("hermes_cli.personality.persist_personality", side_effect=_persist),
patch(
"hermes_cli.config.read_raw_config",
return_value={"agent": {"system_prompt": "manual forever"}},
),
):
cli._handle_personality_command("/personality neutral")
assert cli.system_prompt == "manual forever"
assert ("display.personality", "") in saves
assert not any(k == "agent.system_prompt" for k, _ in saves)
def test_builtin_personality_works_without_config_entry(self):
# Built-ins come from hermes_cli.personality, not from config.
cli = self._make_cli(personalities={})
with patch("hermes_cli.personality.persist_personality", return_value=True):
cli._handle_personality_command("/personality kawaii")
assert "kawaii" in cli.system_prompt.lower()
# ── Gateway tests ──────────────────────────────────────────────────────────
class TestGatewayPersonalityNone:
def _make_event(self, args=""):
event = MagicMock()
event.get_command.return_value = "personality"
event.get_command_args.return_value = args
return event
def _make_runner(self, personalities=None):
from gateway.run import GatewayRunner
runner = GatewayRunner.__new__(GatewayRunner)
runner.config = {
"agent": {
"personalities": personalities or {"helpful": "You are helpful."}
}
}
return runner
def _gateway_env(self, tmp_path):
# The gateway reads via _load_gateway_config (rooted at
# gateway.run._hermes_home) and persists via persist_personality
# (rooted at HERMES_HOME) — point both at the same tmp dir.
return (
patch("gateway.run._hermes_home", tmp_path),
patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}),
)
@pytest.mark.asyncio
async def test_default_clears_ephemeral_prompt(self, tmp_path):
runner = self._make_runner()
config_data = {
"agent": {
"system_prompt": "manual forever",
"personalities": {"helpful": "You are helpful."},
},
"display": {"personality": "helpful"},
}
config_file = tmp_path / "config.yaml"
config_file.write_text(yaml.dump(config_data))
p1, p2 = self._gateway_env(tmp_path)
with p1, p2:
event = self._make_event("default")
result = await runner._handle_personality_command(event)
saved = yaml.safe_load(config_file.read_text())
assert saved["agent"]["system_prompt"] == "manual forever"
assert saved.get("display", {}).get("personality", None) == ""
# The next turn re-resolves from config (no in-memory snapshot).
with p1, p2:
assert runner._get_system_prompt_for_channel(None, "c") == "manual forever"
@pytest.mark.asyncio
async def test_set_persists_display_personality_not_system_prompt(self, tmp_path):
runner = self._make_runner()
config_data = {
"agent": {
"system_prompt": "manual forever",
"personalities": {"helpful": "You are helpful."},
}
}
config_file = tmp_path / "config.yaml"
config_file.write_text(yaml.dump(config_data))
p1, p2 = self._gateway_env(tmp_path)
with p1, p2:
event = self._make_event("helpful")
result = await runner._handle_personality_command(event)
saved = yaml.safe_load(config_file.read_text())
assert saved["agent"]["system_prompt"] == "manual forever"
assert saved["display"]["personality"] == "helpful"
with p1, p2:
assert runner._get_system_prompt_for_channel(None, "c") == "You are helpful."
assert "helpful" in result.lower()
@pytest.mark.asyncio
async def test_unknown_shows_none_in_available(self, tmp_path):
runner = self._make_runner()
config_data = {"agent": {"personalities": {"helpful": "You are helpful."}}}
config_file = tmp_path / "config.yaml"
config_file.write_text(yaml.dump(config_data))
p1, p2 = self._gateway_env(tmp_path)
with p1, p2:
event = self._make_event("nonexistent")
result = await runner._handle_personality_command(event)
assert "none" in result.lower()
@pytest.mark.asyncio
async def test_empty_personality_list_still_lists_builtins(self, tmp_path):
# Built-ins are always available — an empty agent.personalities no
# longer means "no personalities configured".
runner = self._make_runner(personalities={})
(tmp_path / "config.yaml").write_text(yaml.dump({"agent": {"personalities": {}}}))
p1, p2 = self._gateway_env(tmp_path)
with p1, p2:
event = self._make_event("")
result = await runner._handle_personality_command(event)
assert "kawaii" in result.lower()
assert "pirate" in result.lower()
class TestPersonalityDictFormat:
"""Test dict-format custom personalities with description, tone, style."""
def _make_cli(self, personalities):
from cli import HermesCLI
from hermes_cli.personality import available_personalities
cli = HermesCLI.__new__(HermesCLI)
cli.config = {"agent": {"personalities": personalities}}
cli.personalities = available_personalities(cli.config)
cli.system_prompt = ""
cli.agent = None
cli.console = MagicMock()
return cli
def test_dict_personality_uses_system_prompt(self):
cli = self._make_cli({
"coder": {
"description": "Expert programmer",
"system_prompt": "You are an expert programmer.",
"tone": "technical",
"style": "concise",
}
})
with patch("hermes_cli.personality.persist_personality", return_value=True):
cli._handle_personality_command("/personality coder")
assert "You are an expert programmer." in cli.system_prompt
def test_dict_personality_includes_style(self):
cli = self._make_cli({
"coder": {
"system_prompt": "You are an expert programmer.",
"style": "use code examples",
}
})
with patch("hermes_cli.personality.persist_personality", return_value=True):
cli._handle_personality_command("/personality coder")
assert "Style: use code examples" in cli.system_prompt
def test_string_personality_still_works(self):
cli = self._make_cli({"helper": "You are helpful."})
with patch("hermes_cli.personality.persist_personality", return_value=True):
cli._handle_personality_command("/personality helper")
assert cli.system_prompt == "You are helpful."
+35
View File
@@ -0,0 +1,35 @@
"""Regression tests for CLI prefill config key compatibility."""
from __future__ import annotations
import cli
def test_resolve_prefill_messages_file_uses_top_level(monkeypatch):
monkeypatch.delenv("HERMES_PREFILL_MESSAGES_FILE", raising=False)
assert cli._resolve_prefill_messages_file(
{
"prefill_messages_file": "top.json",
"agent": {"prefill_messages_file": "legacy.json"},
}
) == "top.json"
def test_resolve_prefill_messages_file_accepts_legacy_agent_key(monkeypatch):
monkeypatch.delenv("HERMES_PREFILL_MESSAGES_FILE", raising=False)
assert cli._resolve_prefill_messages_file(
{"agent": {"prefill_messages_file": "legacy.json"}}
) == "legacy.json"
def test_resolve_prefill_messages_file_prefers_env(monkeypatch):
monkeypatch.setenv("HERMES_PREFILL_MESSAGES_FILE", "env.json")
assert cli._resolve_prefill_messages_file(
{
"prefill_messages_file": "top.json",
"agent": {"prefill_messages_file": "legacy.json"},
}
) == "env.json"
+52
View File
@@ -0,0 +1,52 @@
"""Tests for cli._prepend_note_to_message.
Regression coverage for the TypeError raised when a queued /model or
/reload-skills note was prepended to a multimodal (image-attached) message:
``can only concatenate str (not "list") to str``.
"""
from cli import _prepend_note_to_message
def test_string_message_gets_note_prepended():
assert _prepend_note_to_message("hello", "NOTE") == "NOTE\n\nhello"
def test_note_is_stripped():
assert _prepend_note_to_message("hello", " NOTE ") == "NOTE\n\nhello"
def test_list_message_folds_note_into_first_text_part():
message = [
{"type": "text", "text": "describe this"},
{"type": "image_url", "image_url": {"url": "data:..."}},
]
result = _prepend_note_to_message(message, "NOTE")
assert result[0]["type"] == "text"
assert result[0]["text"] == "NOTE\n\ndescribe this"
# Image part is preserved untouched.
assert result[1] == {"type": "image_url", "image_url": {"url": "data:..."}}
# Original message is not mutated.
assert message[0]["text"] == "describe this"
def test_image_only_list_gets_leading_text_part():
message = [{"type": "image_url", "image_url": {"url": "data:..."}}]
result = _prepend_note_to_message(message, "NOTE")
assert result[0] == {"type": "text", "text": "NOTE"}
assert result[1]["type"] == "image_url"
def test_unknown_shape_returned_unchanged():
assert _prepend_note_to_message(123, "NOTE") == 123
assert _prepend_note_to_message(None, "NOTE") is None
+436
View File
@@ -0,0 +1,436 @@
"""Tests for the Ctrl+S prompt stash state machine (hermes_cli.prompt_stash).
Covers the pure state machine directly — no prompt_toolkit, no TUI:
- stashing an empty/whitespace buffer is a no-op
- stash → restore round-trips exact text including newlines
- repeated stashes never silently clobber an earlier draft
- indicator / placeholder state
- browse-panel cursor, delete, and restore
- the resolve_ctrl_s decision table
"""
from __future__ import annotations
import pytest
from hermes_cli.prompt_stash import (
ACTION_CLOSE_PANEL,
ACTION_NOOP,
ACTION_OPEN_PANEL,
ACTION_RESTORED,
ACTION_STASHED,
MAX_STASH_ITEMS,
PromptStash,
StashEntry,
build_preview,
resolve_ctrl_s,
)
class _FakeClock:
"""Deterministic monotonic clock for age assertions."""
def __init__(self, start: float = 1000.0):
self.now = start
def __call__(self) -> float:
return self.now
def advance(self, secs: float) -> None:
self.now += secs
@pytest.fixture
def stash():
return PromptStash(clock=_FakeClock())
# --------------------------------------------------------------- no-op cases
class TestStashNoOp:
"""An empty or whitespace-only composer must not create a stash entry."""
@pytest.mark.parametrize("text", ["", " ", "\n", "\t\t", " \n \n ", None])
def test_stash_blank_buffer_is_noop(self, stash, text):
assert stash.stash(text) is False
assert len(stash) == 0
assert stash.indicator() == ""
def test_pop_empty_stash_returns_none(self, stash):
assert stash.pop() is None
def test_peek_empty_stash_returns_none(self, stash):
assert stash.peek() is None
def test_open_panel_on_empty_stash_refused(self, stash):
assert stash.open_panel() is False
assert stash.panel_open is False
def test_delete_on_empty_stash_is_noop(self, stash):
assert stash.delete_at_cursor() is False
def test_restore_at_cursor_on_empty_stash(self, stash):
assert stash.restore_at_cursor() is None
def test_images_only_draft_is_stashable(self, stash):
"""Blank text but attached images is still worth parking."""
assert stash.stash("", ["/tmp/a.png"]) is True
assert len(stash) == 1
assert stash.peek().preview == "(images only)"
# ------------------------------------------------------------- round-tripping
class TestRoundTrip:
"""Restore must return the draft byte-for-byte."""
@pytest.mark.parametrize(
"text",
[
"hello",
"line one\nline two",
"line one\nline two\nline three\n",
"\n leading blank and indented\n",
"trailing spaces ",
" leading spaces",
"para one\n\npara two\n\n\npara three",
"tabs\there\tand\there",
"unicode ünïcödé 中文 🎉 mixed",
"```python\ndef f():\n return 1\n```",
],
)
def test_stash_then_restore_round_trips_exactly(self, stash, text):
assert stash.stash(text) is True
result = stash.pop()
assert result is not None
restored, images = result
assert restored == text
assert images == []
# Popping consumed the entry.
assert len(stash) == 0
def test_multiline_draft_preserves_every_newline(self, stash):
text = "a\nb\nc\nd\ne"
stash.stash(text)
restored, _ = stash.pop()
assert restored.count("\n") == 4
assert restored.splitlines() == ["a", "b", "c", "d", "e"]
def test_round_trip_through_resolve_ctrl_s(self, stash):
"""The full gesture: Ctrl+S to park, Ctrl+S on empty to bring back."""
draft = "a long prompt\nwith several lines\n"
action, payload = resolve_ctrl_s(stash, draft)
assert action == ACTION_STASHED
assert payload is None
assert len(stash) == 1
action, payload = resolve_ctrl_s(stash, "")
assert action == ACTION_RESTORED
assert payload == (draft, [])
assert len(stash) == 0
def test_images_round_trip(self, stash):
imgs = ["/tmp/one.png", "/tmp/two.png"]
stash.stash("with pics", imgs)
text, restored = stash.pop()
assert text == "with pics"
assert restored == imgs
# The stash must hold its own copy — mutating the caller's list after
# stashing cannot corrupt the parked entry.
imgs.append("/tmp/three.png")
assert restored == ["/tmp/one.png", "/tmp/two.png"]
# --------------------------------------------------------------- no clobbering
class TestNoSilentClobber:
"""A second Ctrl+S must not destroy the first draft."""
def test_second_stash_keeps_first(self, stash):
stash.stash("first draft")
stash.stash("second draft")
assert len(stash) == 2
texts = [e.text for e in stash.items]
assert "first draft" in texts
assert "second draft" in texts
def test_newest_first_ordering(self, stash):
stash.stash("oldest")
stash.stash("middle")
stash.stash("newest")
assert [e.text for e in stash.items] == ["newest", "middle", "oldest"]
# Default pop takes the most recent — the "undo my last Ctrl+S" case.
assert stash.pop()[0] == "newest"
def test_two_items_opens_panel_instead_of_guessing(self, stash):
"""With 2+ drafts, Ctrl+S must not silently pick one."""
stash.stash("first")
stash.stash("second")
action, payload = resolve_ctrl_s(stash, "")
assert action == ACTION_OPEN_PANEL
assert payload is None
assert stash.panel_open is True
# Nothing was consumed.
assert len(stash) == 2
def test_stash_cap_drops_oldest_not_newest(self):
s = PromptStash(max_items=3, clock=_FakeClock())
for i in range(5):
s.stash(f"draft {i}")
assert len(s) == 3
assert [e.text for e in s.items] == ["draft 4", "draft 3", "draft 2"]
def test_default_cap_is_bounded(self, stash):
for i in range(MAX_STASH_ITEMS + 10):
stash.stash(f"d{i}")
assert len(stash) == MAX_STASH_ITEMS
# -------------------------------------------------------------- indicator state
class TestIndicatorState:
def test_empty_stash_has_no_indicator(self, stash):
assert stash.indicator() == ""
assert stash.placeholder_hint() == ""
assert bool(stash) is False
def test_single_item_indicator(self, stash):
stash.stash("draft")
assert stash.indicator() == "📌 1"
assert bool(stash) is True
def test_count_grows_with_stash(self, stash):
stash.stash("a")
assert stash.indicator() == "📌 1"
stash.stash("b")
assert stash.indicator() == "📌 2"
stash.stash("c")
assert stash.indicator() == "📌 3"
def test_indicator_marks_open_panel(self, stash):
stash.stash("a")
stash.stash("b")
stash.open_panel()
assert stash.indicator() == "📌 2 ▲"
stash.close_panel()
assert stash.indicator() == "📌 2"
def test_indicator_clears_after_restoring_last_item(self, stash):
stash.stash("only")
stash.pop()
assert stash.indicator() == ""
def test_placeholder_hint_single_shows_preview(self, stash):
stash.stash("write the migration guide")
hint = stash.placeholder_hint()
assert "Ctrl+S" in hint
assert "write the migration guide" in hint
def test_placeholder_hint_multi_shows_count(self, stash):
stash.stash("a")
stash.stash("b")
stash.stash("c")
assert stash.placeholder_hint() == "Ctrl+S to browse 3 stashed drafts"
def test_clear_resets_all_state(self, stash):
stash.stash("a")
stash.stash("b")
stash.open_panel()
stash.clear()
assert len(stash) == 0
assert stash.panel_open is False
assert stash.panel_cursor == 0
assert stash.indicator() == ""
# ----------------------------------------------------------------- previewing
class TestBuildPreview:
def test_empty_text(self):
assert build_preview("") == ""
def test_single_line_passthrough(self):
assert build_preview("hello world") == "hello world"
def test_newlines_collapse_to_marker(self):
assert build_preview("a\nb") == "a ⏎ b"
def test_crlf_normalized(self):
assert build_preview("a\r\nb") == "a ⏎ b"
def test_preview_is_always_single_line(self):
preview = build_preview("x\n" * 30, width=200)
assert "\n" not in preview
def test_long_text_ellipsized_to_width(self):
preview = build_preview("y" * 500, width=20)
assert len(preview) == 20
assert preview.endswith("…")
def test_whitespace_runs_collapsed(self):
assert build_preview("a b\t\tc") == "a b c"
class TestStashEntry:
def test_as_dict_shape_matches_panel_renderer(self, stash):
stash.stash("draft text")
row = stash.panel_rows()[0]
# _render_stash_panel indexes these exact keys.
assert set(row) >= {"text", "images", "stashed_at", "preview"}
assert row["text"] == "draft text"
assert row["preview"] == "draft text"
def test_as_dict_copies_images(self):
imgs = ["/tmp/a.png"]
entry = StashEntry(text="t", images=imgs)
entry.as_dict()["images"].append("/tmp/b.png")
assert imgs == ["/tmp/a.png"]
def test_stashed_at_uses_injected_clock(self):
clock = _FakeClock(start=500.0)
s = PromptStash(clock=clock)
s.stash("first")
clock.advance(60)
s.stash("second")
entries = s.items
assert entries[0].stashed_at == 560.0 # newest first
assert entries[1].stashed_at == 500.0
# --------------------------------------------------------------- panel browsing
class TestPanelBrowsing:
@pytest.fixture
def three(self):
s = PromptStash(clock=_FakeClock())
s.stash("oldest")
s.stash("middle")
s.stash("newest")
s.open_panel()
return s
def test_open_panel_starts_at_top(self, three):
assert three.panel_open is True
assert three.panel_cursor == 0
def test_cursor_moves_and_clamps(self, three):
assert three.move_cursor(1) == 1
assert three.move_cursor(1) == 2
# Clamped at the bottom — no wraparound, no IndexError.
assert three.move_cursor(1) == 2
assert three.move_cursor(-5) == 0
def test_restore_at_cursor_picks_highlighted_entry(self, three):
three.move_cursor(1) # "middle"
result = three.restore_at_cursor()
assert result == ("middle", [])
assert three.panel_open is False
assert [e.text for e in three.items] == ["newest", "oldest"]
def test_delete_at_cursor_removes_only_that_entry(self, three):
three.move_cursor(1)
assert three.delete_at_cursor() is True
assert [e.text for e in three.items] == ["newest", "oldest"]
assert three.panel_open is True
def test_delete_last_row_reclamps_cursor(self, three):
three.move_cursor(2) # bottom row
three.delete_at_cursor()
assert three.panel_cursor == 1 # clamped into the shortened list
def test_deleting_everything_closes_panel(self, three):
for _ in range(3):
three.delete_at_cursor()
assert len(three) == 0
assert three.panel_open is False
assert three.panel_cursor == 0
def test_new_stash_closes_open_panel(self, three):
three.stash("brand new")
assert three.panel_open is False
assert three.panel_cursor == 0
def test_panel_rows_ordered_newest_first(self, three):
assert [r["text"] for r in three.panel_rows()] == [
"newest",
"middle",
"oldest",
]
def test_pop_out_of_range_is_none(self, three):
assert three.pop(99) is None
assert three.pop(-1) is None
assert len(three) == 3
# ------------------------------------------------------ resolve_ctrl_s table
class TestResolveCtrlS:
def test_empty_buffer_empty_stash_is_noop(self, stash):
assert resolve_ctrl_s(stash, "") == (ACTION_NOOP, None)
def test_whitespace_buffer_empty_stash_is_noop(self, stash):
assert resolve_ctrl_s(stash, " \n ") == (ACTION_NOOP, None)
def test_content_stashes(self, stash):
action, payload = resolve_ctrl_s(stash, "some draft")
assert (action, payload) == (ACTION_STASHED, None)
assert len(stash) == 1
def test_open_panel_then_ctrl_s_closes_it(self, stash):
stash.stash("a")
stash.stash("b")
stash.open_panel()
action, payload = resolve_ctrl_s(stash, "")
assert (action, payload) == (ACTION_CLOSE_PANEL, None)
assert stash.panel_open is False
def test_close_panel_takes_priority_over_stashing(self, stash):
"""With the panel open, Ctrl+S closes it rather than stashing text."""
stash.stash("a")
stash.stash("b")
stash.open_panel()
action, _ = resolve_ctrl_s(stash, "text the user typed")
assert action == ACTION_CLOSE_PANEL
assert len(stash) == 2 # nothing new pushed
def test_images_only_buffer_stashes(self, stash):
action, _ = resolve_ctrl_s(stash, "", ["/tmp/x.png"])
assert action == ACTION_STASHED
def test_whitespace_only_buffer_with_stash_restores(self, stash):
"""Whitespace-only counts as empty, so the restore half fires."""
stash.stash("real draft")
action, payload = resolve_ctrl_s(stash, " ")
assert action == ACTION_RESTORED
assert payload == ("real draft", [])
def test_stash_pop_stash_pop_cycle(self, stash):
for text in ("one", "two\nlines", "three\n\nparas"):
assert resolve_ctrl_s(stash, text)[0] == ACTION_STASHED
action, payload = resolve_ctrl_s(stash, "")
assert action == ACTION_RESTORED
assert payload is not None
assert payload[0] == text
assert len(stash) == 0
# --------------------------------------------------------------------- ages
class TestAgeFormatting:
def test_age_reflects_injected_clock(self):
clock = _FakeClock()
s = PromptStash(clock=clock)
s.stash("draft")
entry = s.peek()
assert entry is not None
clock.advance(120)
assert clock() - entry.stashed_at == 120
+289
View File
@@ -0,0 +1,289 @@
"""Tests for the Ctrl+S prompt stash wiring inside HermesCLI.
The state machine itself is covered by tests/cli/test_prompt_stash.py. These
tests verify the cli.py side:
- HermesCLI.__init__ creates a PromptStash
- the layout hook makes room for the stash browse panel
- _render_stash_panel renders bounded, display-width-correct rows
- the status-bar indicator appears / disappears with stash contents
Follows the prompt_toolkit-stub construction pattern from
tests/cli/test_cli_extension_hooks.py.
"""
from __future__ import annotations
import importlib
import sys
from unittest.mock import MagicMock, patch
import pytest
def _make_cli(**kwargs):
"""Create a HermesCLI with prompt_toolkit stubbed out."""
_clean_config = {
"model": {
"default": "anthropic/claude-opus-4.6",
"base_url": "https://openrouter.ai/api/v1",
"provider": "auto",
},
"display": {"compact": False, "tool_progress": "all"},
"agent": {},
"terminal": {"env_type": "local"},
}
clean_env = {"LLM_MODEL": "", "HERMES_MAX_ITERATIONS": ""}
prompt_toolkit_stubs = {
"prompt_toolkit": MagicMock(),
"prompt_toolkit.history": MagicMock(),
"prompt_toolkit.styles": MagicMock(),
"prompt_toolkit.patch_stdout": MagicMock(),
"prompt_toolkit.application": MagicMock(),
"prompt_toolkit.layout": MagicMock(),
"prompt_toolkit.layout.processors": MagicMock(),
"prompt_toolkit.filters": MagicMock(),
"prompt_toolkit.layout.dimension": MagicMock(),
"prompt_toolkit.layout.menus": MagicMock(),
"prompt_toolkit.widgets": MagicMock(),
"prompt_toolkit.key_binding": MagicMock(),
"prompt_toolkit.completion": MagicMock(),
"prompt_toolkit.formatted_text": MagicMock(),
"prompt_toolkit.auto_suggest": MagicMock(),
}
with patch.dict(sys.modules, prompt_toolkit_stubs), patch.dict(
"os.environ", clean_env, clear=False
):
import cli as _cli_mod
_cli_mod = importlib.reload(_cli_mod)
with patch.object(_cli_mod, "get_tool_definitions", return_value=[]), patch.dict(
_cli_mod.__dict__, {"CLI_CONFIG": _clean_config}
):
return _cli_mod.HermesCLI(**kwargs)
@pytest.fixture(scope="module")
def cli():
return _make_cli()
class TestStashStateInit:
def test_cli_has_prompt_stash(self, cli):
assert hasattr(cli, "_prompt_stash")
def test_stash_starts_empty(self, cli):
# Duck-typed rather than isinstance: _make_cli reloads the `cli`
# module, which re-imports hermes_cli.prompt_toolkit stubs and can
# yield a distinct-but-equivalent PromptStash class object.
stash = cli._prompt_stash
assert type(stash).__name__ == "PromptStash"
assert len(stash) == 0
assert stash.panel_open is False
assert stash.indicator() == ""
assert stash.placeholder_hint() == ""
def test_stash_is_per_instance_not_shared(self):
"""Two CLIs must not share one stash — drafts would leak across sessions."""
a = _make_cli()
b = _make_cli()
a._prompt_stash.stash("only in a")
assert len(a._prompt_stash) == 1
assert len(b._prompt_stash) == 0
class TestKeybindingRegistration:
"""Behavioral coverage for the stash keybinding surface.
NOTE: the Ctrl+S / panel-navigation handlers are registered inside
``HermesCLI.run()``'s local ``KeyBindings`` instance, so they are not
importable without launching the TUI. Asserting on cli.py's SOURCE TEXT
to prove they exist is the banned change-detector antipattern (root
AGENTS.md, "Never read source code in tests"): it passes when the
handler exists but is wired wrong, and fails on a correct rename.
The regression that broke PR #4771 (a rebase silently dropping the
keybinding) is instead guarded where the behavior actually lives — the
stash state machine below and in test_prompt_stash.py, which every
handler delegates to. Extracting run()'s bindings into a standalone
registrar would make direct handler tests possible; that refactor is
deliberately out of scope for this salvage.
"""
def test_extension_hook_still_a_noop(self, cli):
"""The stash binding lives in run(), not in the wrapper extension hook."""
from prompt_toolkit.key_binding import KeyBindings
kb = KeyBindings()
assert cli._register_extra_tui_keybindings(kb, input_area=None) is None
assert kb.bindings == []
class TestLayoutSlot:
def test_layout_includes_stash_panel_when_present(self, cli):
cli._stash_panel_widget = "stash-panel"
try:
children = cli._build_tui_layout_children(
sudo_widget="sudo",
secret_widget="secret",
approval_widget="approval",
clarify_widget="clarify",
spinner_widget="spinner",
spacer="spacer",
status_bar="status",
input_rule_top="top-rule",
image_bar="image-bar",
input_area="input-area",
input_rule_bot="bottom-rule",
voice_status_bar="voice-status",
completions_menu="completions-menu",
)
assert "stash-panel" in children
# Panel sits directly above the status bar.
assert children.index("stash-panel") < children.index("status")
finally:
cli._stash_panel_widget = None
def test_layout_omits_stash_panel_when_absent(self, cli):
cli._stash_panel_widget = None
children = cli._build_tui_layout_children(
sudo_widget="sudo",
secret_widget="secret",
approval_widget="approval",
clarify_widget="clarify",
spinner_widget="spinner",
spacer="spacer",
status_bar="status",
input_rule_top="top-rule",
image_bar="image-bar",
input_area="input-area",
input_rule_bot="bottom-rule",
voice_status_bar="voice-status",
completions_menu="completions-menu",
)
assert None not in children
class TestRenderStashPanel:
"""Contributor's panel renderer, now measured in display cells."""
@staticmethod
def _rows(cli, count=3, width=100):
stash = type(cli._prompt_stash)()
for i in range(count):
stash.stash(f"draft number {i}")
return cli._render_stash_panel(stash.panel_rows(), 0, width)
def test_returns_fragments(self, cli):
frags = self._rows(cli)
assert frags
assert all(isinstance(f, tuple) and len(f) == 2 for f in frags)
def test_header_and_footer_present(self, cli):
text = "".join(t for _, t in self._rows(cli))
assert "📌 Stash" in text
assert "Ctrl+S" in text
assert "Enter=restore" in text
assert "D=delete" in text
def test_row_per_entry(self, cli):
text = "".join(t for _, t in self._rows(cli, count=3))
for i in range(3):
assert f"[{i + 1}]" in text
def test_singular_plural_item_label(self, cli):
one = "".join(t for _, t in self._rows(cli, count=1))
assert "(1 item)" in one
two = "".join(t for _, t in self._rows(cli, count=2))
assert "(2 items)" in two
@pytest.mark.parametrize("width", [16, 20, 30, 40, 60, 80, 120, 400])
def test_no_line_exceeds_terminal_width(self, cli, width):
"""Rows must never bleed past the terminal — the bug the PR's three
follow-up commits kept failing to fix by tweaking len()."""
from prompt_toolkit.utils import get_cwidth
text = "".join(t for _, t in self._rows(cli, count=3, width=width))
for line in text.split("\n"):
if line:
assert get_cwidth(line) <= max(width, 12), (
f"line {line!r} is {get_cwidth(line)} cells, width={width}"
)
def test_multiline_draft_renders_as_one_row(self, cli):
stash = type(cli._prompt_stash)()
stash.stash("first line\nsecond line\nthird line")
frags = cli._render_stash_panel(stash.panel_rows(), 0, 100)
text = "".join(t for _, t in frags)
# header + 1 entry row + footer = 3 rendered lines
assert len([ln for ln in text.split("\n") if ln]) == 3
def test_wide_glyph_preview_does_not_overflow(self, cli):
"""CJK previews are 2 cells per char — must still fit the box."""
from prompt_toolkit.utils import get_cwidth
stash = type(cli._prompt_stash)()
stash.stash("中文" * 80)
frags = cli._render_stash_panel(stash.panel_rows(), 0, 60)
for line in "".join(t for _, t in frags).split("\n"):
if line:
assert get_cwidth(line) <= 60
def test_cursor_row_is_styled_differently(self, cli):
stash = type(cli._prompt_stash)()
stash.stash("a")
stash.stash("b")
styles = {s for s, _ in cli._render_stash_panel(stash.panel_rows(), 1, 100)}
assert "class:subagent-selected" in styles
def test_empty_list_still_renders_frame(self, cli):
frags = cli._render_stash_panel([], 0, 80)
text = "".join(t for _, t in frags)
assert "(0 items)" in text
class TestStatusBarIndicator:
def test_no_indicator_when_stash_empty(self, cli):
cli._prompt_stash.clear()
cli._status_bar_visible = True
text = "".join(t for _, t in cli._get_status_bar_fragments())
assert "📌" not in text
def test_indicator_appears_after_stashing(self, cli):
cli._prompt_stash.clear()
cli._status_bar_visible = True
cli._prompt_stash.stash("a parked draft")
try:
text = "".join(t for _, t in cli._get_status_bar_fragments())
assert "📌 1" in text
finally:
cli._prompt_stash.clear()
def test_indicator_count_tracks_stash_size(self, cli):
cli._prompt_stash.clear()
cli._status_bar_visible = True
cli._prompt_stash.stash("a")
cli._prompt_stash.stash("b")
try:
text = "".join(t for _, t in cli._get_status_bar_fragments())
assert "📌 2" in text
finally:
cli._prompt_stash.clear()
def test_indicator_clears_after_restore(self, cli):
cli._prompt_stash.clear()
cli._status_bar_visible = True
cli._prompt_stash.stash("a")
cli._prompt_stash.pop()
text = "".join(t for _, t in cli._get_status_bar_fragments())
assert "📌" not in text
class TestFmtStashAge:
def test_age_buckets(self, cli):
import time
now = time.monotonic()
assert cli._fmt_stash_age(now) == "just now"
assert cli._fmt_stash_age(now - 30).endswith("s ago")
assert "min ago" in cli._fmt_stash_age(now - 300)
assert cli._fmt_stash_age(now - 7200).endswith("h ago")
@@ -0,0 +1,99 @@
"""Tests for ``HermesCLI._prompt_text_input`` thread-safe input dispatch.
Raw ``input()`` prompts can race with prompt_toolkit when called from the TUI.
The normal slash confirmations now use a prompt_toolkit-native modal, but
``_prompt_text_input`` remains as a fallback for non-interactive calls and edge
cases.
"""
import threading
from unittest.mock import MagicMock, patch
def _make_cli():
"""Minimal HermesCLI shell exposing prompt fallback helpers."""
import cli as cli_mod
obj = object.__new__(cli_mod.HermesCLI)
obj._app = MagicMock()
obj._status_bar_visible = True
return obj
class TestPromptTextInputThreadSafety:
def test_main_thread_uses_run_in_terminal(self):
"""On the main thread with an active app, route through run_in_terminal."""
cli = _make_cli()
with patch("prompt_toolkit.application.run_in_terminal") as mock_rit, \
patch("builtins.input", return_value="2"):
cli._prompt_text_input("Choice: ")
# run_in_terminal was invoked; the _ask closure passed to it would
# call input() when driven by the event loop. We assert dispatch path,
# not the orphaned-coroutine result.
assert mock_rit.called
def test_background_thread_cancels_instead_of_hanging(self):
"""On a daemon thread with an active app, cancel cleanly (return None).
stdin is owned by the prompt_toolkit event loop / JSON-RPC pipe on the
non-main (process_loop / slash-worker) thread, so a bare input() there
would block until the worker's timeout (#23185 / billing auto-reload
hang). The guard cancels to None instead of hanging — it must NOT call
run_in_terminal (orphaned coroutine) and must NOT call input().
"""
cli = _make_cli()
result_holder = {}
def run_on_daemon():
with patch("prompt_toolkit.application.run_in_terminal") as mock_rit, \
patch("builtins.input", side_effect=AssertionError("input() must not be called off-main-thread")) as mock_input:
result_holder["value"] = cli._prompt_text_input("Choice [1/2/3]: ")
result_holder["rit_called"] = mock_rit.called
result_holder["input_called"] = mock_input.called
t = threading.Thread(target=run_on_daemon, daemon=True)
t.start()
t.join(timeout=2.0)
assert not t.is_alive(), "daemon thread hung — guard did not cancel cleanly"
# Cancelled cleanly: None returned, neither run_in_terminal nor input() called.
assert result_holder["value"] is None
assert result_holder["rit_called"] is False
assert result_holder["input_called"] is False
def test_no_app_uses_direct_input(self):
"""Without an active prompt_toolkit app, always call input() directly."""
cli = _make_cli()
cli._app = None
with patch("builtins.input", return_value="cancel") as mock_input:
result = cli._prompt_text_input("Choice: ")
assert mock_input.called
assert result == "cancel"
def test_run_in_terminal_exception_falls_back(self):
"""If run_in_terminal raises (WSL / Warp edge cases), fall back to input()."""
cli = _make_cli()
with patch(
"prompt_toolkit.application.run_in_terminal",
side_effect=RuntimeError("event loop dropped the coroutine"),
), patch("builtins.input", return_value="3") as mock_input:
result = cli._prompt_text_input("Choice: ")
assert mock_input.called
assert result == "3"
def test_eof_returns_none(self):
"""EOFError from input() yields None, not an unhandled exception."""
cli = _make_cli()
cli._app = None
with patch("builtins.input", side_effect=EOFError()):
result = cli._prompt_text_input("Choice: ")
assert result is None
+177
View File
@@ -0,0 +1,177 @@
"""Tests for user-defined quick commands that bypass the agent loop."""
import os
import subprocess
from unittest.mock import MagicMock, patch
from rich.text import Text
import pytest
# ── CLI tests ──────────────────────────────────────────────────────────────
class TestCLIQuickCommands:
"""Test quick command dispatch in HermesCLI.process_command."""
@staticmethod
def _printed_plain(call_arg):
if isinstance(call_arg, Text):
return call_arg.plain
return str(call_arg)
def _make_cli(self, quick_commands):
from cli import HermesCLI
cli = HermesCLI.__new__(HermesCLI)
cli.config = {"quick_commands": quick_commands}
cli.console = MagicMock()
cli.agent = None
cli.conversation_history = []
# session_id is accessed by the fallback skill/fuzzy-match path in
# process_command; without it, tests that exercise `/alias args`
# can trip an AttributeError when cross-test state leaks a skill
# command matching the alias target.
cli.session_id = "test-session"
return cli
def test_exec_command_runs_and_prints_output(self):
cli = self._make_cli({"dn": {"type": "exec", "command": "echo daily-note"}})
result = cli.process_command("/dn")
assert result is True
cli.console.print.assert_called_once()
printed = self._printed_plain(cli.console.print.call_args[0][0])
assert printed == "daily-note"
def test_exec_command_uses_chat_console_when_tui_is_live(self):
cli = self._make_cli({"dn": {"type": "exec", "command": "echo daily-note"}})
cli._app = object()
live_console = MagicMock()
with patch("cli.ChatConsole", return_value=live_console):
result = cli.process_command("/dn")
assert result is True
live_console.print.assert_called_once()
printed = self._printed_plain(live_console.print.call_args[0][0])
assert printed == "daily-note"
cli.console.print.assert_not_called()
def test_quick_command_takes_priority_over_skill_commands(self):
"""Quick commands must be checked before skill slash commands."""
cli = self._make_cli({"mygif": {"type": "exec", "command": "echo overridden"}})
with patch("cli._skill_commands", {"/mygif": {"name": "gif-search"}}):
cli.process_command("/mygif")
cli.console.print.assert_called_once()
printed = self._printed_plain(cli.console.print.call_args[0][0])
assert printed == "overridden"
# ── Gateway tests ──────────────────────────────────────────────────────────
class TestGatewayQuickCommands:
"""Test quick command dispatch in GatewayRunner._handle_message."""
def _make_event(self, command, args=""):
event = MagicMock()
event.get_command.return_value = command
event.get_command_args.return_value = args
event.text = f"/{command} {args}".strip()
event.source = MagicMock()
event.source.user_id = "test_user"
event.source.user_name = "Test User"
event.source.platform.value = "telegram"
event.source.chat_type = "dm"
event.source.chat_id = "123"
return event
@pytest.mark.asyncio
async def test_exec_command_returns_output(self):
from gateway.run import GatewayRunner
runner = GatewayRunner.__new__(GatewayRunner)
runner.config = {"quick_commands": {"limits": {"type": "exec", "command": "echo ok"}}}
runner._running_agents = {}
runner._pending_messages = {}
runner._is_user_authorized = MagicMock(return_value=True)
event = self._make_event("limits")
result = await runner._handle_message(event)
assert result == "ok"
@pytest.mark.asyncio
async def test_exec_command_does_not_leak_credentials(self):
"""Quick command exec must sanitize env — API keys must not appear in output."""
from gateway.run import GatewayRunner
runner = GatewayRunner.__new__(GatewayRunner)
runner.config = {"quick_commands": {"leak": {"type": "exec", "command": "env"}}}
runner._running_agents = {}
runner._pending_messages = {}
runner._is_user_authorized = MagicMock(return_value=True)
event = self._make_event("leak")
with patch.dict(os.environ, {"OPENROUTER_API_KEY": "sk-or-secret-12345"}):
result = await runner._handle_message(event)
assert "sk-or-secret-12345" not in result, \
"Quick command leaked OPENROUTER_API_KEY — exec runs without env sanitization"
@pytest.mark.asyncio
async def test_exec_command_output_is_redacted(self, monkeypatch):
"""Quick command output must redact sensitive patterns before returning."""
from gateway.run import GatewayRunner
# Ensure redaction is active regardless of host HERMES_REDACT_SECRETS state
# or test ordering
monkeypatch.setattr("agent.redact._REDACT_ENABLED", True)
runner = GatewayRunner.__new__(GatewayRunner)
runner.config = {"quick_commands": {"token": {"type": "exec", "command": "echo sk-ant-api03-supersecretkey1234567890"}}}
runner._running_agents = {}
runner._pending_messages = {}
runner._is_user_authorized = MagicMock(return_value=True)
event = self._make_event("token")
result = await runner._handle_message(event)
assert "supersecretkey1234567890" not in result, \
"Quick command output not redacted — raw API key returned to user"
@pytest.mark.asyncio
async def test_timeout_returns_error(self):
from gateway.run import GatewayRunner
import asyncio
runner = GatewayRunner.__new__(GatewayRunner)
runner.config = {"quick_commands": {"slow": {"type": "exec", "command": "sleep 100"}}}
runner._running_agents = {}
runner._pending_messages = {}
runner._is_user_authorized = MagicMock(return_value=True)
event = self._make_event("slow")
with patch("asyncio.wait_for", side_effect=asyncio.TimeoutError):
result = await runner._handle_message(event)
assert result is not None
assert "timed out" in result.lower()
@pytest.mark.asyncio
async def test_gateway_config_object_supports_quick_commands(self):
from gateway.config import GatewayConfig
from gateway.run import GatewayRunner
runner = GatewayRunner.__new__(GatewayRunner)
runner.config = GatewayConfig(
quick_commands={"limits": {"type": "exec", "command": "echo ok"}}
)
runner._running_agents = {}
runner._pending_messages = {}
runner._is_user_authorized = MagicMock(return_value=True)
event = self._make_event("limits")
result = await runner._handle_message(event)
assert result == "ok"
+62
View File
@@ -0,0 +1,62 @@
"""``hermes chat -Q`` passes the dispatcher's HERMES_TURN_AUTHOR to ``run_conversation`` as ``turn_author``.
A bot-to-bot delivery runs the recipient's turn as a ``-Q`` subprocess with that variable set.
A human's ``-Q`` run has it unset and the turn stays unattributed.
"""
from __future__ import annotations
import json
import os
from types import SimpleNamespace
import pytest
import cli
from agent.turn_author import TURN_AUTHOR_ENV
AUTHOR = {"id": "bot:coder", "name": "coder", "is_bot": True}
@pytest.fixture(autouse=True)
def _plain_one_shot_env(monkeypatch):
monkeypatch.delenv("HERMES_KANBAN_GOAL_MODE", raising=False)
monkeypatch.delenv("HERMES_KANBAN_TASK", raising=False)
def _run(monkeypatch, env_value, run_conversation=None):
"""One quiet turn with HERMES_TURN_AUTHOR set to ``env_value`` (unset for None); returns the recorded call kwargs."""
if env_value is None:
monkeypatch.delenv(TURN_AUTHOR_ENV, raising=False)
else:
monkeypatch.setenv(TURN_AUTHOR_ENV, env_value)
recorded = []
def record(**kwargs):
recorded.append(kwargs)
return {"final_response": "ok"}
agent = SimpleNamespace(run_conversation=run_conversation or record, session_id="s-1")
with pytest.raises(SystemExit) as exc:
cli._run_quiet_single_query(SimpleNamespace(agent=agent, conversation_history=[], session_id="s-1"), "hello")
assert exc.value.code == 0
return recorded
def test_quiet_one_shot_passes_turn_author_from_env(monkeypatch, capsys):
recorded = _run(monkeypatch, json.dumps(AUTHOR))
assert recorded == [{"user_message": "hello", "conversation_history": [], "turn_author": AUTHOR}]
assert capsys.readouterr().out.strip() == "ok"
def test_quiet_one_shot_consumes_the_variable_before_the_turn(monkeypatch):
"""Tool subprocesses spawned during the turn must not see the dispatcher's author."""
seen = {}
def run_conversation(**kwargs):
seen["env"] = os.environ.get(TURN_AUTHOR_ENV)
return {"final_response": "ok"}
_run(monkeypatch, json.dumps(AUTHOR), run_conversation)
assert seen["env"] is None
assert TURN_AUTHOR_ENV not in os.environ

Some files were not shown because too many files have changed in this diff Show More