{"repo":"NousResearch/hermes-agent","public_id":"719","display_label":"Session resume recap and tool preview display fixes","summary":"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.","subsystem":"hermes_cli","counts":{"members":11,"open_issues":2,"open_issues_distinct":2,"open_prs":2,"closed_issues":3,"merged_prs":2,"attempt_prs":4},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":734},"resolution":{"state":"partially_resolved","by":"pr-734,pr-31695"},"freshness":{"state":"verified","last_verified_at":"2026-05-24T22:36:37+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.13527272727272727},"fix_edges":[{"issue_number":719,"pr_number":734,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":4337,"pr_number":31695,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":30351,"pr_number":31695,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false}],"members":[{"kind":"issue","number":719,"title":"Feature: Display Previous Messages When Resuming a Session in CLI TUI","url":null,"state":"closed"},{"kind":"issue","number":4337,"title":"[Bug] hermes -c resume recap is unusable in tool-heavy sessions: tool-call clutter and hardcoded truncation limits","url":null,"state":"closed"},{"kind":"issue","number":5703,"title":"CLI resume UX: show summary + recent tail + full-history hint","url":null,"state":"open"},{"kind":"issue","number":41846,"title":"[Bug] TUI 模式下 display.tool_preview_length 配置不生效","url":null,"state":"open"},{"kind":"issue","number":52601,"title":"[Bug]: TUI truncates every Terminal tool call in transcript even with display.tool_preview_length=0","url":null,"state":"closed"},{"kind":"pr","number":734,"title":"feat: display previous messages when resuming a session in CLI","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["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":[{"file":"AGENTS.md","hunks":["@@ -185,6 +185,8 @@ Key components:\n - `agent/skill_commands.py` - Scans skills and builds invocation messages (shared with gateway)\n - `load_cli_config()` - Loads config, sets environment variables for terminal\n - `build_welcome_banner()` - Displays ASCII art logo, tools, and skills summary\n+- `_preload_resumed_session()` - Loads session history early (before banner) for immediate display on resume\n+- `_display_resumed_history()` - Renders a compact conversation recap in a Rich Panel on session resume\n \n CLI UX notes:\n - Thinking spinner (during LLM API call) shows animated kawaii face + verb (`(⌐■_■) deliberating...`)","@@ -194,6 +196,7 @@ CLI UX notes:\n - The prompt shows `⚕ ❯` when the agent is working, `❯` when idle\n - Pasting 5+ lines auto-saves to `~/.hermes/pastes/` and collapses to a reference\n - Multi-line input via Alt+Enter or Ctrl+J\n+- When resuming a session (`--continue`/`--resume`), a \"Previous Conversation\" panel shows previous messages before the input prompt (configurable via `display.resume_display`)\n - `/commands` - Process user commands like `/help`, `/clear`, `/personality`, etc.\n - `/skill-name` - Invoke installed skills directly (e.g., `/axolotl`, `/gif-search`)\n "]},{"file":"cli.py","hunks":["@@ -193,6 +193,7 @@ def load_cli_config() -> Dict[str, Any]:\n         \"toolsets\": [\"all\"],\n         \"display\": {\n             \"compact\": False,\n+            \"resume_display\": \"full\",\n         },\n         \"clarify\": {\n             \"timeout\": 120,  # Seconds to wait for a clarify answer before auto-proceeding","@@ -1008,6 +1009,8 @@ def __init__(\n         self.compact = compact if compact is not None else CLI_CONFIG[\"display\"].get(\"compact\", False)\n         # tool_progress: \"off\", \"new\", \"all\", \"verbose\" (from config.yaml display section)\n         self.tool_progress_mode = CLI_CONFIG[\"display\"].get(\"tool_progress\", \"all\")\n+        # resume_display: \"full\" (show history) | \"minimal\" (one-liner only)\n+        self.resume_display = CLI_CONFIG[\"display\"].get(\"resume_display\", \"full\")\n         self.verbose = verbose if verbose is not None else (self.tool_progress_mode == \"verbose\")\n         \n         # Configuration - priority: CLI args > env vars > config file","@@ -1266,8 +1269,11 @@ def _init_agent(self) -> bool:\n             except Exception as e:\n                 logger.debug(\"SQLite session store not available: %s\", e)\n         \n-        # If resuming, validate the session exists and load its history\n-        if self._resumed and self._session_db:\n+        # If resuming, validate the session exists and load its history.\n+        # _preload_resumed_session() may have already loaded it (called from\n+        # run() for immediate display).  In that case, conversation_history\n+        # is non-empty and we skip the DB round-trip.\n+        if self._resumed and self._session_db and not self.conversation_history:\n             session_meta = self._session_db.get_session(self.session_id)\n             if not session_meta:\n                 _cprint(f\"\\033[1;31mSession not found: {self.session_id}{_RST}\")","@@ -1371,7 +1377,202 @@ def show_banner(self):\n         self._show_tool_availability_warnings()\n         \n         self.console.print()\n-    \n+\n+    def _preload_resumed_session(self) -> bool:\n+        \"\"\"Load a resumed session's history from the DB early (before first chat).\n+\n+        Called from run() so the conversation history is available for display\n+        before the user sends their first message.  Sets\n+        ``self.conversation_history`` and prints the one-liner status.  Returns\n+        True if history was loaded, False otherwise.\n+\n+        The corresponding block in ``_init_agent()`` checks whether history is\n+        already populated and skips the DB round-trip.\n+        \"\"\"\n+        if not self._resumed or not self._session_db:\n+            return False\n+\n+        session_meta = self._session_db.get_session(self.session_id)\n+        if not session_meta:\n+            self.console.print(\n+                f\"[bold red]Session not found: {self.session_id}[/]\"\n+            )\n+            self.console.print(\n+                \"[dim]Use a session ID from a previous CLI run \"\n+                \"(hermes sessions list).[/]\"\n+            )\n+            return False\n+\n+        restored = self._session_db.get_messages_as_conversation(self.session_id)\n+        if restored:\n+            self.conversation_history = restored\n+            msg_count = len([m for m in restored if m.get(\"role\") == \"user\"])\n+            title_part = \"\"\n+            if session_meta.get(\"title\"):\n+                title_part = f' \"{session_meta[\"title\"]}\"'\n+            self.console.print(\n+                f\"[#DAA520]↻ Resumed session [bold]{self.session_id}[/bold]\"\n+                f\"{title_part} \"\n+                f\"({msg_count} user message{'s' if msg_count != 1 else ''}, \"\n+                f\"{len(restored)} total messages)[/]\"\n+            )\n+        else:\n+            self.console.print(\n+                f\"[#DAA520]Session {self.session_id} found but has no \"\n+                f\"messages. Starting fresh.[/]\"\n+            )\n+            return False\n+\n+        # Re-open the session (clear ended_at so it's active again)\n+        try:\n+            self._session_db._conn.execute(\n+                \"UPDATE sessions SET ended_at = NULL, end_reason = NULL \"\n+                \"WHERE id = ?\",\n+                (self.session_id,),\n+            )\n+            self._session_db._conn.commit()\n+        except Exception:\n+            pass\n+\n+        return True\n+\n+    def _display_resumed_history(self):\n+        \"\"\"Render a compact recap of previous conversation messages.\n+\n+        Uses Rich markup with dim/muted styling so the recap is visually\n+        distinct from the active conversation.  Caps the display at the\n+        last ``MAX_DISPLAY_EXCHANGES`` user/assistant exchanges and shows\n+        an indicator for earlier hidden messages.\n+        \"\"\"\n+        if not self.conversation_history:\n+            return\n+\n+        # Check config: resume_display setting\n+        if self.resume_display == \"minimal\":\n+            return\n+\n+        MAX_DISPLAY_EXCHANGES = 10   # max user+assistant pairs to show\n+        MAX_USER_LEN = 300           # truncate user messages\n+        MAX_ASST_LEN = 200           # truncate assistant text\n+        MAX_ASST_LINES = 3           # max lines of assistant text\n+\n+        def _strip_reasoning(text: str) -> str:\n+            \"\"\"Remove <REASONING_SCRATCHPAD>...</REASONING_SCRATCHPAD> blocks\n+            from displayed text (reasoning model internal thoughts).\"\"\"\n+            import re\n+            cleaned = re.sub(\n+                r\"<REASONING_SCRATCHPAD>.*?</REASONING_SCRATCHPAD>\\s*\",\n+                \"\", text, flags=re.DOTALL,\n+            )\n+            # Also strip unclosed reasoning tags at the end\n+            cleaned = re.sub(\n+                r\"<REASONING_SCRATCHPAD>.*$\",\n+                \"\", cleaned, flags=re.DOTALL,\n+            )\n+            return cleaned.strip()\n+\n+        # Collect displayable entries (skip system, tool-result messages)\n+        entries = []  # list of (role, display_text)\n+        for msg in self.conversation_history:\n+            role = msg.get(\"role\", \"\")\n+            content = msg.get(\"content\")\n+            tool_calls = msg.get(\"tool_calls\") or []\n+\n+            if role == \"system\":\n+                continue\n+            if role == \"tool\":\n+                continue\n+\n+            if role == \"user\":\n+                text = \"\" if content is None else str(content)\n+                # Handle multimodal content (list of dicts)\n+                if isinstance(content, list):\n+                    parts = []\n+                    for part in content:\n+                        if isinstance(part, dict) and part.get(\"type\") == \"text\":\n+                            parts.append(part.get(\"text\", \"\"))\n+                        elif isinstance(part, dict) and part.get(\"type\") == \"image_url\":\n+                            parts.append(\"[image]\")\n+                    text = \" \".join(parts)\n+                if len(text) > MAX_USER_LEN:\n+                    text = text[:MAX_USER_LEN] + \"...\"\n+                entries.append((\"user\", text))\n+\n+            elif role == \"assistant\":\n+                text = \"\" if content is None else str(content)\n+                text = _strip_reasoning(text)\n+                parts = []\n+                if text:\n+                    lines = text.splitlines()\n+                    if len(lines) > MAX_ASST_LINES:\n+                        text = \"\\n\".join(lines[:MAX_ASST_LINES]) + \" ...\"\n+                    if len(text) > MAX_ASST_LEN:\n+                        text = text[:MAX_ASST_LEN] + \"...\"\n+                    parts.append(text)\n+                if tool_calls:\n+                    tc_count = len(tool_calls)\n+                    # Extract tool names\n+                    names = []\n+                    for tc in tool_calls:\n+                        fn = tc.get(\"function\", {})\n+                        name = fn.get(\"name\", \"unknown\") if isinstance(fn, dict) else \"unknown\"\n+                        if name not in names:\n+                            names.append(name)\n+                    names_str = \", \".join(names[:4])\n+                    if len(names) > 4:\n+                        names_str += \", ...\"\n+                    noun = \"call\" if tc_count == 1 else \"calls\"\n+                    parts.append(f\"[{tc_count} tool {noun}: {names_str}]\")\n+                if not parts:\n+                    # Skip pure-reasoning messages that have no visible output\n+                    continue\n+                entries.append((\"assistant\", \" \".join(parts)))\n+\n+        if not entries:\n+            return\n+\n+        # Determine if we need to truncate\n+        skipped = 0\n+        if len(entries) > MAX_DISPLAY_EXCHANGES * 2:\n+            skipped = len(entries) - MAX_DISPLAY_EXCHANGES * 2\n+            entries = entries[skipped:]\n+\n+        # Build the display using Rich\n+        from rich.panel import Panel\n+        from rich.text import Text\n+\n+        lines = Text()\n+        if skipped:\n+            lines.append(\n+                f\"  ... {skipped} earlier messages ...\\n\\n\",\n+                style=\"dim italic\",\n+            )\n+\n+        for i, (role, text) in enumerate(entries):\n+            if role == \"user\":\n+                lines.append(\"  ● You: \", style=\"dim bold #DAA520\")\n+                # Show first line inline, indent rest\n+                msg_lines = text.splitlines()\n+                lines.append(msg_lines[0] + \"\\n\", style=\"dim\")\n+                for ml in msg_lines[1:]:\n+                    lines.append(f\"         {ml}\\n\", style=\"dim\")\n+            else:\n+                lines.append(\"  ◆ Hermes: \", style=\"dim bold #8FBC8F\")\n+                msg_lines = text.splitlines()\n+                lines.append(msg_lines[0] + \"\\n\", style=\"dim\")\n+                for ml in msg_lines[1:]:\n+                    lines.append(f\"            {ml}\\n\", style=\"dim\")\n+            if i < len(entries) - 1:\n+                lines.append(\"\")  # small gap\n+\n+        panel = Panel(\n+            lines,\n+            title=\"[dim #DAA520]Previous Conversation[/]\",\n+            border_style=\"dim #8B8682\",\n+            padding=(0, 1),\n+        )\n+        self.console.print(panel)\n+\n     def _try_attach_clipboard_image(self) -> bool:\n         \"\"\"Check clipboard for an image and attach it if found.\n ","@@ -2948,6 +3149,13 @@ def _print_exit_summary(self):\n     def run(self):\n         \"\"\"Run the interactive CLI loop with persistent input at bottom.\"\"\"\n         self.show_banner()\n+\n+        # If resuming a session, load history and display it immediately\n+        # so the user has context before typing their first message.\n+        if self._resumed:\n+            if self._preload_resumed_session():\n+                self._display_resumed_history()\n+\n         self.console.print(\"[#FFF8DC]Welcome to Hermes Agent! Type your message or /help for commands.[/]\")\n         self.console.print()\n         "]},{"file":"hermes_cli/config.py","hunks":["@@ -92,6 +92,7 @@ def ensure_hermes_home():\n     \"display\": {\n         \"compact\": False,\n         \"personality\": \"kawaii\",\n+        \"resume_display\": \"full\",  # \"full\" (show previous messages) | \"minimal\" (one-liner only)\n     },\n     \n     # Text-to-speech configuration"]},{"file":"tests/test_resume_display.py","hunks":["@@ -0,0 +1,488 @@\n+\"\"\"Tests for session resume history display — _display_resumed_history() and\n+_preload_resumed_session().\n+\n+Verifies that resuming a session shows a compact recap of the previous\n+conversation with correct formatting, truncation, and config behavior.\n+\"\"\"\n+\n+import os\n+import sys\n+from io import StringIO\n+from unittest.mock import MagicMock, patch\n+\n+import pytest\n+\n+sys.path.insert(0, os.path.join(os.path.dirname(__file__), \"..\"))\n+\n+\n+def _make_cli(config_overrides=None, env_overrides=None, **kwargs):\n+    \"\"\"Create a HermesCLI instance with minimal mocking.\"\"\"\n+    import cli as _cli_mod\n+    from cli import HermesCLI\n+\n+    _clean_config = {\n+        \"model\": {\n+            \"default\": \"anthropic/claude-opus-4.6\",\n+            \"base_url\": \"https://openrouter.ai/api/v1\",\n+            \"provider\": \"auto\",\n+        },\n+        \"display\": {\"compact\": False, \"tool_progress\": \"all\", \"resume_display\": \"full\"},\n+        \"agent\": {},\n+        \"terminal\": {\"env_type\": \"local\"},\n+    }\n+    if config_overrides:\n+        for k, v in config_overrides.items():\n+            if isinstance(v, dict) and k in _clean_config and isinstance(_clean_config[k], dict):\n+                _clean_config[k].update(v)\n+            else:\n+                _clean_config[k] = v\n+\n+    clean_env = {\"LLM_MODEL\": \"\", \"HERMES_MAX_ITERATIONS\": \"\"}\n+    if env_overrides:\n+        clean_env.update(env_overrides)\n+    with (\n+        patch(\"cli.get_tool_definitions\", return_value=[]),\n+        patch.dict(\"os.environ\", clean_env, clear=False),\n+        patch.dict(_cli_mod.__dict__, {\"CLI_CONFIG\": _clean_config}),\n+    ):\n+        return HermesCLI(**kwargs)\n+\n+\n+# ── Sample conversation histories for tests ──────────────────────────\n+\n+\n+def _simple_history():\n+    \"\"\"Two-turn conversation: user → assistant → user → assistant.\"\"\"\n+    return [\n+        {\"role\": \"system\", \"content\": \"You are a helpful assistant.\"},\n+        {\"role\": \"user\", \"content\": \"What is Python?\"},\n+        {\"role\": \"assistant\", \"content\": \"Python is a high-level programming language.\"},\n+        {\"role\": \"user\", \"content\": \"How do I install it?\"},\n+        {\"role\": \"assistant\", \"content\": \"You can install Python from python.org.\"},\n+    ]\n+\n+\n+def _tool_call_history():\n+    \"\"\"Conversation with tool calls and tool results.\"\"\"\n+    return [\n+        {\"role\": \"system\", \"content\": \"system prompt\"},\n+        {\"role\": \"user\", \"content\": \"Search for Python tutorials\"},\n+        {\n+            \"role\": \"assistant\",\n+            \"content\": None,\n+            \"tool_calls\": [\n+                {\n+                    \"id\": \"call_1\",\n+                    \"type\": \"function\",\n+                    \"function\": {\"name\": \"web_search\", \"arguments\": '{\"query\":\"python tutorials\"}'},\n+                },\n+                {\n+                    \"id\": \"call_2\",\n+                    \"type\": \"function\",\n+                    \"function\": {\"name\": \"web_extract\", \"arguments\": '{\"urls\":[\"https://example.com\"]}'},\n+                },\n+            ],\n+        },\n+        {\"role\": \"tool\", \"tool_call_id\": \"call_1\", \"content\": \"Found 5 results...\"},\n+        {\"role\": \"tool\", \"tool_call_id\": \"call_2\", \"content\": \"Page content...\"},\n+        {\"role\": \"assistant\", \"content\": \"Here are some great Python tutorials I found.\"},\n+    ]\n+\n+\n+def _large_history(n_exchanges=15):\n+    \"\"\"Build a history with many exchanges to test truncation.\"\"\"\n+    msgs = [{\"role\": \"system\", \"content\": \"system prompt\"}]\n+    for i in range(n_exchanges):\n+        msgs.append({\"role\": \"user\", \"content\": f\"Question #{i + 1}: What is item {i + 1}?\"})\n+        msgs.append({\"role\": \"assistant\", \"content\": f\"Answer #{i + 1}: Item {i + 1} is great.\"})\n+    return msgs\n+\n+\n+def _multimodal_history():\n+    \"\"\"Conversation with multimodal (image) content.\"\"\"\n+    return [\n+        {\"role\": \"system\", \"content\": \"system prompt\"},\n+        {\n+            \"role\": \"user\",\n+            \"content\": [\n+                {\"type\": \"text\", \"text\": \"What's in this image?\"},\n+                {\"type\": \"image_url\", \"image_url\": {\"url\": \"https://example.com/cat.jpg\"}},\n+            ],\n+        },\n+        {\"role\": \"assistant\", \"content\": \"I see a cat in the image.\"},\n+    ]\n+\n+\n+# ── Tests for _display_resumed_history ───────────────────────────────\n+\n+\n+class TestDisplayResumedHistory:\n+    \"\"\"_display_resumed_history() renders a Rich panel with conversation recap.\"\"\"\n+\n+    def _capture_display(self, cli_obj):\n+        \"\"\"Run _display_resumed_history and capture the Rich console output.\"\"\"\n+        buf = StringIO()\n+        cli_obj.console.file = buf\n+        cli_obj._display_resumed_history()\n+        return buf.getvalue()\n+\n+    def test_simple_history_shows_user_and_assistant(self):\n+        cli = _make_cli()\n+        cli.conversation_history = _simple_history()\n+        output = self._capture_display(cli)\n+\n+        assert \"You:\" in output\n+        assert \"Hermes:\" in output\n+        assert \"What is Python?\" in output\n+        assert \"Python is a high-level programming language.\" in output\n+        assert \"How do I install it?\" in output\n+\n+    def test_system_messages_hidden(self):\n+        cli = _make_cli()\n+        cli.conversation_history = _simple_history()\n+        output = self._capture_display(cli)\n+\n+        assert \"You are a helpful assistant\" not in output\n+\n+    def test_tool_messages_hidden(self):\n+        cli = _make_cli()\n+        cli.conversation_history = _tool_call_history()\n+        output = self._capture_display(cli)\n+\n+        # Tool result content should NOT appear\n+        assert \"Found 5 results\" not in output\n+        assert \"Page content\" not in output\n+\n+    def test_tool_calls_shown_as_summary(self):\n+        cli = _make_cli()\n+        cli.conversation_history = _tool_call_history()\n+        output = self._capture_display(cli)\n+\n+        assert \"2 tool calls\" in output\n+        assert \"web_search\" in output\n+        assert \"web_extract\" in output\n+\n+    def test_long_user_message_truncated(self):\n+        cli = _make_cli()\n+        long_text = \"A\" * 500\n+        cli.conversation_history = [\n+            {\"role\": \"user\", \"content\": long_text},\n+            {\"role\": \"assistant\", \"content\": \"OK.\"},\n+        ]\n+        output = self._capture_display(cli)\n+\n+        # Should have truncation indicator and NOT contain the full 500 chars\n+        assert \"...\" in output\n+        assert \"A\" * 500 not in output\n+        # The 300-char truncated text is present but may be line-wrapped by\n+        # Rich's panel renderer, so check the total A count in the output\n+        a_count = output.count(\"A\")\n+        assert 200 <= a_count <= 310  # roughly 300 chars (±panel padding)\n+\n+    def test_long_assistant_message_truncated(self):\n+        cli = _make_cli()\n+        long_text = \"B\" * 400\n+        cli.conversation_history = [\n+            {\"role\": \"user\", \"content\": \"Tell me a lot.\"},\n+            {\"role\": \"assistant\", \"content\": long_text},\n+        ]\n+        output = self._capture_display(cli)\n+\n+        assert \"...\" in output\n+        assert \"B\" * 400 not in output\n+\n+    def test_multiline_assistant_truncated(self):\n+        cli = _make_cli()\n+        multi = \"\\n\".join([f\"Line {i}\" for i in range(20)])\n+        cli.conversation_history = [\n+            {\"role\": \"user\", \"content\": \"Show me lines.\"},\n+            {\"role\": \"assistant\", \"content\": multi},\n+        ]\n+        output = self._capture_display(cli)\n+\n+        # First 3 lines should be there\n+        assert \"Line 0\" in output\n+        assert \"Line 1\" in output\n+        assert \"Line 2\" in output\n+        # Line 19 should NOT be there (truncated after 3 lines)\n+        assert \"Line 19\" not in output\n+\n+    def test_large_history_shows_truncation_indicator(self):\n+        cli = _make_cli()\n+        cli.conversation_history = _large_history(n_exchanges=15)\n+        output = self._capture_display(cli)\n+\n+        # Should show \"earlier messages\" indicator\n+        assert \"earlier messages\" in output\n+        # Last question should still be visible\n+        assert \"Question #15\" in output\n+\n+    def test_multimodal_content_handled(self):\n+        cli = _make_cli()\n+        cli.conversation_history = _multimodal_history()\n+        output = self._capture_display(cli)\n+\n+        assert \"What's in this image?\" in output\n+        assert \"[image]\" in output\n+\n+    def test_empty_history_no_output(self):\n+        cli = _make_cli()\n+        cli.conversation_history = []\n+        output = self._capture_display(cli)\n+\n+        assert output.strip() == \"\"\n+\n+    def test_minimal_config_suppresses_display(self):\n+        cli = _make_cli(config_overrides={\"display\": {\"resume_display\": \"minimal\"}})\n+        # resume_display is captured as an instance variable during __init__\n+        assert cli.resume_display == \"minimal\"\n+        cli.conversation_history = _simple_history()\n+        output = self._capture_display(cli)\n+\n+        assert output.strip() == \"\"\n+\n+    def test_panel_has_title(self):\n+        cli = _make_cli()\n+        cli.conversation_history = _simple_history()\n+        output = self._capture_display(cli)\n+\n+        assert \"Previous Conversation\" in output\n+\n+    def test_assistant_with_no_content_no_tools_skipped(self):\n+        \"\"\"Assistant messages with no visible output (e.g. pure reasoning)\n+        are skipped in the recap.\"\"\"\n+        cli = _make_cli()\n+        cli.conversation_history = [\n+            {\"role\": \"user\", \"content\": \"Hello\"},\n+            {\"role\": \"assistant\", \"content\": None},\n+        ]\n+        output = self._capture_display(cli)\n+\n+        # The assistant entry should be skipped, only the user message shown\n+        assert \"You:\" in output\n+        assert \"Hermes:\" not in output\n+\n+    def test_only_system_messages_no_output(self):\n+        cli = _make_cli()\n+        cli.conversation_history = [\n+            {\"role\": \"system\", \"content\": \"You are helpful.\"},\n+        ]\n+        output = self._capture_display(cli)\n+\n+        assert output.strip() == \"\"\n+\n+    def test_reasoning_scratchpad_stripped(self):\n+        \"\"\"<REASONING_SCRATCHPAD> blocks should be stripped from display.\"\"\"\n+        cli = _make_cli()\n+        cli.conversation_history = [\n+            {\"role\": \"user\", \"content\": \"Think about this\"},\n+            {\n+                \"role\": \"assistant\",\n+                \"content\": (\n+                    \"<REASONING_SCRATCHPAD>\\nLet me think step by step.\\n\"\n+                    \"</REASONING_SCRATCHPAD>\\n\\nThe answer is 42.\"\n+                ),\n+            },\n+        ]\n+        output = self._capture_display(cli)\n+\n+        assert \"REASONING_SCRATCHPAD\" not in output\n+        assert \"Let me think step by step\" not in output\n+        assert \"The answer is 42\" in output\n+\n+    def test_pure_reasoning_message_skipped(self):\n+        \"\"\"Assistant messages that are only reasoning should be skipped.\"\"\"\n+        cli = _make_cli()\n+        cli.conversation_history = [\n+            {\"role\": \"user\", \"content\": \"Hello\"},\n+            {\n+                \"role\": \"assistant\",\n+                \"content\": \"<REASONING_SCRATCHPAD>\\nJust thinking...\\n</REASONING_SCRATCHPAD>\",\n+            },\n+            {\"role\": \"assistant\", \"content\": \"Hi there!\"},\n+        ]\n+        output = self._capture_display(cli)\n+\n+        assert \"Just thinking\" not in output\n+        assert \"Hi there!\" in output\n+\n+    def test_assistant_with_text_and_tool_calls(self):\n+        \"\"\"When an assistant message has both text content AND tool_calls.\"\"\"\n+        cli = _make_cli()\n+        cli.conversation_history = [\n+            {\"role\": \"user\", \"content\": \"Do something complex\"},\n+            {\n+                \"role\": \"assistant\",\n+                \"content\": \"Let me search for that.\",\n+                \"tool_calls\": [\n+                    {\n+                        \"id\": \"call_1\",\n+                        \"type\": \"function\",\n+                        \"function\": {\"name\": \"terminal\", \"arguments\": '{\"command\":\"ls\"}'},\n+                    }\n+                ],\n+            },\n+        ]\n+        output = self._capture_display(cli)\n+\n+        assert \"Let me search for that.\" in output\n+        assert \"1 tool call\" in output\n+        assert \"terminal\" in output\n+\n+\n+# ── Tests for _preload_resumed_session ──────────────────────────────\n+\n+\n+class TestPreloadResumedSession:\n+    \"\"\"_preload_resumed_session() loads session from DB early.\"\"\"\n+\n+    def test_returns_false_when_not_resumed(self):\n+        cli = _make_cli()\n+        assert cli._preload_resumed_session() is False\n+\n+    def test_returns_false_when_no_session_db(self):\n+        cli = _make_cli(resume=\"test_session_id\")\n+        cli._session_db = None\n+        assert cli._preload_resumed_session() is False\n+\n+    def test_returns_false_when_session_not_found(self):\n+        cli = _make_cli(resume=\"nonexistent_session\")\n+        mock_db = MagicMock()\n+        mock_db.get_session.return_value = None\n+        cli._session_db = mock_db\n+\n+        buf = StringIO()\n+        cli.console.file = buf\n+        result = cli._preload_resumed_session()\n+\n+        assert result is False\n+        output = buf.getvalue()\n+        assert \"Session not found\" in output\n+\n+    def test_returns_false_when_session_has_no_messages(self):\n+        cli = _make_cli(resume=\"empty_session\")\n+        mock_db = MagicMock()\n+        mock_db.get_session.return_value = {\"id\": \"empty_session\", \"title\": None}\n+        mock_db.get_messages_as_conversation.return_value = []\n+        cli._session_db = mock_db\n+\n+        buf = StringIO()\n+        cli.console.file = buf\n+        result = cli._preload_resumed_session()\n+\n+        assert result is False\n+        output = buf.getvalue()\n+        assert \"no messages\" in output\n+\n+    def test_loads_session_successfully(self):\n+        cli = _make_cli(resume=\"good_session\")\n+        messages = _simple_history()\n+        mock_db = MagicMock()\n+        mock_db.get_session.return_value = {\"id\": \"good_session\", \"title\": \"Test Session\"}\n+        mock_db.get_messages_as_conversation.return_value = messages\n+        cli._session_db = mock_db\n+\n+        buf = StringIO()\n+        cli.console.file = buf\n+        result = cli._preload_resumed_session()\n+\n+        assert result is True\n+        assert cli.conversation_history == messages\n+        output = buf.getvalue()\n+        assert \"Resumed session\" in output\n+        assert \"good_session\" in output\n+        assert \"Test Session\" in output\n+        assert \"2 user messages\" in output\n+\n+    def test_reopens_session_in_db(self):\n+        cli = _make_cli(resume=\"reopen_session\")\n+        messages = [{\"role\": \"user\", \"content\": \"hi\"}]\n+        mock_db = MagicMock()\n+        mock_db.get_session.return_value = {\"id\": \"reopen_session\", \"title\": None}\n+        mock_db.get_messages_as_conversation.return_value = messages\n+        mock_conn = MagicMock()\n+        mock_db._conn = mock_conn\n+        cli._session_db = mock_db\n+\n+        buf = StringIO()\n+        cli.console.file = buf\n+        cli._preload_resumed_session()\n+\n+        # Should have executed UPDATE to clear ended_at\n+        mock_conn.execute.assert_called_once()\n+        call_args = mock_conn.execute.call_args\n+        assert \"ended_at = NULL\" in call_args[0][0]\n+        mock_conn.commit.assert_called_once()\n+\n+    def test_singular_user_message_grammar(self):\n+        \"\"\"1 user message should say 'message' not 'messages'.\"\"\"\n+        cli = _make_cli(resume=\"one_msg_session\")\n+        messages = [\n+            {\"role\": \"user\", \"content\": \"hello\"},\n+            {\"role\": \"assistant\", \"content\": \"hi\"},\n+        ]\n+        mock_db = MagicMock()\n+        mock_db.get_session.return_value = {\"id\": \"one_msg_session\", \"title\": None}\n+        mock_db.get_messages_as_conversation.return_value = messages\n+        mock_db._conn = MagicMock()\n+        cli._session_db = mock_db\n+\n+        buf = StringIO()\n+        cli.console.file = buf\n+        cli._preload_resumed_session()\n+\n+        output = buf.getvalue()\n+        assert \"1 user message,\" in output\n+        assert \"1 user messages\" not in output\n+\n+\n+# ── Integration: _init_agent skips when preloaded ────────────────────\n+\n+\n+class TestInitAgentSkipsPreloaded:\n+    \"\"\"_init_agent() should skip DB load when history is already populated.\"\"\"\n+\n+    def test_init_agent_skips_db_when_preloaded(self):\n+        \"\"\"If conversation_history is already set, _init_agent should not\n+        reload from the DB.\"\"\"\n+        cli = _make_cli(resume=\"preloaded_session\")\n+        cli.conversation_history = _simple_history()\n+\n+        mock_db = MagicMock()\n+        cli._session_db = mock_db\n+\n+        # _init_agent will fail at credential resolution (no real API key),\n+        # but the session-loading block should be skipped entirely\n+        with patch.object(cli, \"_ensure_runtime_credentials\", return_value=False):\n+            cli._init_agent()\n+\n+        # get_messages_as_conversation should NOT have been called\n+        mock_db.get_messages_as_conversation.assert_not_called()\n+\n+\n+# ── Config default tests ─────────────────────────────────────────────\n+\n+\n+class TestResumeDisplayConfig:\n+    \"\"\"resume_display config option defaults and behavior.\"\"\"\n+\n+    def test_default_config_has_resume_display(self):\n+        \"\"\"DEFAULT_CONFIG in hermes_cli/config.py includes resume_display.\"\"\"\n+        from hermes_cli.config import DEFAULT_CONFIG\n+        display = DEFAULT_CONFIG.get(\"display\", {})\n+        assert \"resume_display\" in display\n+        assert display[\"resume_display\"] == \"full\"\n+\n+    def test_cli_defaults_have_resume_display(self):\n+        \"\"\"cli.py load_cli_config defaults include resume_display.\"\"\"\n+        import cli as _cli_mod\n+        from cli import load_cli_config\n+\n+        with (\n+            patch(\"pathlib.Path.exists\", return_value=False),\n+            patch.dict(\"os.environ\", {\"LLM_MODEL\": \"\"}, clear=False),\n+        ):\n+            config = load_cli_config()\n+\n+        display = config.get(\"display\", {})\n+        assert display.get(\"resume_display\") == \"full\""]},{"file":"website/docs/user-guide/cli.md","hunks":["@@ -65,6 +65,10 @@ hermes -w -q \"Fix issue #123\"     # Single query in worktree\n \n The welcome banner shows your model, terminal backend, working directory, available tools, and installed skills at a glance.\n \n+### Session Resume Display\n+\n+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.\n+\n ## Keybindings\n \n | Key | Action |"]},{"file":"website/docs/user-guide/configuration.md","hunks":["@@ -468,6 +468,7 @@ display:\n   tool_progress: all    # off | new | all | verbose\n   personality: \"kawaii\"  # Default personality for the CLI\n   compact: false         # Compact output mode (less whitespace)\n+  resume_display: full   # full (show previous messages on resume) | minimal (one-liner only)\n ```\n \n | Mode | What you see |"]},{"file":"website/docs/user-guide/sessions.md","hunks":["@@ -84,6 +84,35 @@ hermes chat --resume 20250305_091523_a1b2c3d4\n \n Session IDs are shown when you exit a CLI session, and can be found with `hermes sessions list`.\n \n+### Conversation Recap on Resume\n+\n+When you resume a session, Hermes displays a compact recap of the previous conversation in a styled panel before the input prompt:\n+\n+```text\n+╭─────────────────────────── Previous Conversation ────────────────────────────╮\n+│   ● You: What is Python?                                                     │\n+│   ◆ Hermes: Python is a high-level programming language.                     │\n+│   ● You: How do I install it?                                                │\n+│   ◆ Hermes: [3 tool calls: web_search, web_extract, terminal]                │\n+│   ◆ Hermes: You can download Python from python.org...                       │\n+╰──────────────────────────────────────────────────────────────────────────────╯\n+```\n+\n+The recap:\n+- Shows **user messages** (gold `●`) and **assistant responses** (green `◆`)\n+- **Truncates** long messages (300 chars for user, 200 chars / 3 lines for assistant)\n+- **Collapses tool calls** to a count with tool names (e.g., `[3 tool calls: terminal, web_search]`)\n+- **Hides** system messages, tool results, and internal reasoning\n+- **Caps** at the last 10 exchanges with a \"... N earlier messages ...\" indicator\n+- Uses **dim styling** to distinguish from the active conversation\n+\n+To disable the recap and keep the minimal one-liner behavior, set in `~/.hermes/config.yaml`:\n+\n+```yaml\n+display:\n+  resume_display: minimal   # default: full\n+```\n+\n :::tip\n 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`.\n :::\n"]}]},{"kind":"pr","number":4434,"title":"fix(cli): skip tool-call-only entries in resume recap, expose limits as config options","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#31695","number":31695},{"kind":"pr","value":"#31695","number":31695},{"kind":"pr","value":"#31695","number":31695}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":5704,"title":"feat(cli): improve resumed session recap","url":null,"state":"closed","close_reason":{"category":"uncategorized","implication":"needs_llm"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":31695,"title":"fix(cli): show recap after in-session /resume + tunable recap limits","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["cli.py","hermes_cli/config.py","scripts/release.py","tests/cli/test_resume_display.py"],"fix_hunks":[{"file":"cli.py","hunks":["@@ -415,6 +415,12 @@ def load_cli_config() -> Dict[str, Any]:\n         \"display\": {\n             \"compact\": False,\n             \"resume_display\": \"full\",\n+            # Recap tuning for /resume — see hermes_cli/config.py DEFAULT_CONFIG.\n+            \"resume_exchanges\": 10,\n+            \"resume_max_user_chars\": 300,\n+            \"resume_max_assistant_chars\": 200,\n+            \"resume_max_assistant_lines\": 3,\n+            \"resume_skip_tool_only\": True,\n             \"show_reasoning\": False,\n             \"streaming\": True,\n             \"busy_input_mode\": \"interrupt\",","@@ -5088,10 +5094,13 @@ def _display_resumed_history(self):\n         if self.resume_display == \"minimal\":\n             return\n \n-        MAX_DISPLAY_EXCHANGES = 10   # max user+assistant pairs to show\n-        MAX_USER_LEN = 300           # truncate user messages\n-        MAX_ASST_LEN = 200           # truncate assistant text\n-        MAX_ASST_LINES = 3           # max lines of assistant text\n+        # Read limits from config (with hardcoded defaults)\n+        _disp = CLI_CONFIG.get(\"display\", {})\n+        MAX_DISPLAY_EXCHANGES = int(_disp.get(\"resume_exchanges\", 10))\n+        MAX_USER_LEN = int(_disp.get(\"resume_max_user_chars\", 300))\n+        MAX_ASST_LEN = int(_disp.get(\"resume_max_assistant_chars\", 200))\n+        MAX_ASST_LINES = int(_disp.get(\"resume_max_assistant_lines\", 3))\n+        SKIP_TOOL_ONLY = _disp.get(\"resume_skip_tool_only\", True)\n \n         # Collect displayable entries (skip system, tool-result messages)\n         entries = []  # list of (role, display_text)","@@ -5154,6 +5163,10 @@ def _display_resumed_history(self):\n                 if not parts:\n                     # Skip pure-reasoning messages that have no visible output\n                     continue\n+                # Skip tool-call-only entries when SKIP_TOOL_ONLY is enabled\n+                has_text = bool(text)\n+                if SKIP_TOOL_ONLY and not has_text and tool_calls:\n+                    continue\n                 entries.append((\"assistant\", \" \".join(parts)))\n                 _last_asst_idx = len(entries) - 1\n                 _last_asst_full = \" \".join(full_parts)","@@ -6617,6 +6630,7 @@ def _handle_resume_command(self, cmd_original: str) -> None:\n                 f\" ({msg_count} user message{'s' if msg_count != 1 else ''},\"\n                 f\" {len(self.conversation_history)} total)\"\n             )\n+            self._display_resumed_history()\n         else:\n             _cprint(f\"  ↻ Resumed session {target_id}{title_part} — no messages, starting fresh.\")\n "]},{"file":"hermes_cli/config.py","hunks":["@@ -1008,6 +1008,19 @@ def _ensure_hermes_home_managed(home: Path):\n         \"compact\": False,\n         \"personality\": \"kawaii\",\n         \"resume_display\": \"full\",\n+        # Recap tuning for /resume and startup resume. The defaults match the\n+        # historical hardcoded values; expose them as config so power users can\n+        # widen or tighten the snapshot to taste.\n+        \"resume_exchanges\": 10,            # max user+assistant pairs to show\n+        \"resume_max_user_chars\": 300,      # truncate user message text\n+        \"resume_max_assistant_chars\": 200, # truncate non-last assistant text\n+        \"resume_max_assistant_lines\": 3,   # truncate non-last assistant lines\n+        # When True (default), assistant entries that are *only* tool calls\n+        # (no visible text) are skipped in the recap. This prevents the recap\n+        # from being dominated by `[2 tool calls: terminal, read_file]` lines\n+        # when an exchange was tool-heavy. Set False to restore the legacy\n+        # behavior of showing tool-call summaries inline.\n+        \"resume_skip_tool_only\": True,\n         \"busy_input_mode\": \"interrupt\",  # interrupt | queue | steer\n         # When true, `hermes --tui` auto-resumes the most recent human-\n         # facing session on launch instead of forging a fresh one."]},{"file":"scripts/release.py","hunks":["@@ -1278,6 +1278,7 @@\n     \"[redacted:email]\": \"erikengervall\",  # PR #28774 (firecrawl integration tag)\n     \"[redacted:email]\": \"egilewski\",  # PR #30432 (MEDIA path traversal fix, GHSA-jmf9-9729-7pp8)\n     \"[redacted:email]\": \"McClean-Edison\",  # PR #29817 (register_auxiliary_task plugin API)\n+    \"[redacted:email]\": \"SamuelZ12\",  # PR #7480 (show recap after in-session resume)\n }\n \n "]},{"file":"tests/cli/test_resume_display.py","hunks":["@@ -155,14 +155,34 @@ def test_tool_messages_hidden(self):\n         assert \"Page content\" not in output\n \n     def test_tool_calls_shown_as_summary(self):\n-        cli = _make_cli()\n+        # Disable tool-only skip so the summary line is rendered for this fixture.\n+        cli = _make_cli(config_overrides={\"display\": {\"resume_skip_tool_only\": False}})\n         cli.conversation_history = _tool_call_history()\n-        output = self._capture_display(cli)\n+        import cli as _cli_mod\n+        # CLI_CONFIG is read at call-time inside _display_resumed_history, so\n+        # apply the override for the duration of the capture, not just at init.\n+        with patch.dict(_cli_mod.__dict__, {\"CLI_CONFIG\": {\n+            \"display\": {\"resume_skip_tool_only\": False, \"resume_display\": \"full\"}\n+        }}):\n+            output = self._capture_display(cli)\n \n         assert \"2 tool calls\" in output\n         assert \"web_search\" in output\n         assert \"web_extract\" in output\n \n+    def test_tool_only_message_skipped_by_default(self):\n+        \"\"\"Assistant messages with only tool_calls (no text) are skipped when\n+        resume_skip_tool_only=True (the default). The summary line is hidden.\n+        \"\"\"\n+        cli = _make_cli()\n+        cli.conversation_history = _tool_call_history()\n+        output = self._capture_display(cli)\n+\n+        # The tool-only assistant entry should be skipped\n+        assert \"2 tool calls\" not in output\n+        # The final text reply should still appear\n+        assert \"Here are some great Python tutorials\" in output\n+\n     def test_long_user_message_truncated(self):\n         cli = _make_cli()\n         long_text = \"A\" * 500","@@ -611,6 +631,55 @@ def test_singular_user_message_grammar(self):\n         assert \"1 user messages\" not in output\n \n \n+# ── Tests for _handle_resume_command recap display ───────────────────\n+\n+\n+class TestHandleResumeCommandRecap:\n+    \"\"\"In-session /resume should show the same recap panel as startup resume.\"\"\"\n+\n+    def test_resume_command_displays_recap_when_messages_restored(self):\n+        cli = _make_cli()\n+        cli.session_id = \"current_session\"\n+        messages = _simple_history()\n+\n+        mock_db = MagicMock()\n+        mock_db.get_session.return_value = {\"id\": \"target_session\", \"title\": \"Test Session\"}\n+        mock_db.get_messages_as_conversation.return_value = messages\n+        # resolve_resume_session_id passes the id through when no compression chain.\n+        mock_db.resolve_resume_session_id.return_value = \"target_session\"\n+        cli._session_db = mock_db\n+\n+        with (\n+            patch(\"hermes_cli.main._resolve_session_by_name_or_id\", return_value=\"target_session\"),\n+            patch.object(cli, \"_display_resumed_history\") as display_mock,\n+        ):\n+            cli._handle_resume_command(\"/resume test session\")\n+\n+        assert cli.session_id == \"target_session\"\n+        assert cli.conversation_history == messages\n+        mock_db.end_session.assert_called_once_with(\"current_session\", \"resumed_other\")\n+        mock_db.reopen_session.assert_called_once_with(\"target_session\")\n+        display_mock.assert_called_once_with()\n+\n+    def test_resume_command_skips_recap_when_session_has_no_messages(self):\n+        cli = _make_cli()\n+        cli.session_id = \"current_session\"\n+\n+        mock_db = MagicMock()\n+        mock_db.get_session.return_value = {\"id\": \"target_session\", \"title\": None}\n+        mock_db.get_messages_as_conversation.return_value = []\n+        mock_db.resolve_resume_session_id.return_value = \"target_session\"\n+        cli._session_db = mock_db\n+\n+        with (\n+            patch(\"hermes_cli.main._resolve_session_by_name_or_id\", return_value=\"target_session\"),\n+            patch.object(cli, \"_display_resumed_history\") as display_mock,\n+        ):\n+            cli._handle_resume_command(\"/resume target_session\")\n+\n+        display_mock.assert_not_called()\n+\n+\n # ── Integration: _init_agent skips when preloaded ────────────────────\n \n \n"]}]},{"kind":"pr","number":41852,"title":"fix(tui): honor configured tool preview length","url":null,"state":"open","close_reason":{"category":"uncategorized","implication":"needs_llm"},"merged":false},{"kind":"pr","number":41854,"title":"fix(tui): respect display.tool_preview_length config in TUI mode","url":null,"state":"open","close_reason":{"refs":[{"kind":"commit","value":"481caa66f23c75eede53867e99a68400012cf501","number":0}],"category":"uncategorized","implication":"needs_llm"},"merged":false}],"trust":{"default_tier":"verified","includes_suggested":false}}