hermes-agent/tests/cli/test_resume_display.py
Teknium 28524adb0e fix(tests): eliminate flaky/broken tests — shadow sys.path inserts, unmocked network in compressor tests, stale-SDK feishu pin guard, quadratic redact regexes
- Remove tests/-shadowing sys.path.insert(dirname/'..') from 11 test files:
  it prepended the tests/ dir itself to sys.path, so 'import agent' /
  'import hermes_cli' resolved to the test packages and collection died
  with ModuleNotFoundError depending on import order (2 files failed in
  every full-suite run; 9 more were latent).
- Patch call_llm in 5 context-compressor tests that called compress()
  unmocked: each burned ~50s attempting live LLM traffic through the
  relay before falling back (572s file — the slowest in the suite, and
  flaky under the 300s per-file timeout). File now runs in ~5s.
- agent/redact.py: fix two catastrophically-backtracking regexes hit by
  the compressor's redaction pass on large payloads —
  _STRICT_URL_USERINFO_RE anchors on the mandatory '//' (optional-scheme
  prefix backtracked O(n^2): ~55s on a 320KB payload, now sub-ms;
  output-equivalence fuzz-verified on 20k random strings), and the
  _CFG_DOTTED_RE/_CFG_ANCHORED_RE subs gain an exact linear keyword
  pre-gate so secret-free text skips the quadratic pattern entirely.
- tests/gateway/test_feishu.py: version-guard the extra_ua_tags SDK
  signature check; the repo pins lark-oapi==1.6.8 but stale local
  installs (1.5.3) fail the assertion — skip below the pin.
- tests/tools/test_managed_browserbase_and_modal.py: stub
  agent.redact + agent.credential_persistence in the fake agent package
  (empty __path__ blocks all real agent.* imports added since the fake
  was written).
- tests/gateway/test_startup_restart_race.py: raise wait_for timeouts
  2s -> 30s; 2s wall-clock on a loaded 40-worker box flaked in the
  baseline run (passes instantly when the box is quiet).
2026-07-29 15:12:28 -07:00

409 lines
15 KiB
Python

