Session resume recap and tool preview display fixes
NousResearch/hermes-agent · cluster 719 · hermes cli
Data as of
4 open items. Fix merged in #734. Check it covers your case.
The short answer
- Known?
- Yes. 11 related items grouped here, 3 issues already closed.
- Fix event
- A fix was merged to main in PR #734. Check it covers your case. No release data is tracked.
- Tracker
- The maintainers' tracker marks this
partially resolved, deciding PRs #734, #31695. That is separate from the fix event. - Still open
- 2 issues and 2 PRs.
Next step: read #734, then check whether your report is one of the 2 still open.
Read #734Pick up a Lab taskThese issues and PRs address two related display problems: truncation of tool calls ignoring configuration in TUI and CLI modes, and poor usability of the session resume recap due to tool-call clutter and hardcoded limits. Work spans bug reports, UX improvements, and fixes across CLI, TUI, and gateway components.
Open issues2
Open PRs2
Fixes on record
-
#734 feat: display previous messages when resuming a session in CLI
files:
AGENTS.md,cli.py,hermes_cli/config.py,tests/test_resume_display.py,website/docs/user-guide/cli.md,website/docs/user-guide/configuration.md,website/docs/user-guide/sessions.mdShow the diff
-
AGENTS.md@@ -185,6 +185,8 @@ Key components: - `agent/skill_commands.py` - Scans skills and builds invocation messages (shared with gateway) - `load_cli_config()` - Loads config, sets environment variables for terminal - `build_welcome_banner()` - Displays ASCII art logo, tools, and skills summary +- `_preload_resumed_session()` - Loads session history early (before banner) for immediate display on resume +- `_display_resumed_history()` - Renders a compact conversation recap in a Rich Panel on session resume CLI UX notes: - Thinking spinner (during LLM API call) shows animated kawaii face + verb (`(⌐■_■) deliberating...`) @@ -194,6 +196,7 @@ CLI UX notes: - The prompt shows `⚕ ❯` when the agent is working, `❯` when idle - Pasting 5+ lines auto-saves to `~/.hermes/pastes/` and collapses to a reference - Multi-line input via Alt+Enter or Ctrl+J +- When resuming a session (`--continue`/`--resume`), a "Previous Conversation" panel shows previous messages before the input prompt (configurable via `display.resume_display`) - `/commands` - Process user commands like `/help`, `/clear`, `/personality`, etc. - `/skill-name` - Invoke installed skills directly (e.g., `/axolotl`, `/gif-search`) -
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 @@ def __init__( 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 @@ def _init_agent(self) -> bool: 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 @@ def show_banner(self): 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 <REASONING_SCRATCHPAD>...</REASONING_SCRATCHPAD> blocks + from displayed text (reasoning model internal thoughts).""" + import re + cleaned = re.sub( + r"<REASONING_SCRATCHPAD>.*?</REASONING_SCRATCHPAD>\s*", + "", text, flags=re.DOTALL, + ) + # Also strip unclosed reasoning tags at the end + cleaned = re.sub( + r"<REASONING_SCRATCHPAD>.*$", + "", 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 @@ def _print_exit_summary(self): 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() -
hermes_cli/config.py@@ -92,6 +92,7 @@ def ensure_hermes_home(): "display": { "compact": False, "personality": "kawaii", + "resume_display": "full", # "full" (show previous messages) | "minimal" (one-liner only) }, # Text-to-speech configuration -
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): + """<REASONING_SCRATCHPAD> blocks should be stripped from display.""" + cli = _make_cli() + cli.conversation_history = [ + {"role": "user", "content": "Think about this"}, + { + "role": "assistant", + "content": ( + "<REASONING_SCRATCHPAD>\nLet me think step by step.\n" + "</REASONING_SCRATCHPAD>\n\nThe answer is 42." + ), + }, + ] + output = self._capture_display(cli) + + assert "REASONING_SCRATCHPAD" not in output + assert "Let me think step by step" not in output + assert "The answer is 42" in output + + def test_pure_reasoning_message_skipped(self): + """Assistant messages that are only reasoning should be skipped.""" + 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_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" -
website/docs/user-guide/cli.md@@ -65,6 +65,10 @@ hermes -w -q "Fix issue #123" # Single query in worktree The welcome banner shows your model, terminal backend, working directory, available tools, and installed skills at a glance. +### Session Resume Display + +When resuming a previous session (`hermes -c` or `hermes --resume <id>`), a "Previous Conversation" panel appears between the banner and the input prompt, showing a compact recap of the conversation history. See [Sessions — Conversation Recap on Resume](sessions.md#conversation-recap-on-resume) for details and configuration. + ## Keybindings | Key | Action | -
website/docs/user-guide/configuration.md@@ -468,6 +468,7 @@ display: tool_progress: all # off | new | all | verbose personality: "kawaii" # Default personality for the CLI compact: false # Compact output mode (less whitespace) + resume_display: full # full (show previous messages on resume) | minimal (one-liner only) ``` | Mode | What you see | -
website/docs/user-guide/sessions.md@@ -84,6 +84,35 @@ hermes chat --resume 20250305_091523_a1b2c3d4 Session IDs are shown when you exit a CLI session, and can be found with `hermes sessions list`. +### Conversation Recap on Resume + +When you resume a session, Hermes displays a compact recap of the previous conversation in a styled panel before the input prompt: + +```text +╭─────────────────────────── Previous Conversation ────────────────────────────╮ +│ ● You: What is Python? │ +│ ◆ Hermes: Python is a high-level programming language. │ +│ ● You: How do I install it? │ +│ ◆ Hermes: [3 tool calls: web_search, web_extract, terminal] │ +│ ◆ Hermes: You can download Python from python.org... │ +╰──────────────────────────────────────────────────────────────────────────────╯ +``` + +The recap: +- Shows **user messages** (gold `●`) and **assistant responses** (green `◆`) +- **Truncates** long messages (300 chars for user, 200 chars / 3 lines for assistant) +- **Collapses tool calls** to a count with tool names (e.g., `[3 tool calls: terminal, web_search]`) +- **Hides** system messages, tool results, and internal reasoning +- **Caps** at the last 10 exchanges with a "... N earlier messages ..." indicator +- Uses **dim styling** to distinguish from the active conversation + +To disable the recap and keep the minimal one-liner behavior, set in `~/.hermes/config.yaml`: + +```yaml +display: + resume_display: minimal # default: full +``` + :::tip Session IDs follow the format `YYYYMMDD_HHMMSS_<8-char-hex>`, e.g. `20250305_091523_a1b2c3d4`. You can resume by ID or by title — both work with `-c` and `-r`. :::
-
-
#31695 fix(cli): show recap after in-session /resume + tunable recap limits
files:
cli.py,hermes_cli/config.py,scripts/release.py,tests/cli/test_resume_display.pyShow the diff
-
cli.py@@ -415,6 +415,12 @@ def load_cli_config() -> Dict[str, Any]: "display": { "compact": False, "resume_display": "full", + # Recap tuning for /resume — see hermes_cli/config.py DEFAULT_CONFIG. + "resume_exchanges": 10, + "resume_max_user_chars": 300, + "resume_max_assistant_chars": 200, + "resume_max_assistant_lines": 3, + "resume_skip_tool_only": True, "show_reasoning": False, "streaming": True, "busy_input_mode": "interrupt", @@ -5088,10 +5094,13 @@ def _display_resumed_history(self): 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 + # Read limits from config (with hardcoded defaults) + _disp = CLI_CONFIG.get("display", {}) + MAX_DISPLAY_EXCHANGES = int(_disp.get("resume_exchanges", 10)) + MAX_USER_LEN = int(_disp.get("resume_max_user_chars", 300)) + MAX_ASST_LEN = int(_disp.get("resume_max_assistant_chars", 200)) + MAX_ASST_LINES = int(_disp.get("resume_max_assistant_lines", 3)) + SKIP_TOOL_ONLY = _disp.get("resume_skip_tool_only", True) # Collect displayable entries (skip system, tool-result messages) entries = [] # list of (role, display_text) @@ -5154,6 +5163,10 @@ def _display_resumed_history(self): if not parts: # Skip pure-reasoning messages that have no visible output continue + # Skip tool-call-only entries when SKIP_TOOL_ONLY is enabled + has_text = bool(text) + if SKIP_TOOL_ONLY and not has_text and tool_calls: + continue entries.append(("assistant", " ".join(parts))) _last_asst_idx = len(entries) - 1 _last_asst_full = " ".join(full_parts) @@ -6617,6 +6630,7 @@ def _handle_resume_command(self, cmd_original: str) -> None: f" ({msg_count} user message{'s' if msg_count != 1 else ''}," f" {len(self.conversation_history)} total)" ) + self._display_resumed_history() else: _cprint(f" ↻ Resumed session {target_id}{title_part} — no messages, starting fresh.") -
hermes_cli/config.py@@ -1008,6 +1008,19 @@ def _ensure_hermes_home_managed(home: Path): "compact": False, "personality": "kawaii", "resume_display": "full", + # Recap tuning for /resume and startup resume. The defaults match the + # historical hardcoded values; expose them as config so power users can + # widen or tighten the snapshot to taste. + "resume_exchanges": 10, # max user+assistant pairs to show + "resume_max_user_chars": 300, # truncate user message text + "resume_max_assistant_chars": 200, # truncate non-last assistant text + "resume_max_assistant_lines": 3, # truncate non-last assistant lines + # When True (default), assistant entries that are *only* tool calls + # (no visible text) are skipped in the recap. This prevents the recap + # from being dominated by `[2 tool calls: terminal, read_file]` lines + # when an exchange was tool-heavy. Set False to restore the legacy + # behavior of showing tool-call summaries inline. + "resume_skip_tool_only": True, "busy_input_mode": "interrupt", # interrupt | queue | steer # When true, `hermes --tui` auto-resumes the most recent human- # facing session on launch instead of forging a fresh one. -
scripts/release.py@@ -1278,6 +1278,7 @@ "[redacted:email]": "erikengervall", # PR #28774 (firecrawl integration tag) "[redacted:email]": "egilewski", # PR #30432 (MEDIA path traversal fix, GHSA-jmf9-9729-7pp8) "[redacted:email]": "McClean-Edison", # PR #29817 (register_auxiliary_task plugin API) + "[redacted:email]": "SamuelZ12", # PR #7480 (show recap after in-session resume) } -
tests/cli/test_resume_display.py@@ -155,14 +155,34 @@ def test_tool_messages_hidden(self): assert "Page content" not in output def test_tool_calls_shown_as_summary(self): - cli = _make_cli() + # Disable tool-only skip so the summary line is rendered for this fixture. + cli = _make_cli(config_overrides={"display": {"resume_skip_tool_only": False}}) cli.conversation_history = _tool_call_history() - output = self._capture_display(cli) + import cli as _cli_mod + # CLI_CONFIG is read at call-time inside _display_resumed_history, so + # apply the override for the duration of the capture, not just at init. + with patch.dict(_cli_mod.__dict__, {"CLI_CONFIG": { + "display": {"resume_skip_tool_only": False, "resume_display": "full"} + }}): + output = self._capture_display(cli) assert "2 tool calls" in output assert "web_search" in output assert "web_extract" in output + def test_tool_only_message_skipped_by_default(self): + """Assistant messages with only tool_calls (no text) are skipped when + 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_long_user_message_truncated(self): cli = _make_cli() long_text = "A" * 500 @@ -611,6 +631,55 @@ def test_singular_user_message_grammar(self): assert "1 user messages" not in output +# ── 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_messages_as_conversation.return_value = 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 + 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_skips_recap_when_session_has_no_messages(self): + cli = _make_cli() + cli.session_id = "current_session" + + mock_db = MagicMock() + mock_db.get_session.return_value = {"id": "target_session", "title": None} + mock_db.get_messages_as_conversation.return_value = [] + mock_db.resolve_resume_session_id.return_value = "target_session" + cli._session_db = mock_db + + with ( + patch("hermes_cli.main._resolve_session_by_name_or_id", return_value="target_session"), + patch.object(cli, "_display_resumed_history") as display_mock, + ): + cli._handle_resume_command("/resume target_session") + + display_mock.assert_not_called() + + # ── Integration: _init_agent skips when preloaded ────────────────────
-
2 PRs closed without merging
From the close comments: merged via another change 1 · other 1
Closed issues · 3
- Closed#719Feature: Display Previous Messages When Resuming a Session in CLI TUI
- Closed#4337[Bug] hermes -c resume recap is unusable in tool-heavy sessions: tool-call clutter and hardcoded truncation limits
- Closed#52601[Bug]: TUI truncates every Terminal tool call in transcript even with display.tool_preview_length=0
How this cluster was grouped
-
Link evidence (
partly structural links): some members are connected by structural evidence (shared changed files, or a closing/superseded reference); the rest are joined by an automated relevance judgement over their text. - Members outside that structural core may belong to a different underlying problem.
-
Pairwise link density
0.14: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36.1.00means every pair is linked.
Pick up a Lab task
Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.
Open the Lab