Gateway tool progress display and chat formatting fixes

NousResearch/hermes-agent · cluster 13584 · acp

Data as of

2 open items. Fix merged in #42420. Check it covers your case.

The short answer

2open issues
0open PRs
15items grouped
Known?
Yes. 15 related items grouped here, 3 issues already closed.
Fix event
A fix was merged to main in PR #42420. Check it covers your case. No release data is tracked.
Tracker
The maintainers' tracker marks this partially resolved, deciding PR #42420. That is separate from the fix event.
Still open
2 issues and 0 PRs.

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.

Open issues2

  • Issue#13584Telegram tool progress shows raw terminal date command instead of result
  • Issue#50723[Bug]: ACP tool completions can be missing

Open PRs0

No open pull requests.

Fixes on record

Merged to main. A stamp counts the issues a fix is linked to.

  • #42420 fix(gateway): stop terminal progress from posting full commands to messaging chats

    merged to main

    files: gateway/platforms/base.py, gateway/run.py, tests/gateway/test_run_progress_topics.py

    Show the diff
    • 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):
    • gateway/run.py
      @@ -12971,32 +12971,10 @@ def progress_callback(event_type: str, tool_name: str = None, preview: str = Non
                   # 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 @@ def progress_callback(event_type: str, tool_name: str = None, preview: str = Non
                   # "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
    • 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}
      +
      +
      [redacted:email]
      +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
      +
      +
      [redacted:email]
      +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
  • #42729 fix(gateway): cap terminal code-block preview in non-verbose mode

    merged to main

    files: gateway/run.py, tests/gateway/test_run_progress_topics.py

    Show the diff
    • gateway/run.py
      @@ -13002,13 +13002,20 @@ def progress_callback(event_type: str, tool_name: str = None, preview: str = Non
                   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 @@ def progress_callback(event_type: str, tool_name: str = None, preview: str = Non
                       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 @@ def progress_callback(event_type: str, tool_name: str = None, preview: str = Non
                   # "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()
    • tests/gateway/test_run_progress_topics.py
      @@ -1297,8 +1297,10 @@ def run_conversation(self, message, conversation_history=None, task_id=None):
       @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
       
       
      [redacted:email]
      +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
  • #114914 fix(acp): every tool call reaches a terminal status before the turn ends (#114395, salvage #114442)

    merged to main

    files: 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

7 PRs closed without merging

From the close comments: superseded by another PR 2 · done elsewhere 1 · duplicate 1 · merged via another change 1 · other 2

  • #13599 fix(gateway): update Telegram progress lines on tool completion · other
  • #41733 fix(gateway): keep Discord terminal progress compact · superseded by another PR → #42634, #54432
  • #41794 fix(progress): skip fenced bash block in compact tool progress mode (#41732) · other
  • #41950 fix(gateway): stop terminal progress from posting the full command to messaging chats · merged via another change → 421226e, #42420, #41215, #41955, #41732
  • #42638 feat(gateway): add per-platform terminal_progress knob to keep terminal commands compact in chat · done elsewhere → #42729
Show 2 more
  • #50741 fix(acp): emit tool.completed updates immediately via progress callback (#50723) · superseded by another PR → 58e38f4, #114914, #114395, #114442
  • #50742 fix(acp): emit tool completion updates from progress callback (#50723) · duplicate → #50741, #50723
Closed issues · 3
  • Closed#41732Discord compact tool progress leaks terminal commands as code blocks
  • Closed#41955Gateway tool progress leaks raw shell commands and code bodies to messaging chats
  • Closed#42637[Bug]: Terminal tool progress posts the full command as a fenced code block to messaging chats (regression after #42576)
How this cluster was grouped
  • Link evidence (partly structural links): some members are connected by structural evidence (shared changed files, or a closing/superseded reference); the rest are joined by an automated relevance judgement over their text.
  • Members outside that structural core may belong to a different underlying problem.
  • Pairwise link density 0.12: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. 1.00 means every pair is linked.

Pick up a Lab task

Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.

Open the Lab

At a glance

Subsystem
acp
Merged PRs
#42420, #42729, #114914
Closed issues
3
Attempts
7 PRs closed without merging
Fix link checked
18 Sep 2026

For your agent

The same facts, typed.