chore: anchor fresh-start history to upstream
This commit is contained in:
@@ -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()
|
||||
@@ -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"]
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
)
|
||||
@@ -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",
|
||||
}
|
||||
)
|
||||
@@ -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"
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -0,0 +1,138 @@
|
||||
"""Tests for protected HermesCLI TUI extension hooks.
|
||||
|
||||
Verifies that wrapper CLIs can extend the TUI via:
|
||||
- _get_extra_tui_widgets()
|
||||
- _register_extra_tui_keybindings()
|
||||
- _build_tui_layout_children()
|
||||
without overriding run().
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import sys
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from prompt_toolkit.key_binding import KeyBindings
|
||||
|
||||
|
||||
def _make_cli(**kwargs):
|
||||
"""Create a HermesCLI with prompt_toolkit stubs (same pattern as test_cli_init)."""
|
||||
_clean_config = {
|
||||
"model": {
|
||||
"default": "anthropic/claude-opus-4.6",
|
||||
"base_url": "https://openrouter.ai/api/v1",
|
||||
"provider": "auto",
|
||||
},
|
||||
"display": {"compact": False, "tool_progress": "all"},
|
||||
"agent": {},
|
||||
"terminal": {"env_type": "local"},
|
||||
}
|
||||
clean_env = {"LLM_MODEL": "", "HERMES_MAX_ITERATIONS": ""}
|
||||
prompt_toolkit_stubs = {
|
||||
"prompt_toolkit": MagicMock(),
|
||||
"prompt_toolkit.history": MagicMock(),
|
||||
"prompt_toolkit.styles": MagicMock(),
|
||||
"prompt_toolkit.patch_stdout": MagicMock(),
|
||||
"prompt_toolkit.application": MagicMock(),
|
||||
"prompt_toolkit.layout": MagicMock(),
|
||||
"prompt_toolkit.layout.processors": MagicMock(),
|
||||
"prompt_toolkit.filters": MagicMock(),
|
||||
"prompt_toolkit.layout.dimension": MagicMock(),
|
||||
"prompt_toolkit.layout.menus": MagicMock(),
|
||||
"prompt_toolkit.widgets": MagicMock(),
|
||||
"prompt_toolkit.key_binding": MagicMock(),
|
||||
"prompt_toolkit.completion": MagicMock(),
|
||||
"prompt_toolkit.formatted_text": MagicMock(),
|
||||
"prompt_toolkit.auto_suggest": MagicMock(),
|
||||
}
|
||||
with patch.dict(sys.modules, prompt_toolkit_stubs), patch.dict(
|
||||
"os.environ", clean_env, clear=False
|
||||
):
|
||||
import cli as _cli_mod
|
||||
|
||||
_cli_mod = importlib.reload(_cli_mod)
|
||||
with patch.object(_cli_mod, "get_tool_definitions", return_value=[]), patch.dict(
|
||||
_cli_mod.__dict__, {"CLI_CONFIG": _clean_config}
|
||||
):
|
||||
return _cli_mod.HermesCLI(**kwargs)
|
||||
|
||||
|
||||
class TestExtensionHookDefaults:
|
||||
def test_extra_tui_widgets_default_empty(self):
|
||||
cli = _make_cli()
|
||||
assert cli._get_extra_tui_widgets() == []
|
||||
|
||||
def test_register_extra_tui_keybindings_default_noop(self):
|
||||
cli = _make_cli()
|
||||
kb = KeyBindings()
|
||||
result = cli._register_extra_tui_keybindings(kb, input_area=None)
|
||||
assert result is None
|
||||
assert kb.bindings == []
|
||||
|
||||
def test_build_tui_layout_children_returns_all_widgets_in_order(self):
|
||||
cli = _make_cli()
|
||||
children = cli._build_tui_layout_children(
|
||||
sudo_widget="sudo",
|
||||
secret_widget="secret",
|
||||
approval_widget="approval",
|
||||
clarify_widget="clarify",
|
||||
spinner_widget="spinner",
|
||||
spacer="spacer",
|
||||
status_bar="status",
|
||||
input_rule_top="top-rule",
|
||||
image_bar="image-bar",
|
||||
input_area="input-area",
|
||||
input_rule_bot="bottom-rule",
|
||||
voice_status_bar="voice-status",
|
||||
completions_menu="completions-menu",
|
||||
)
|
||||
# First element is Window(height=0), rest are the named widgets
|
||||
assert children[1:] == [
|
||||
"sudo", "secret", "approval", "clarify", "spinner",
|
||||
"spacer", "status", "top-rule", "image-bar", "input-area",
|
||||
"bottom-rule", "voice-status", "completions-menu",
|
||||
]
|
||||
|
||||
|
||||
class TestExtensionHookSubclass:
|
||||
def test_extra_widgets_inserted_before_status_bar(self):
|
||||
cli = _make_cli()
|
||||
# Monkey-patch to simulate subclass override
|
||||
cli._get_extra_tui_widgets = lambda: ["radio-menu", "mini-player"]
|
||||
|
||||
children = cli._build_tui_layout_children(
|
||||
sudo_widget="sudo",
|
||||
secret_widget="secret",
|
||||
approval_widget="approval",
|
||||
clarify_widget="clarify",
|
||||
spinner_widget="spinner",
|
||||
spacer="spacer",
|
||||
status_bar="status",
|
||||
input_rule_top="top-rule",
|
||||
image_bar="image-bar",
|
||||
input_area="input-area",
|
||||
input_rule_bot="bottom-rule",
|
||||
voice_status_bar="voice-status",
|
||||
completions_menu="completions-menu",
|
||||
)
|
||||
# Extra widgets should appear between spacer and status bar
|
||||
spacer_idx = children.index("spacer")
|
||||
status_idx = children.index("status")
|
||||
assert children[spacer_idx + 1] == "radio-menu"
|
||||
assert children[spacer_idx + 2] == "mini-player"
|
||||
assert children[spacer_idx + 3] == "status"
|
||||
assert status_idx == spacer_idx + 3
|
||||
|
||||
def test_extra_keybindings_can_add_bindings(self):
|
||||
cli = _make_cli()
|
||||
kb = KeyBindings()
|
||||
|
||||
def _custom_hook(kb, *, input_area):
|
||||
@kb.add("f2")
|
||||
def _toggle(event):
|
||||
return None
|
||||
|
||||
cli._register_extra_tui_keybindings = _custom_hook
|
||||
cli._register_extra_tui_keybindings(kb, input_area=None)
|
||||
assert len(kb.bindings) == 1
|
||||
@@ -0,0 +1,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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
@@ -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."
|
||||
)
|
||||
@@ -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) == ""
|
||||
@@ -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()
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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''"
|
||||
@@ -0,0 +1,72 @@
|
||||
"""Regression tests for loading feedback on slow slash commands."""
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
from cli import HermesCLI
|
||||
|
||||
|
||||
class TestCLILoadingIndicator:
|
||||
def _make_cli(self):
|
||||
cli_obj = HermesCLI.__new__(HermesCLI)
|
||||
cli_obj._app = None
|
||||
cli_obj._last_invalidate = 0.0
|
||||
cli_obj._command_running = False
|
||||
cli_obj._command_status = ""
|
||||
return cli_obj
|
||||
|
||||
def test_skills_command_sets_busy_state_and_prints_status(self, capsys):
|
||||
cli_obj = self._make_cli()
|
||||
seen = {}
|
||||
|
||||
def fake_handle(cmd: str):
|
||||
seen["cmd"] = cmd
|
||||
seen["running"] = cli_obj._command_running
|
||||
seen["status"] = cli_obj._command_status
|
||||
print("skills done")
|
||||
|
||||
with patch.object(cli_obj, "_handle_skills_command", side_effect=fake_handle), \
|
||||
patch.object(cli_obj, "_invalidate") as invalidate_mock:
|
||||
assert cli_obj.process_command("/skills search kubernetes")
|
||||
|
||||
output = capsys.readouterr().out
|
||||
assert "⏳ Searching skills..." in output
|
||||
assert "skills done" in output
|
||||
assert seen == {
|
||||
"cmd": "/skills search kubernetes",
|
||||
"running": True,
|
||||
"status": "Searching skills...",
|
||||
}
|
||||
assert cli_obj._command_running is False
|
||||
assert cli_obj._command_status == ""
|
||||
assert invalidate_mock.call_count == 2
|
||||
|
||||
def test_reload_mcp_sets_busy_state_and_prints_status(self, capsys):
|
||||
cli_obj = self._make_cli()
|
||||
seen = {}
|
||||
|
||||
def fake_reload():
|
||||
seen["running"] = cli_obj._command_running
|
||||
seen["status"] = cli_obj._command_status
|
||||
print("reload done")
|
||||
|
||||
# /reload-mcp now wraps the actual reload in a prompt-cache-invalidation
|
||||
# confirmation prompt (commit 4d7fc0f37). This test exercises the
|
||||
# loading-indicator path, not the confirmation UX, so pre-approve the
|
||||
# reload via config so the handler goes straight into _reload_mcp().
|
||||
fake_cfg = {"approvals": {"mcp_reload_confirm": False}}
|
||||
|
||||
with patch.object(cli_obj, "_reload_mcp", side_effect=fake_reload), \
|
||||
patch.object(cli_obj, "_invalidate") as invalidate_mock, \
|
||||
patch("cli.load_cli_config", return_value=fake_cfg):
|
||||
assert cli_obj.process_command("/reload-mcp")
|
||||
|
||||
output = capsys.readouterr().out
|
||||
assert "⏳ Reloading MCP servers..." in output
|
||||
assert "reload done" in output
|
||||
assert seen == {
|
||||
"running": True,
|
||||
"status": "Reloading MCP servers...",
|
||||
}
|
||||
assert cli_obj._command_running is False
|
||||
assert cli_obj._command_status == ""
|
||||
assert invalidate_mock.call_count == 2
|
||||
@@ -0,0 +1,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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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")
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,99 @@
|
||||
"""Tests for the ``/reload-skills`` CLI slash command (``HermesCLI._reload_skills``).
|
||||
|
||||
The CLI handler prints the diff (name + description) for the user and —
|
||||
when any skills were added or removed — queues a one-shot note on
|
||||
``self._pending_skills_reload_note``. The note is prepended to the NEXT
|
||||
user message (see cli.py ~L8770, same pattern as
|
||||
``_pending_model_switch_note``) and cleared after use, so no phantom user
|
||||
turn is persisted to ``conversation_history``.
|
||||
"""
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
|
||||
def _make_cli():
|
||||
"""Build a minimal HermesCLI shell exposing ``_reload_skills``."""
|
||||
import cli as cli_mod
|
||||
|
||||
obj = object.__new__(cli_mod.HermesCLI)
|
||||
obj._command_running = False
|
||||
obj.conversation_history = []
|
||||
obj.agent = None
|
||||
return obj
|
||||
|
||||
|
||||
class TestReloadSkillsCLI:
|
||||
def test_reports_added_and_removed_and_queues_note(self, capsys):
|
||||
cli = _make_cli()
|
||||
with patch(
|
||||
"agent.skill_commands.reload_skills",
|
||||
return_value={
|
||||
"added": [
|
||||
{"name": "alpha", "description": "Run alpha to do xyz"},
|
||||
{"name": "beta", "description": "Run beta to do abc"},
|
||||
],
|
||||
"removed": [
|
||||
{"name": "gamma", "description": "Old removed skill"},
|
||||
],
|
||||
"unchanged": ["delta"],
|
||||
"total": 3,
|
||||
"commands": 3,
|
||||
},
|
||||
):
|
||||
cli._reload_skills()
|
||||
|
||||
out = capsys.readouterr().out
|
||||
assert "Added Skills:" in out
|
||||
assert "- alpha: Run alpha to do xyz" in out
|
||||
assert "- beta: Run beta to do abc" in out
|
||||
assert "Removed Skills:" in out
|
||||
assert "- gamma: Old removed skill" in out
|
||||
assert "3 skill(s) available" in out
|
||||
|
||||
# Must NOT pollute conversation_history — alternation-safe.
|
||||
assert cli.conversation_history == []
|
||||
|
||||
# One-shot note queued with system-prompt-style formatting.
|
||||
note = getattr(cli, "_pending_skills_reload_note", None)
|
||||
assert note is not None
|
||||
assert note.startswith("[USER INITIATED SKILLS RELOAD:")
|
||||
assert note.endswith("Use skills_list to see the updated catalog.]")
|
||||
assert "Added Skills:" in note
|
||||
assert " - alpha: Run alpha to do xyz" in note
|
||||
assert " - beta: Run beta to do abc" in note
|
||||
assert "Removed Skills:" in note
|
||||
assert " - gamma: Old removed skill" in note
|
||||
|
||||
def test_reports_no_changes_and_queues_nothing(self, capsys):
|
||||
cli = _make_cli()
|
||||
with patch(
|
||||
"agent.skill_commands.reload_skills",
|
||||
return_value={
|
||||
"added": [],
|
||||
"removed": [],
|
||||
"unchanged": ["alpha"],
|
||||
"total": 1,
|
||||
"commands": 1,
|
||||
},
|
||||
):
|
||||
cli._reload_skills()
|
||||
|
||||
out = capsys.readouterr().out
|
||||
assert "No new skills detected" in out
|
||||
assert "1 skill(s) available" in out
|
||||
assert cli.conversation_history == []
|
||||
assert getattr(cli, "_pending_skills_reload_note", None) is None
|
||||
|
||||
def test_handles_reload_failure_gracefully(self, capsys):
|
||||
cli = _make_cli()
|
||||
with patch(
|
||||
"agent.skill_commands.reload_skills",
|
||||
side_effect=RuntimeError("boom"),
|
||||
):
|
||||
cli._reload_skills()
|
||||
|
||||
out = capsys.readouterr().out
|
||||
assert "Skills reload failed" in out
|
||||
assert "boom" in out
|
||||
assert cli.conversation_history == []
|
||||
assert getattr(cli, "_pending_skills_reload_note", None) is None
|
||||
@@ -0,0 +1,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()
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -0,0 +1,147 @@
|
||||
import queue
|
||||
import threading
|
||||
import time
|
||||
from unittest.mock import patch
|
||||
|
||||
import cli as cli_module
|
||||
import tools.skills_tool as skills_tool_module
|
||||
from cli import HermesCLI
|
||||
from hermes_cli.callbacks import prompt_for_secret
|
||||
from tools.skills_tool import set_secret_capture_callback
|
||||
|
||||
|
||||
class _FakeBuffer:
|
||||
def __init__(self):
|
||||
self.reset_called = False
|
||||
|
||||
def reset(self):
|
||||
self.reset_called = True
|
||||
|
||||
|
||||
class _FakeApp:
|
||||
def __init__(self):
|
||||
self.invalidated = False
|
||||
self.current_buffer = _FakeBuffer()
|
||||
|
||||
def invalidate(self):
|
||||
self.invalidated = True
|
||||
|
||||
|
||||
def _make_cli_stub(with_app=False):
|
||||
cli = HermesCLI.__new__(HermesCLI)
|
||||
cli._app = _FakeApp() if with_app else None
|
||||
cli._last_invalidate = 0.0
|
||||
cli._secret_state = None
|
||||
cli._secret_deadline = 0
|
||||
return cli
|
||||
|
||||
|
||||
def test_secret_capture_callback_can_be_completed_from_cli_state_machine():
|
||||
cli = _make_cli_stub(with_app=True)
|
||||
results = []
|
||||
|
||||
with patch("hermes_cli.callbacks.save_env_value_secure") as save_secret:
|
||||
save_secret.return_value = {
|
||||
"success": True,
|
||||
"stored_as": "TENOR_API_KEY",
|
||||
"validated": False,
|
||||
}
|
||||
|
||||
thread = threading.Thread(
|
||||
target=lambda: results.append(
|
||||
cli._secret_capture_callback("TENOR_API_KEY", "Tenor API key")
|
||||
)
|
||||
)
|
||||
thread.start()
|
||||
|
||||
deadline = time.time() + 2
|
||||
while cli._secret_state is None and time.time() < deadline:
|
||||
time.sleep(0.01)
|
||||
|
||||
assert cli._secret_state is not None
|
||||
cli._submit_secret_response("super-secret-value")
|
||||
thread.join(timeout=2)
|
||||
|
||||
assert results[0]["success"] is True
|
||||
assert results[0]["stored_as"] == "TENOR_API_KEY"
|
||||
assert results[0]["skipped"] is False
|
||||
|
||||
|
||||
def test_cancel_secret_capture_marks_setup_skipped():
|
||||
cli = _make_cli_stub()
|
||||
cli._secret_state = {
|
||||
"response_queue": queue.Queue(),
|
||||
"var_name": "TENOR_API_KEY",
|
||||
"prompt": "Tenor API key",
|
||||
"metadata": {},
|
||||
}
|
||||
cli._secret_deadline = 123
|
||||
|
||||
cli._cancel_secret_capture()
|
||||
|
||||
assert cli._secret_state is None
|
||||
assert cli._secret_deadline == 0
|
||||
|
||||
|
||||
def test_secret_capture_uses_masked_prompt_without_tui():
|
||||
cli = _make_cli_stub()
|
||||
|
||||
with patch("hermes_cli.callbacks.masked_secret_prompt", return_value="secret-value"), patch(
|
||||
"hermes_cli.callbacks.save_env_value_secure"
|
||||
) as save_secret:
|
||||
save_secret.return_value = {
|
||||
"success": True,
|
||||
"stored_as": "TENOR_API_KEY",
|
||||
"validated": False,
|
||||
}
|
||||
result = prompt_for_secret(cli, "TENOR_API_KEY", "Tenor API key")
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["stored_as"] == "TENOR_API_KEY"
|
||||
assert result["skipped"] is False
|
||||
|
||||
|
||||
def test_secret_capture_timeout_clears_hidden_input_buffer():
|
||||
cli = _make_cli_stub(with_app=True)
|
||||
cleared = {"value": False}
|
||||
|
||||
def clear_buffer():
|
||||
cleared["value"] = True
|
||||
|
||||
cli._clear_secret_input_buffer = clear_buffer
|
||||
|
||||
with patch("hermes_cli.callbacks.queue.Queue.get", side_effect=queue.Empty), patch(
|
||||
"hermes_cli.callbacks._time.monotonic",
|
||||
side_effect=[0, 121],
|
||||
):
|
||||
result = prompt_for_secret(cli, "TENOR_API_KEY", "Tenor API key")
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["skipped"] is True
|
||||
assert result["reason"] == "timeout"
|
||||
assert cleared["value"] is True
|
||||
|
||||
|
||||
def test_cli_chat_registers_secret_capture_callback():
|
||||
clean_config = {
|
||||
"model": {
|
||||
"default": "anthropic/claude-opus-4.6",
|
||||
"base_url": "https://openrouter.ai/api/v1",
|
||||
"provider": "auto",
|
||||
},
|
||||
"display": {"compact": False, "tool_progress": "all"},
|
||||
"agent": {},
|
||||
"terminal": {"env_type": "local"},
|
||||
}
|
||||
|
||||
with patch("cli.get_tool_definitions", return_value=[]), patch.dict(
|
||||
"os.environ", {"LLM_MODEL": "", "HERMES_MAX_ITERATIONS": ""}, clear=False
|
||||
), patch.dict(cli_module.__dict__, {"CLI_CONFIG": clean_config}):
|
||||
cli_obj = HermesCLI()
|
||||
with patch.object(cli_obj, "_ensure_runtime_credentials", return_value=False):
|
||||
cli_obj.chat("hello")
|
||||
|
||||
try:
|
||||
assert skills_tool_module._secret_capture_callback == cli_obj._secret_capture_callback
|
||||
finally:
|
||||
set_secret_capture_callback(None)
|
||||
@@ -0,0 +1,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"
|
||||
]
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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"
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
"""Regression tests for terminal navigation/focus escape sequences.
|
||||
|
||||
Ghostty/macOS window and tab navigation can deliver terminal focus reports
|
||||
(CSI I / CSI O) to the running TUI. These must be consumed by the input parser,
|
||||
not inserted into the prompt buffer and cleaned up later.
|
||||
"""
|
||||
|
||||
from prompt_toolkit.input.vt100_parser import Vt100Parser
|
||||
from prompt_toolkit.keys import Keys
|
||||
|
||||
from hermes_cli.pt_input_extras import install_ignored_terminal_sequences
|
||||
|
||||
|
||||
def _parse_keys(data: str):
|
||||
events = []
|
||||
parser = Vt100Parser(events.append)
|
||||
parser.feed_and_flush(data)
|
||||
return [(event.key, event.data) for event in events]
|
||||
|
||||
|
||||
def test_focus_events_are_parser_level_ignored_before_prompt_buffer():
|
||||
install_ignored_terminal_sequences()
|
||||
|
||||
assert _parse_keys("\x1b[O\x1b[Ihello") == [
|
||||
(Keys.Ignore, "\x1b[O"),
|
||||
(Keys.Ignore, "\x1b[I"),
|
||||
("h", "h"),
|
||||
("e", "e"),
|
||||
("l", "l"),
|
||||
("l", "l"),
|
||||
("o", "o"),
|
||||
]
|
||||
|
||||
|
||||
def test_regular_escape_shortcuts_still_parse_normally():
|
||||
install_ignored_terminal_sequences()
|
||||
|
||||
assert _parse_keys("\x1bg") == [(Keys.Escape, "\x1b"), ("g", "g")]
|
||||
|
||||
|
||||
def test_install_is_idempotent_and_setdefault_safe():
|
||||
"""Second call should return 0 (no new mappings); existing user
|
||||
registrations must not be overwritten."""
|
||||
first = install_ignored_terminal_sequences()
|
||||
second = install_ignored_terminal_sequences()
|
||||
# At most first should be 2 (both CSI I + CSI O), second always 0
|
||||
# since the entries are now present.
|
||||
assert second == 0
|
||||
assert first in (0, 1, 2) # 0 if a prior test in same process already installed
|
||||
@@ -0,0 +1,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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,118 @@
|
||||
"""Tests for /compress <focus> — guided compression with focus topic.
|
||||
|
||||
Inspired by Claude Code's /compact <focus> feature.
|
||||
"""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from tests.cli.test_cli_init import _make_cli
|
||||
|
||||
|
||||
def _make_history() -> list[dict[str, str]]:
|
||||
return [
|
||||
{"role": "user", "content": "one"},
|
||||
{"role": "assistant", "content": "two"},
|
||||
{"role": "user", "content": "three"},
|
||||
{"role": "assistant", "content": "four"},
|
||||
]
|
||||
|
||||
|
||||
def test_focus_topic_extracted_and_passed(capsys):
|
||||
"""Focus topic is extracted from the command and passed to _compress_context."""
|
||||
shell = _make_cli()
|
||||
history = _make_history()
|
||||
compressed = [history[0], history[-1]]
|
||||
shell.conversation_history = history
|
||||
shell.agent = MagicMock()
|
||||
shell.agent.compression_enabled = True
|
||||
shell.agent._cached_system_prompt = ""
|
||||
shell.agent._compress_context.return_value = (compressed, "")
|
||||
|
||||
def _estimate(messages):
|
||||
if messages is history:
|
||||
return 100
|
||||
return 50
|
||||
|
||||
with patch("agent.model_metadata.estimate_messages_tokens_rough", side_effect=_estimate):
|
||||
shell._manual_compress("/compress database schema")
|
||||
|
||||
output = capsys.readouterr().out
|
||||
assert 'focus: "database schema"' in output
|
||||
|
||||
# Verify focus_topic was passed through
|
||||
shell.agent._compress_context.assert_called_once()
|
||||
call_kwargs = shell.agent._compress_context.call_args
|
||||
assert call_kwargs.kwargs.get("focus_topic") == "database schema"
|
||||
|
||||
|
||||
def test_no_focus_topic_when_bare_command(capsys):
|
||||
"""When no focus topic is provided, None is passed."""
|
||||
shell = _make_cli()
|
||||
history = _make_history()
|
||||
shell.conversation_history = history
|
||||
shell.agent = MagicMock()
|
||||
shell.agent.compression_enabled = True
|
||||
shell.agent._cached_system_prompt = ""
|
||||
shell.agent._compress_context.return_value = (list(history), "")
|
||||
|
||||
with patch("agent.model_metadata.estimate_messages_tokens_rough", return_value=100):
|
||||
shell._manual_compress("/compress")
|
||||
|
||||
shell.agent._compress_context.assert_called_once()
|
||||
call_kwargs = shell.agent._compress_context.call_args
|
||||
assert call_kwargs.kwargs.get("focus_topic") is None
|
||||
|
||||
|
||||
def test_empty_focus_after_command_treated_as_none(capsys):
|
||||
"""Trailing whitespace after /compress does not produce a focus topic."""
|
||||
shell = _make_cli()
|
||||
history = _make_history()
|
||||
shell.conversation_history = history
|
||||
shell.agent = MagicMock()
|
||||
shell.agent.compression_enabled = True
|
||||
shell.agent._cached_system_prompt = ""
|
||||
shell.agent._compress_context.return_value = (list(history), "")
|
||||
|
||||
with patch("agent.model_metadata.estimate_messages_tokens_rough", return_value=100):
|
||||
shell._manual_compress("/compress ")
|
||||
|
||||
shell.agent._compress_context.assert_called_once()
|
||||
call_kwargs = shell.agent._compress_context.call_args
|
||||
assert call_kwargs.kwargs.get("focus_topic") is None
|
||||
|
||||
|
||||
def test_focus_topic_printed_in_compression_banner(capsys):
|
||||
"""The focus topic shows in the compression progress banner."""
|
||||
shell = _make_cli()
|
||||
history = _make_history()
|
||||
compressed = [history[0], history[-1]]
|
||||
shell.conversation_history = history
|
||||
shell.agent = MagicMock()
|
||||
shell.agent.compression_enabled = True
|
||||
shell.agent._cached_system_prompt = ""
|
||||
shell.agent._compress_context.return_value = (compressed, "")
|
||||
|
||||
with patch("agent.model_metadata.estimate_messages_tokens_rough", return_value=100):
|
||||
shell._manual_compress("/compress API endpoints")
|
||||
|
||||
output = capsys.readouterr().out
|
||||
assert 'focus: "API endpoints"' in output
|
||||
|
||||
|
||||
def test_no_focus_prints_standard_banner(capsys):
|
||||
"""Without focus, the standard banner (no focus: line) is printed."""
|
||||
shell = _make_cli()
|
||||
history = _make_history()
|
||||
compressed = [history[0], history[-1]]
|
||||
shell.conversation_history = history
|
||||
shell.agent = MagicMock()
|
||||
shell.agent.compression_enabled = True
|
||||
shell.agent._cached_system_prompt = ""
|
||||
shell.agent._compress_context.return_value = (compressed, "")
|
||||
|
||||
with patch("agent.model_metadata.estimate_messages_tokens_rough", return_value=100):
|
||||
shell._manual_compress("/compress")
|
||||
|
||||
output = capsys.readouterr().out
|
||||
assert "focus:" not in output
|
||||
assert "Compressing" in output
|
||||
@@ -0,0 +1,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"
|
||||
@@ -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}"
|
||||
)
|
||||
@@ -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
|
||||
@@ -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) == []
|
||||
@@ -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
|
||||
@@ -0,0 +1,99 @@
|
||||
"""Tests for CLI/TUI CWD resolution in load_cli_config().
|
||||
|
||||
Rules:
|
||||
- Local backend CLI/TUI: always os.getcwd(), ignoring config and inherited env.
|
||||
- Non-local with placeholder: pop cwd for backend default.
|
||||
- Non-local with explicit path: keep as-is.
|
||||
"""
|
||||
|
||||
|
||||
_CWD_PLACEHOLDERS = (".", "auto", "cwd")
|
||||
|
||||
|
||||
def _resolve_cwd(terminal_config: dict, defaults: dict, env: dict):
|
||||
"""Mirror the CWD resolution logic from cli.py load_cli_config()."""
|
||||
effective_backend = terminal_config.get("env_type", "local")
|
||||
|
||||
if effective_backend == "local":
|
||||
terminal_config["cwd"] = "/fake/getcwd"
|
||||
defaults["terminal"]["cwd"] = terminal_config["cwd"]
|
||||
elif terminal_config.get("cwd") in _CWD_PLACEHOLDERS:
|
||||
terminal_config.pop("cwd", None)
|
||||
|
||||
# Bridge: TERMINAL_CWD always exported in CLI, skipped in gateway
|
||||
_is_gateway = env.get("_HERMES_GATEWAY") == "1"
|
||||
if "cwd" in terminal_config:
|
||||
if _is_gateway:
|
||||
pass # don't touch env
|
||||
else:
|
||||
env["TERMINAL_CWD"] = str(terminal_config["cwd"])
|
||||
|
||||
return env.get("TERMINAL_CWD", "")
|
||||
|
||||
|
||||
class TestLocalBackendCli:
|
||||
"""Local backend always uses os.getcwd()."""
|
||||
|
||||
def test_explicit_config_ignored(self):
|
||||
env = {}
|
||||
tc = {"cwd": "/explicit/path", "env_type": "local"}
|
||||
d = {"terminal": {"cwd": "/explicit/path"}}
|
||||
assert _resolve_cwd(tc, d, env) == "/fake/getcwd"
|
||||
|
||||
def test_inherited_env_overwritten(self):
|
||||
env = {"TERMINAL_CWD": "/parent/hermes"}
|
||||
tc = {"cwd": "/home/user", "env_type": "local"}
|
||||
d = {"terminal": {"cwd": "/home/user"}}
|
||||
assert _resolve_cwd(tc, d, env) == "/fake/getcwd"
|
||||
|
||||
def test_placeholder_resolved(self):
|
||||
env = {}
|
||||
tc = {"cwd": "."}
|
||||
d = {"terminal": {"cwd": "."}}
|
||||
assert _resolve_cwd(tc, d, env) == "/fake/getcwd"
|
||||
|
||||
def test_env_and_no_config_file(self):
|
||||
env = {"TERMINAL_CWD": "/stale/value"}
|
||||
tc = {"cwd": ".", "env_type": "local"}
|
||||
d = {"terminal": {"cwd": "."}}
|
||||
assert _resolve_cwd(tc, d, env) == "/fake/getcwd"
|
||||
|
||||
|
||||
class TestNonLocalBackends:
|
||||
"""Non-local backends use config or per-backend defaults."""
|
||||
|
||||
def test_placeholder_popped(self):
|
||||
env = {}
|
||||
tc = {"cwd": ".", "env_type": "docker"}
|
||||
d = {"terminal": {"cwd": "."}}
|
||||
assert _resolve_cwd(tc, d, env) == ""
|
||||
|
||||
def test_explicit_path_kept(self):
|
||||
env = {}
|
||||
tc = {"cwd": "/srv/app", "env_type": "ssh"}
|
||||
d = {"terminal": {"cwd": "/srv/app"}}
|
||||
assert _resolve_cwd(tc, d, env) == "/srv/app"
|
||||
|
||||
def test_auto_placeholder_popped(self):
|
||||
env = {}
|
||||
tc = {"cwd": "auto", "env_type": "modal"}
|
||||
d = {"terminal": {"cwd": "auto"}}
|
||||
assert _resolve_cwd(tc, d, env) == ""
|
||||
|
||||
|
||||
class TestGatewayLazyImport:
|
||||
"""Gateway lazy import of cli.py must not clobber TERMINAL_CWD."""
|
||||
|
||||
def test_gateway_cwd_preserved(self):
|
||||
env = {"_HERMES_GATEWAY": "1", "TERMINAL_CWD": "/home/user/project"}
|
||||
tc = {"cwd": "/home/user", "env_type": "local"}
|
||||
d = {"terminal": {"cwd": "/home/user"}}
|
||||
result = _resolve_cwd(tc, d, env)
|
||||
assert result == "/home/user/project"
|
||||
|
||||
def test_cli_overwrites_stale_env(self):
|
||||
env = {"TERMINAL_CWD": "/stale/from/dotenv"}
|
||||
tc = {"cwd": "/home/user", "env_type": "local"}
|
||||
d = {"terminal": {"cwd": "/home/user"}}
|
||||
result = _resolve_cwd(tc, d, env)
|
||||
assert result == "/fake/getcwd"
|
||||
@@ -0,0 +1,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"
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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"], "")
|
||||
@@ -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()
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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)
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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"
|
||||
|
||||
@@ -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))
|
||||
@@ -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())
|
||||
@@ -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
|
||||
@@ -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."
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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
Reference in New Issue
Block a user