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
- 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
Open PRs0
Fixes on record
-
#31293 fix(cli): bring tool-call display back — verbose mode, specific failure reasons, todo progress
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.pyShow 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)
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
Closed issues · 2
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.00means 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