From 3aded1d4e5e9a112ba966f3ac8e3dfbc4fb1e0d8 Mon Sep 17 00:00:00 2001 From: teknium1 Date: Sun, 8 Mar 2026 17:45:45 -0700 Subject: [PATCH] feat: display previous messages when resuming a session in CLI When resuming a session via --continue or --resume, show a compact recap of the previous conversation inside a Rich panel before the input prompt. This gives users immediate visual context about what was discussed. Changes: - Add _preload_resumed_session() to load session history early (in run(), before banner) so _init_agent() doesn't need a separate DB round-trip - Add _display_resumed_history() that renders a formatted recap panel: * User messages shown with gold bullet (truncated at 300 chars) * Assistant responses shown with green diamond (truncated at 200 chars / 3 lines) * Tool calls collapsed to count + tool names * System messages and tool results hidden * blocks stripped from display * Pure-reasoning messages (no visible output) skipped entirely * Capped at last 10 exchanges with 'N earlier messages' indicator * Dim/muted styling distinguishes recap from active conversation - Add display.resume_display config option: 'full' (default) or 'minimal' - Store resume_display as instance variable (like compact) for testability - 27 new tests covering all display scenarios, config, and edge cases Closes #719 --- cli.py | 214 ++++++++++++++- hermes_cli/config.py | 1 + tests/test_resume_display.py | 488 +++++++++++++++++++++++++++++++++++ 3 files changed, 700 insertions(+), 3 deletions(-) create mode 100644 tests/test_resume_display.py diff --git a/cli.py b/cli.py index 937966b05..cd13b820c 100755 --- a/cli.py +++ b/cli.py @@ -193,6 +193,7 @@ def load_cli_config() -> Dict[str, Any]: "toolsets": ["all"], "display": { "compact": False, + "resume_display": "full", }, "clarify": { "timeout": 120, # Seconds to wait for a clarify answer before auto-proceeding @@ -1008,6 +1009,8 @@ class HermesCLI: self.compact = compact if compact is not None else CLI_CONFIG["display"].get("compact", False) # tool_progress: "off", "new", "all", "verbose" (from config.yaml display section) self.tool_progress_mode = CLI_CONFIG["display"].get("tool_progress", "all") + # resume_display: "full" (show history) | "minimal" (one-liner only) + self.resume_display = CLI_CONFIG["display"].get("resume_display", "full") self.verbose = verbose if verbose is not None else (self.tool_progress_mode == "verbose") # Configuration - priority: CLI args > env vars > config file @@ -1266,8 +1269,11 @@ class HermesCLI: except Exception as e: logger.debug("SQLite session store not available: %s", e) - # If resuming, validate the session exists and load its history - if self._resumed and self._session_db: + # If resuming, validate the session exists and load its history. + # _preload_resumed_session() may have already loaded it (called from + # run() for immediate display). In that case, conversation_history + # is non-empty and we skip the DB round-trip. + if self._resumed and self._session_db and not self.conversation_history: session_meta = self._session_db.get_session(self.session_id) if not session_meta: _cprint(f"\033[1;31mSession not found: {self.session_id}{_RST}") @@ -1371,7 +1377,202 @@ class HermesCLI: self._show_tool_availability_warnings() self.console.print() - + + def _preload_resumed_session(self) -> bool: + """Load a resumed session's history from the DB early (before first chat). + + Called from run() so the conversation history is available for display + before the user sends their first message. Sets + ``self.conversation_history`` and prints the one-liner status. Returns + True if history was loaded, False otherwise. + + The corresponding block in ``_init_agent()`` checks whether history is + already populated and skips the DB round-trip. + """ + if not self._resumed or not self._session_db: + return False + + session_meta = self._session_db.get_session(self.session_id) + if not session_meta: + self.console.print( + f"[bold red]Session not found: {self.session_id}[/]" + ) + self.console.print( + "[dim]Use a session ID from a previous CLI run " + "(hermes sessions list).[/]" + ) + return False + + restored = self._session_db.get_messages_as_conversation(self.session_id) + if restored: + self.conversation_history = restored + msg_count = len([m for m in restored if m.get("role") == "user"]) + title_part = "" + if session_meta.get("title"): + title_part = f' "{session_meta["title"]}"' + self.console.print( + f"[#DAA520]↻ Resumed session [bold]{self.session_id}[/bold]" + f"{title_part} " + f"({msg_count} user message{'s' if msg_count != 1 else ''}, " + f"{len(restored)} total messages)[/]" + ) + else: + self.console.print( + f"[#DAA520]Session {self.session_id} found but has no " + f"messages. Starting fresh.[/]" + ) + return False + + # Re-open the session (clear ended_at so it's active again) + try: + self._session_db._conn.execute( + "UPDATE sessions SET ended_at = NULL, end_reason = NULL " + "WHERE id = ?", + (self.session_id,), + ) + self._session_db._conn.commit() + except Exception: + pass + + return True + + def _display_resumed_history(self): + """Render a compact recap of previous conversation messages. + + Uses Rich markup with dim/muted styling so the recap is visually + distinct from the active conversation. Caps the display at the + last ``MAX_DISPLAY_EXCHANGES`` user/assistant exchanges and shows + an indicator for earlier hidden messages. + """ + if not self.conversation_history: + return + + # Check config: resume_display setting + if self.resume_display == "minimal": + return + + MAX_DISPLAY_EXCHANGES = 10 # max user+assistant pairs to show + MAX_USER_LEN = 300 # truncate user messages + MAX_ASST_LEN = 200 # truncate assistant text + MAX_ASST_LINES = 3 # max lines of assistant text + + def _strip_reasoning(text: str) -> str: + """Remove ... blocks + from displayed text (reasoning model internal thoughts).""" + import re + cleaned = re.sub( + r".*?\s*", + "", text, flags=re.DOTALL, + ) + # Also strip unclosed reasoning tags at the end + cleaned = re.sub( + r".*$", + "", cleaned, flags=re.DOTALL, + ) + return cleaned.strip() + + # Collect displayable entries (skip system, tool-result messages) + entries = [] # list of (role, display_text) + for msg in self.conversation_history: + role = msg.get("role", "") + content = msg.get("content") + tool_calls = msg.get("tool_calls") or [] + + if role == "system": + continue + if role == "tool": + continue + + if role == "user": + text = "" if content is None else str(content) + # Handle multimodal content (list of dicts) + if isinstance(content, list): + parts = [] + for part in content: + if isinstance(part, dict) and part.get("type") == "text": + parts.append(part.get("text", "")) + elif isinstance(part, dict) and part.get("type") == "image_url": + parts.append("[image]") + text = " ".join(parts) + if len(text) > MAX_USER_LEN: + text = text[:MAX_USER_LEN] + "..." + entries.append(("user", text)) + + elif role == "assistant": + text = "" if content is None else str(content) + text = _strip_reasoning(text) + parts = [] + if text: + lines = text.splitlines() + if len(lines) > MAX_ASST_LINES: + text = "\n".join(lines[:MAX_ASST_LINES]) + " ..." + if len(text) > MAX_ASST_LEN: + text = text[:MAX_ASST_LEN] + "..." + parts.append(text) + if tool_calls: + tc_count = len(tool_calls) + # Extract tool names + names = [] + for tc in tool_calls: + fn = tc.get("function", {}) + name = fn.get("name", "unknown") if isinstance(fn, dict) else "unknown" + if name not in names: + names.append(name) + names_str = ", ".join(names[:4]) + if len(names) > 4: + names_str += ", ..." + noun = "call" if tc_count == 1 else "calls" + parts.append(f"[{tc_count} tool {noun}: {names_str}]") + if not parts: + # Skip pure-reasoning messages that have no visible output + continue + entries.append(("assistant", " ".join(parts))) + + if not entries: + return + + # Determine if we need to truncate + skipped = 0 + if len(entries) > MAX_DISPLAY_EXCHANGES * 2: + skipped = len(entries) - MAX_DISPLAY_EXCHANGES * 2 + entries = entries[skipped:] + + # Build the display using Rich + from rich.panel import Panel + from rich.text import Text + + lines = Text() + if skipped: + lines.append( + f" ... {skipped} earlier messages ...\n\n", + style="dim italic", + ) + + for i, (role, text) in enumerate(entries): + if role == "user": + lines.append(" ● You: ", style="dim bold #DAA520") + # Show first line inline, indent rest + msg_lines = text.splitlines() + lines.append(msg_lines[0] + "\n", style="dim") + for ml in msg_lines[1:]: + lines.append(f" {ml}\n", style="dim") + else: + lines.append(" ◆ Hermes: ", style="dim bold #8FBC8F") + msg_lines = text.splitlines() + lines.append(msg_lines[0] + "\n", style="dim") + for ml in msg_lines[1:]: + lines.append(f" {ml}\n", style="dim") + if i < len(entries) - 1: + lines.append("") # small gap + + panel = Panel( + lines, + title="[dim #DAA520]Previous Conversation[/]", + border_style="dim #8B8682", + padding=(0, 1), + ) + self.console.print(panel) + def _try_attach_clipboard_image(self) -> bool: """Check clipboard for an image and attach it if found. @@ -2948,6 +3149,13 @@ class HermesCLI: def run(self): """Run the interactive CLI loop with persistent input at bottom.""" self.show_banner() + + # If resuming a session, load history and display it immediately + # so the user has context before typing their first message. + if self._resumed: + if self._preload_resumed_session(): + self._display_resumed_history() + self.console.print("[#FFF8DC]Welcome to Hermes Agent! Type your message or /help for commands.[/]") self.console.print() diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 0e6f51c1a..ed782e6a9 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -92,6 +92,7 @@ DEFAULT_CONFIG = { "display": { "compact": False, "personality": "kawaii", + "resume_display": "full", # "full" (show previous messages) | "minimal" (one-liner only) }, # Text-to-speech configuration diff --git a/tests/test_resume_display.py b/tests/test_resume_display.py new file mode 100644 index 000000000..d0c156d13 --- /dev/null +++ b/tests/test_resume_display.py @@ -0,0 +1,488 @@ +"""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. +""" + +import os +import sys +from io import StringIO +from unittest.mock import MagicMock, patch + +import pytest + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + + +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_system_messages_hidden(self): + cli = _make_cli() + cli.conversation_history = _simple_history() + output = self._capture_display(cli) + + assert "You are a helpful assistant" not in output + + def test_tool_messages_hidden(self): + cli = _make_cli() + cli.conversation_history = _tool_call_history() + output = self._capture_display(cli) + + # Tool result content should NOT appear + assert "Found 5 results" not in output + assert "Page content" not in output + + def test_tool_calls_shown_as_summary(self): + cli = _make_cli() + cli.conversation_history = _tool_call_history() + output = self._capture_display(cli) + + assert "2 tool calls" in output + assert "web_search" in output + assert "web_extract" in output + + def test_long_user_message_truncated(self): + cli = _make_cli() + long_text = "A" * 500 + cli.conversation_history = [ + {"role": "user", "content": long_text}, + {"role": "assistant", "content": "OK."}, + ] + output = self._capture_display(cli) + + # Should have truncation indicator and NOT contain the full 500 chars + assert "..." in output + assert "A" * 500 not in output + # The 300-char truncated text is present but may be line-wrapped by + # Rich's panel renderer, so check the total A count in the output + a_count = output.count("A") + assert 200 <= a_count <= 310 # roughly 300 chars (±panel padding) + + def test_long_assistant_message_truncated(self): + cli = _make_cli() + long_text = "B" * 400 + cli.conversation_history = [ + {"role": "user", "content": "Tell me a lot."}, + {"role": "assistant", "content": long_text}, + ] + output = self._capture_display(cli) + + assert "..." in output + assert "B" * 400 not in output + + def test_multiline_assistant_truncated(self): + cli = _make_cli() + multi = "\n".join([f"Line {i}" for i in range(20)]) + cli.conversation_history = [ + {"role": "user", "content": "Show me lines."}, + {"role": "assistant", "content": multi}, + ] + output = self._capture_display(cli) + + # First 3 lines should be there + assert "Line 0" in output + assert "Line 1" in output + assert "Line 2" in output + # Line 19 should NOT be there (truncated after 3 lines) + assert "Line 19" not in output + + def test_large_history_shows_truncation_indicator(self): + cli = _make_cli() + cli.conversation_history = _large_history(n_exchanges=15) + output = self._capture_display(cli) + + # Should show "earlier messages" indicator + assert "earlier messages" in output + # Last question should still be visible + assert "Question #15" in output + + def test_multimodal_content_handled(self): + cli = _make_cli() + cli.conversation_history = _multimodal_history() + output = self._capture_display(cli) + + assert "What's in this image?" in output + assert "[image]" in output + + def test_empty_history_no_output(self): + cli = _make_cli() + cli.conversation_history = [] + output = self._capture_display(cli) + + assert output.strip() == "" + + def test_minimal_config_suppresses_display(self): + cli = _make_cli(config_overrides={"display": {"resume_display": "minimal"}}) + # 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_panel_has_title(self): + cli = _make_cli() + cli.conversation_history = _simple_history() + output = self._capture_display(cli) + + assert "Previous Conversation" in output + + def test_assistant_with_no_content_no_tools_skipped(self): + """Assistant messages with no visible output (e.g. pure reasoning) + are skipped in the recap.""" + cli = _make_cli() + cli.conversation_history = [ + {"role": "user", "content": "Hello"}, + {"role": "assistant", "content": None}, + ] + output = self._capture_display(cli) + + # The assistant entry should be skipped, only the user message shown + assert "You:" in output + assert "Hermes:" not in output + + def test_only_system_messages_no_output(self): + cli = _make_cli() + cli.conversation_history = [ + {"role": "system", "content": "You are helpful."}, + ] + output = self._capture_display(cli) + + assert output.strip() == "" + + def test_reasoning_scratchpad_stripped(self): + """ blocks should be stripped from display.""" + cli = _make_cli() + cli.conversation_history = [ + {"role": "user", "content": "Think about this"}, + { + "role": "assistant", + "content": ( + "\nLet me think step by step.\n" + "\n\nThe answer is 42." + ), + }, + ] + output = self._capture_display(cli) + + assert "REASONING_SCRATCHPAD" not in output + assert "Let me think step by step" not in output + assert "The answer is 42" in output + + def test_pure_reasoning_message_skipped(self): + """Assistant messages that are only reasoning should be skipped.""" + cli = _make_cli() + cli.conversation_history = [ + {"role": "user", "content": "Hello"}, + { + "role": "assistant", + "content": "\nJust thinking...\n", + }, + {"role": "assistant", "content": "Hi there!"}, + ] + output = self._capture_display(cli) + + assert "Just thinking" not in output + assert "Hi there!" in output + + def test_assistant_with_text_and_tool_calls(self): + """When an assistant message has both text content AND tool_calls.""" + cli = _make_cli() + cli.conversation_history = [ + {"role": "user", "content": "Do something complex"}, + { + "role": "assistant", + "content": "Let me search for that.", + "tool_calls": [ + { + "id": "call_1", + "type": "function", + "function": {"name": "terminal", "arguments": '{"command":"ls"}'}, + } + ], + }, + ] + output = self._capture_display(cli) + + assert "Let me search for that." in output + assert "1 tool call" in output + assert "terminal" in output + + +# ── Tests for _preload_resumed_session ────────────────────────────── + + +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_no_session_db(self): + cli = _make_cli(resume="test_session_id") + cli._session_db = None + assert cli._preload_resumed_session() is False + + def test_returns_false_when_session_not_found(self): + cli = _make_cli(resume="nonexistent_session") + 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_returns_false_when_session_has_no_messages(self): + cli = _make_cli(resume="empty_session") + mock_db = MagicMock() + mock_db.get_session.return_value = {"id": "empty_session", "title": None} + mock_db.get_messages_as_conversation.return_value = [] + cli._session_db = mock_db + + buf = StringIO() + cli.console.file = buf + result = cli._preload_resumed_session() + + assert result is False + output = buf.getvalue() + assert "no messages" in output + + def test_loads_session_successfully(self): + cli = _make_cli(resume="good_session") + messages = _simple_history() + mock_db = MagicMock() + mock_db.get_session.return_value = {"id": "good_session", "title": "Test Session"} + mock_db.get_messages_as_conversation.return_value = messages + cli._session_db = mock_db + + buf = StringIO() + cli.console.file = buf + result = cli._preload_resumed_session() + + assert result is True + assert cli.conversation_history == messages + output = buf.getvalue() + assert "Resumed session" in output + assert "good_session" in output + assert "Test Session" in output + assert "2 user messages" in output + + def test_reopens_session_in_db(self): + cli = _make_cli(resume="reopen_session") + messages = [{"role": "user", "content": "hi"}] + mock_db = MagicMock() + mock_db.get_session.return_value = {"id": "reopen_session", "title": None} + mock_db.get_messages_as_conversation.return_value = 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() + + def test_singular_user_message_grammar(self): + """1 user message should say 'message' not 'messages'.""" + cli = _make_cli(resume="one_msg_session") + messages = [ + {"role": "user", "content": "hello"}, + {"role": "assistant", "content": "hi"}, + ] + mock_db = MagicMock() + mock_db.get_session.return_value = {"id": "one_msg_session", "title": None} + mock_db.get_messages_as_conversation.return_value = messages + mock_db._conn = MagicMock() + cli._session_db = mock_db + + buf = StringIO() + cli.console.file = buf + cli._preload_resumed_session() + + output = buf.getvalue() + assert "1 user message," in output + assert "1 user messages" not in output + + +# ── 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" + + def test_cli_defaults_have_resume_display(self): + """cli.py load_cli_config defaults include resume_display.""" + import cli as _cli_mod + from cli import load_cli_config + + with ( + patch("pathlib.Path.exists", return_value=False), + patch.dict("os.environ", {"LLM_MODEL": ""}, clear=False), + ): + config = load_cli_config() + + display = config.get("display", {}) + assert display.get("resume_display") == "full"