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