"""Tests for session resume history display — _display_resumed_history() and
_preload_resumed_session().
Verifies that resuming a session shows a compact recap of the previous
conversation with correct formatting, truncation, and config behavior.
"""
from io import StringIO
from unittest.mock import MagicMock, patch
import cli as cli_mod
def _make_cli(config_overrides=None, env_overrides=None, **kwargs):
"""Create a HermesCLI instance with minimal mocking."""
import cli as _cli_mod
from cli import HermesCLI
_clean_config = {
"model": {
"default": "anthropic/claude-opus-4.6",
"base_url": "https://openrouter.ai/api/v1",
"provider": "auto",
},
"display": {"compact": False, "tool_progress": "all", "resume_display": "full"},
"agent": {},
"terminal": {"env_type": "local"},
}
if config_overrides:
for k, v in config_overrides.items():
if isinstance(v, dict) and k in _clean_config and isinstance(_clean_config[k], dict):
_clean_config[k].update(v)
else:
_clean_config[k] = v
clean_env = {"LLM_MODEL": "", "HERMES_MAX_ITERATIONS": ""}
if env_overrides:
clean_env.update(env_overrides)
with (
patch("cli.get_tool_definitions", return_value=[]),
patch.dict("os.environ", clean_env, clear=False),
patch.dict(_cli_mod.__dict__, {"CLI_CONFIG": _clean_config}),
):
return HermesCLI(**kwargs)
# ── Sample conversation histories for tests ──────────────────────────
def _simple_history():
"""Two-turn conversation: user → assistant → user → assistant."""
return [
{"role": "system", "content": "You are a helpful assistant."},
{"role": "user", "content": "What is Python?"},
{"role": "assistant", "content": "Python is a high-level programming language."},
{"role": "user", "content": "How do I install it?"},
{"role": "assistant", "content": "You can install Python from python.org."},
]
def _tool_call_history():
"""Conversation with tool calls and tool results."""
return [
{"role": "system", "content": "system prompt"},
{"role": "user", "content": "Search for Python tutorials"},
{
"role": "assistant",
"content": None,
"tool_calls": [
{
"id": "call_1",
"type": "function",
"function": {"name": "web_search", "arguments": '{"query":"python tutorials"}'},
},
{
"id": "call_2",
"type": "function",
"function": {"name": "web_extract", "arguments": '{"urls":["https://example.com"]}'},
},
],
},
{"role": "tool", "tool_call_id": "call_1", "content": "Found 5 results..."},
{"role": "tool", "tool_call_id": "call_2", "content": "Page content..."},
{"role": "assistant", "content": "Here are some great Python tutorials I found."},
]
def _large_history(n_exchanges=15):
"""Build a history with many exchanges to test truncation."""
msgs = [{"role": "system", "content": "system prompt"}]
for i in range(n_exchanges):
msgs.append({"role": "user", "content": f"Question #{i + 1}: What is item {i + 1}?"})
msgs.append({"role": "assistant", "content": f"Answer #{i + 1}: Item {i + 1} is great."})
return msgs
def _multimodal_history():
"""Conversation with multimodal (image) content."""
return [
{"role": "system", "content": "system prompt"},
{
"role": "user",
"content": [
{"type": "text", "text": "What's in this image?"},
{"type": "image_url", "image_url": {"url": "https://example.com/cat.jpg"}},
],
},
{"role": "assistant", "content": "I see a cat in the image."},
]
# ── Tests for _display_resumed_history ───────────────────────────────
class TestDisplayResumedHistory:
"""_display_resumed_history() renders a Rich panel with conversation recap."""
def _capture_display(self, cli_obj):
"""Run _display_resumed_history and capture the Rich console output."""
buf = StringIO()
cli_obj.console.file = buf
cli_obj._display_resumed_history()
return buf.getvalue()
def test_simple_history_shows_user_and_assistant(self):
cli = _make_cli()
cli.conversation_history = _simple_history()
output = self._capture_display(cli)
assert "You:" in output
assert "Hermes:" in output
assert "What is Python?" in output
assert "Python is a high-level programming language." in output
assert "How do I install it?" in output
def test_timeline_markers_render_as_events_not_user_input(self):
cli = _make_cli()
cli.conversation_history = [
{"role": "user", "content": "opaque model context", "display_kind": "model_switch"},
{"role": "user", "content": "opaque delegation context", "display_kind": "async_delegation_complete"},
{"role": "user", "content": "opaque hidden context", "display_kind": "hidden"},
]
output = self._capture_display(cli)
assert "◈ model changed" in output
assert "◈ background delegation completed" in output
assert "You:" not in output
assert "opaque" not in output
def test_tool_only_message_skipped_by_default(self):
"""Assistant messages with only tool_calls (no text) are skipped when
resume_skip_tool_only=True (the default). The summary line is hidden.
"""
cli = _make_cli()
cli.conversation_history = _tool_call_history()
output = self._capture_display(cli)
# The tool-only assistant entry should be skipped
assert "2 tool calls" not in output
# The final text reply should still appear
assert "Here are some great Python tutorials" in output
def test_minimal_config_suppresses_display(self):
cli = _make_cli(config_overrides={"display": {"resume_display": "minimal"}})
# resume_display is captured as an instance variable during __init__
assert cli.resume_display == "minimal"
cli.conversation_history = _simple_history()
output = self._capture_display(cli)
assert output.strip() == ""
def test_pure_reasoning_message_skipped(self):
"""Assistant messages that are only reasoning should be skipped."""
cli = _make_cli()
cli.conversation_history = [
{"role": "user", "content": "Hello"},
{
"role": "assistant",
"content": "<REASONING_SCRATCHPAD>\nJust thinking...\n</REASONING_SCRATCHPAD>",
},
{"role": "assistant", "content": "Hi there!"},
]
output = self._capture_display(cli)
assert "Just thinking" not in output
assert "Hi there!" in output
def test_unclosed_think_tag_stripped(self):
"""Unclosed <think> (truncated generation) should not leak reasoning."""
cli = _make_cli()
cli.conversation_history = [
{"role": "user", "content": "Truncated response"},
{
"role": "assistant",
"content": "Some text before.\n<think>\nUnfinished reasoning...",
},
]
output = self._capture_display(cli)
assert "<think>" not in output
assert "Unfinished reasoning" not in output
assert "Some text before" in output
# ── Tests for _preload_resumed_session ──────────────────────────────
class TestPreloadResumedSession:
"""_preload_resumed_session() loads session from DB early."""
def test_returns_false_when_not_resumed(self):
cli = _make_cli()
assert cli._preload_resumed_session() is False
def test_returns_false_when_session_not_found(self):
cli = _make_cli(resume="nonexistent_session")
mock_db = MagicMock()
mock_db.get_session.return_value = None
cli._session_db = mock_db
buf = StringIO()
cli.console.file = buf
result = cli._preload_resumed_session()
assert result is False
output = buf.getvalue()
assert "Session not found" in output
def test_reopens_session_in_db(self):
cli = _make_cli(resume="reopen_session")
messages = [{"role": "user", "content": "hi"}]
mock_db = MagicMock()
mock_db.get_session.return_value = {"id": "reopen_session", "title": None}
mock_db.get_resume_conversations.return_value = (messages, messages)
mock_conn = MagicMock()
mock_db._conn = mock_conn
cli._session_db = mock_db
buf = StringIO()
cli.console.file = buf
cli._preload_resumed_session()
# Should have executed UPDATE to clear ended_at
mock_conn.execute.assert_called_once()
call_args = mock_conn.execute.call_args
assert "ended_at = NULL" in call_args[0][0]
mock_conn.commit.assert_called_once()
# ── Tests for _handle_resume_command recap display ───────────────────
class TestHandleResumeCommandRecap:
"""In-session /resume should show the same recap panel as startup resume."""
def test_resume_command_displays_recap_when_messages_restored(self):
cli = _make_cli()
cli.session_id = "current_session"
messages = _simple_history()
mock_db = MagicMock()
mock_db.get_session.return_value = {"id": "target_session", "title": "Test Session"}
mock_db.get_resume_conversations.return_value = (messages, messages)
# resolve_resume_session_id passes the id through when no compression chain.
mock_db.resolve_resume_session_id.return_value = "target_session"
cli._session_db = mock_db
with (
patch("hermes_cli.main._resolve_session_by_name_or_id", return_value="target_session"),
patch.object(cli, "_display_resumed_history") as display_mock,
):
cli._handle_resume_command("/resume test session")
assert cli.session_id == "target_session"
assert cli.conversation_history == messages
assert cli._resume_display_history == messages
mock_db.end_session.assert_called_once_with("current_session", "resumed_other")
mock_db.reopen_session.assert_called_once_with("target_session")
display_mock.assert_called_once_with()
def test_resume_command_replaces_stale_display_history(self):
"""In-session /resume B after startup --resume A must show B's recap,
not A's. The _resume_display_history attribute set by startup resume
must be replaced, not retained."""
cli = _make_cli(resume="session_a")
cli.session_id = "session_a"
# Simulate startup --resume A having populated both projections.
messages_a = [{"role": "user", "content": "from session A"}]
cli.conversation_history = messages_a
cli._resume_display_history = messages_a
messages_b = [{"role": "user", "content": "from session B"}]
mock_db = MagicMock()
mock_db.get_session.return_value = {"id": "session_b", "title": "Session B"}
mock_db.get_resume_conversations.return_value = (messages_b, messages_b)
mock_db.resolve_resume_session_id.return_value = "session_b"
cli._session_db = mock_db
with (
patch("hermes_cli.main._resolve_session_by_name_or_id", return_value="session_b"),
patch.object(cli, "_display_resumed_history") as display_mock,
):
cli._handle_resume_command("/resume session_b")
assert cli.session_id == "session_b"
assert cli.conversation_history == messages_b
# The stale A display history must have been replaced by B's.
assert cli._resume_display_history == messages_b
assert "from session A" not in [
m.get("content", "") for m in cli._resume_display_history
]
display_mock.assert_called_once_with()
# ── Integration: _init_agent skips when preloaded ────────────────────
class TestInitAgentSkipsPreloaded:
"""_init_agent() should skip DB load when history is already populated."""
def test_init_agent_skips_db_when_preloaded(self):
"""If conversation_history is already set, _init_agent should not
reload from the DB."""
cli = _make_cli(resume="preloaded_session")
cli.conversation_history = _simple_history()
mock_db = MagicMock()
cli._session_db = mock_db
# _init_agent will fail at credential resolution (no real API key),
# but the session-loading block should be skipped entirely
with patch.object(cli, "_ensure_runtime_credentials", return_value=False):
cli._init_agent()
# get_messages_as_conversation should NOT have been called
mock_db.get_messages_as_conversation.assert_not_called()
# ── Config default tests ─────────────────────────────────────────────
class TestResumeDisplayConfig:
"""resume_display config option defaults and behavior."""
def test_default_config_has_resume_display(self):
"""DEFAULT_CONFIG in hermes_cli/config.py includes resume_display."""
from hermes_cli.config import DEFAULT_CONFIG
display = DEFAULT_CONFIG.get("display", {})
assert "resume_display" in display
assert display["resume_display"] == "full"
class TestResumeDisplaySanitization:
"""Stored history replayed by /resume must not carry raw terminal
escapes or control chars (openai/codex#31494 bug class)."""
def _capture_display(self, cli_obj):
buf = StringIO()
cli_obj.console.file = buf
cli_obj._display_resumed_history()
return buf.getvalue()
def test_escape_sequences_stripped_from_user_and_assistant(self):
cli = _make_cli()
cli.conversation_history = [
{"role": "user", "content": "hi \x1b[2J\x1b]0;pwned\x07 there"},
{"role": "assistant", "content": "ok \x9b31m fine\x07"},
]
output = self._capture_display(cli)
# Rich adds its own SGR styling escapes when force_terminal is on;
# what must NOT survive are the injected non-SGR sequences.
assert "\x1b[2J" not in output
assert "\x1b]0;pwned" not in output
assert "\x9b" not in output
assert "\x07" not in output
assert "hi" in output and "there" in output
assert "fine" in output