From 8d99b5bc4f3fd5998d55b22ddb0e6dba38ae4c7f Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Tue, 9 Jun 2026 02:28:47 -0700 Subject: [PATCH] fix(gateway): cap terminal code-block preview in non-verbose mode (#42729) The markdown code-block change rendered args['command'] in full in both verbose AND non-verbose (all/new) modes, so a long or multi-line terminal command bypassed the tool_preview_length cap (default 40) and rendered as a huge block. Non-verbose now collapses to a single line capped at the preview length while keeping the fence; verbose keeps the full command. --- gateway/run.py | 48 ++++++++++++------ tests/gateway/test_run_progress_topics.py | 61 +++++++++++++++++++++-- 2 files changed, 91 insertions(+), 18 deletions(-) diff --git a/gateway/run.py b/gateway/run.py index 26368cd394e..57f86d7ab31 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -13002,13 +13002,20 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew emoji = get_tool_emoji(tool_name, default="⚙️") # Markdown-capable platforms render a terminal command as a fenced - # code block (full command, no truncation) instead of the compact - # `terminal: "cmd…"` preview. Gated on the adapter's - # ``supports_code_blocks`` capability so plain-text platforms keep - # the short line. No language tag is emitted — Slack mrkdwn renders - # the tag as a literal first code line ("bash"), and a bare fence - # renders correctly everywhere that supports blocks. - _code_block = None + # code block instead of the compact `terminal: "cmd…"` preview. + # Gated on the adapter's ``supports_code_blocks`` capability so + # plain-text platforms keep the short line. No language tag is + # emitted — Slack mrkdwn renders the tag as a literal first code + # line ("bash"), and a bare fence renders correctly everywhere + # that supports blocks. + # + # Verbose mode shows the FULL command. Non-verbose ("all"/"new") + # modes still wrap in a fence but truncate to a single line capped + # at ``tool_preview_length`` (default 40) so a long or multi-line + # command doesn't render as a huge block — matching the budget the + # non-terminal preview path already applies (#42634). + _code_block_full = None + _code_block_short = None try: _progress_adapter = self.adapters.get(source.platform) except Exception: @@ -13020,12 +13027,25 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew and isinstance(args.get("command"), str) and args["command"].strip() ): - _code_block = f"{emoji} {tool_name}\n```\n{args['command'].rstrip()}\n```" + from agent.display import get_tool_preview_max_len + _cmd_full = args["command"].rstrip() + _code_block_full = f"{emoji} {tool_name}\n```\n{_cmd_full}\n```" + # Single-line, capped preview for non-verbose modes. + _pl = get_tool_preview_max_len() + _cap = _pl if _pl > 0 else 40 + _lines = _cmd_full.splitlines() + _cmd_short = _lines[0] if _lines else _cmd_full + _multiline = len(_lines) > 1 + if len(_cmd_short) > _cap: + _cmd_short = _cmd_short[:_cap - 3] + "..." + elif _multiline: + _cmd_short = _cmd_short + " ..." + _code_block_short = f"{emoji} {tool_name}\n```\n{_cmd_short}\n```" # Verbose mode: show detailed arguments, respects tool_preview_length if progress_mode == "verbose": - if _code_block is not None: - progress_queue.put(_code_block) + if _code_block_full is not None: + progress_queue.put(_code_block_full) return if args: from agent.display import get_tool_preview_max_len @@ -13047,10 +13067,10 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew # "all" / "new" modes: short preview, respects tool_preview_length # config (defaults to 40 chars when unset to keep gateway messages # compact — unlike CLI spinners, these persist as permanent messages). - # Terminal commands on markdown platforms get the full fenced block - # built above instead of the truncated preview. - if _code_block is not None: - msg = _code_block + # Terminal commands on markdown platforms get a single-line capped + # fenced block (built above) instead of the truncated preview. + if _code_block_short is not None: + msg = _code_block_short elif preview: from agent.display import get_tool_preview_max_len _pl = get_tool_preview_max_len() diff --git a/tests/gateway/test_run_progress_topics.py b/tests/gateway/test_run_progress_topics.py index 4515d75a0dd..28d7327fcdd 100644 --- a/tests/gateway/test_run_progress_topics.py +++ b/tests/gateway/test_run_progress_topics.py @@ -1297,8 +1297,10 @@ class TerminalCommandAgent: @pytest.mark.asyncio async def test_terminal_progress_renders_fenced_code_block(monkeypatch, tmp_path): """Terminal progress on a markdown-capable (supports_code_blocks) gateway - renders the full command in a bare fenced code block — no language tag - (Slack mrkdwn would print 'bash' as a literal first code line).""" + renders a bare fenced code block — no language tag (Slack mrkdwn would print + 'bash' as a literal first code line). In non-verbose ("all"/"new") mode the + command is collapsed to a single line capped at tool_preview_length so a long + or multi-line command doesn't render as a huge block (#42634).""" monkeypatch.setenv("HERMES_TOOL_PROGRESS_MODE", "all") fake_dotenv = types.ModuleType("dotenv") @@ -1338,12 +1340,63 @@ async def test_terminal_progress_renders_fenced_code_block(monkeypatch, tmp_path # Bare fenced block, no language tag (no '```bash'). assert "```" in all_content assert "```bash" not in all_content - # The full multi-line command body IS present in the block. - assert "npm install -g hyperframes@latest" in all_content + # Non-verbose collapses to the first line + truncation marker — the later + # command lines must NOT appear (this was the "huge block" regression). + assert "set -euo pipefail" in all_content + assert "npm install -g hyperframes@latest" not in all_content + assert "node --version" not in all_content # No truncated quoted preview for the terminal command. assert 'terminal: "' not in all_content +@pytest.mark.asyncio +async def test_terminal_progress_verbose_shows_full_command(monkeypatch, tmp_path): + """Verbose mode on a markdown-capable gateway renders the FULL multi-line + command in a bare fenced block (no truncation, no 'bash' tag). This is the + parity guarantee for #42634: verbose keeps full detail, non-verbose caps.""" + monkeypatch.setenv("HERMES_TOOL_PROGRESS_MODE", "verbose") + + fake_dotenv = types.ModuleType("dotenv") + fake_dotenv.load_dotenv = lambda *args, **kwargs: None + monkeypatch.setitem(sys.modules, "dotenv", fake_dotenv) + + fake_run_agent = types.ModuleType("run_agent") + fake_run_agent.AIAgent = TerminalCommandAgent + monkeypatch.setitem(sys.modules, "run_agent", fake_run_agent) + import tools.terminal_tool # noqa: F401 - register terminal emoji + + adapter = CodeBlockProgressAdapter(platform=Platform.TELEGRAM) + runner = _make_runner(adapter) + gateway_run = importlib.import_module("gateway.run") + monkeypatch.setattr(gateway_run, "_hermes_home", tmp_path) + monkeypatch.setattr(gateway_run, "_resolve_runtime_agent_kwargs", lambda: {"api_key": "***"}) + + source = SessionSource( + platform=Platform.TELEGRAM, + chat_id="12345", + chat_type="dm", + thread_id=None, + ) + + result = await runner._run_agent( + message="hello", + context_prompt="", + history=[], + source=source, + session_id="sess-terminal-code-block-verbose", + session_key="agent:main:telegram:dm:12345", + ) + + assert result["final_response"] == "done" + all_content = " ".join(call["content"] for call in adapter.sent) + all_content += " ".join(call["content"] for call in adapter.edits) + assert "```" in all_content + assert "```bash" not in all_content + # Full command body present — verbose is uncapped. + assert "npm install -g hyperframes@latest" in all_content + assert "node --version" in all_content + + @pytest.mark.asyncio async def test_terminal_progress_no_bash_block_in_verbose_mode(monkeypatch, tmp_path): """#41215 also rendered the bash block in verbose mode. The revert removed it