From 637716755cb81e01eccd0aeea6ea41a251350a9f Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sun, 23 Aug 2026 16:40:08 -0700 Subject: [PATCH] =?UTF-8?q?fix(cli):=20-Q=20stdout=20carries=20only=20the?= =?UTF-8?q?=20final=20response=20=E2=80=94=20no=20tool=20diffs,=20spinner?= =?UTF-8?q?=20lines,=20or=20reasoning?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Widens the cherry-picked reasoning-callback fix to the whole leak class (#93220): - quiet branch also neutralizes tool_progress_callback, tool_start_callback, tool_complete_callback (inline diff rendering via render_edit_diff_with_delta was gated by NEITHER quiet_mode nor tool_progress_mode) and syncs agent.tool_progress_mode='off'. - _should_emit_quiet_tool_messages() returns False under suppress_status_output: with callbacks neutralized, the quiet-mode KawaiiSpinner fallback printed '[tool]'/'[done]' lines into captured stdout. Also covers oneshot.py and background-review forks, which set the same flag and expect strict silence. E2E (isolated HERMES_HOME, live model, write_file turn): base leaks '┊ review diff' + full SVG source into stdout; head emits exactly the final response. Regression tests pin the quiet-branch statements and the gate (sabotage-verified). Co-authored-by: liuhao1024 --- cli.py | 14 ++++ run_agent.py | 8 +++ tests/test_cli_quiet_stdout_leak.py | 105 ++++++++++++++++++++++++++++ 3 files changed, 127 insertions(+) create mode 100644 tests/test_cli_quiet_stdout_leak.py diff --git a/cli.py b/cli.py index 9f62700df5..8c235aaeab 100644 --- a/cli.py +++ b/cli.py @@ -21380,6 +21380,20 @@ def main( cli.agent.stream_delta_callback = None cli.agent.tool_gen_callback = None cli.agent.reasoning_callback = None + # Inline-diff and progress callbacks print directly to + # stdout and are gated by NEITHER quiet_mode nor + # tool_progress_mode: _on_tool_complete renders full + # file diffs via render_edit_diff_with_delta, and + # _on_tool_progress prints MoA reference blocks before + # its mode check. Neutralize them too so -Q stdout + # carries only the final response (#93220). + cli.agent.tool_progress_callback = None + cli.agent.tool_start_callback = None + cli.agent.tool_complete_callback = None + # Belt-and-braces for the executor's direct prints + # (they check agent.tool_progress_mode, initialized + # from display.tool_progress at construction). + cli.agent.tool_progress_mode = "off" try: result = cli.agent.run_conversation( user_message=effective_query, diff --git a/run_agent.py b/run_agent.py index 831a14748f..d5c2447860 100644 --- a/run_agent.py +++ b/run_agent.py @@ -969,7 +969,15 @@ class AIAgent: callers. The CLI may still want compact progress hints when no callback owns rendering. Embedded/library callers, on the other hand, expect quiet mode to be truly silent. + + ``suppress_status_output`` (the strict machine-readable mode used by + ``hermes chat -Q``) always wins: those flows neutralize the rendering + callbacks, and without this gate the "no callback owns rendering" + fallback would print ``[tool]``/``[done]`` spinner lines into the + captured stdout it exists to keep clean (#93220). """ + if getattr(self, "suppress_status_output", False): + return False return ( self.quiet_mode and not self.tool_progress_callback diff --git a/tests/test_cli_quiet_stdout_leak.py b/tests/test_cli_quiet_stdout_leak.py new file mode 100644 index 0000000000..857135e97e --- /dev/null +++ b/tests/test_cli_quiet_stdout_leak.py @@ -0,0 +1,105 @@ +"""`hermes chat -Q` must not leak presentation output into stdout (#93220). + +The quiet single-query branch lives inline in ``cli.py``'s main flow (no +standalone function to call), so these pin the branch's required statements +at the source level — the established convention for behavior with no +runtime mirror (see the install.ps1 source-text tests). Deleting any +neutralization reintroduces a leak: + +- ``reasoning_callback`` → streaming ``┌─ Reasoning ─┐`` box +- ``tool_complete_callback`` → full file diffs via render_edit_diff_with_delta +- ``tool_progress_callback`` → MoA reference blocks (printed before the + mode check in _on_tool_progress) +- ``tool_start_callback`` → snapshot capture paired with tool_complete +- ``agent.tool_progress_mode`` → executor's direct progress prints +""" + +from __future__ import annotations + +from pathlib import Path + +import cli as cli_mod + +_QUIET_ANCHOR = "# Quiet mode: suppress banner, spinner, tool previews." + + +def _quiet_branch() -> str: + """Return the source slice of the quiet single-query branch.""" + source = Path(cli_mod.__file__).read_text(encoding="utf-8") + start = source.index(_QUIET_ANCHOR) + # A generous window covers the whole branch body up to the + # run_conversation call and beyond. + return source[start : start + 8000] + + +def test_quiet_branch_clears_reasoning_callback(): + branch = _quiet_branch() + assert "cli.agent.reasoning_callback = None" in branch, ( + "-Q must clear the reasoning callback on the live agent, or the " + "streaming Reasoning box renders into captured stdout (#93220)." + ) + + +def test_quiet_branch_clears_inline_diff_callbacks(): + branch = _quiet_branch() + for attr in ( + "cli.agent.tool_start_callback = None", + "cli.agent.tool_complete_callback = None", + ): + assert attr in branch, ( + f"-Q must set `{attr}`: the inline-diff callbacks print full " + "file diffs via render_edit_diff_with_delta and are gated by " + "neither quiet_mode nor tool_progress_mode (#93220)." + ) + + +def test_quiet_branch_clears_tool_progress_callback(): + branch = _quiet_branch() + assert "cli.agent.tool_progress_callback = None" in branch, ( + "-Q must clear tool_progress_callback: _on_tool_progress prints " + "MoA reference blocks before its tool_progress_mode check (#93220)." + ) + + +def test_quiet_branch_syncs_tool_progress_off_to_agent(): + branch = _quiet_branch() + assert 'cli.agent.tool_progress_mode = "off"' in branch, ( + "-Q must sync tool_progress_mode='off' to the live agent so the " + "tool_executor rendering path stays silent (#93220)." + ) + + +def test_suppress_status_output_gates_quiet_tool_messages(): + """The executor's [tool]/[done] fallback must stay silent under -Q. + + ``_should_emit_quiet_tool_messages`` is the gate for the quiet-mode + KawaiiSpinner fallback in agent/tool_executor.py; with the rendering + callbacks neutralized it would otherwise print ``[tool]``/``[done]`` + lines straight into -Q's captured stdout (#93220). + """ + from run_agent import AIAgent + + agent = object.__new__(AIAgent) + agent.quiet_mode = True + agent.tool_progress_callback = None + agent.platform = "cli" + + agent.suppress_status_output = False + assert agent._should_emit_quiet_tool_messages() is True + + agent.suppress_status_output = True + assert agent._should_emit_quiet_tool_messages() is False + + +def test_quiet_branch_neutralizations_precede_run_conversation(): + branch = _quiet_branch() + run_idx = branch.index("run_conversation(") + for attr in ( + "cli.agent.reasoning_callback = None", + "cli.agent.tool_progress_callback = None", + "cli.agent.tool_start_callback = None", + "cli.agent.tool_complete_callback = None", + ): + assert branch.index(attr) < run_idx, ( + f"`{attr}` must run before run_conversation in the quiet branch" + )