{"repo":"NousResearch/hermes-agent","public_id":"7723","display_label":"CLI tool progress display mode fixes","summary":"Multiple fixes and bugs related to how the CLI renders tool output across different display modes (verbose, full/all, quiet). Issues center on tool_progress configuration not being respected correctly in various contexts.","subsystem":"hermes_cli","counts":{"members":6,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":2,"merged_prs":2,"attempt_prs":2},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":31293},"resolution":{"state":"partially_resolved","by":"pr-31293"},"freshness":{"state":"verified","last_verified_at":"2026-06-08T18:29:53+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.29066666666666663},"fix_edges":[{"issue_number":7723,"pr_number":31293,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":25939,"pr_number":42252,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":33860,"pr_number":42252,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":42039,"pr_number":42252,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false}],"members":[{"kind":"issue","number":7723,"title":"[Bug]: CLI tool_progress=verbose now renders like full/all instead of showing complete verbose output","url":null,"state":"closed"},{"kind":"issue","number":33860,"title":"Interactive CLI hides tool output despite display.tool_progress=all, and hermes chat -v does not restore it","url":null,"state":"closed"},{"kind":"pr","number":29665,"title":"Fix CLI verbose tool progress config fallback","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"6a1aa420e","number":0},{"kind":"pr","value":"#31293","number":31293},{"kind":"pr","value":"#7723","number":7723}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":31293,"title":"fix(cli): bring tool-call display back — verbose mode, specific failure reasons, todo progress","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["agent/display.py","agent/tool_executor.py","cli.py","hermes_cli/_parser.py","hermes_cli/main.py","tests/agent/test_display_todo_progress.py","tests/agent/test_display_tool_failure.py","tests/cli/test_tool_progress_scrollback.py","tests/hermes_cli/test_argparse_flag_propagation.py"],"fix_hunks":[{"file":"agent/display.py","hunks":["@@ -787,33 +787,65 @@ def __exit__(self, exc_type, exc_val, exc_tb):\n # Cute tool message (completion line that replaces the spinner)\n # =========================================================================\n \n+_ERROR_SUFFIX_MAX_LEN = 48\n+\n+\n+def _trim_error(msg: str) -> str:\n+    \"\"\"Shrink an error message for inline display in a tool status line.\n+\n+    Strips overly long absolute paths down to just the filename so the\n+    suffix stays readable on narrow terminals.\n+    \"\"\"\n+    msg = msg.strip()\n+    # Common case: \"File not found: /very/long/absolute/path/foo.py\"\n+    if \"File not found:\" in msg:\n+        _, _, tail = msg.partition(\"File not found:\")\n+        tail = tail.strip()\n+        if \"/\" in tail:\n+            msg = f\"File not found: {tail.rsplit('/', 1)[-1]}\"\n+    if len(msg) > _ERROR_SUFFIX_MAX_LEN:\n+        msg = msg[: _ERROR_SUFFIX_MAX_LEN - 3] + \"...\"\n+    return msg\n+\n+\n def _detect_tool_failure(tool_name: str, result: str | None) -> tuple[bool, str]:\n     \"\"\"Inspect a tool result string for signs of failure.\n \n-    Returns ``(is_failure, suffix)`` where *suffix* is an informational tag\n-    like ``\" [exit 1]\"`` for terminal failures, or ``\" [error]\"`` for generic\n-    failures.  On success, returns ``(False, \"\")``.\n+    Returns ``(is_failure, suffix)`` where *suffix* is a short informational\n+    tag like ``\" [exit 1]\"`` for terminal failures, ``\" [full]\"`` for memory\n+    overflow, or a trimmed error message (``\" [File not found: foo.py]\"``).\n+    On success returns ``(False, \"\")``.\n     \"\"\"\n     if result is None:\n         return False, \"\"\n     if file_mutation_result_landed(tool_name, result):\n         return False, \"\"\n \n+    data = safe_json_loads(result)\n+\n+    # Terminal: non-zero exit code is the canonical failure signal.\n     if tool_name == \"terminal\":\n-        data = safe_json_loads(result)\n         if isinstance(data, dict):\n             exit_code = data.get(\"exit_code\")\n             if exit_code is not None and exit_code != 0:\n+                err_msg = data.get(\"error\")\n+                if err_msg:\n+                    return True, f\" [{_trim_error(str(err_msg))}]\"\n                 return True, f\" [exit {exit_code}]\"\n         return False, \"\"\n \n-    # Memory-specific: distinguish \"full\" from real errors\n+    # Memory: distinguish \"store full\" from real errors.\n     if tool_name == \"memory\":\n-        data = safe_json_loads(result)\n         if isinstance(data, dict):\n             if data.get(\"success\") is False and \"exceed the limit\" in data.get(\"error\", \"\"):\n                 return True, \" [full]\"\n \n+    # Structured error in JSON result (any tool that surfaces {\"error\": ...}).\n+    if isinstance(data, dict):\n+        err = data.get(\"error\") or data.get(\"message\")\n+        if err and (data.get(\"success\") is False or \"error\" in data):\n+            return True, f\" [{_trim_error(str(err))}]\"\n+\n     # Generic heuristic for non-terminal tools\n     # Multimodal tool results (dicts with _multimodal=True) are not strings —\n     # treat them as successes since failures would be JSON-encoded strings.","@@ -921,11 +953,29 @@ def _wrap(line: str) -> str:\n     if tool_name == \"todo\":\n         todos_arg = args.get(\"todos\")\n         merge = args.get(\"merge\", False)\n+        # Parse result for completion progress\n+        total = 0\n+        done = 0\n+        if result:\n+            try:\n+                data = safe_json_loads(result)\n+                if data:\n+                    s = data.get(\"summary\", {})\n+                    total = s.get(\"total\", 0)\n+                    done = s.get(\"completed\", 0)\n+            except Exception:\n+                pass\n         if todos_arg is None:\n+            if total > 0:\n+                return _wrap(f\"┊ 📋 plan      {done}/{total} task(s)  {dur}\")\n             return _wrap(f\"┊ 📋 plan      reading tasks  {dur}\")\n         elif merge:\n+            if total > 0 and done > 0:\n+                return _wrap(f\"┊ 📋 plan      update {done}/{total} ✓  {dur}\")\n             return _wrap(f\"┊ 📋 plan      update {len(todos_arg)} task(s)  {dur}\")\n         else:\n+            if total > 0 and done > 0:\n+                return _wrap(f\"┊ 📋 plan      {done}/{total} task(s)  {dur}\")\n             return _wrap(f\"┊ 📋 plan      {len(todos_arg)} task(s)  {dur}\")\n     if tool_name == \"session_search\":\n         return _wrap(f\"┊ 🔍 recall    \\\"{_trunc(args.get('query', ''), 35)}\\\"  {dur}\")"]},{"file":"agent/tool_executor.py","hunks":["@@ -388,6 +388,7 @@ def _run_tool(index, tool_call, function_name, function_args):\n                     agent.tool_progress_callback(\n                         \"tool.completed\", function_name, None, None,\n                         duration=tool_duration, is_error=is_error,\n+                        result=function_result,\n                     )\n                 except Exception as cb_err:\n                     logging.debug(f\"Tool progress callback error: {cb_err}\")","@@ -822,6 +823,7 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe\n                 agent.tool_progress_callback(\n                     \"tool.completed\", function_name, None, None,\n                     duration=tool_duration, is_error=_is_error_result,\n+                    result=function_result,\n                 )\n             except Exception as cb_err:\n                 logging.debug(f\"Tool progress callback error: {cb_err}\")"]},{"file":"cli.py","hunks":["@@ -2819,7 +2819,7 @@ def __init__(\n         api_key: str = None,\n         base_url: str = None,\n         max_turns: int = None,\n-        verbose: bool = False,\n+        verbose: Optional[bool] = None,\n         compact: bool = False,\n         resume: str = None,\n         checkpoints: bool = False,","@@ -10264,9 +10264,7 @@ def _on_tool_progress(self, event_type: str, function_name: str = None, preview:\n                 self._last_scrollback_tool = function_name\n                 try:\n                     from agent.display import get_cute_tool_message\n-                    line = get_cute_tool_message(function_name, stored_args, duration)\n-                    if is_error:\n-                        line = f\"{line} [error]\"\n+                    line = get_cute_tool_message(function_name, stored_args, duration, result=kwargs.get(\"result\"))\n                     _cprint(f\"  {line}\")\n                 except Exception:\n                     pass","@@ -14439,7 +14437,7 @@ def main(\n     api_key: str = None,\n     base_url: str = None,\n     max_turns: int = None,\n-    verbose: bool = False,\n+    verbose: Optional[bool] = None,\n     quiet: bool = False,\n     compact: bool = False,\n     list_tools: bool = False,"]},{"file":"hermes_cli/_parser.py","hunks":["@@ -269,7 +269,11 @@ def build_top_level_parser():\n         help=\"Inference provider (default: auto). Built-in or a user-defined name from `providers:` in config.yaml.\",\n     )\n     chat_parser.add_argument(\n-        \"-v\", \"--verbose\", action=\"store_true\", help=\"Verbose output\"\n+        \"-v\",\n+        \"--verbose\",\n+        action=\"store_true\",\n+        default=argparse.SUPPRESS,\n+        help=\"Verbose output\",\n     )\n     chat_parser.add_argument(\n         \"-Q\","]},{"file":"hermes_cli/main.py","hunks":["@@ -1454,7 +1454,7 @@ def _launch_tui(\n     provider: Optional[str] = None,\n     toolsets: object = None,\n     skills: object = None,\n-    verbose: bool = False,\n+    verbose: Optional[bool] = None,\n     quiet: bool = False,\n     query: Optional[str] = None,\n     image: Optional[str] = None,","@@ -1763,7 +1763,7 @@ def cmd_chat(args):\n             provider=getattr(args, \"provider\", None),\n             toolsets=getattr(args, \"toolsets\", None),\n             skills=getattr(args, \"skills\", None),\n-            verbose=getattr(args, \"verbose\", False),\n+            verbose=getattr(args, \"verbose\", None),\n             quiet=getattr(args, \"quiet\", False),\n             query=getattr(args, \"query\", None),\n             image=getattr(args, \"image\", None),","@@ -1783,7 +1783,7 @@ def cmd_chat(args):\n         \"provider\": getattr(args, \"provider\", None),\n         \"toolsets\": args.toolsets,\n         \"skills\": getattr(args, \"skills\", None),\n-        \"verbose\": args.verbose,\n+        \"verbose\": getattr(args, \"verbose\", None),\n         \"quiet\": getattr(args, \"quiet\", False),\n         \"query\": args.query,\n         \"image\": getattr(args, \"image\", None),","@@ -13811,7 +13811,7 @@ def cmd_acp(args):\n             (\"model\", None),\n             (\"provider\", None),\n             (\"toolsets\", None),\n-            (\"verbose\", False),\n+            (\"verbose\", None),\n             (\"worktree\", False),\n         ]:\n             if not hasattr(args, attr):","@@ -13826,7 +13826,7 @@ def cmd_acp(args):\n             (\"model\", None),\n             (\"provider\", None),\n             (\"toolsets\", None),\n-            (\"verbose\", False),\n+            (\"verbose\", None),\n             (\"resume\", None),\n             (\"continue_last\", None),\n             (\"worktree\", False),"]},{"file":"tests/agent/test_display_todo_progress.py","hunks":["@@ -0,0 +1,243 @@\n+\"\"\"Tests for get_cute_tool_message todo progress display.\n+\n+Verifies the completion status rendering (done/total ✓) on all three\n+todo tool call paths: read, create (merge=False), update (merge=True).\n+\"\"\"\n+\n+import json\n+import pytest\n+from agent.display import get_cute_tool_message\n+\n+\n+def _todo_result(total: int, completed: int) -> str:\n+    \"\"\"Build a fake todo_tool return value.\"\"\"\n+    return json.dumps({\n+        \"todos\": [],\n+        \"summary\": {\n+            \"total\": total,\n+            \"pending\": total - completed,\n+            \"in_progress\": 0,\n+            \"completed\": completed,\n+            \"cancelled\": 0,\n+        },\n+    })\n+\n+\n+class TestTodoRead:\n+    \"\"\"get_cute_tool_message(…, result=…) when todos_arg is None (read path).\"\"\"\n+\n+    def test_read_no_result(self):\n+        msg = get_cute_tool_message(\"todo\", {}, 0.5)\n+        assert \"reading tasks\" in msg\n+        assert \"0.5s\" in msg\n+\n+    def test_read_with_progress(self):\n+        msg = get_cute_tool_message(\"todo\", {}, 0.5,\n+                                    result=_todo_result(4, 2))\n+        assert \"2/4\" in msg\n+        assert \"task(s)\" in msg\n+\n+    def test_read_all_done(self):\n+        msg = get_cute_tool_message(\"todo\", {}, 0.5,\n+                                    result=_todo_result(4, 4))\n+        assert \"4/4\" in msg\n+        assert \"task(s)\" in msg\n+\n+    def test_read_zero_total(self):\n+        \"\"\"Edge case: empty todo list returns summary with total=0.\"\"\"\n+        msg = get_cute_tool_message(\"todo\", {}, 0.5,\n+                                    result=_todo_result(0, 0))\n+        assert \"reading tasks\" in msg\n+\n+    def test_read_invalid_result_fallback(self):\n+        \"\"\"Garbage result should not crash; fall back to reading tasks.\"\"\"\n+        msg = get_cute_tool_message(\"todo\", {}, 0.5, result=\"not json\")\n+        assert \"reading tasks\" in msg\n+\n+    def test_read_result_missing_summary(self):\n+        msg = get_cute_tool_message(\"todo\", {}, 0.5,\n+                                    result='{\"todos\": []}')\n+        assert \"reading tasks\" in msg\n+\n+\n+class TestTodoCreate:\n+    \"\"\"get_cute_tool_message when merge=False (new plan creation).\"\"\"\n+\n+    def test_create_default(self):\n+        \"\"\"Brand-new plan: all pending, no result — plain count.\"\"\"\n+        msg = get_cute_tool_message(\"todo\",\n+                                    {\"todos\": [\n+                                        {\"id\": \"a\", \"content\": \"x\", \"status\": \"pending\"},\n+                                    ]}, 0.3)\n+        assert \"1 task(s)\" in msg\n+        assert \"0.3s\" in msg\n+        assert \"/\" not in msg  # no progress fraction\n+\n+    def test_create_multiple(self):\n+        msg = get_cute_tool_message(\"todo\",\n+                                    {\"todos\": [\n+                                        {\"id\": \"a\", \"content\": \"x\", \"status\": \"pending\"},\n+                                        {\"id\": \"b\", \"content\": \"y\", \"status\": \"pending\"},\n+                                        {\"id\": \"c\", \"content\": \"z\", \"status\": \"pending\"},\n+                                    ]}, 0.2)\n+        assert \"3 task(s)\" in msg\n+\n+    def test_create_with_result_shows_progress_when_done(self):\n+        \"\"\"Even on create, if result has completed tasks show it.\"\"\"\n+        msg = get_cute_tool_message(\"todo\",\n+                                    {\"todos\": [{\"id\": \"a\", \"content\": \"x\", \"status\": \"completed\"}]},\n+                                    0.4,\n+                                    result=_todo_result(1, 1))\n+        assert \"1/1\" in msg\n+        assert \"task(s)\" in msg\n+\n+    def test_create_with_result_zero_done(self):\n+        \"\"\"New plan with 0 done — plain count, no progress fraction.\"\"\"\n+        msg = get_cute_tool_message(\"todo\",\n+                                    {\"todos\": [\n+                                        {\"id\": \"a\", \"content\": \"x\", \"status\": \"pending\"},\n+                                        {\"id\": \"b\", \"content\": \"y\", \"status\": \"pending\"},\n+                                    ]},\n+                                    0.3,\n+                                    result=_todo_result(2, 0))\n+        assert \"2 task(s)\" in msg\n+        assert \"/\" not in msg\n+\n+\n+class TestTodoUpdate:\n+    \"\"\"get_cute_tool_message when merge=True (incremental update).\"\"\"\n+\n+    def test_update_no_result(self):\n+        \"\"\"No result available — plain update N task(s).\"\"\"\n+        msg = get_cute_tool_message(\"todo\",\n+                                    {\"todos\": [{\"id\": \"a\", \"status\": \"completed\"}],\n+                                     \"merge\": True}, 0.5)\n+        assert \"update 1 task(s)\" in msg\n+\n+    def test_update_partial_progress(self):\n+        \"\"\"1/4 tasks completed — show fraction with checkmark.\"\"\"\n+        msg = get_cute_tool_message(\"todo\",\n+                                    {\"todos\": [{\"id\": \"a\", \"status\": \"completed\"}],\n+                                     \"merge\": True},\n+                                    0.5,\n+                                    result=_todo_result(4, 1))\n+        assert \"update\" in msg\n+        assert \"1/4\" in msg\n+        assert \"✓\" in msg\n+\n+    def test_update_halfway(self):\n+        \"\"\"2/4 — midpoint progress.\"\"\"\n+        msg = get_cute_tool_message(\"todo\",\n+                                    {\"todos\": [{\"id\": \"b\", \"status\": \"in_progress\"}],\n+                                     \"merge\": True},\n+                                    0.7,\n+                                    result=_todo_result(4, 2))\n+        assert \"2/4\" in msg\n+        assert \"✓\" in msg\n+\n+    def test_update_all_completed(self):\n+        \"\"\"4/4 — full checkmark.\"\"\"\n+        msg = get_cute_tool_message(\"todo\",\n+                                    {\"todos\": [{\"id\": \"d\", \"status\": \"completed\"}],\n+                                     \"merge\": True},\n+                                    0.2,\n+                                    result=_todo_result(4, 4))\n+        assert \"4/4\" in msg\n+        assert \"✓\" in msg\n+\n+    def test_update_zero_done(self):\n+        \"\"\"No completed tasks yet — plain update N task(s).\"\"\"\n+        msg = get_cute_tool_message(\"todo\",\n+                                    {\"todos\": [{\"id\": \"a\", \"status\": \"pending\"}],\n+                                     \"merge\": True},\n+                                    0.3,\n+                                    result=_todo_result(3, 0))\n+        assert \"update 1 task(s)\" in msg\n+        assert \"✓\" not in msg\n+        assert \"/\" not in msg  # no progress fraction when done=0\n+\n+    def test_update_invalid_result_fallback(self):\n+        \"\"\"Bad JSON result — fall back to plain update N task(s).\"\"\"\n+        msg = get_cute_tool_message(\"todo\",\n+                                    {\"todos\": [{\"id\": \"a\", \"status\": \"completed\"}],\n+                                     \"merge\": True},\n+                                    0.6,\n+                                    result=\"{broken\")\n+        assert \"update 1 task(s)\" in msg\n+        assert \"✓\" not in msg\n+\n+    def test_update_result_missing_summary(self):\n+        \"\"\"Result no summary key — fall back to plain update.\"\"\"\n+        msg = get_cute_tool_message(\"todo\",\n+                                    {\"todos\": [{\"id\": \"a\", \"status\": \"completed\"}],\n+                                     \"merge\": True},\n+                                    0.4,\n+                                    result='{\"todos\": []}')\n+        assert \"update 1 task(s)\" in msg\n+        assert \"✓\" not in msg\n+\n+    def test_update_total_not_in_summary(self):\n+        \"\"\"Result summary missing total key.\"\"\"\n+        msg = get_cute_tool_message(\"todo\",\n+                                    {\"todos\": [{\"id\": \"a\", \"status\": \"completed\"}],\n+                                     \"merge\": True},\n+                                    0.3,\n+                                    result=json.dumps({\"summary\": {\"completed\": 2}}))\n+        assert \"update 1 task(s)\" in msg\n+        assert \"✓\" not in msg\n+\n+    def test_update_multiple_tasks_in_line(self):\n+        \"\"\"Update line with several tasks in the update request.\"\"\"\n+        msg = get_cute_tool_message(\"todo\",\n+                                    {\"todos\": [\n+                                        {\"id\": \"a\", \"status\": \"completed\"},\n+                                        {\"id\": \"b\", \"status\": \"in_progress\"},\n+                                    ], \"merge\": True},\n+                                    0.5,\n+                                    result=_todo_result(5, 3))\n+        assert \"update\" in msg\n+        assert \"3/5\" in msg\n+        assert \"✓\" in msg\n+\n+\n+class TestTodoEdgeCases:\n+    \"\"\"Boundary cases that should not crash.\"\"\"\n+\n+    def test_merge_default_value(self):\n+        \"\"\"merge defaults to False in function signature, should be False when absent.\"\"\"\n+        msg = get_cute_tool_message(\"todo\",\n+                                    {\"todos\": [{\"id\": \"a\", \"content\": \"x\", \"status\": \"pending\"}]},\n+                                    1.0)\n+        assert \"1 task(s)\" in msg\n+\n+    def test_duration_formatting(self):\n+        \"\"\"Duration formatting works correctly.\"\"\"\n+        msg = get_cute_tool_message(\"todo\", {}, 0.123)\n+        assert \"0.1s\" in msg\n+\n+        msg = get_cute_tool_message(\"todo\", {}, 1.0)\n+        assert \"1.0s\" in msg\n+\n+        msg = get_cute_tool_message(\"todo\", {}, 123.456)\n+        assert \"123.5s\" in msg\n+\n+    def test_large_task_count(self):\n+        \"\"\"Many tasks should not break formatting.\"\"\"\n+        many = [{\"id\": str(i), \"content\": \"x\", \"status\": \"pending\"} for i in range(50)]\n+        msg = get_cute_tool_message(\"todo\", {\"todos\": many}, 0.5)\n+        assert \"50 task(s)\" in msg\n+\n+    def test_read_with_no_args_and_no_result(self):\n+        \"\"\"Completely empty call.\"\"\"\n+        msg = get_cute_tool_message(\"todo\", {}, 0.0)\n+        assert \"reading tasks\" in msg\n+\n+\n+class TestTodoSkinIntegration:\n+    \"\"\"Verify the skin prefix is applied to todo messages too.\n+    This uses the same pattern as test_skin_engine test_tool_message_uses_skin_prefix.\n+    \"\"\"\n+\n+    def test_default_skin_prefix(self):\n+        msg = get_cute_tool_message(\"todo\", {}, 0.5)\n+        assert msg.startswith(\"┊\")"]},{"file":"tests/agent/test_display_tool_failure.py","hunks":["@@ -0,0 +1,185 @@\n+\"\"\"Tests for _detect_tool_failure + _trim_error + get_cute_tool_message\n+inline failure suffix rendering.\n+\n+Covers the user-visible promise: when a tool fails, the CLI shows a short,\n+specific reason in square brackets at the end of the completion line —\n+not a generic \"[error]\".\n+\"\"\"\n+\n+import json\n+import pytest\n+\n+from agent.display import (\n+    _detect_tool_failure,\n+    _trim_error,\n+    _ERROR_SUFFIX_MAX_LEN,\n+    get_cute_tool_message,\n+)\n+\n+\n+class TestTrimError:\n+    \"\"\"The helper that shrinks an error message for inline display.\"\"\"\n+\n+    def test_short_message_unchanged(self):\n+        assert _trim_error(\"nope\") == \"nope\"\n+\n+    def test_whitespace_stripped(self):\n+        assert _trim_error(\"  bad input  \") == \"bad input\"\n+\n+    def test_long_message_truncated_to_cap(self):\n+        msg = \"x\" * 200\n+        trimmed = _trim_error(msg)\n+        assert len(trimmed) <= _ERROR_SUFFIX_MAX_LEN\n+        assert trimmed.endswith(\"...\")\n+\n+    def test_file_not_found_path_collapsed_to_filename(self):\n+        long_path = \"File not found: /home/teknium/.hermes/hermes-agent/very/deep/path/foo.py\"\n+        assert _trim_error(long_path) == \"File not found: foo.py\"\n+\n+    def test_file_not_found_already_short_unchanged(self):\n+        assert _trim_error(\"File not found: foo.py\") == \"File not found: foo.py\"\n+\n+    def test_file_not_found_relative_path_unchanged(self):\n+        # Without a slash there's no path to trim.\n+        assert _trim_error(\"File not found: foo.py\") == \"File not found: foo.py\"\n+\n+\n+class TestDetectToolFailureTerminal:\n+    \"\"\"terminal: non-zero exit_code is the canonical failure signal.\"\"\"\n+\n+    def test_success_returns_no_suffix(self):\n+        result = json.dumps({\"output\": \"ok\\n\", \"exit_code\": 0})\n+        assert _detect_tool_failure(\"terminal\", result) == (False, \"\")\n+\n+    def test_nonzero_exit_with_no_error_shows_exit_code(self):\n+        result = json.dumps({\"output\": \"\", \"exit_code\": 1})\n+        is_failure, suffix = _detect_tool_failure(\"terminal\", result)\n+        assert is_failure is True\n+        assert suffix == \" [exit 1]\"\n+\n+    def test_nonzero_exit_with_error_shows_message(self):\n+        result = json.dumps({\n+            \"output\": \"\",\n+            \"exit_code\": 127,\n+            \"error\": \"ls: cannot access 'foo': No such file or directory\",\n+        })\n+        is_failure, suffix = _detect_tool_failure(\"terminal\", result)\n+        assert is_failure is True\n+        assert \"cannot access\" in suffix\n+        # Trimmed to the cap, in brackets\n+        assert suffix.startswith(\" [\")\n+        assert suffix.endswith(\"]\")\n+\n+    def test_malformed_json_returns_no_suffix(self):\n+        # Terminal is special: only exit_code matters. Malformed JSON should\n+        # not crash and should not be flagged as failure.\n+        assert _detect_tool_failure(\"terminal\", \"not json\") == (False, \"\")\n+\n+    def test_none_result_returns_no_suffix(self):\n+        assert _detect_tool_failure(\"terminal\", None) == (False, \"\")\n+\n+\n+class TestDetectToolFailureMemory:\n+    \"\"\"memory: 'full' is distinct from real errors.\"\"\"\n+\n+    def test_memory_full_returns_full_suffix(self):\n+        result = json.dumps({\"success\": False, \"error\": \"would exceed the limit\"})\n+        assert _detect_tool_failure(\"memory\", result) == (True, \" [full]\")\n+\n+    def test_memory_other_error_returns_specific_message(self):\n+        # An error that's NOT a \"full\" overflow falls through to the\n+        # structured-error path and surfaces the actual message.\n+        result = json.dumps({\"success\": False, \"error\": \"invalid action: zap\"})\n+        is_failure, suffix = _detect_tool_failure(\"memory\", result)\n+        assert is_failure is True\n+        assert \"invalid action\" in suffix\n+\n+\n+class TestDetectToolFailureStructured:\n+    \"\"\"Generic path: any tool that returns {\"error\": ...} JSON.\"\"\"\n+\n+    def test_read_file_error_surfaced(self):\n+        result = json.dumps({\n+            \"path\": \"/nope/missing.py\",\n+            \"success\": False,\n+            \"error\": \"File not found: /nope/missing.py\",\n+        })\n+        is_failure, suffix = _detect_tool_failure(\"read_file\", result)\n+        assert is_failure is True\n+        # _trim_error reduces the path to the basename.\n+        assert suffix == \" [File not found: missing.py]\"\n+\n+    def test_error_without_success_key_still_flagged(self):\n+        # Some tools return {\"error\": \"...\"} with no explicit success flag.\n+        result = json.dumps({\"error\": \"remote unavailable\"})\n+        is_failure, suffix = _detect_tool_failure(\"web_search\", result)\n+        assert is_failure is True\n+        assert suffix == \" [remote unavailable]\"\n+\n+    def test_message_field_only_with_success_false_flagged(self):\n+        # When success is False and only 'message' is set, surface it.\n+        result = json.dumps({\"success\": False, \"message\": \"rate limited\"})\n+        is_failure, suffix = _detect_tool_failure(\"web_search\", result)\n+        assert is_failure is True\n+        assert \"rate limited\" in suffix\n+\n+    def test_successful_result_not_flagged(self):\n+        result = json.dumps({\"success\": True, \"data\": \"hello\"})\n+        assert _detect_tool_failure(\"web_search\", result) == (False, \"\")\n+\n+    def test_dict_without_error_or_success_uses_generic_heuristic(self):\n+        # Plain successful dict — should pass through the generic\n+        # heuristic which only fires on the string \"Error\" / '\"error\"' / etc.\n+        result = json.dumps({\"data\": \"hello\"})\n+        is_failure, _ = _detect_tool_failure(\"web_search\", result)\n+        assert is_failure is False\n+\n+\n+class TestGetCuteToolMessageFailureSuffix:\n+    \"\"\"End-to-end: failure suffix is appended by get_cute_tool_message.\"\"\"\n+\n+    def test_read_file_failure_suffix_appended(self):\n+        fail = json.dumps({\n+            \"path\": \"/etc/missing\",\n+            \"success\": False,\n+            \"error\": \"File not found: /etc/missing\",\n+        })\n+        line = get_cute_tool_message(\"read_file\", {\"path\": \"/etc/missing\"}, 0.1, result=fail)\n+        assert \"[File not found: missing]\" in line\n+\n+    def test_terminal_exit_only_suffix(self):\n+        fail = json.dumps({\"output\": \"\", \"exit_code\": 2})\n+        line = get_cute_tool_message(\"terminal\", {\"command\": \"false\"}, 0.1, result=fail)\n+        assert \"[exit 2]\" in line\n+\n+    def test_terminal_with_stderr_uses_message(self):\n+        fail = json.dumps({\n+            \"output\": \"\",\n+            \"exit_code\": 127,\n+            \"error\": \"command not found: notathing\",\n+        })\n+        line = get_cute_tool_message(\"terminal\", {\"command\": \"notathing\"}, 0.1, result=fail)\n+        assert \"command not found\" in line\n+        # No '[exit 127]' tag when we have a specific message\n+        assert \"exit 127\" not in line\n+\n+    def test_memory_full_suffix(self):\n+        fail = json.dumps({\"success\": False, \"error\": \"would exceed the limit\"})\n+        line = get_cute_tool_message(\n+            \"memory\",\n+            {\"action\": \"add\", \"target\": \"memory\", \"content\": \"x\"},\n+            0.05,\n+            result=fail,\n+        )\n+        assert \"[full]\" in line\n+\n+    def test_success_has_no_suffix(self):\n+        ok = json.dumps({\"success\": True, \"data\": \"hi\"})\n+        line = get_cute_tool_message(\"web_search\", {\"query\": \"hi\"}, 0.2, result=ok)\n+        assert \"[\" not in line.split(\"0.2s\", 1)[1]\n+\n+    def test_no_result_has_no_suffix(self):\n+        # No result passed at all — display function should not invent a\n+        # failure suffix.\n+        line = get_cute_tool_message(\"terminal\", {\"command\": \"ls\"}, 0.2)\n+        assert \"[\" not in line.split(\"0.2s\", 1)[1]"]},{"file":"tests/cli/test_tool_progress_scrollback.py","hunks":["@@ -14,9 +14,10 @@\n \n # Module-level reference to the cli module (set by _make_cli on first call)\n _cli_mod = None\n+_UNSET = object()\n \n \n-def _make_cli(tool_progress=\"all\"):\n+def _make_cli(tool_progress=\"all\", verbose=_UNSET):\n     \"\"\"Create a HermesCLI instance with minimal mocking.\"\"\"\n     global _cli_mod\n     _clean_config = {","@@ -54,7 +55,9 @@ def _make_cli(tool_progress=\"all\"):\n         _cli_mod = mod\n         with patch.object(mod, \"get_tool_definitions\", return_value=[]), \\\n              patch.dict(mod.__dict__, {\"CLI_CONFIG\": _clean_config}):\n-            return mod.HermesCLI()\n+            if verbose is _UNSET:\n+                return mod.HermesCLI()\n+            return mod.HermesCLI(verbose=verbose)\n \n \n class TestToolProgressScrollback:","@@ -122,14 +125,21 @@ def test_off_mode_no_scrollback(self):\n         mock_print.assert_not_called()\n \n     def test_error_suffix_on_failed_tool(self):\n-        \"\"\"When is_error=True, the stacked line includes [error].\"\"\"\n+        \"\"\"When a failed tool's result is forwarded, the stacked line surfaces\n+        the specific error (e.g. ``[exit 1]`` or ``[File not found: x]``)\n+        instead of the legacy generic ``[error]`` suffix.\"\"\"\n+        import json\n         cli = _make_cli(tool_progress=\"all\")\n-        cli._on_tool_progress(\"tool.started\", \"terminal\", \"bad cmd\", {\"command\": \"bad cmd\"})\n+        cli._on_tool_progress(\"tool.started\", \"terminal\", \"false\", {\"command\": \"false\"})\n         with patch.object(_cli_mod, \"_cprint\") as mock_print:\n-            cli._on_tool_progress(\"tool.completed\", \"terminal\", None, None, duration=0.5, is_error=True)\n+            cli._on_tool_progress(\n+                \"tool.completed\", \"terminal\", None, None,\n+                duration=0.5, is_error=True,\n+                result=json.dumps({\"output\": \"\", \"exit_code\": 1}),\n+            )\n \n         line = mock_print.call_args[0][0]\n-        assert \"[error]\" in line\n+        assert \"[exit 1]\" in line\n \n     def test_spinner_still_updates_on_started(self):\n         \"\"\"tool.started still updates the spinner text for live display.\"\"\"","@@ -168,6 +178,20 @@ def test_verbose_mode_no_duplicate_scrollback(self):\n \n         mock_print.assert_not_called()\n \n+    def test_verbose_mode_config_enables_cli_verbose_by_default(self):\n+        \"\"\"Config-only display.tool_progress=verbose should enable verbose output.\"\"\"\n+        cli = _make_cli(tool_progress=\"verbose\")\n+\n+        assert cli.tool_progress_mode == \"verbose\"\n+        assert cli.verbose is True\n+\n+    def test_explicit_non_verbose_argument_still_overrides_verbose_config(self):\n+        \"\"\"An explicit non-verbose value should keep overriding the config fallback.\"\"\"\n+        cli = _make_cli(tool_progress=\"verbose\", verbose=False)\n+\n+        assert cli.tool_progress_mode == \"verbose\"\n+        assert cli.verbose is False\n+\n     def test_pending_info_stores_on_started(self):\n         \"\"\"tool.started stores args for later use by tool.completed.\"\"\"\n         cli = _make_cli(tool_progress=\"all\")"]},{"file":"tests/hermes_cli/test_argparse_flag_propagation.py","hunks":["@@ -57,6 +57,59 @@ def _build_parser():\n     return parser\n \n \n+class TestChatVerboseArg:\n+    \"\"\"Verify chat --verbose preserves config fallback when absent.\"\"\"\n+\n+    def test_chat_without_verbose_leaves_attribute_unset(self):\n+        from hermes_cli._parser import build_top_level_parser\n+\n+        parser, _subparsers, _chat_parser = build_top_level_parser()\n+        args = parser.parse_args([\"chat\"])\n+\n+        assert not hasattr(args, \"verbose\")\n+\n+    def test_chat_verbose_sets_attribute_true(self):\n+        from hermes_cli._parser import build_top_level_parser\n+\n+        parser, _subparsers, _chat_parser = build_top_level_parser()\n+        args = parser.parse_args([\"chat\", \"--verbose\"])\n+\n+        assert args.verbose is True\n+\n+    def test_cmd_chat_forwards_none_when_verbose_is_absent(self, monkeypatch):\n+        import types\n+        import sys\n+\n+        import hermes_cli.main as main_mod\n+        from hermes_cli._parser import build_top_level_parser\n+\n+        parser, _subparsers, chat_parser = build_top_level_parser()\n+        chat_parser.set_defaults(func=main_mod.cmd_chat)\n+        args = parser.parse_args([\"chat\"])\n+        captured = {}\n+        fake_cli = types.ModuleType(\"cli\")\n+\n+        def fake_main(**kwargs):\n+            captured.update(kwargs)\n+\n+        setattr(fake_cli, \"main\", fake_main)\n+        fake_banner = types.ModuleType(\"hermes_cli.banner\")\n+        setattr(fake_banner, \"prefetch_update_check\", lambda: None)\n+        fake_skills_sync = types.ModuleType(\"tools.skills_sync\")\n+        setattr(fake_skills_sync, \"sync_skills\", lambda quiet=True: None)\n+\n+        monkeypatch.setitem(sys.modules, \"cli\", fake_cli)\n+        monkeypatch.setitem(sys.modules, \"hermes_cli.banner\", fake_banner)\n+        monkeypatch.setitem(sys.modules, \"tools.skills_sync\", fake_skills_sync)\n+        monkeypatch.setattr(main_mod, \"_has_any_provider_configured\", lambda: True)\n+        monkeypatch.setattr(main_mod, \"_pin_kanban_board_env\", lambda: None)\n+\n+        main_mod.cmd_chat(args)\n+\n+        assert captured[\"quiet\"] is False\n+        assert \"verbose\" not in captured\n+\n+\n class TestYoloEnvVar:\n     \"\"\"Verify --yolo sets HERMES_YOLO_MODE regardless of flag position.\n \n"]}]},{"kind":"pr","number":34075,"title":"fix(cli): gate tool-rendering paths with tool_progress_mode, not quiet_mode","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"550b72dd8","number":0},{"kind":"pr","value":"#42252","number":42252}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":42252,"title":"fix(cli/gateway): salvage state.db dedup (#42039), tool_progress display (#33860), OSC-8 banner strip (#25939)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["agent/agent_init.py","agent/tool_executor.py","cli.py","gateway/run.py","hermes_cli/cli_agent_setup_mixin.py","run_agent.py","scripts/release.py","tests/gateway/test_42039_duplicate_user_message.py"]}],"trust":{"default_tier":"verified","includes_suggested":false}}