Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions cli-config.yaml.example
Original file line number Diff line number Diff line change
Expand Up @@ -939,6 +939,23 @@ display:
# Toggle at runtime with /verbose in the CLI
tool_progress: all

# How the terminal tool's command is shown in tool progress on
# markdown-capable chat platforms (Telegram, WhatsApp, Slack, Feishu, Matrix,
# Weixin). Only affects the terminal tool; other breadcrumbs (search_files,
# read_file, patch, todo, etc.) are unchanged.
# code_block: Render the full command in a fenced code block (default)
# compact: Keep the short truncated `terminal: "..."` preview, so the
# full command is not posted to the chat
# Default preserves the fenced-block behavior. Set compact per platform to
# keep terminal progress quiet on a mobile or shared channel without turning
# off all tool progress.
# Per-platform override example:
# display:
# platforms:
# telegram:
# terminal_progress: compact
terminal_progress: code_block

# Per-platform defaults can be quieter than the global setting. Telegram
# tunes for mobile: tool_progress and busy_ack_detail default off (no
# per-tool breadcrumb stream, no "iteration 21/60" debug detail in busy
Expand Down
13 changes: 13 additions & 0 deletions gateway/display_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,14 @@
# live, just cleaned up after success so the chat doesn't fill up with
# stale breadcrumbs. Failed runs leave bubbles in place as breadcrumbs.
"cleanup_progress": False,
# How the terminal tool's command is rendered in tool progress on
# markdown-capable platforms (those with supports_code_blocks). Two values:
# "code_block" (default) renders the full command in a bare fenced block.
# "compact" keeps the short truncated `terminal: "..."` preview that every
# other tool already uses, so the full command is not posted to the chat.
# Only affects the terminal tool; other breadcrumbs (search_files, read_file,
# patch, todo, etc.) are unchanged. Default preserves current behavior.
"terminal_progress": "code_block",
}

# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -237,4 +245,9 @@ def _normalise(setting: str, value: Any) -> Any:
return int(value)
except (TypeError, ValueError):
return 0
if setting == "terminal_progress":
normalised = str(value).strip().lower()
if normalised in {"compact", "code_block"}:
return normalised
return "code_block"
return value
55 changes: 44 additions & 11 deletions gateway/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -12849,6 +12849,14 @@ def _run_still_current() -> bool:
if _env_tp and not _tool_progress_configured
else (_resolved_tp or _env_tp or "all")
)
# Terminal-command progress rendering on markdown-capable platforms.
# "code_block" (default) keeps the full fenced command; "compact" falls
# back to the short truncated `terminal: "..."` preview so the full
# command is not posted to chat. Resolved once here, read in the
# progress callback below.
_terminal_progress = resolve_display_setting(
user_config, platform_key, "terminal_progress", "code_block"
)
# Disable tool progress for webhooks - they don't support message editing,
# so each progress line would be sent as a separate message.
from gateway.config import Platform
Expand Down Expand Up @@ -13004,24 +13012,28 @@ def progress_callback(event_type: str, tool_name: str = None, preview: str = Non
# Markdown-capable platforms render a terminal command as a fenced
# 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.
# plain-text platforms keep the short line, and on the
# ``terminal_progress`` display setting so deployments can resolve it
# to "compact" to keep the truncated preview (and never post the full
# command to chat). 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).
# Verbose mode shows the FULL command (``_code_block_full``).
# Non-verbose ("all"/"new") modes still wrap in a fence but truncate
# to a single line capped at ``tool_preview_length`` (default 40) via
# ``_code_block_short`` 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:
_progress_adapter = None
if (
getattr(_progress_adapter, "supports_code_blocks", False)
_terminal_progress == "code_block"
and getattr(_progress_adapter, "supports_code_blocks", False)
and tool_name == "terminal"
and isinstance(args, dict)
and isinstance(args.get("command"), str)
Expand All @@ -13047,6 +13059,25 @@ def progress_callback(event_type: str, tool_name: str = None, preview: str = Non
if _code_block_full is not None:
progress_queue.put(_code_block_full)
return
# terminal_progress=compact keeps the terminal command compact
# even in verbose mode: the full command must not be posted to
# chat. Render the truncated `terminal: "..."` preview with a
# hard 40-char cap when tool_preview_length is unset (verbose
# otherwise leaves tool_preview_length at 0, which would dump the
# whole command via the args branch below).
if (
tool_name == "terminal"
and _terminal_progress == "compact"
and preview
):
from agent.display import get_tool_preview_max_len
_pl = get_tool_preview_max_len()
_cap = _pl if _pl > 0 else 40
_cmd_preview = preview
if len(_cmd_preview) > _cap:
_cmd_preview = _cmd_preview[:_cap - 3] + "..."
progress_queue.put(f"{emoji} {tool_name}: \"{_cmd_preview}\"")
return
if args:
from agent.display import get_tool_preview_max_len
_pl = get_tool_preview_max_len()
Expand All @@ -13068,7 +13099,9 @@ def progress_callback(event_type: str, tool_name: str = None, preview: str = Non
# 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 a single-line capped
# fenced block (built above) instead of the truncated preview.
# fenced block (built above) instead of the truncated preview,
# unless terminal_progress resolved to "compact", in which case no
# block was built and the truncated preview below is used.
if _code_block_short is not None:
msg = _code_block_short
elif preview:
Expand Down
72 changes: 72 additions & 0 deletions tests/gateway/test_display_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -436,3 +436,75 @@ def test_yaml_true_string_normalises_to_true(self):
}
}
assert resolve_display_setting(config, "telegram", "cleanup_progress") is True, val


class TestTerminalProgress:
"""``terminal_progress`` controls terminal-command rendering on
markdown-capable platforms; defaults to ``code_block`` everywhere."""

def test_default_code_block_for_all_platforms(self):
"""No config set → terminal_progress resolves to 'code_block'."""
from gateway.display_config import resolve_display_setting

for plat in ("telegram", "whatsapp", "slack", "discord", "email", "unknown"):
assert (
resolve_display_setting({}, plat, "terminal_progress")
== "code_block"
), plat

def test_global_compact_applies_to_all_platforms(self):
"""display.terminal_progress=compact opts in globally."""
from gateway.display_config import resolve_display_setting

config = {"display": {"terminal_progress": "compact"}}
assert resolve_display_setting(config, "telegram", "terminal_progress") == "compact"
assert resolve_display_setting(config, "slack", "terminal_progress") == "compact"

def test_per_platform_override_wins(self):
"""display.platforms.<plat>.terminal_progress beats the global value."""
from gateway.display_config import resolve_display_setting

config = {
"display": {
"terminal_progress": "code_block",
"platforms": {
"telegram": {"terminal_progress": "compact"},
},
}
}
assert resolve_display_setting(config, "telegram", "terminal_progress") == "compact"
assert resolve_display_setting(config, "slack", "terminal_progress") == "code_block"

def test_unknown_value_normalises_to_code_block(self):
"""An unrecognised value falls back to the safe default."""
from gateway.display_config import resolve_display_setting

config = {"display": {"terminal_progress": "loud"}}
assert resolve_display_setting(config, "telegram", "terminal_progress") == "code_block"

def test_non_string_scalar_normalises_to_code_block(self):
"""YAML scalars like bare true / 1 stringify to a non-matching value
and fall back to the safe default rather than crashing."""
from gateway.display_config import resolve_display_setting

for val in (True, False, 1, 0):
config = {"display": {"platforms": {"telegram": {"terminal_progress": val}}}}
assert (
resolve_display_setting(config, "telegram", "terminal_progress")
== "code_block"
), val

def test_value_is_case_insensitive(self):
"""Mixed-case values normalise to lowercase canonical forms."""
from gateway.display_config import resolve_display_setting

cfg_compact = {"display": {"platforms": {"telegram": {"terminal_progress": "Compact"}}}}
cfg_block = {"display": {"platforms": {"telegram": {"terminal_progress": "CODE_BLOCK"}}}}
assert resolve_display_setting(cfg_compact, "telegram", "terminal_progress") == "compact"
assert resolve_display_setting(cfg_block, "telegram", "terminal_progress") == "code_block"

def test_terminal_progress_is_overrideable_key(self):
"""The key participates in per-platform override validation."""
from gateway.display_config import OVERRIDEABLE_KEYS

assert "terminal_progress" in OVERRIDEABLE_KEYS
172 changes: 172 additions & 0 deletions tests/gateway/test_run_progress_topics.py
Original file line number Diff line number Diff line change
Expand Up @@ -1439,3 +1439,175 @@ 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


def _write_terminal_progress_config(tmp_path, value):
"""Write a minimal config.yaml that sets telegram's terminal_progress.

_load_gateway_config() reads _hermes_home / 'config.yaml' (monkeypatched to
tmp_path), so this exercises real per-platform display resolution rather
than stubbing resolve_display_setting.
"""
(tmp_path / "config.yaml").write_text(
"display:\n"
" platforms:\n"
" telegram:\n"
f" terminal_progress: {value}\n"
)


@pytest.mark.asyncio
async def test_terminal_progress_compact_knob_truncates_not_code_block(monkeypatch, tmp_path):
"""With display.platforms.telegram.terminal_progress=compact, a terminal
command in 'all' mode must render the short truncated `terminal: "..."`
preview, not a fenced block with the full command. This is the regression
that returned after #42576: the full command must not be posted to chat.

Drives the real {"command": ...} path (TerminalCommandAgent), so it would
not pass against #42576's code, unlike the {}-arg truncation tests."""
monkeypatch.setenv("HERMES_TOOL_PROGRESS_MODE", "all")
_write_terminal_progress_config(tmp_path, "compact")

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-compact-all",
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)
# No fenced block at all (bare fence too, not only the ```bash tag).
assert "```" not in all_content
# The compact quoted preview IS used for the terminal command.
assert 'terminal: "' in all_content
# The command tail (past the 40-char cap) is truncated away, not posted
# verbatim or partially.
assert "hyperfram" not in all_content


@pytest.mark.asyncio
async def test_terminal_progress_compact_knob_verbose_no_code_block(monkeypatch, tmp_path):
"""terminal_progress=compact also suppresses the fenced block in verbose
mode. Verbose may show a truncated args preview, but never the full command
as a code block."""
monkeypatch.setenv("HERMES_TOOL_PROGRESS_MODE", "verbose")
_write_terminal_progress_config(tmp_path, "compact")

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-compact-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)
# No fenced block, and the compact truncated preview is used instead.
assert "```" not in all_content
assert 'terminal: "' in all_content
# The command tail (well past the 40-char cap) must not appear at all, even
# partially. Guards against the full command being dumped via the verbose
# args branch when terminal_progress is compact.
assert "hyperfram" not in all_content


@pytest.mark.asyncio
async def test_terminal_progress_explicit_code_block_still_renders(monkeypatch, tmp_path):
"""Setting terminal_progress=code_block explicitly keeps the fenced block
(capped to the first line in non-verbose mode per #42634), not compact's
plain `terminal: "..."` preview. Guards that the knob's explicit value
resolves correctly (not only the absent-config default) and that #42576's
fenced-block behavior stays available."""
monkeypatch.setenv("HERMES_TOOL_PROGRESS_MODE", "all")
_write_terminal_progress_config(tmp_path, "code_block")

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-explicit-code-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)
# Explicit code_block resolves to a fenced block (the first line, capped in
# non-verbose mode), not compact's plain quoted preview.
assert "```" in all_content
assert "set -euo pipefail" in all_content
assert 'terminal: "' not in all_content
Loading