CLI tool progress display mode fixes

NousResearch/hermes-agent · cluster 7723 · hermes cli

Data as of

Nothing open on GitHub. Fix merged in #31293. Check it covers your case.

The short answer

0open issues
0open PRs
6items grouped
Known?
Yes. 6 related items grouped here, 2 issues already closed.
Fix event
A fix was merged to main in PR #31293. Check it covers your case. No release data is tracked.
Tracker
The maintainers' tracker marks this partially resolved, deciding PR #31293. That is separate from the fix event.
Still open
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.

Open issues0

No open issues.

Open PRs0

No open pull requests.

Fixes on record

Merged to main. A stamp counts the issues a fix is linked to.

  • #31293 fix(cli): bring tool-call display back — verbose mode, specific failure reasons, todo progress

    merged to main

    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

    Show the diff
    • agent/display.py
      @@ -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.
      @@ -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
      @@ -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}")
      @@ -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
      @@ -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,
      @@ -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
      @@ -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
      @@ -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
      @@ -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,
      @@ -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),
      @@ -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),
      @@ -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):
      @@ -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
      @@ -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
      @@ -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
      @@ -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 = {
      @@ -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:
      @@ -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."""
      @@ -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
      @@ -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

    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

2 PRs closed without merging

From the close comments: merged via another change 2

  • #29665 Fix CLI verbose tool progress config fallback · merged via another change → 6a1aa42, #31293, #7723
  • #34075 fix(cli): gate tool-rendering paths with tool_progress_mode, not quiet_mode · merged via another change → 550b72d, #42252
Closed issues · 2
  • Closed#7723[Bug]: CLI tool_progress=verbose now renders like full/all instead of showing complete verbose output
  • Closed#33860Interactive CLI hides tool output despite display.tool_progress=all, and hermes chat -v does not restore it
How this cluster was grouped
  • Link evidence (partly structural links): some members are connected by structural evidence (shared changed files, or a closing/superseded reference); the rest are joined by an automated relevance judgement over their text.
  • Members outside that structural core may belong to a different underlying problem.
  • Pairwise link density 0.29: the share of item 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.

Pick up a Lab task

Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.

Open the Lab

At a glance

Subsystem
hermes cli
Merged PRs
#31293, #42252
Closed issues
2
Attempts
2 PRs closed without merging
Fix link checked
8 Jun 2026

For your agent

The same facts, typed.