# NousResearch/hermes-agent · Session resume recap and tool preview display fixes

## Open now

- 2 issues, 2 PRs open on GitHub.
- #5703: CLI resume UX: show summary + recent tail + full-history hint
- #41846: \[Bug\] TUI 模式下 display.tool_preview_length 配置不生效
- PR #41852: fix(tui): honor configured tool preview length
- PR #41854: fix(tui): respect display.tool_preview_length config in TUI mode

These 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.

## TL;DR

- 11 member(s): 2 open issue(s), 3 closed issue(s), 2 open PR(s), 2 merged PR(s), 2 PR(s) closed without merging.
- A fix was merged to main in PR #734. Verify it addresses your case.
- **Recommended:** read PR #734 (and any competing PRs below) before filing. Confirm the merged change covers your symptom.

## Maintainer resolution verdict

- Internal-tracker verdict over this cluster's issue members: `partially_resolved` · deciding PR(s): `pr-734,pr-31695`. This is distinct from the fix status above. Weigh it yourself.

## How this cluster was grouped

- Link evidence (`STRUCTURAL_PARTIAL`): 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 member pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. `1.00` means every pair is linked.

## Open related issues

- #5703: CLI resume UX: show summary + recent tail + full-history hint
- #41846: \[Bug\] TUI 模式下 display.tool_preview_length 配置不生效

## Closed / duplicate issues

- #719: Feature: Display Previous Messages When Resuming a Session in CLI TUI
- #4337: \[Bug\] hermes -c resume recap is unusable in tool-heavy sessions: tool-call clutter and hardcoded truncation limits
- #52601: \[Bug\]: TUI truncates every Terminal tool call in transcript even with display.tool_preview_length=0

## Prior & competing PRs

- **#734: feat: display previous messages when resuming a session in CLI**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - 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.md`
  - fix hunks:
  - `AGENTS.md`

    ```diff
    @@ -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...`)
    ```

    ```diff
    @@ -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`

    ```diff
    @@ -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
    ```

    ```diff
    @@ -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
    ```

    ```diff
    @@ -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}")
    ```

    ```diff
    @@ -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.
     
    ```

    ```diff
    @@ -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`

    ```diff
    @@ -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`

    ```diff
    @@ -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`

    ```diff
    @@ -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`

    ```diff
    @@ -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`

    ```diff
    @@ -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`.
     :::
    ```
- **#5704: feat(cli): improve resumed session recap**. closed without merge · fix tier `suggested`
  - maintainer direction: category `uncategorized`; implication `needs_llm`
  - _suggested-tier fix detail withheld; re-request with `?tier=all` to include it._
- **#31695: fix(cli): show recap after in-session /resume + tunable recap limits**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `cli.py`, `hermes_cli/config.py`, `scripts/release.py`, `tests/cli/test_resume_display.py`
  - fix hunks:
  - `cli.py`

    ```diff
    @@ -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",
    ```

    ```diff
    @@ -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)
    ```

    ```diff
    @@ -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)
    ```

    ```diff
    @@ -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`

    ```diff
    @@ -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`

    ```diff
    @@ -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`

    ```diff
    @@ -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
    ```

    ```diff
    @@ -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 ────────────────────
     
     
    ```
- **#4434: fix(cli): skip tool-call-only entries in resume recap, expose limits as config options**. closed without merge
  - maintainer direction: category `merged_via`; implication `superseded`; refs `#31695`, `#31695`, `#31695`
- **#41852: fix(tui): honor configured tool preview length**. open
  - maintainer direction: category `uncategorized`; implication `needs_llm`
  - files: `tests/tui_gateway/test_protocol.py`, `tui_gateway/server.py`
- **#41854: fix(tui): respect display.tool_preview_length config in TUI mode**. open
  - maintainer direction: category `uncategorized`; implication `needs_llm`; refs `481caa66f23c75eede53867e99a68400012cf501`
  - files: `tests/test_tui_gateway_server.py`, `tui_gateway/entry.py`, `tui_gateway/server.py`

## Fix status

- Merged to main in PR #734 (cited above). No release data is tracked. Verify downstream.
- Fix last verified (merged) `2026-05-24`.
- Subsystem: `hermes_cli`
