{"repo":"NousResearch/hermes-agent","public_id":"13584","display_label":"Gateway tool progress display and chat formatting fixes","summary":"These items address how terminal tool progress and command output are formatted and truncated when sent to messaging platforms like Discord and Telegram, alongside related ACP tool completion update issues. Most members share the theme of controlling what shell command content appears in chat progress messages.","subsystem":"acp","counts":{"members":15,"open_issues":2,"open_issues_distinct":2,"open_prs":0,"closed_issues":3,"merged_prs":3,"attempt_prs":7},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":42420},"resolution":{"state":"partially_resolved","by":"pr-42420"},"freshness":{"state":"verified","last_verified_at":"2026-09-18T17:33:54+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.1158095238095238},"fix_edges":[{"issue_number":41955,"pr_number":42420,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":42637,"pr_number":42729,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":50723,"pr_number":114914,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":114395,"pr_number":114914,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true}],"members":[{"kind":"issue","number":13584,"title":"Telegram tool progress shows raw terminal date command instead of result","url":null,"state":"open"},{"kind":"issue","number":41732,"title":"Discord compact tool progress leaks terminal commands as code blocks","url":null,"state":"closed"},{"kind":"issue","number":41955,"title":"Gateway tool progress leaks raw shell commands and code bodies to messaging chats","url":null,"state":"closed"},{"kind":"issue","number":42637,"title":"[Bug]: Terminal tool progress posts the full command as a fenced code block to messaging chats (regression after #42576)","url":null,"state":"closed"},{"kind":"issue","number":50723,"title":"[Bug]: ACP tool completions can be missing","url":null,"state":"open"},{"kind":"pr","number":13599,"title":"fix(gateway): update Telegram progress lines on tool completion","url":null,"state":"closed","close_reason":{"category":"uncategorized","implication":"needs_llm"},"merged":false},{"kind":"pr","number":41733,"title":"fix(gateway): keep Discord terminal progress compact","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#42634","number":42634},{"kind":"pr","value":"#54432","number":54432}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":41794,"title":"fix(progress): skip fenced bash block in compact tool progress mode (#41732)","url":null,"state":"closed","close_reason":{"category":"uncategorized","implication":"needs_llm"},"merged":false},{"kind":"pr","number":41950,"title":"fix(gateway): stop terminal progress from posting the full command to messaging chats","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"421226e40","number":0},{"kind":"pr","value":"#42420","number":42420},{"kind":"pr","value":"#41215","number":41215},{"kind":"pr","value":"#41955","number":41955},{"kind":"pr","value":"#41732","number":41732}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":42420,"title":"fix(gateway): stop terminal progress from posting full commands to messaging chats","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["gateway/platforms/base.py","gateway/run.py","tests/gateway/test_run_progress_topics.py"],"fix_hunks":[{"file":"gateway/platforms/base.py","hunks":["@@ -1795,9 +1795,11 @@ class BasePlatformAdapter(ABC):\n \n     # Whether this platform renders triple-backtick fenced code blocks (i.e.\n     # ``format_message`` translates/preserves markdown fences into a real code\n-    # block).  Drives presentation choices like rendering a ``terminal`` tool\n-    # call's command as a ```bash block instead of a flat preview line.\n+    # block).  Capability flag for markdown-aware presentation choices.\n     # Default False (plain-text platforms); markdown-rendering adapters set True.\n+    # Note: tool-progress deliberately does NOT use this to render a terminal\n+    # command as a ```bash block — that exposed full commands in chat. Progress\n+    # shows a short truncated preview only (see gateway/run.py progress_callback).\n     supports_code_blocks: bool = False\n \n     def __init__(self, config: PlatformConfig, platform: Platform):"]},{"file":"gateway/run.py","hunks":["@@ -12971,32 +12971,10 @@ def progress_callback(event_type: str, tool_name: str = None, preview: str = Non\n             # Build progress message with primary argument preview\n             from agent.display import get_tool_emoji\n             emoji = get_tool_emoji(tool_name, default=\"⚙️\")\n-\n-            # Markdown-capable platforms render a terminal command as a native\n-            # ```bash fenced block (full command, no quotes, no label, no\n-            # truncation) instead of the noisy `terminal: \"cmd…\"` line.  Gated\n-            # on the adapter's ``supports_code_blocks`` capability so every\n-            # markdown-rendering platform (and plugin adapters that opt in) gets\n-            # it, while plain-text platforms keep the compact line.\n-            _bash_block = None\n-            try:\n-                _progress_adapter = self.adapters.get(source.platform)\n-            except Exception:\n-                _progress_adapter = None\n-            if (\n-                getattr(_progress_adapter, \"supports_code_blocks\", False)\n-                and tool_name == \"terminal\"\n-                and isinstance(args, dict)\n-                and isinstance(args.get(\"command\"), str)\n-                and args[\"command\"].strip()\n-            ):\n-                _bash_block = f\"```bash\\n{args['command'].rstrip()}\\n```\"\n             \n             # Verbose mode: show detailed arguments, respects tool_preview_length\n             if progress_mode == \"verbose\":\n-                if _bash_block is not None:\n-                    msg = _bash_block\n-                elif args:\n+                if args:\n                     from agent.display import get_tool_preview_max_len\n                     _pl = get_tool_preview_max_len()\n                     args_str = json.dumps(args, ensure_ascii=False, default=str)","@@ -13016,9 +12994,7 @@ def progress_callback(event_type: str, tool_name: str = None, preview: str = Non\n             # \"all\" / \"new\" modes: short preview, respects tool_preview_length\n             # config (defaults to 40 chars when unset to keep gateway messages\n             # compact — unlike CLI spinners, these persist as permanent messages).\n-            if _bash_block is not None:\n-                msg = _bash_block\n-            elif preview:\n+            if preview:\n                 from agent.display import get_tool_preview_max_len\n                 _pl = get_tool_preview_max_len()\n                 _cap = _pl if _pl > 0 else 40"]},{"file":"tests/gateway/test_run_progress_topics.py","hunks":["@@ -1264,3 +1264,123 @@ async def test_verbose_mode_respects_explicit_tool_preview_length(monkeypatch, t\n     assert VerboseAgent.LONG_CODE not in all_content\n     # But should still contain the truncated portion with \"...\"\n     assert \"...\" in all_content\n+\n+\n+class CodeBlockProgressAdapter(ProgressCaptureAdapter):\n+    \"\"\"A markdown-capable progress adapter (declares supports_code_blocks).\"\"\"\n+\n+    supports_code_blocks = True\n+\n+\n+class TerminalCommandAgent:\n+    \"\"\"Emits a terminal tool.started with a real, multi-line command arg.\"\"\"\n+\n+    CMD = (\n+        \"set -euo pipefail\\n\"\n+        \"printf 'node: '; node --version\\n\"\n+        \"npm install -g hyperframes@latest\"\n+    )\n+\n+    def __init__(self, **kwargs):\n+        self.tool_progress_callback = kwargs.get(\"tool_progress_callback\")\n+        self.tools = []\n+\n+    def run_conversation(self, message, conversation_history=None, task_id=None):\n+        self.tool_progress_callback(\n+            \"tool.started\", \"terminal\", self.CMD, {\"command\": self.CMD}\n+        )\n+        # Let the async progress task drain the queue and send before returning.\n+        time.sleep(0.35)\n+        return {\"final_response\": \"done\", \"messages\": [], \"api_calls\": 1}\n+\n+\n[redacted:email]\n+async def test_terminal_progress_is_truncated_preview_not_bash_block(monkeypatch, tmp_path):\n+    \"\"\"Regression for #41215: terminal progress must render as a short truncated\n+    preview, never the full command in a fenced ```bash block, even on a\n+    markdown-capable (supports_code_blocks) gateway.\"\"\"\n+    monkeypatch.setenv(\"HERMES_TOOL_PROGRESS_MODE\", \"all\")\n+\n+    fake_dotenv = types.ModuleType(\"dotenv\")\n+    fake_dotenv.load_dotenv = lambda *args, **kwargs: None\n+    monkeypatch.setitem(sys.modules, \"dotenv\", fake_dotenv)\n+\n+    fake_run_agent = types.ModuleType(\"run_agent\")\n+    fake_run_agent.AIAgent = TerminalCommandAgent\n+    monkeypatch.setitem(sys.modules, \"run_agent\", fake_run_agent)\n+    import tools.terminal_tool  # noqa: F401 - register terminal emoji\n+\n+    adapter = CodeBlockProgressAdapter(platform=Platform.TELEGRAM)\n+    runner = _make_runner(adapter)\n+    gateway_run = importlib.import_module(\"gateway.run\")\n+    monkeypatch.setattr(gateway_run, \"_hermes_home\", tmp_path)\n+    monkeypatch.setattr(gateway_run, \"_resolve_runtime_agent_kwargs\", lambda: {\"api_key\": \"***\"})\n+\n+    source = SessionSource(\n+        platform=Platform.TELEGRAM,\n+        chat_id=\"12345\",\n+        chat_type=\"dm\",\n+        thread_id=None,\n+    )\n+\n+    result = await runner._run_agent(\n+        message=\"hello\",\n+        context_prompt=\"\",\n+        history=[],\n+        source=source,\n+        session_id=\"sess-terminal-no-bash-block\",\n+        session_key=\"agent:main:telegram:dm:12345\",\n+    )\n+\n+    assert result[\"final_response\"] == \"done\"\n+    all_content = \" \".join(call[\"content\"] for call in adapter.sent)\n+    all_content += \" \".join(call[\"content\"] for call in adapter.edits)\n+    # Compact truncated preview, not a fenced bash block.\n+    assert \"```bash\" not in all_content\n+    assert 'terminal: \"' in all_content\n+    # The full multi-line command body must not reach the chat.\n+    assert \"npm install -g hyperframes@latest\" not in all_content\n+\n+\n[redacted:email]\n+async def test_terminal_progress_no_bash_block_in_verbose_mode(monkeypatch, tmp_path):\n+    \"\"\"#41215 also rendered the bash block in verbose mode. The revert removed it\n+    from both branches, so verbose progress must not emit a fenced ```bash block\n+    either (verbose still shows args by opt-in, just not as a code block).\"\"\"\n+    monkeypatch.setenv(\"HERMES_TOOL_PROGRESS_MODE\", \"verbose\")\n+\n+    fake_dotenv = types.ModuleType(\"dotenv\")\n+    fake_dotenv.load_dotenv = lambda *args, **kwargs: None\n+    monkeypatch.setitem(sys.modules, \"dotenv\", fake_dotenv)\n+\n+    fake_run_agent = types.ModuleType(\"run_agent\")\n+    fake_run_agent.AIAgent = TerminalCommandAgent\n+    monkeypatch.setitem(sys.modules, \"run_agent\", fake_run_agent)\n+    import tools.terminal_tool  # noqa: F401 - register terminal emoji\n+\n+    adapter = CodeBlockProgressAdapter(platform=Platform.TELEGRAM)\n+    runner = _make_runner(adapter)\n+    gateway_run = importlib.import_module(\"gateway.run\")\n+    monkeypatch.setattr(gateway_run, \"_hermes_home\", tmp_path)\n+    monkeypatch.setattr(gateway_run, \"_resolve_runtime_agent_kwargs\", lambda: {\"api_key\": \"***\"})\n+\n+    source = SessionSource(\n+        platform=Platform.TELEGRAM,\n+        chat_id=\"12345\",\n+        chat_type=\"dm\",\n+        thread_id=None,\n+    )\n+\n+    result = await runner._run_agent(\n+        message=\"hello\",\n+        context_prompt=\"\",\n+        history=[],\n+        source=source,\n+        session_id=\"sess-terminal-verbose-no-bash\",\n+        session_key=\"agent:main:telegram:dm:12345\",\n+    )\n+\n+    assert result[\"final_response\"] == \"done\"\n+    all_content = \" \".join(call[\"content\"] for call in adapter.sent)\n+    all_content += \" \".join(call[\"content\"] for call in adapter.edits)\n+    assert \"```bash\" not in all_content\n"]}]},{"kind":"pr","number":42638,"title":"feat(gateway): add per-platform terminal_progress knob to keep terminal commands compact in chat","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#42729","number":42729}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":42729,"title":"fix(gateway): cap terminal code-block preview in non-verbose mode","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["gateway/run.py","tests/gateway/test_run_progress_topics.py"],"fix_hunks":[{"file":"gateway/run.py","hunks":["@@ -13002,13 +13002,20 @@ def progress_callback(event_type: str, tool_name: str = None, preview: str = Non\n             emoji = get_tool_emoji(tool_name, default=\"⚙️\")\n \n             # Markdown-capable platforms render a terminal command as a fenced\n-            # code block (full command, no truncation) instead of the compact\n-            # `terminal: \"cmd…\"` preview.  Gated on the adapter's\n-            # ``supports_code_blocks`` capability so plain-text platforms keep\n-            # the short line.  No language tag is emitted — Slack mrkdwn renders\n-            # the tag as a literal first code line (\"bash\"), and a bare fence\n-            # renders correctly everywhere that supports blocks.\n-            _code_block = None\n+            # code block instead of the compact `terminal: \"cmd…\"` preview.\n+            # Gated on the adapter's ``supports_code_blocks`` capability so\n+            # plain-text platforms keep the short line.  No language tag is\n+            # emitted — Slack mrkdwn renders the tag as a literal first code\n+            # line (\"bash\"), and a bare fence renders correctly everywhere\n+            # that supports blocks.\n+            #\n+            # Verbose mode shows the FULL command.  Non-verbose (\"all\"/\"new\")\n+            # modes still wrap in a fence but truncate to a single line capped\n+            # at ``tool_preview_length`` (default 40) so a long or multi-line\n+            # command doesn't render as a huge block — matching the budget the\n+            # non-terminal preview path already applies (#42634).\n+            _code_block_full = None\n+            _code_block_short = None\n             try:\n                 _progress_adapter = self.adapters.get(source.platform)\n             except Exception:","@@ -13020,12 +13027,25 @@ def progress_callback(event_type: str, tool_name: str = None, preview: str = Non\n                 and isinstance(args.get(\"command\"), str)\n                 and args[\"command\"].strip()\n             ):\n-                _code_block = f\"{emoji} {tool_name}\\n```\\n{args['command'].rstrip()}\\n```\"\n+                from agent.display import get_tool_preview_max_len\n+                _cmd_full = args[\"command\"].rstrip()\n+                _code_block_full = f\"{emoji} {tool_name}\\n```\\n{_cmd_full}\\n```\"\n+                # Single-line, capped preview for non-verbose modes.\n+                _pl = get_tool_preview_max_len()\n+                _cap = _pl if _pl > 0 else 40\n+                _lines = _cmd_full.splitlines()\n+                _cmd_short = _lines[0] if _lines else _cmd_full\n+                _multiline = len(_lines) > 1\n+                if len(_cmd_short) > _cap:\n+                    _cmd_short = _cmd_short[:_cap - 3] + \"...\"\n+                elif _multiline:\n+                    _cmd_short = _cmd_short + \" ...\"\n+                _code_block_short = f\"{emoji} {tool_name}\\n```\\n{_cmd_short}\\n```\"\n \n             # Verbose mode: show detailed arguments, respects tool_preview_length\n             if progress_mode == \"verbose\":\n-                if _code_block is not None:\n-                    progress_queue.put(_code_block)\n+                if _code_block_full is not None:\n+                    progress_queue.put(_code_block_full)\n                     return\n                 if args:\n                     from agent.display import get_tool_preview_max_len","@@ -13047,10 +13067,10 @@ def progress_callback(event_type: str, tool_name: str = None, preview: str = Non\n             # \"all\" / \"new\" modes: short preview, respects tool_preview_length\n             # config (defaults to 40 chars when unset to keep gateway messages\n             # compact — unlike CLI spinners, these persist as permanent messages).\n-            # Terminal commands on markdown platforms get the full fenced block\n-            # built above instead of the truncated preview.\n-            if _code_block is not None:\n-                msg = _code_block\n+            # Terminal commands on markdown platforms get a single-line capped\n+            # fenced block (built above) instead of the truncated preview.\n+            if _code_block_short is not None:\n+                msg = _code_block_short\n             elif preview:\n                 from agent.display import get_tool_preview_max_len\n                 _pl = get_tool_preview_max_len()"]},{"file":"tests/gateway/test_run_progress_topics.py","hunks":["@@ -1297,8 +1297,10 @@ def run_conversation(self, message, conversation_history=None, task_id=None):\n @pytest.mark.asyncio\n async def test_terminal_progress_renders_fenced_code_block(monkeypatch, tmp_path):\n     \"\"\"Terminal progress on a markdown-capable (supports_code_blocks) gateway\n-    renders the full command in a bare fenced code block — no language tag\n-    (Slack mrkdwn would print 'bash' as a literal first code line).\"\"\"\n+    renders a bare fenced code block — no language tag (Slack mrkdwn would print\n+    'bash' as a literal first code line).  In non-verbose (\"all\"/\"new\") mode the\n+    command is collapsed to a single line capped at tool_preview_length so a long\n+    or multi-line command doesn't render as a huge block (#42634).\"\"\"\n     monkeypatch.setenv(\"HERMES_TOOL_PROGRESS_MODE\", \"all\")\n \n     fake_dotenv = types.ModuleType(\"dotenv\")","@@ -1338,12 +1340,63 @@ async def test_terminal_progress_renders_fenced_code_block(monkeypatch, tmp_path\n     # Bare fenced block, no language tag (no '```bash').\n     assert \"```\" in all_content\n     assert \"```bash\" not in all_content\n-    # The full multi-line command body IS present in the block.\n-    assert \"npm install -g hyperframes@latest\" in all_content\n+    # Non-verbose collapses to the first line + truncation marker — the later\n+    # command lines must NOT appear (this was the \"huge block\" regression).\n+    assert \"set -euo pipefail\" in all_content\n+    assert \"npm install -g hyperframes@latest\" not in all_content\n+    assert \"node --version\" not in all_content\n     # No truncated quoted preview for the terminal command.\n     assert 'terminal: \"' not in all_content\n \n \n[redacted:email]\n+async def test_terminal_progress_verbose_shows_full_command(monkeypatch, tmp_path):\n+    \"\"\"Verbose mode on a markdown-capable gateway renders the FULL multi-line\n+    command in a bare fenced block (no truncation, no 'bash' tag).  This is the\n+    parity guarantee for #42634: verbose keeps full detail, non-verbose caps.\"\"\"\n+    monkeypatch.setenv(\"HERMES_TOOL_PROGRESS_MODE\", \"verbose\")\n+\n+    fake_dotenv = types.ModuleType(\"dotenv\")\n+    fake_dotenv.load_dotenv = lambda *args, **kwargs: None\n+    monkeypatch.setitem(sys.modules, \"dotenv\", fake_dotenv)\n+\n+    fake_run_agent = types.ModuleType(\"run_agent\")\n+    fake_run_agent.AIAgent = TerminalCommandAgent\n+    monkeypatch.setitem(sys.modules, \"run_agent\", fake_run_agent)\n+    import tools.terminal_tool  # noqa: F401 - register terminal emoji\n+\n+    adapter = CodeBlockProgressAdapter(platform=Platform.TELEGRAM)\n+    runner = _make_runner(adapter)\n+    gateway_run = importlib.import_module(\"gateway.run\")\n+    monkeypatch.setattr(gateway_run, \"_hermes_home\", tmp_path)\n+    monkeypatch.setattr(gateway_run, \"_resolve_runtime_agent_kwargs\", lambda: {\"api_key\": \"***\"})\n+\n+    source = SessionSource(\n+        platform=Platform.TELEGRAM,\n+        chat_id=\"12345\",\n+        chat_type=\"dm\",\n+        thread_id=None,\n+    )\n+\n+    result = await runner._run_agent(\n+        message=\"hello\",\n+        context_prompt=\"\",\n+        history=[],\n+        source=source,\n+        session_id=\"sess-terminal-code-block-verbose\",\n+        session_key=\"agent:main:telegram:dm:12345\",\n+    )\n+\n+    assert result[\"final_response\"] == \"done\"\n+    all_content = \" \".join(call[\"content\"] for call in adapter.sent)\n+    all_content += \" \".join(call[\"content\"] for call in adapter.edits)\n+    assert \"```\" in all_content\n+    assert \"```bash\" not in all_content\n+    # Full command body present — verbose is uncapped.\n+    assert \"npm install -g hyperframes@latest\" in all_content\n+    assert \"node --version\" in all_content\n+\n+\n @pytest.mark.asyncio\n async def test_terminal_progress_no_bash_block_in_verbose_mode(monkeypatch, tmp_path):\n     \"\"\"#41215 also rendered the bash block in verbose mode. The revert removed it\n"]}]},{"kind":"pr","number":50741,"title":"fix(acp): emit tool.completed updates immediately via progress callback (#50723)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"58e38f466dea","number":0},{"kind":"pr","value":"#114914","number":114914},{"kind":"pr","value":"#114914","number":114914},{"kind":"pr","value":"#114395","number":114395},{"kind":"pr","value":"#114442","number":114442},{"kind":"pr","value":"#114395","number":114395}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":50742,"title":"fix(acp): emit tool completion updates from progress callback (#50723)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#50741","number":50741},{"kind":"pr","value":"#50723","number":50723}],"category":"duplicate_of","implication":"duplicate"},"merged":false},{"kind":"pr","number":114914,"title":"fix(acp): every tool call reaches a terminal status before the turn ends (#114395, salvage #114442)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["acp_adapter/edit_approval.py","acp_adapter/events.py","acp_adapter/permissions.py","acp_adapter/server.py","acp_adapter/tools.py","contributors/emails/[redacted:email]","tests/acp_adapter/test_events.py","tests/acp_adapter/test_permissions.py","tests/acp_adapter/test_server.py","website/docs/developer-guide/acp-internals.md"]}],"trust":{"default_tier":"verified","includes_suggested":false}}