From 421226e404a14f9a43df2ae3262d4bedc4ca82b3 Mon Sep 17 00:00:00 2001 From: GodsBoy Date: Mon, 8 Jun 2026 13:02:17 +0200 Subject: [PATCH] fix(gateway): stop terminal progress from posting the full command to messaging chats #41215 rendered a terminal tool call as a native ```bash fenced block on markdown platforms (Telegram, WhatsApp, Slack, and others), showing the full command with no truncation, in both all/new and verbose modes. That posted complete shell commands (heredocs, internal paths, destructive commands) into the chat before the final answer, visible to everyone in it. This restores the prior behavior: terminal progress shows the short, truncated preview line that every other tool already uses, capped at tool_preview_length. The supports_code_blocks capability flag is left in place for future use. CLI/TUI rendering is a separate path and was unaffected. Adds a regression test asserting terminal progress renders as a truncated preview, not a fenced bash block, even on a markdown-capable gateway. Fixes #41955 --- gateway/platforms/base.py | 6 +- gateway/run.py | 28 +---- tests/gateway/test_run_progress_topics.py | 120 ++++++++++++++++++++++ 3 files changed, 126 insertions(+), 28 deletions(-) diff --git a/gateway/platforms/base.py b/gateway/platforms/base.py index adac5fad2a7..36cc9c4495d 100644 --- a/gateway/platforms/base.py +++ b/gateway/platforms/base.py @@ -1795,9 +1795,11 @@ class BasePlatformAdapter(ABC): # Whether this platform renders triple-backtick fenced code blocks (i.e. # ``format_message`` translates/preserves markdown fences into a real code - # block). Drives presentation choices like rendering a ``terminal`` tool - # call's command as a ```bash block instead of a flat preview line. + # block). Capability flag for markdown-aware presentation choices. # Default False (plain-text platforms); markdown-rendering adapters set True. + # Note: tool-progress deliberately does NOT use this to render a terminal + # command as a ```bash block — that exposed full commands in chat. Progress + # shows a short truncated preview only (see gateway/run.py progress_callback). supports_code_blocks: bool = False def __init__(self, config: PlatformConfig, platform: Platform): diff --git a/gateway/run.py b/gateway/run.py index d5b39c31df1..109c666c4f3 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -12971,32 +12971,10 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew # Build progress message with primary argument preview from agent.display import get_tool_emoji emoji = get_tool_emoji(tool_name, default="⚙️") - - # Markdown-capable platforms render a terminal command as a native - # ```bash fenced block (full command, no quotes, no label, no - # truncation) instead of the noisy `terminal: "cmd…"` line. Gated - # on the adapter's ``supports_code_blocks`` capability so every - # markdown-rendering platform (and plugin adapters that opt in) gets - # it, while plain-text platforms keep the compact line. - _bash_block = None - try: - _progress_adapter = self.adapters.get(source.platform) - except Exception: - _progress_adapter = None - if ( - getattr(_progress_adapter, "supports_code_blocks", False) - and tool_name == "terminal" - and isinstance(args, dict) - and isinstance(args.get("command"), str) - and args["command"].strip() - ): - _bash_block = f"```bash\n{args['command'].rstrip()}\n```" # Verbose mode: show detailed arguments, respects tool_preview_length if progress_mode == "verbose": - if _bash_block is not None: - msg = _bash_block - elif args: + if args: from agent.display import get_tool_preview_max_len _pl = get_tool_preview_max_len() args_str = json.dumps(args, ensure_ascii=False, default=str) @@ -13016,9 +12994,7 @@ 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). - if _bash_block is not None: - msg = _bash_block - elif preview: + if preview: from agent.display import get_tool_preview_max_len _pl = get_tool_preview_max_len() _cap = _pl if _pl > 0 else 40 diff --git a/tests/gateway/test_run_progress_topics.py b/tests/gateway/test_run_progress_topics.py index 5b7dfb821b0..88dee23b3fe 100644 --- a/tests/gateway/test_run_progress_topics.py +++ b/tests/gateway/test_run_progress_topics.py @@ -1264,3 +1264,123 @@ async def test_verbose_mode_respects_explicit_tool_preview_length(monkeypatch, t assert VerboseAgent.LONG_CODE not in all_content # But should still contain the truncated portion with "..." assert "..." in all_content + + +class CodeBlockProgressAdapter(ProgressCaptureAdapter): + """A markdown-capable progress adapter (declares supports_code_blocks).""" + + supports_code_blocks = True + + +class TerminalCommandAgent: + """Emits a terminal tool.started with a real, multi-line command arg.""" + + CMD = ( + "set -euo pipefail\n" + "printf 'node: '; node --version\n" + "npm install -g hyperframes@latest" + ) + + def __init__(self, **kwargs): + self.tool_progress_callback = kwargs.get("tool_progress_callback") + self.tools = [] + + def run_conversation(self, message, conversation_history=None, task_id=None): + self.tool_progress_callback( + "tool.started", "terminal", self.CMD, {"command": self.CMD} + ) + # Let the async progress task drain the queue and send before returning. + time.sleep(0.35) + return {"final_response": "done", "messages": [], "api_calls": 1} + + +@pytest.mark.asyncio +async def test_terminal_progress_is_truncated_preview_not_bash_block(monkeypatch, tmp_path): + """Regression for #41215: terminal progress must render as a short truncated + preview, never the full command in a fenced ```bash block, even on a + markdown-capable (supports_code_blocks) gateway.""" + monkeypatch.setenv("HERMES_TOOL_PROGRESS_MODE", "all") + + 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-no-bash-block", + 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) + # Compact truncated preview, not a fenced bash block. + assert "```bash" not in all_content + assert 'terminal: "' in all_content + # The full multi-line command body must not reach the chat. + assert "npm install -g hyperframes@latest" not 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 + from both branches, so verbose progress must not emit a fenced ```bash block + either (verbose still shows args by opt-in, just not as a code block).""" + 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-verbose-no-bash", + 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 "```bash" not in all_content