# NousResearch/hermes-agent · CLI tool progress display mode fixes

## Open now

- Nothing open on GitHub.

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.

## TL;DR

- 6 member(s): 0 open issue(s), 2 closed issue(s), 0 open PR(s), 2 merged PR(s), 2 PR(s) closed without merging.
- A fix was merged to main in PR #31293. Verify it addresses your case.
- **Recommended:** read PR #31293 (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-31293`. 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.29`: 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.

## Closed / duplicate issues

- #7723: \[Bug\]: CLI tool_progress=verbose now renders like full/all instead of showing complete verbose output
- #33860: Interactive CLI hides tool output despite display.tool_progress=all, and hermes chat -v does not restore it

## Prior & competing PRs

- **#31293: fix(cli): bring tool-call display back — verbose mode, specific failure reasons, todo progress**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `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:
  - `agent/display.py`

    ```diff
    @@ -787,33 +787,65 @@ def __exit__(self, exc_type, exc_val, exc_tb):
     # Cute tool message (completion line that replaces the spinner)
     # =========================================================================
     
    +_ERROR_SUFFIX_MAX_LEN = 48
    +
    +
    +def _trim_error(msg: str) -> str:
    +    """Shrink an error message for inline display in a tool status line.
    +
    +    Strips overly long absolute paths down to just the filename so the
    +    suffix stays readable on narrow terminals.
    +    """
    +    msg = msg.strip()
    +    # Common case: "File not found: /very/long/absolute/path/foo.py"
    +    if "File not found:" in msg:
    +        _, _, tail = msg.partition("File not found:")
    +        tail = tail.strip()
    +        if "/" in tail:
    +            msg = f"File not found: {tail.rsplit('/', 1)[-1]}"
    +    if len(msg) > _ERROR_SUFFIX_MAX_LEN:
    +        msg = msg[: _ERROR_SUFFIX_MAX_LEN - 3] + "..."
    +    return msg
    +
    +
     def _detect_tool_failure(tool_name: str, result: str | None) -> tuple[bool, str]:
         """Inspect a tool result string for signs of failure.
     
    -    Returns ``(is_failure, suffix)`` where *suffix* is an informational tag
    -    like ``" [exit 1]"`` for terminal failures, or ``" [error]"`` for generic
    -    failures.  On success, returns ``(False, "")``.
    +    Returns ``(is_failure, suffix)`` where *suffix* is a short informational
    +    tag like ``" [exit 1]"`` for terminal failures, ``" [full]"`` for memory
    +    overflow, or a trimmed error message (``" [File not found: foo.py]"``).
    +    On success returns ``(False, "")``.
         """
         if result is None:
             return False, ""
         if file_mutation_result_landed(tool_name, result):
             return False, ""
     
    +    data = safe_json_loads(result)
    +
    +    # Terminal: non-zero exit code is the canonical failure signal.
         if tool_name == "terminal":
    -        data = safe_json_loads(result)
             if isinstance(data, dict):
                 exit_code = data.get("exit_code")
                 if exit_code is not None and exit_code != 0:
    +                err_msg = data.get("error")
    +                if err_msg:
    +                    return True, f" [{_trim_error(str(err_msg))}]"
                     return True, f" [exit {exit_code}]"
             return False, ""
     
    -    # Memory-specific: distinguish "full" from real errors
    +    # Memory: distinguish "store full" from real errors.
         if tool_name == "memory":
    -        data = safe_json_loads(result)
             if isinstance(data, dict):
                 if data.get("success") is False and "exceed the limit" in data.get("error", ""):
                     return True, " [full]"
     
    +    # Structured error in JSON result (any tool that surfaces {"error": ...}).
    +    if isinstance(data, dict):
    +        err = data.get("error") or data.get("message")
    +        if err and (data.get("success") is False or "error" in data):
    +            return True, f" [{_trim_error(str(err))}]"
    +
         # Generic heuristic for non-terminal tools
         # Multimodal tool results (dicts with _multimodal=True) are not strings —
         # treat them as successes since failures would be JSON-encoded strings.
    ```

    ```diff
    @@ -921,11 +953,29 @@ def _wrap(line: str) -> str:
         if tool_name == "todo":
             todos_arg = args.get("todos")
             merge = args.get("merge", False)
    +        # Parse result for completion progress
    +        total = 0
    +        done = 0
    +        if result:
    +            try:
    +                data = safe_json_loads(result)
    +                if data:
    +                    s = data.get("summary", {})
    +                    total = s.get("total", 0)
    +                    done = s.get("completed", 0)
    +            except Exception:
    +                pass
             if todos_arg is None:
    +            if total > 0:
    +                return _wrap(f"┊ 📋 plan      {done}/{total} task(s)  {dur}")
                 return _wrap(f"┊ 📋 plan      reading tasks  {dur}")
             elif merge:
    +            if total > 0 and done > 0:
    +                return _wrap(f"┊ 📋 plan      update {done}/{total} ✓  {dur}")
                 return _wrap(f"┊ 📋 plan      update {len(todos_arg)} task(s)  {dur}")
             else:
    +            if total > 0 and done > 0:
    +                return _wrap(f"┊ 📋 plan      {done}/{total} task(s)  {dur}")
                 return _wrap(f"┊ 📋 plan      {len(todos_arg)} task(s)  {dur}")
         if tool_name == "session_search":
             return _wrap(f"┊ 🔍 recall    \"{_trunc(args.get('query', ''), 35)}\"  {dur}")
    ```
  - `agent/tool_executor.py`

    ```diff
    @@ -388,6 +388,7 @@ def _run_tool(index, tool_call, function_name, function_args):
                         agent.tool_progress_callback(
                             "tool.completed", function_name, None, None,
                             duration=tool_duration, is_error=is_error,
    +                        result=function_result,
                         )
                     except Exception as cb_err:
                         logging.debug(f"Tool progress callback error: {cb_err}")
    ```

    ```diff
    @@ -822,6 +823,7 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe
                     agent.tool_progress_callback(
                         "tool.completed", function_name, None, None,
                         duration=tool_duration, is_error=_is_error_result,
    +                    result=function_result,
                     )
                 except Exception as cb_err:
                     logging.debug(f"Tool progress callback error: {cb_err}")
    ```
  - `cli.py`

    ```diff
    @@ -2819,7 +2819,7 @@ def __init__(
             api_key: str = None,
             base_url: str = None,
             max_turns: int = None,
    -        verbose: bool = False,
    +        verbose: Optional[bool] = None,
             compact: bool = False,
             resume: str = None,
             checkpoints: bool = False,
    ```

    ```diff
    @@ -10264,9 +10264,7 @@ def _on_tool_progress(self, event_type: str, function_name: str = None, preview:
                     self._last_scrollback_tool = function_name
                     try:
                         from agent.display import get_cute_tool_message
    -                    line = get_cute_tool_message(function_name, stored_args, duration)
    -                    if is_error:
    -                        line = f"{line} [error]"
    +                    line = get_cute_tool_message(function_name, stored_args, duration, result=kwargs.get("result"))
                         _cprint(f"  {line}")
                     except Exception:
                         pass
    ```

    ```diff
    @@ -14439,7 +14437,7 @@ def main(
         api_key: str = None,
         base_url: str = None,
         max_turns: int = None,
    -    verbose: bool = False,
    +    verbose: Optional[bool] = None,
         quiet: bool = False,
         compact: bool = False,
         list_tools: bool = False,
    ```
  - `hermes_cli/_parser.py`

    ```diff
    @@ -269,7 +269,11 @@ def build_top_level_parser():
             help="Inference provider (default: auto). Built-in or a user-defined name from `providers:` in config.yaml.",
         )
         chat_parser.add_argument(
    -        "-v", "--verbose", action="store_true", help="Verbose output"
    +        "-v",
    +        "--verbose",
    +        action="store_true",
    +        default=argparse.SUPPRESS,
    +        help="Verbose output",
         )
         chat_parser.add_argument(
             "-Q",
    ```
  - `hermes_cli/main.py`

    ```diff
    @@ -1454,7 +1454,7 @@ def _launch_tui(
         provider: Optional[str] = None,
         toolsets: object = None,
         skills: object = None,
    -    verbose: bool = False,
    +    verbose: Optional[bool] = None,
         quiet: bool = False,
         query: Optional[str] = None,
         image: Optional[str] = None,
    ```

    ```diff
    @@ -1763,7 +1763,7 @@ def cmd_chat(args):
                 provider=getattr(args, "provider", None),
                 toolsets=getattr(args, "toolsets", None),
                 skills=getattr(args, "skills", None),
    -            verbose=getattr(args, "verbose", False),
    +            verbose=getattr(args, "verbose", None),
                 quiet=getattr(args, "quiet", False),
                 query=getattr(args, "query", None),
                 image=getattr(args, "image", None),
    ```

    ```diff
    @@ -1783,7 +1783,7 @@ def cmd_chat(args):
             "provider": getattr(args, "provider", None),
             "toolsets": args.toolsets,
             "skills": getattr(args, "skills", None),
    -        "verbose": args.verbose,
    +        "verbose": getattr(args, "verbose", None),
             "quiet": getattr(args, "quiet", False),
             "query": args.query,
             "image": getattr(args, "image", None),
    ```

    ```diff
    @@ -13811,7 +13811,7 @@ def cmd_acp(args):
                 ("model", None),
                 ("provider", None),
                 ("toolsets", None),
    -            ("verbose", False),
    +            ("verbose", None),
                 ("worktree", False),
             ]:
                 if not hasattr(args, attr):
    ```

    ```diff
    @@ -13826,7 +13826,7 @@ def cmd_acp(args):
                 ("model", None),
                 ("provider", None),
                 ("toolsets", None),
    -            ("verbose", False),
    +            ("verbose", None),
                 ("resume", None),
                 ("continue_last", None),
                 ("worktree", False),
    ```
  - `tests/agent/test_display_todo_progress.py`

    ```diff
    @@ -0,0 +1,243 @@
    +"""Tests for get_cute_tool_message todo progress display.
    +
    +Verifies the completion status rendering (done/total ✓) on all three
    +todo tool call paths: read, create (merge=False), update (merge=True).
    +"""
    +
    +import json
    +import pytest
    +from agent.display import get_cute_tool_message
    +
    +
    +def _todo_result(total: int, completed: int) -> str:
    +    """Build a fake todo_tool return value."""
    +    return json.dumps({
    +        "todos": [],
    +        "summary": {
    +            "total": total,
    +            "pending": total - completed,
    +            "in_progress": 0,
    +            "completed": completed,
    +            "cancelled": 0,
    +        },
    +    })
    +
    +
    +class TestTodoRead:
    +    """get_cute_tool_message(…, result=…) when todos_arg is None (read path)."""
    +
    +    def test_read_no_result(self):
    +        msg = get_cute_tool_message("todo", {}, 0.5)
    +        assert "reading tasks" in msg
    +        assert "0.5s" in msg
    +
    +    def test_read_with_progress(self):
    +        msg = get_cute_tool_message("todo", {}, 0.5,
    +                                    result=_todo_result(4, 2))
    +        assert "2/4" in msg
    +        assert "task(s)" in msg
    +
    +    def test_read_all_done(self):
    +        msg = get_cute_tool_message("todo", {}, 0.5,
    +                                    result=_todo_result(4, 4))
    +        assert "4/4" in msg
    +        assert "task(s)" in msg
    +
    +    def test_read_zero_total(self):
    +        """Edge case: empty todo list returns summary with total=0."""
    +        msg = get_cute_tool_message("todo", {}, 0.5,
    +                                    result=_todo_result(0, 0))
    +        assert "reading tasks" in msg
    +
    +    def test_read_invalid_result_fallback(self):
    +        """Garbage result should not crash; fall back to reading tasks."""
    +        msg = get_cute_tool_message("todo", {}, 0.5, result="not json")
    +        assert "reading tasks" in msg
    +
    +    def test_read_result_missing_summary(self):
    +        msg = get_cute_tool_message("todo", {}, 0.5,
    +                                    result='{"todos": []}')
    +        assert "reading tasks" in msg
    +
    +
    +class TestTodoCreate:
    +    """get_cute_tool_message when merge=False (new plan creation)."""
    +
    +    def test_create_default(self):
    +        """Brand-new plan: all pending, no result — plain count."""
    +        msg = get_cute_tool_message("todo",
    +                                    {"todos": [
    +                                        {"id": "a", "content": "x", "status": "pending"},
    +                                    ]}, 0.3)
    +        assert "1 task(s)" in msg
    +        assert "0.3s" in msg
    +        assert "/" not in msg  # no progress fraction
    +
    +    def test_create_multiple(self):
    +        msg = get_cute_tool_message("todo",
    +                                    {"todos": [
    +                                        {"id": "a", "content": "x", "status": "pending"},
    +                                        {"id": "b", "content": "y", "status": "pending"},
    +                                        {"id": "c", "content": "z", "status": "pending"},
    +                                    ]}, 0.2)
    +        assert "3 task(s)" in msg
    +
    +    def test_create_with_result_shows_progress_when_done(self):
    +        """Even on create, if result has completed tasks show it."""
    +        msg = get_cute_tool_message("todo",
    +                                    {"todos": [{"id": "a", "content": "x", "status": "completed"}]},
    +                                    0.4,
    +                                    result=_todo_result(1, 1))
    +        assert "1/1" in msg
    +        assert "task(s)" in msg
    +
    +    def test_create_with_result_zero_done(self):
    +        """New plan with 0 done — plain count, no progress fraction."""
    +        msg = get_cute_tool_message("todo",
    +                                    {"todos": [
    +                                        {"id": "a", "content": "x", "status": "pending"},
    +                                        {"id": "b", "content": "y", "status": "pending"},
    +                                    ]},
    +                                    0.3,
    +                                    result=_todo_result(2, 0))
    +        assert "2 task(s)" in msg
    +        assert "/" not in msg
    +
    +
    +class TestTodoUpdate:
    +    """get_cute_tool_message when merge=True (incremental update)."""
    +
    +    def test_update_no_result(self):
    +        """No result available — plain update N task(s)."""
    +        msg = get_cute_tool_message("todo",
    +                                    {"todos": [{"id": "a", "status": "completed"}],
    +                                     "merge": True}, 0.5)
    +        assert "update 1 task(s)" in msg
    +
    +    def test_update_partial_progress(self):
    +        """1/4 tasks completed — show fraction with checkmark."""
    +        msg = get_cute_tool_message("todo",
    +                                    {"todos": [{"id": "a", "status": "completed"}],
    +                                     "merge": True},
    +                                    0.5,
    +                                    result=_todo_result(4, 1))
    +        assert "update" in msg
    +        assert "1/4" in msg
    +        assert "✓" in msg
    +
    +    def test_update_halfway(self):
    +        """2/4 — midpoint progress."""
    +        msg = get_cute_tool_message("todo",
    +                                    {"todos": [{"id": "b", "status": "in_progress"}],
    +                                     "merge": True},
    +                                    0.7,
    +                                    result=_todo_result(4, 2))
    +        assert "2/4" in msg
    +        assert "✓" in msg
    +
    +    def test_update_all_completed(self):
    +        """4/4 — full checkmark."""
    +        msg = get_cute_tool_message("todo",
    +                                    {"todos": [{"id": "d", "status": "completed"}],
    +                                     "merge": True},
    +                                    0.2,
    +                                    result=_todo_result(4, 4))
    +        assert "4/4" in msg
    +        assert "✓" in msg
    +
    +    def test_update_zero_done(self):
    +        """No completed tasks yet — plain update N task(s)."""
    +        msg = get_cute_tool_message("todo",
    +                                    {"todos": [{"id": "a", "status": "pending"}],
    +                                     "merge": True},
    +                                    0.3,
    +                                    result=_todo_result(3, 0))
    +        assert "update 1 task(s)" in msg
    +        assert "✓" not in msg
    +        assert "/" not in msg  # no progress fraction when done=0
    +
    +    def test_update_invalid_result_fallback(self):
    +        """Bad JSON result — fall back to plain update N task(s)."""
    +        msg = get_cute_tool_message("todo",
    +                                    {"todos": [{"id": "a", "status": "completed"}],
    +                                     "merge": True},
    +                                    0.6,
    +                                    result="{broken")
    +        assert "update 1 task(s)" in msg
    +        assert "✓" not in msg
    +
    +    def test_update_result_missing_summary(self):
    +        """Result no summary key — fall back to plain update."""
    +        msg = get_cute_tool_message("todo",
    +                                    {"todos": [{"id": "a", "status": "completed"}],
    +                                     "merge": True},
    +                                    0.4,
    +                                    result='{"todos": []}')
    +        assert "update 1 task(s)" in msg
    +        assert "✓" not in msg
    +
    +    def test_update_total_not_in_summary(self):
    +        """Result summary missing total key."""
    +        msg = get_cute_tool_message("todo",
    +                                    {"todos": [{"id": "a", "status": "completed"}],
    +                                     "merge": True},
    +                                    0.3,
    +                                    result=json.dumps({"summary": {"completed": 2}}))
    +        assert "update 1 task(s)" in msg
    +        assert "✓" not in msg
    +
    +    def test_update_multiple_tasks_in_line(self):
    +        """Update line with several tasks in the update request."""
    +        msg = get_cute_tool_message("todo",
    +                                    {"todos": [
    +                                        {"id": "a", "status": "completed"},
    +                                        {"id": "b", "status": "in_progress"},
    +                                    ], "merge": True},
    +                                    0.5,
    +                                    result=_todo_result(5, 3))
    +        assert "update" in msg
    +        assert "3/5" in msg
    +        assert "✓" in msg
    +
    +
    +class TestTodoEdgeCases:
    +    """Boundary cases that should not crash."""
    +
    +    def test_merge_default_value(self):
    +        """merge defaults to False in function signature, should be False when absent."""
    +        msg = get_cute_tool_message("todo",
    +                                    {"todos": [{"id": "a", "content": "x", "status": "pending"}]},
    +                                    1.0)
    +        assert "1 task(s)" in msg
    +
    +    def test_duration_formatting(self):
    +        """Duration formatting works correctly."""
    +        msg = get_cute_tool_message("todo", {}, 0.123)
    +        assert "0.1s" in msg
    +
    +        msg = get_cute_tool_message("todo", {}, 1.0)
    +        assert "1.0s" in msg
    +
    +        msg = get_cute_tool_message("todo", {}, 123.456)
    +        assert "123.5s" in msg
    +
    +    def test_large_task_count(self):
    +        """Many tasks should not break formatting."""
    +        many = [{"id": str(i), "content": "x", "status": "pending"} for i in range(50)]
    +        msg = get_cute_tool_message("todo", {"todos": many}, 0.5)
    +        assert "50 task(s)" in msg
    +
    +    def test_read_with_no_args_and_no_result(self):
    +        """Completely empty call."""
    +        msg = get_cute_tool_message("todo", {}, 0.0)
    +        assert "reading tasks" in msg
    +
    +
    +class TestTodoSkinIntegration:
    +    """Verify the skin prefix is applied to todo messages too.
    +    This uses the same pattern as test_skin_engine test_tool_message_uses_skin_prefix.
    +    """
    +
    +    def test_default_skin_prefix(self):
    +        msg = get_cute_tool_message("todo", {}, 0.5)
    +        assert msg.startswith("┊")
    ```
  - `tests/agent/test_display_tool_failure.py`

    ```diff
    @@ -0,0 +1,185 @@
    +"""Tests for _detect_tool_failure + _trim_error + get_cute_tool_message
    +inline failure suffix rendering.
    +
    +Covers the user-visible promise: when a tool fails, the CLI shows a short,
    +specific reason in square brackets at the end of the completion line —
    +not a generic "[error]".
    +"""
    +
    +import json
    +import pytest
    +
    +from agent.display import (
    +    _detect_tool_failure,
    +    _trim_error,
    +    _ERROR_SUFFIX_MAX_LEN,
    +    get_cute_tool_message,
    +)
    +
    +
    +class TestTrimError:
    +    """The helper that shrinks an error message for inline display."""
    +
    +    def test_short_message_unchanged(self):
    +        assert _trim_error("nope") == "nope"
    +
    +    def test_whitespace_stripped(self):
    +        assert _trim_error("  bad input  ") == "bad input"
    +
    +    def test_long_message_truncated_to_cap(self):
    +        msg = "x" * 200
    +        trimmed = _trim_error(msg)
    +        assert len(trimmed) <= _ERROR_SUFFIX_MAX_LEN
    +        assert trimmed.endswith("...")
    +
    +    def test_file_not_found_path_collapsed_to_filename(self):
    +        long_path = "File not found: /home/teknium/.hermes/hermes-agent/very/deep/path/foo.py"
    +        assert _trim_error(long_path) == "File not found: foo.py"
    +
    +    def test_file_not_found_already_short_unchanged(self):
    +        assert _trim_error("File not found: foo.py") == "File not found: foo.py"
    +
    +    def test_file_not_found_relative_path_unchanged(self):
    +        # Without a slash there's no path to trim.
    +        assert _trim_error("File not found: foo.py") == "File not found: foo.py"
    +
    +
    +class TestDetectToolFailureTerminal:
    +    """terminal: non-zero exit_code is the canonical failure signal."""
    +
    +    def test_success_returns_no_suffix(self):
    +        result = json.dumps({"output": "ok\n", "exit_code": 0})
    +        assert _detect_tool_failure("terminal", result) == (False, "")
    +
    +    def test_nonzero_exit_with_no_error_shows_exit_code(self):
    +        result = json.dumps({"output": "", "exit_code": 1})
    +        is_failure, suffix = _detect_tool_failure("terminal", result)
    +        assert is_failure is True
    +        assert suffix == " [exit 1]"
    +
    +    def test_nonzero_exit_with_error_shows_message(self):
    +        result = json.dumps({
    +            "output": "",
    +            "exit_code": 127,
    +            "error": "ls: cannot access 'foo': No such file or directory",
    +        })
    +        is_failure, suffix = _detect_tool_failure("terminal", result)
    +        assert is_failure is True
    +        assert "cannot access" in suffix
    +        # Trimmed to the cap, in brackets
    +        assert suffix.startswith(" [")
    +        assert suffix.endswith("]")
    +
    +    def test_malformed_json_returns_no_suffix(self):
    +        # Terminal is special: only exit_code matters. Malformed JSON should
    +        # not crash and should not be flagged as failure.
    +        assert _detect_tool_failure("terminal", "not json") == (False, "")
    +
    +    def test_none_result_returns_no_suffix(self):
    +        assert _detect_tool_failure("terminal", None) == (False, "")
    +
    +
    +class TestDetectToolFailureMemory:
    +    """memory: 'full' is distinct from real errors."""
    +
    +    def test_memory_full_returns_full_suffix(self):
    +        result = json.dumps({"success": False, "error": "would exceed the limit"})
    +        assert _detect_tool_failure("memory", result) == (True, " [full]")
    +
    +    def test_memory_other_error_returns_specific_message(self):
    +        # An error that's NOT a "full" overflow falls through to the
    +        # structured-error path and surfaces the actual message.
    +        result = json.dumps({"success": False, "error": "invalid action: zap"})
    +        is_failure, suffix = _detect_tool_failure("memory", result)
    +        assert is_failure is True
    +        assert "invalid action" in suffix
    +
    +
    +class TestDetectToolFailureStructured:
    +    """Generic path: any tool that returns {"error": ...} JSON."""
    +
    +    def test_read_file_error_surfaced(self):
    +        result = json.dumps({
    +            "path": "/nope/missing.py",
    +            "success": False,
    +            "error": "File not found: /nope/missing.py",
    +        })
    +        is_failure, suffix = _detect_tool_failure("read_file", result)
    +        assert is_failure is True
    +        # _trim_error reduces the path to the basename.
    +        assert suffix == " [File not found: missing.py]"
    +
    +    def test_error_without_success_key_still_flagged(self):
    +        # Some tools return {"error": "..."} with no explicit success flag.
    +        result = json.dumps({"error": "remote unavailable"})
    +        is_failure, suffix = _detect_tool_failure("web_search", result)
    +        assert is_failure is True
    +        assert suffix == " [remote unavailable]"
    +
    +    def test_message_field_only_with_success_false_flagged(self):
    +        # When success is False and only 'message' is set, surface it.
    +        result = json.dumps({"success": False, "message": "rate limited"})
    +        is_failure, suffix = _detect_tool_failure("web_search", result)
    +        assert is_failure is True
    +        assert "rate limited" in suffix
    +
    +    def test_successful_result_not_flagged(self):
    +        result = json.dumps({"success": True, "data": "hello"})
    +        assert _detect_tool_failure("web_search", result) == (False, "")
    +
    +    def test_dict_without_error_or_success_uses_generic_heuristic(self):
    +        # Plain successful dict — should pass through the generic
    +        # heuristic which only fires on the string "Error" / '"error"' / etc.
    +        result = json.dumps({"data": "hello"})
    +        is_failure, _ = _detect_tool_failure("web_search", result)
    +        assert is_failure is False
    +
    +
    +class TestGetCuteToolMessageFailureSuffix:
    +    """End-to-end: failure suffix is appended by get_cute_tool_message."""
    +
    +    def test_read_file_failure_suffix_appended(self):
    +        fail = json.dumps({
    +            "path": "/etc/missing",
    +            "success": False,
    +            "error": "File not found: /etc/missing",
    +        })
    +        line = get_cute_tool_message("read_file", {"path": "/etc/missing"}, 0.1, result=fail)
    +        assert "[File not found: missing]" in line
    +
    +    def test_terminal_exit_only_suffix(self):
    +        fail = json.dumps({"output": "", "exit_code": 2})
    +        line = get_cute_tool_message("terminal", {"command": "false"}, 0.1, result=fail)
    +        assert "[exit 2]" in line
    +
    +    def test_terminal_with_stderr_uses_message(self):
    +        fail = json.dumps({
    +            "output": "",
    +            "exit_code": 127,
    +            "error": "command not found: notathing",
    +        })
    +        line = get_cute_tool_message("terminal", {"command": "notathing"}, 0.1, result=fail)
    +        assert "command not found" in line
    +        # No '[exit 127]' tag when we have a specific message
    +        assert "exit 127" not in line
    +
    +    def test_memory_full_suffix(self):
    +        fail = json.dumps({"success": False, "error": "would exceed the limit"})
    +        line = get_cute_tool_message(
    +            "memory",
    +            {"action": "add", "target": "memory", "content": "x"},
    +            0.05,
    +            result=fail,
    +        )
    +        assert "[full]" in line
    +
    +    def test_success_has_no_suffix(self):
    +        ok = json.dumps({"success": True, "data": "hi"})
    +        line = get_cute_tool_message("web_search", {"query": "hi"}, 0.2, result=ok)
    +        assert "[" not in line.split("0.2s", 1)[1]
    +
    +    def test_no_result_has_no_suffix(self):
    +        # No result passed at all — display function should not invent a
    +        # failure suffix.
    +        line = get_cute_tool_message("terminal", {"command": "ls"}, 0.2)
    +        assert "[" not in line.split("0.2s", 1)[1]
    ```
  - `tests/cli/test_tool_progress_scrollback.py`

    ```diff
    @@ -14,9 +14,10 @@
     
     # Module-level reference to the cli module (set by _make_cli on first call)
     _cli_mod = None
    +_UNSET = object()
     
     
    -def _make_cli(tool_progress="all"):
    +def _make_cli(tool_progress="all", verbose=_UNSET):
         """Create a HermesCLI instance with minimal mocking."""
         global _cli_mod
         _clean_config = {
    ```

    ```diff
    @@ -54,7 +55,9 @@ def _make_cli(tool_progress="all"):
             _cli_mod = mod
             with patch.object(mod, "get_tool_definitions", return_value=[]), \
                  patch.dict(mod.__dict__, {"CLI_CONFIG": _clean_config}):
    -            return mod.HermesCLI()
    +            if verbose is _UNSET:
    +                return mod.HermesCLI()
    +            return mod.HermesCLI(verbose=verbose)
     
     
     class TestToolProgressScrollback:
    ```

    ```diff
    @@ -122,14 +125,21 @@ def test_off_mode_no_scrollback(self):
             mock_print.assert_not_called()
     
         def test_error_suffix_on_failed_tool(self):
    -        """When is_error=True, the stacked line includes [error]."""
    +        """When a failed tool's result is forwarded, the stacked line surfaces
    +        the specific error (e.g. ``[exit 1]`` or ``[File not found: x]``)
    +        instead of the legacy generic ``[error]`` suffix."""
    +        import json
             cli = _make_cli(tool_progress="all")
    -        cli._on_tool_progress("tool.started", "terminal", "bad cmd", {"command": "bad cmd"})
    +        cli._on_tool_progress("tool.started", "terminal", "false", {"command": "false"})
             with patch.object(_cli_mod, "_cprint") as mock_print:
    -            cli._on_tool_progress("tool.completed", "terminal", None, None, duration=0.5, is_error=True)
    +            cli._on_tool_progress(
    +                "tool.completed", "terminal", None, None,
    +                duration=0.5, is_error=True,
    +                result=json.dumps({"output": "", "exit_code": 1}),
    +            )
     
             line = mock_print.call_args[0][0]
    -        assert "[error]" in line
    +        assert "[exit 1]" in line
     
         def test_spinner_still_updates_on_started(self):
             """tool.started still updates the spinner text for live display."""
    ```

    ```diff
    @@ -168,6 +178,20 @@ def test_verbose_mode_no_duplicate_scrollback(self):
     
             mock_print.assert_not_called()
     
    +    def test_verbose_mode_config_enables_cli_verbose_by_default(self):
    +        """Config-only display.tool_progress=verbose should enable verbose output."""
    +        cli = _make_cli(tool_progress="verbose")
    +
    +        assert cli.tool_progress_mode == "verbose"
    +        assert cli.verbose is True
    +
    +    def test_explicit_non_verbose_argument_still_overrides_verbose_config(self):
    +        """An explicit non-verbose value should keep overriding the config fallback."""
    +        cli = _make_cli(tool_progress="verbose", verbose=False)
    +
    +        assert cli.tool_progress_mode == "verbose"
    +        assert cli.verbose is False
    +
         def test_pending_info_stores_on_started(self):
             """tool.started stores args for later use by tool.completed."""
             cli = _make_cli(tool_progress="all")
    ```
  - `tests/hermes_cli/test_argparse_flag_propagation.py`

    ```diff
    @@ -57,6 +57,59 @@ def _build_parser():
         return parser
     
     
    +class TestChatVerboseArg:
    +    """Verify chat --verbose preserves config fallback when absent."""
    +
    +    def test_chat_without_verbose_leaves_attribute_unset(self):
    +        from hermes_cli._parser import build_top_level_parser
    +
    +        parser, _subparsers, _chat_parser = build_top_level_parser()
    +        args = parser.parse_args(["chat"])
    +
    +        assert not hasattr(args, "verbose")
    +
    +    def test_chat_verbose_sets_attribute_true(self):
    +        from hermes_cli._parser import build_top_level_parser
    +
    +        parser, _subparsers, _chat_parser = build_top_level_parser()
    +        args = parser.parse_args(["chat", "--verbose"])
    +
    +        assert args.verbose is True
    +
    +    def test_cmd_chat_forwards_none_when_verbose_is_absent(self, monkeypatch):
    +        import types
    +        import sys
    +
    +        import hermes_cli.main as main_mod
    +        from hermes_cli._parser import build_top_level_parser
    +
    +        parser, _subparsers, chat_parser = build_top_level_parser()
    +        chat_parser.set_defaults(func=main_mod.cmd_chat)
    +        args = parser.parse_args(["chat"])
    +        captured = {}
    +        fake_cli = types.ModuleType("cli")
    +
    +        def fake_main(**kwargs):
    +            captured.update(kwargs)
    +
    +        setattr(fake_cli, "main", fake_main)
    +        fake_banner = types.ModuleType("hermes_cli.banner")
    +        setattr(fake_banner, "prefetch_update_check", lambda: None)
    +        fake_skills_sync = types.ModuleType("tools.skills_sync")
    +        setattr(fake_skills_sync, "sync_skills", lambda quiet=True: None)
    +
    +        monkeypatch.setitem(sys.modules, "cli", fake_cli)
    +        monkeypatch.setitem(sys.modules, "hermes_cli.banner", fake_banner)
    +        monkeypatch.setitem(sys.modules, "tools.skills_sync", fake_skills_sync)
    +        monkeypatch.setattr(main_mod, "_has_any_provider_configured", lambda: True)
    +        monkeypatch.setattr(main_mod, "_pin_kanban_board_env", lambda: None)
    +
    +        main_mod.cmd_chat(args)
    +
    +        assert captured["quiet"] is False
    +        assert "verbose" not in captured
    +
    +
     class TestYoloEnvVar:
         """Verify --yolo sets HERMES_YOLO_MODE regardless of flag position.
     
    ```
- **#42252: fix(cli/gateway): salvage state.db dedup (#42039), tool_progress display (#33860), OSC-8 banner strip (#25939)**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `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`
- **#29665: Fix CLI verbose tool progress config fallback**. closed without merge
  - maintainer direction: category `merged_via`; implication `superseded`; refs `6a1aa420e`, `#31293`, `#7723`
- **#34075: fix(cli): gate tool-rendering paths with tool_progress_mode, not quiet_mode**. closed without merge
  - maintainer direction: category `merged_via`; implication `superseded`; refs `550b72dd8`, `#42252`

## Fix status

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