From 13f1efdd15ae583f69c5be986545c026b258607d Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 10 Jun 2026 22:30:27 -0700 Subject: [PATCH] fix(gateway): collapse repeated terminal headers in consecutive tool progress blocks (#43968) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the agent runs several terminal commands back-to-back, each progress line repeated the '💻 terminal' header above its fenced code block, cluttering the progress bubble. Now only the first terminal call in a streak emits the header; subsequent consecutive terminal calls render adjacent code blocks. Any other tool (or non-block preview) resets the streak so the next terminal call gets a fresh header. --- gateway/run.py | 19 ++++++- tests/gateway/test_run_progress_topics.py | 69 +++++++++++++++++++++++ 2 files changed, 86 insertions(+), 2 deletions(-) diff --git a/gateway/run.py b/gateway/run.py index 58c68a4b99c..708106ad8c8 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -12932,6 +12932,10 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew last_tool = [None] # Mutable container for tracking in closure last_progress_msg = [None] # Track last message for dedup repeat_count = [0] # How many times the same message repeated + # True when the previously enqueued progress line was a terminal + # fenced code block — consecutive terminal calls then drop the + # repeated "💻 terminal" header and render back-to-back blocks. + last_was_terminal_block = [False] # ── Discord voice "verbal ack before tool calls" ──────────────── # When the bot is in a voice channel with the continuous mixer @@ -13088,7 +13092,13 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew ): 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```" + # Consecutive terminal calls: drop the repeated + # "💻 terminal" header so back-to-back commands render as + # adjacent code blocks under a single header. + _block_header = ( + "" if last_was_terminal_block[0] else f"{emoji} {tool_name}\n" + ) + _code_block_full = f"{_block_header}```\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 @@ -13099,13 +13109,15 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew _cmd_short = _cmd_short[:_cap - 3] + "..." elif _multiline: _cmd_short = _cmd_short + " ..." - _code_block_short = f"{emoji} {tool_name}\n```\n{_cmd_short}\n```" + _code_block_short = f"{_block_header}```\n{_cmd_short}\n```" # Verbose mode: show detailed arguments, respects tool_preview_length if progress_mode == "verbose": if _code_block_full is not None: + last_was_terminal_block[0] = True progress_queue.put(_code_block_full) return + last_was_terminal_block[0] = False if args: from agent.display import get_tool_preview_max_len _pl = get_tool_preview_max_len() @@ -13130,6 +13142,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew # fenced block (built above) instead of the truncated preview. if _code_block_short is not None: msg = _code_block_short + last_was_terminal_block[0] = True elif preview: from agent.display import get_tool_preview_max_len _pl = get_tool_preview_max_len() @@ -13137,8 +13150,10 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew if len(preview) > _cap: preview = preview[:_cap - 3] + "..." msg = f"{emoji} {tool_name}: \"{preview}\"" + last_was_terminal_block[0] = False else: msg = f"{emoji} {tool_name}..." + last_was_terminal_block[0] = False # Dedup: collapse consecutive identical progress messages. # Common with execute_code where models iterate with the same diff --git a/tests/gateway/test_run_progress_topics.py b/tests/gateway/test_run_progress_topics.py index 646ad92976b..9d20ed5b793 100644 --- a/tests/gateway/test_run_progress_topics.py +++ b/tests/gateway/test_run_progress_topics.py @@ -1488,3 +1488,72 @@ async def test_terminal_progress_no_bash_block_in_verbose_mode(monkeypatch, tmp_ 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 + +class MultiTerminalCommandAgent: + """Emits several consecutive terminal tool.started events, then a + different tool, then terminal again — to exercise header collapsing.""" + + 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): + cb = self.tool_progress_callback + cb("tool.started", "terminal", "echo one", {"command": "echo one"}) + cb("tool.started", "terminal", "echo two", {"command": "echo two"}) + cb("tool.started", "terminal", "echo three", {"command": "echo three"}) + cb("tool.started", "web_search", "query stuff", {"query": "query stuff"}) + cb("tool.started", "terminal", "echo four", {"command": "echo four"}) + time.sleep(0.35) + return {"final_response": "done", "messages": [], "api_calls": 1} + + +@pytest.mark.asyncio +async def test_consecutive_terminal_progress_collapses_headers(monkeypatch, tmp_path): + """Back-to-back terminal calls render ONE "terminal" header followed by + adjacent code blocks; a different tool in between resets the header so the + next terminal call gets a fresh one.""" + 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 = MultiTerminalCommandAgent + 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-consecutive", + session_key="agent:main:telegram:dm:12345", + ) + + assert result["final_response"] == "done" + contents = [call["content"] for call in adapter.sent] + [ + call["content"] for call in adapter.edits + ] + final = max(contents, key=len) if contents else "" + # All four commands present as code blocks. + for cmd in ("echo one", "echo two", "echo three", "echo four"): + assert cmd in final + # Exactly TWO terminal headers: one for the first run of three calls, + # one for the terminal call after web_search broke the streak. + assert final.count("terminal\n```") == 2