mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-25 00:51:20 +00:00
feat: per-platform display verbosity configuration (#8006)
Add display.platforms section to config.yaml for per-platform overrides of
display settings (tool_progress, show_reasoning, streaming, tool_preview_length).
Each platform gets sensible built-in defaults based on capability tier:
- High (telegram, discord): tool_progress=all, streaming follows global
- Medium (slack, mattermost, matrix, feishu): tool_progress=new
- Low (signal, whatsapp, bluebubbles, wecom, etc.): tool_progress=off, streaming=false
- Minimal (email, sms, webhook, homeassistant): tool_progress=off, streaming=false
Example config:
display:
platforms:
telegram:
tool_progress: all
show_reasoning: true
slack:
tool_progress: off
Resolution order: platform override > global setting > built-in platform default.
Changes:
- New gateway/display_config.py: resolver module with tier-based platform defaults
- gateway/run.py: tool_progress, tool_preview_length, streaming, show_reasoning
all resolve per-platform via the new resolver
- /verbose command: now cycles tool_progress per-platform (saves to
display.platforms.<platform>.tool_progress instead of global)
- /reasoning show|hide: now saves show_reasoning per-platform
- Config version 15 -> 16: migrates tool_progress_overrides into display.platforms
- Backward compat: legacy tool_progress_overrides still read as fallback
- 27 new tests for resolver, normalization, migration, backward compat
- Updated verbose command tests for per-platform behavior
Addresses community request for per-channel verbosity control (Guillaume Meyer,
Nathan Danielsen) — high verbosity on backchannel Telegram, low on customer-facing
Slack, none on email.
This commit is contained in:
parent
14ccd32cee
commit
723b5bec85
6 changed files with 695 additions and 49 deletions
206
gateway/display_config.py
Normal file
206
gateway/display_config.py
Normal file
|
|
@ -0,0 +1,206 @@
|
|||
"""Per-platform display/verbosity configuration resolver.
|
||||
|
||||
Provides ``resolve_display_setting()`` — the single entry-point for reading
|
||||
display settings with platform-specific overrides and sensible defaults.
|
||||
|
||||
Resolution order (first non-None wins):
|
||||
1. ``display.platforms.<platform>.<key>`` — explicit per-platform user override
|
||||
2. ``display.<key>`` — global user setting
|
||||
3. ``_PLATFORM_DEFAULTS[<platform>][<key>]`` — built-in sensible default
|
||||
4. ``_GLOBAL_DEFAULTS[<key>]`` — built-in global default
|
||||
|
||||
Backward compatibility: ``display.tool_progress_overrides`` is still read as a
|
||||
fallback for ``tool_progress`` when no ``display.platforms`` entry exists. A
|
||||
config migration (version bump) automatically moves the old format into the new
|
||||
``display.platforms`` structure.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Overrideable display settings and their global defaults
|
||||
# ---------------------------------------------------------------------------
|
||||
# These are the settings that can be configured per-platform.
|
||||
# Other display settings (compact, personality, skin, etc.) are CLI-only
|
||||
# and don't participate in per-platform resolution.
|
||||
|
||||
_GLOBAL_DEFAULTS: dict[str, Any] = {
|
||||
"tool_progress": "all",
|
||||
"show_reasoning": False,
|
||||
"tool_preview_length": 0,
|
||||
"streaming": None, # None = follow top-level streaming config
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Sensible per-platform defaults — tiered by platform capability
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tier 1 (high): Supports message editing, typically personal/team use
|
||||
# Tier 2 (medium): Supports editing but often workspace/customer-facing
|
||||
# Tier 3 (low): No edit support — each progress msg is permanent
|
||||
# Tier 4 (minimal): Batch/non-interactive delivery
|
||||
|
||||
_TIER_HIGH = {
|
||||
"tool_progress": "all",
|
||||
"show_reasoning": False,
|
||||
"tool_preview_length": 40,
|
||||
"streaming": None, # follow global
|
||||
}
|
||||
|
||||
_TIER_MEDIUM = {
|
||||
"tool_progress": "new",
|
||||
"show_reasoning": False,
|
||||
"tool_preview_length": 40,
|
||||
"streaming": None,
|
||||
}
|
||||
|
||||
_TIER_LOW = {
|
||||
"tool_progress": "off",
|
||||
"show_reasoning": False,
|
||||
"tool_preview_length": 40,
|
||||
"streaming": False,
|
||||
}
|
||||
|
||||
_TIER_MINIMAL = {
|
||||
"tool_progress": "off",
|
||||
"show_reasoning": False,
|
||||
"tool_preview_length": 0,
|
||||
"streaming": False,
|
||||
}
|
||||
|
||||
_PLATFORM_DEFAULTS: dict[str, dict[str, Any]] = {
|
||||
# Tier 1 — full edit support, personal/team use
|
||||
"telegram": _TIER_HIGH,
|
||||
"discord": _TIER_HIGH,
|
||||
|
||||
# Tier 2 — edit support, often customer/workspace channels
|
||||
"slack": _TIER_MEDIUM,
|
||||
"mattermost": _TIER_MEDIUM,
|
||||
"matrix": _TIER_MEDIUM,
|
||||
"feishu": _TIER_MEDIUM,
|
||||
|
||||
# Tier 3 — no edit support, progress messages are permanent
|
||||
"signal": _TIER_LOW,
|
||||
"whatsapp": _TIER_LOW,
|
||||
"bluebubbles": _TIER_LOW,
|
||||
"weixin": _TIER_LOW,
|
||||
"wecom": _TIER_LOW,
|
||||
"wecom_callback": _TIER_LOW,
|
||||
"dingtalk": _TIER_LOW,
|
||||
|
||||
# Tier 4 — batch or non-interactive delivery
|
||||
"email": _TIER_MINIMAL,
|
||||
"sms": _TIER_MINIMAL,
|
||||
"webhook": _TIER_MINIMAL,
|
||||
"homeassistant": _TIER_MINIMAL,
|
||||
"api_server": {**_TIER_HIGH, "tool_preview_length": 0},
|
||||
}
|
||||
|
||||
# Canonical set of per-platform overrideable keys (for validation).
|
||||
OVERRIDEABLE_KEYS = frozenset(_GLOBAL_DEFAULTS.keys())
|
||||
|
||||
|
||||
def resolve_display_setting(
|
||||
user_config: dict,
|
||||
platform_key: str,
|
||||
setting: str,
|
||||
fallback: Any = None,
|
||||
) -> Any:
|
||||
"""Resolve a display setting with per-platform override support.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
user_config : dict
|
||||
The full parsed config.yaml dict.
|
||||
platform_key : str
|
||||
Platform config key (e.g. ``"telegram"``, ``"slack"``). Use
|
||||
``_platform_config_key(source.platform)`` from gateway/run.py.
|
||||
setting : str
|
||||
Display setting name (e.g. ``"tool_progress"``, ``"show_reasoning"``).
|
||||
fallback : Any
|
||||
Fallback value when the setting isn't found anywhere.
|
||||
|
||||
Returns
|
||||
-------
|
||||
The resolved value, or *fallback* if nothing is configured.
|
||||
"""
|
||||
display_cfg = user_config.get("display") or {}
|
||||
|
||||
# 1. Explicit per-platform override (display.platforms.<platform>.<key>)
|
||||
platforms = display_cfg.get("platforms") or {}
|
||||
plat_overrides = platforms.get(platform_key)
|
||||
if isinstance(plat_overrides, dict):
|
||||
val = plat_overrides.get(setting)
|
||||
if val is not None:
|
||||
return _normalise(setting, val)
|
||||
|
||||
# 1b. Backward compat: display.tool_progress_overrides.<platform>
|
||||
if setting == "tool_progress":
|
||||
legacy = display_cfg.get("tool_progress_overrides")
|
||||
if isinstance(legacy, dict):
|
||||
val = legacy.get(platform_key)
|
||||
if val is not None:
|
||||
return _normalise(setting, val)
|
||||
|
||||
# 2. Global user setting (display.<key>)
|
||||
val = display_cfg.get(setting)
|
||||
if val is not None:
|
||||
return _normalise(setting, val)
|
||||
|
||||
# 3. Built-in platform default
|
||||
plat_defaults = _PLATFORM_DEFAULTS.get(platform_key)
|
||||
if plat_defaults:
|
||||
val = plat_defaults.get(setting)
|
||||
if val is not None:
|
||||
return val
|
||||
|
||||
# 4. Built-in global default
|
||||
val = _GLOBAL_DEFAULTS.get(setting)
|
||||
if val is not None:
|
||||
return val
|
||||
|
||||
return fallback
|
||||
|
||||
|
||||
def get_platform_defaults(platform_key: str) -> dict[str, Any]:
|
||||
"""Return the built-in default display settings for a platform.
|
||||
|
||||
Falls back to ``_GLOBAL_DEFAULTS`` for unknown platforms.
|
||||
"""
|
||||
return dict(_PLATFORM_DEFAULTS.get(platform_key, _GLOBAL_DEFAULTS))
|
||||
|
||||
|
||||
def get_effective_display(user_config: dict, platform_key: str) -> dict[str, Any]:
|
||||
"""Return the fully-resolved display settings for a platform.
|
||||
|
||||
Useful for status commands that want to show all effective settings.
|
||||
"""
|
||||
return {
|
||||
key: resolve_display_setting(user_config, platform_key, key)
|
||||
for key in OVERRIDEABLE_KEYS
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _normalise(setting: str, value: Any) -> Any:
|
||||
"""Normalise YAML quirks (bare ``off`` → False in YAML 1.1)."""
|
||||
if setting == "tool_progress":
|
||||
if value is False:
|
||||
return "off"
|
||||
if value is True:
|
||||
return "all"
|
||||
return str(value).lower()
|
||||
if setting in ("show_reasoning", "streaming"):
|
||||
if isinstance(value, str):
|
||||
return value.lower() in ("true", "1", "yes", "on")
|
||||
return bool(value)
|
||||
if setting == "tool_preview_length":
|
||||
try:
|
||||
return int(value)
|
||||
except (TypeError, ValueError):
|
||||
return 0
|
||||
return value
|
||||
101
gateway/run.py
101
gateway/run.py
|
|
@ -3461,8 +3461,18 @@ class GatewayRunner:
|
|||
if agent_result.get("session_id") and agent_result["session_id"] != session_entry.session_id:
|
||||
session_entry.session_id = agent_result["session_id"]
|
||||
|
||||
# Prepend reasoning/thinking if display is enabled
|
||||
if getattr(self, "_show_reasoning", False) and response:
|
||||
# Prepend reasoning/thinking if display is enabled (per-platform)
|
||||
try:
|
||||
from gateway.display_config import resolve_display_setting as _rds
|
||||
_show_reasoning_effective = _rds(
|
||||
_load_gateway_config(),
|
||||
_platform_config_key(source.platform),
|
||||
"show_reasoning",
|
||||
getattr(self, "_show_reasoning", False),
|
||||
)
|
||||
except Exception:
|
||||
_show_reasoning_effective = getattr(self, "_show_reasoning", False)
|
||||
if _show_reasoning_effective and response:
|
||||
last_reasoning = agent_result.get("last_reasoning")
|
||||
if last_reasoning:
|
||||
# Collapse long reasoning to keep messages readable
|
||||
|
|
@ -5448,16 +5458,20 @@ class GatewayRunner:
|
|||
"_Usage:_ `/reasoning <none|minimal|low|medium|high|xhigh|show|hide>`"
|
||||
)
|
||||
|
||||
# Display toggle
|
||||
# Display toggle (per-platform)
|
||||
platform_key = _platform_config_key(event.source.platform)
|
||||
if args in ("show", "on"):
|
||||
self._show_reasoning = True
|
||||
_save_config_key("display.show_reasoning", True)
|
||||
return "🧠 ✓ Reasoning display: **ON**\nModel thinking will be shown before each response."
|
||||
_save_config_key(f"display.platforms.{platform_key}.show_reasoning", True)
|
||||
return (
|
||||
"🧠 ✓ Reasoning display: **ON**\n"
|
||||
f"Model thinking will be shown before each response on **{platform_key}**."
|
||||
)
|
||||
|
||||
if args in ("hide", "off"):
|
||||
self._show_reasoning = False
|
||||
_save_config_key("display.show_reasoning", False)
|
||||
return "🧠 ✓ Reasoning display: **OFF**"
|
||||
_save_config_key(f"display.platforms.{platform_key}.show_reasoning", False)
|
||||
return f"🧠 ✓ Reasoning display: **OFF** for **{platform_key}**"
|
||||
|
||||
# Effort level change
|
||||
effort = args.strip()
|
||||
|
|
@ -5560,11 +5574,14 @@ class GatewayRunner:
|
|||
|
||||
Gated by ``display.tool_progress_command`` in config.yaml (default off).
|
||||
When enabled, cycles the tool progress mode through off → new → all →
|
||||
verbose → off, same as the CLI.
|
||||
verbose → off for the *current platform*. The setting is saved to
|
||||
``display.platforms.<platform>.tool_progress`` so each channel can
|
||||
have its own verbosity level independently.
|
||||
"""
|
||||
import yaml
|
||||
|
||||
config_path = _hermes_home / "config.yaml"
|
||||
platform_key = _platform_config_key(event.source.platform)
|
||||
|
||||
# --- check config gate ------------------------------------------------
|
||||
try:
|
||||
|
|
@ -5583,7 +5600,7 @@ class GatewayRunner:
|
|||
"display:\n tool_progress_command: true\n```"
|
||||
)
|
||||
|
||||
# --- cycle mode -------------------------------------------------------
|
||||
# --- cycle mode (per-platform) ----------------------------------------
|
||||
cycle = ["off", "new", "all", "verbose"]
|
||||
descriptions = {
|
||||
"off": "⚙️ Tool progress: **OFF** — no tool activity shown.",
|
||||
|
|
@ -5592,26 +5609,29 @@ class GatewayRunner:
|
|||
"verbose": "⚙️ Tool progress: **VERBOSE** — every tool call with full arguments.",
|
||||
}
|
||||
|
||||
raw_progress = user_config.get("display", {}).get("tool_progress", "all")
|
||||
# YAML 1.1 parses bare "off" as boolean False — normalise back
|
||||
if raw_progress is False:
|
||||
current = "off"
|
||||
elif raw_progress is True:
|
||||
current = "all"
|
||||
else:
|
||||
current = str(raw_progress).lower()
|
||||
# Read current effective mode for this platform via the resolver
|
||||
from gateway.display_config import resolve_display_setting
|
||||
current = resolve_display_setting(user_config, platform_key, "tool_progress", "all")
|
||||
if current not in cycle:
|
||||
current = "all"
|
||||
idx = (cycle.index(current) + 1) % len(cycle)
|
||||
new_mode = cycle[idx]
|
||||
|
||||
# Save to config.yaml
|
||||
# Save to display.platforms.<platform>.tool_progress
|
||||
try:
|
||||
if "display" not in user_config or not isinstance(user_config.get("display"), dict):
|
||||
user_config["display"] = {}
|
||||
user_config["display"]["tool_progress"] = new_mode
|
||||
display = user_config["display"]
|
||||
if "platforms" not in display or not isinstance(display.get("platforms"), dict):
|
||||
display["platforms"] = {}
|
||||
if platform_key not in display["platforms"] or not isinstance(display["platforms"].get(platform_key), dict):
|
||||
display["platforms"][platform_key] = {}
|
||||
display["platforms"][platform_key]["tool_progress"] = new_mode
|
||||
atomic_yaml_write(config_path, user_config)
|
||||
return f"{descriptions[new_mode]}\n_(saved to config — takes effect on next message)_"
|
||||
return (
|
||||
f"{descriptions[new_mode]}\n"
|
||||
f"_(saved for **{platform_key}** — takes effect on next message)_"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("Failed to save tool_progress mode: %s", e)
|
||||
return f"{descriptions[new_mode]}\n_(could not save to config: {e})_"
|
||||
|
|
@ -7083,32 +7103,23 @@ class GatewayRunner:
|
|||
if not isinstance(display_config, dict):
|
||||
display_config = {}
|
||||
|
||||
# Per-platform display settings — resolve via display_config module
|
||||
# which checks display.platforms.<platform>.<key> first, then
|
||||
# display.<key> global, then built-in platform defaults.
|
||||
from gateway.display_config import resolve_display_setting
|
||||
|
||||
# Apply tool preview length config (0 = no limit)
|
||||
try:
|
||||
from agent.display import set_tool_preview_max_len
|
||||
_tpl = display_config.get("tool_preview_length", 0)
|
||||
_tpl = resolve_display_setting(user_config, platform_key, "tool_preview_length", 0)
|
||||
set_tool_preview_max_len(int(_tpl) if _tpl else 0)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Tool progress mode from config.yaml: "all", "new", "verbose", "off"
|
||||
# Falls back to env vars for backward compatibility.
|
||||
# YAML 1.1 parses bare `off` as boolean False — normalise before
|
||||
# the `or` chain so it doesn't silently fall through to "all".
|
||||
#
|
||||
# Per-platform overrides (display.tool_progress_overrides) take
|
||||
# priority over the global setting — e.g. Signal users can set
|
||||
# tool_progress to "off" while keeping Telegram on "all".
|
||||
_overrides = display_config.get("tool_progress_overrides", {})
|
||||
if not isinstance(_overrides, dict):
|
||||
_overrides = {}
|
||||
_raw_tp = _overrides.get(platform_key)
|
||||
if _raw_tp is None:
|
||||
_raw_tp = display_config.get("tool_progress")
|
||||
if _raw_tp is False:
|
||||
_raw_tp = "off"
|
||||
# Tool progress mode — resolved per-platform with env var fallback
|
||||
_resolved_tp = resolve_display_setting(user_config, platform_key, "tool_progress")
|
||||
progress_mode = (
|
||||
_raw_tp
|
||||
_resolved_tp
|
||||
or os.getenv("HERMES_TOOL_PROGRESS_MODE")
|
||||
or "all"
|
||||
)
|
||||
|
|
@ -7446,7 +7457,19 @@ class GatewayRunner:
|
|||
from gateway.config import StreamingConfig
|
||||
_scfg = StreamingConfig()
|
||||
|
||||
_want_stream_deltas = _scfg.enabled and _scfg.transport != "off"
|
||||
# Per-platform streaming gate: display.platforms.<plat>.streaming
|
||||
# can disable streaming for specific platforms even when the global
|
||||
# streaming config is enabled.
|
||||
_plat_streaming = resolve_display_setting(
|
||||
user_config, platform_key, "streaming"
|
||||
)
|
||||
# None = no per-platform override → follow global config
|
||||
_streaming_enabled = (
|
||||
_scfg.enabled and _scfg.transport != "off"
|
||||
if _plat_streaming is None
|
||||
else bool(_plat_streaming)
|
||||
)
|
||||
_want_stream_deltas = _streaming_enabled
|
||||
_want_interim_messages = interim_assistant_messages_enabled
|
||||
_want_interim_consumer = _want_interim_messages
|
||||
if _want_stream_deltas or _want_interim_consumer:
|
||||
|
|
|
|||
|
|
@ -517,8 +517,9 @@ DEFAULT_CONFIG = {
|
|||
"skin": "default",
|
||||
"interim_assistant_messages": True, # Gateway: show natural mid-turn assistant status messages
|
||||
"tool_progress_command": False, # Enable /verbose command in messaging gateway
|
||||
"tool_progress_overrides": {}, # Per-platform overrides: {"signal": "off", "telegram": "all"}
|
||||
"tool_progress_overrides": {}, # DEPRECATED — use display.platforms instead
|
||||
"tool_preview_length": 0, # Max chars for tool call previews (0 = no limit, show full paths/commands)
|
||||
"platforms": {}, # Per-platform display overrides: {"telegram": {"tool_progress": "all"}, "slack": {"tool_progress": "off"}}
|
||||
},
|
||||
|
||||
# Privacy settings
|
||||
|
|
@ -706,7 +707,7 @@ DEFAULT_CONFIG = {
|
|||
},
|
||||
|
||||
# Config schema version - bump this when adding new required fields
|
||||
"_config_version": 15,
|
||||
"_config_version": 16,
|
||||
}
|
||||
|
||||
# =============================================================================
|
||||
|
|
@ -1947,6 +1948,30 @@ def migrate_config(interactive: bool = True, quiet: bool = False) -> Dict[str, A
|
|||
if not quiet:
|
||||
print(" ✓ Added display.interim_assistant_messages=true")
|
||||
|
||||
# ── Version 15 → 16: migrate tool_progress_overrides into display.platforms ──
|
||||
if current_ver < 16:
|
||||
config = read_raw_config()
|
||||
display = config.get("display", {})
|
||||
if not isinstance(display, dict):
|
||||
display = {}
|
||||
old_overrides = display.get("tool_progress_overrides")
|
||||
if isinstance(old_overrides, dict) and old_overrides:
|
||||
platforms = display.get("platforms", {})
|
||||
if not isinstance(platforms, dict):
|
||||
platforms = {}
|
||||
for plat, mode in old_overrides.items():
|
||||
if plat not in platforms:
|
||||
platforms[plat] = {}
|
||||
if "tool_progress" not in platforms[plat]:
|
||||
platforms[plat]["tool_progress"] = mode
|
||||
display["platforms"] = platforms
|
||||
config["display"] = display
|
||||
save_config(config)
|
||||
if not quiet:
|
||||
migrated = ", ".join(f"{p}={m}" for p, m in old_overrides.items())
|
||||
print(f" ✓ Migrated tool_progress_overrides → display.platforms: {migrated}")
|
||||
results["config_added"].append("display.platforms (migrated from tool_progress_overrides)")
|
||||
|
||||
if current_ver < latest_ver and not quiet:
|
||||
print(f"Config version: {current_ver} → {latest_ver}")
|
||||
|
||||
|
|
|
|||
355
tests/gateway/test_display_config.py
Normal file
355
tests/gateway/test_display_config.py
Normal file
|
|
@ -0,0 +1,355 @@
|
|||
"""Tests for gateway.display_config — per-platform display/verbosity resolver."""
|
||||
import pytest
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Resolver: resolution order
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestResolveDisplaySetting:
|
||||
"""resolve_display_setting() resolves with correct priority."""
|
||||
|
||||
def test_explicit_platform_override_wins(self):
|
||||
"""display.platforms.<plat>.<key> takes top priority."""
|
||||
from gateway.display_config import resolve_display_setting
|
||||
|
||||
config = {
|
||||
"display": {
|
||||
"tool_progress": "all",
|
||||
"platforms": {
|
||||
"telegram": {"tool_progress": "verbose"},
|
||||
},
|
||||
}
|
||||
}
|
||||
assert resolve_display_setting(config, "telegram", "tool_progress") == "verbose"
|
||||
|
||||
def test_global_setting_when_no_platform_override(self):
|
||||
"""Falls back to display.<key> when no platform override exists."""
|
||||
from gateway.display_config import resolve_display_setting
|
||||
|
||||
config = {
|
||||
"display": {
|
||||
"tool_progress": "new",
|
||||
"platforms": {},
|
||||
}
|
||||
}
|
||||
assert resolve_display_setting(config, "telegram", "tool_progress") == "new"
|
||||
|
||||
def test_platform_default_when_no_user_config(self):
|
||||
"""Falls back to built-in platform default."""
|
||||
from gateway.display_config import resolve_display_setting
|
||||
|
||||
# Empty config — should get built-in defaults
|
||||
config = {}
|
||||
# Telegram defaults to tier_high → "all"
|
||||
assert resolve_display_setting(config, "telegram", "tool_progress") == "all"
|
||||
# Email defaults to tier_minimal → "off"
|
||||
assert resolve_display_setting(config, "email", "tool_progress") == "off"
|
||||
|
||||
def test_global_default_for_unknown_platform(self):
|
||||
"""Unknown platforms get the global defaults."""
|
||||
from gateway.display_config import resolve_display_setting
|
||||
|
||||
config = {}
|
||||
# Unknown platform, no config → global default "all"
|
||||
assert resolve_display_setting(config, "unknown_platform", "tool_progress") == "all"
|
||||
|
||||
def test_fallback_parameter_used_last(self):
|
||||
"""Explicit fallback is used when nothing else matches."""
|
||||
from gateway.display_config import resolve_display_setting
|
||||
|
||||
config = {}
|
||||
# "nonexistent_key" isn't in any defaults
|
||||
result = resolve_display_setting(config, "telegram", "nonexistent_key", "my_fallback")
|
||||
assert result == "my_fallback"
|
||||
|
||||
def test_platform_override_only_affects_that_platform(self):
|
||||
"""Other platforms are unaffected by a specific platform override."""
|
||||
from gateway.display_config import resolve_display_setting
|
||||
|
||||
config = {
|
||||
"display": {
|
||||
"tool_progress": "all",
|
||||
"platforms": {
|
||||
"slack": {"tool_progress": "off"},
|
||||
},
|
||||
}
|
||||
}
|
||||
assert resolve_display_setting(config, "slack", "tool_progress") == "off"
|
||||
assert resolve_display_setting(config, "telegram", "tool_progress") == "all"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Backward compatibility: tool_progress_overrides
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestBackwardCompat:
|
||||
"""Legacy tool_progress_overrides is still respected as a fallback."""
|
||||
|
||||
def test_legacy_overrides_read(self):
|
||||
"""tool_progress_overrides is read when no platforms entry exists."""
|
||||
from gateway.display_config import resolve_display_setting
|
||||
|
||||
config = {
|
||||
"display": {
|
||||
"tool_progress": "all",
|
||||
"tool_progress_overrides": {
|
||||
"signal": "off",
|
||||
"telegram": "verbose",
|
||||
},
|
||||
}
|
||||
}
|
||||
assert resolve_display_setting(config, "signal", "tool_progress") == "off"
|
||||
assert resolve_display_setting(config, "telegram", "tool_progress") == "verbose"
|
||||
|
||||
def test_new_platforms_takes_precedence_over_legacy(self):
|
||||
"""display.platforms beats tool_progress_overrides."""
|
||||
from gateway.display_config import resolve_display_setting
|
||||
|
||||
config = {
|
||||
"display": {
|
||||
"tool_progress": "all",
|
||||
"tool_progress_overrides": {"telegram": "verbose"},
|
||||
"platforms": {"telegram": {"tool_progress": "new"}},
|
||||
}
|
||||
}
|
||||
assert resolve_display_setting(config, "telegram", "tool_progress") == "new"
|
||||
|
||||
def test_legacy_overrides_only_for_tool_progress(self):
|
||||
"""Legacy overrides don't affect other settings."""
|
||||
from gateway.display_config import resolve_display_setting
|
||||
|
||||
config = {
|
||||
"display": {
|
||||
"tool_progress_overrides": {"telegram": "verbose"},
|
||||
}
|
||||
}
|
||||
# show_reasoning should NOT read from tool_progress_overrides
|
||||
assert resolve_display_setting(config, "telegram", "show_reasoning") is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# YAML normalisation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestYAMLNormalisation:
|
||||
"""YAML 1.1 quirks (bare off → False, on → True) are handled."""
|
||||
|
||||
def test_tool_progress_false_normalised_to_off(self):
|
||||
"""YAML's bare `off` parses as False — normalised to 'off' string."""
|
||||
from gateway.display_config import resolve_display_setting
|
||||
|
||||
config = {"display": {"tool_progress": False}}
|
||||
assert resolve_display_setting(config, "telegram", "tool_progress") == "off"
|
||||
|
||||
def test_tool_progress_true_normalised_to_all(self):
|
||||
"""YAML's bare `on` parses as True — normalised to 'all'."""
|
||||
from gateway.display_config import resolve_display_setting
|
||||
|
||||
config = {"display": {"tool_progress": True}}
|
||||
assert resolve_display_setting(config, "telegram", "tool_progress") == "all"
|
||||
|
||||
def test_show_reasoning_string_true(self):
|
||||
"""String 'true' is normalised to bool True."""
|
||||
from gateway.display_config import resolve_display_setting
|
||||
|
||||
config = {"display": {"platforms": {"telegram": {"show_reasoning": "true"}}}}
|
||||
assert resolve_display_setting(config, "telegram", "show_reasoning") is True
|
||||
|
||||
def test_tool_preview_length_string(self):
|
||||
"""String numbers are normalised to int."""
|
||||
from gateway.display_config import resolve_display_setting
|
||||
|
||||
config = {"display": {"platforms": {"slack": {"tool_preview_length": "80"}}}}
|
||||
assert resolve_display_setting(config, "slack", "tool_preview_length") == 80
|
||||
|
||||
def test_platform_override_false_tool_progress(self):
|
||||
"""Per-platform bare off → normalised."""
|
||||
from gateway.display_config import resolve_display_setting
|
||||
|
||||
config = {"display": {"platforms": {"slack": {"tool_progress": False}}}}
|
||||
assert resolve_display_setting(config, "slack", "tool_progress") == "off"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Built-in platform defaults (tier system)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestPlatformDefaults:
|
||||
"""Built-in defaults reflect platform capability tiers."""
|
||||
|
||||
def test_high_tier_platforms(self):
|
||||
"""Telegram and Discord default to 'all' tool progress."""
|
||||
from gateway.display_config import resolve_display_setting
|
||||
|
||||
for plat in ("telegram", "discord"):
|
||||
assert resolve_display_setting({}, plat, "tool_progress") == "all", plat
|
||||
|
||||
def test_medium_tier_platforms(self):
|
||||
"""Slack, Mattermost, Matrix default to 'new' tool progress."""
|
||||
from gateway.display_config import resolve_display_setting
|
||||
|
||||
for plat in ("slack", "mattermost", "matrix", "feishu"):
|
||||
assert resolve_display_setting({}, plat, "tool_progress") == "new", plat
|
||||
|
||||
def test_low_tier_platforms(self):
|
||||
"""Signal, WhatsApp, etc. default to 'off' tool progress."""
|
||||
from gateway.display_config import resolve_display_setting
|
||||
|
||||
for plat in ("signal", "whatsapp", "bluebubbles", "weixin", "wecom", "dingtalk"):
|
||||
assert resolve_display_setting({}, plat, "tool_progress") == "off", plat
|
||||
|
||||
def test_minimal_tier_platforms(self):
|
||||
"""Email, SMS, webhook default to 'off' tool progress."""
|
||||
from gateway.display_config import resolve_display_setting
|
||||
|
||||
for plat in ("email", "sms", "webhook", "homeassistant"):
|
||||
assert resolve_display_setting({}, plat, "tool_progress") == "off", plat
|
||||
|
||||
def test_low_tier_streaming_defaults_to_false(self):
|
||||
"""Low-tier platforms default streaming to False."""
|
||||
from gateway.display_config import resolve_display_setting
|
||||
|
||||
assert resolve_display_setting({}, "signal", "streaming") is False
|
||||
assert resolve_display_setting({}, "email", "streaming") is False
|
||||
|
||||
def test_high_tier_streaming_defaults_to_none(self):
|
||||
"""High-tier platforms default streaming to None (follow global)."""
|
||||
from gateway.display_config import resolve_display_setting
|
||||
|
||||
assert resolve_display_setting({}, "telegram", "streaming") is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# get_effective_display / get_platform_defaults
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestHelpers:
|
||||
"""Helper functions return correct composite results."""
|
||||
|
||||
def test_get_effective_display_merges_correctly(self):
|
||||
from gateway.display_config import get_effective_display
|
||||
|
||||
config = {
|
||||
"display": {
|
||||
"tool_progress": "new",
|
||||
"show_reasoning": True,
|
||||
"platforms": {
|
||||
"telegram": {"tool_progress": "verbose"},
|
||||
},
|
||||
}
|
||||
}
|
||||
eff = get_effective_display(config, "telegram")
|
||||
assert eff["tool_progress"] == "verbose" # platform override
|
||||
assert eff["show_reasoning"] is True # global
|
||||
assert "tool_preview_length" in eff # default filled in
|
||||
|
||||
def test_get_platform_defaults_returns_dict(self):
|
||||
from gateway.display_config import get_platform_defaults
|
||||
|
||||
defaults = get_platform_defaults("telegram")
|
||||
assert "tool_progress" in defaults
|
||||
assert "show_reasoning" in defaults
|
||||
# Returns a new dict (not the shared tier dict)
|
||||
defaults["tool_progress"] = "changed"
|
||||
assert get_platform_defaults("telegram")["tool_progress"] != "changed"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Config migration: tool_progress_overrides → display.platforms
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestConfigMigration:
|
||||
"""Version 16 migration moves tool_progress_overrides into display.platforms."""
|
||||
|
||||
def test_migration_creates_platforms_entries(self, tmp_path, monkeypatch):
|
||||
"""Old overrides are migrated into display.platforms.<plat>.tool_progress."""
|
||||
import yaml
|
||||
|
||||
config_path = tmp_path / "config.yaml"
|
||||
config = {
|
||||
"_config_version": 15,
|
||||
"display": {
|
||||
"tool_progress_overrides": {
|
||||
"signal": "off",
|
||||
"telegram": "all",
|
||||
},
|
||||
},
|
||||
}
|
||||
config_path.write_text(yaml.dump(config))
|
||||
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
# Re-import to pick up the new HERMES_HOME
|
||||
import importlib
|
||||
import hermes_cli.config as cfg_mod
|
||||
importlib.reload(cfg_mod)
|
||||
|
||||
result = cfg_mod.migrate_config(interactive=False, quiet=True)
|
||||
# Re-read config
|
||||
updated = yaml.safe_load(config_path.read_text())
|
||||
platforms = updated.get("display", {}).get("platforms", {})
|
||||
assert platforms.get("signal", {}).get("tool_progress") == "off"
|
||||
assert platforms.get("telegram", {}).get("tool_progress") == "all"
|
||||
|
||||
def test_migration_preserves_existing_platforms_entries(self, tmp_path, monkeypatch):
|
||||
"""Existing display.platforms entries are NOT overwritten by migration."""
|
||||
import yaml
|
||||
|
||||
config_path = tmp_path / "config.yaml"
|
||||
config = {
|
||||
"_config_version": 15,
|
||||
"display": {
|
||||
"tool_progress_overrides": {"telegram": "off"},
|
||||
"platforms": {"telegram": {"tool_progress": "verbose"}},
|
||||
},
|
||||
}
|
||||
config_path.write_text(yaml.dump(config))
|
||||
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
import importlib
|
||||
import hermes_cli.config as cfg_mod
|
||||
importlib.reload(cfg_mod)
|
||||
|
||||
cfg_mod.migrate_config(interactive=False, quiet=True)
|
||||
updated = yaml.safe_load(config_path.read_text())
|
||||
# Existing "verbose" should NOT be overwritten by legacy "off"
|
||||
assert updated["display"]["platforms"]["telegram"]["tool_progress"] == "verbose"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Streaming per-platform (None = follow global)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestStreamingPerPlatform:
|
||||
"""Streaming per-platform override semantics."""
|
||||
|
||||
def test_none_means_follow_global(self):
|
||||
"""When streaming is None, the caller should use global config."""
|
||||
from gateway.display_config import resolve_display_setting
|
||||
|
||||
config = {}
|
||||
# Telegram has no streaming override in defaults → None
|
||||
result = resolve_display_setting(config, "telegram", "streaming")
|
||||
assert result is None # caller should check global StreamingConfig
|
||||
|
||||
def test_explicit_false_disables(self):
|
||||
"""Explicit False disables streaming for that platform."""
|
||||
from gateway.display_config import resolve_display_setting
|
||||
|
||||
config = {
|
||||
"display": {
|
||||
"platforms": {"telegram": {"streaming": False}},
|
||||
}
|
||||
}
|
||||
assert resolve_display_setting(config, "telegram", "streaming") is False
|
||||
|
||||
def test_explicit_true_enables(self):
|
||||
"""Explicit True enables streaming for that platform."""
|
||||
from gateway.display_config import resolve_display_setting
|
||||
|
||||
config = {
|
||||
"display": {
|
||||
"platforms": {"email": {"streaming": True}},
|
||||
}
|
||||
}
|
||||
assert resolve_display_setting(config, "email", "streaming") is True
|
||||
|
|
@ -63,7 +63,7 @@ class TestVerboseCommand:
|
|||
|
||||
@pytest.mark.asyncio
|
||||
async def test_enabled_cycles_mode(self, tmp_path, monkeypatch):
|
||||
"""When enabled, /verbose cycles tool_progress mode."""
|
||||
"""When enabled, /verbose cycles tool_progress mode per-platform."""
|
||||
hermes_home = tmp_path / "hermes"
|
||||
hermes_home.mkdir()
|
||||
config_path = hermes_home / "config.yaml"
|
||||
|
|
@ -79,10 +79,11 @@ class TestVerboseCommand:
|
|||
|
||||
# all -> verbose
|
||||
assert "VERBOSE" in result
|
||||
assert "telegram" in result.lower() # per-platform feedback
|
||||
|
||||
# Verify config was saved
|
||||
# Verify config was saved to display.platforms.telegram
|
||||
saved = yaml.safe_load(config_path.read_text(encoding="utf-8"))
|
||||
assert saved["display"]["tool_progress"] == "verbose"
|
||||
assert saved["display"]["platforms"]["telegram"]["tool_progress"] == "verbose"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cycles_through_all_modes(self, tmp_path, monkeypatch):
|
||||
|
|
@ -103,8 +104,9 @@ class TestVerboseCommand:
|
|||
for mode in expected:
|
||||
result = await runner._handle_verbose_command(_make_event())
|
||||
saved = yaml.safe_load(config_path.read_text(encoding="utf-8"))
|
||||
assert saved["display"]["tool_progress"] == mode, \
|
||||
f"Expected {mode}, got {saved['display']['tool_progress']}"
|
||||
actual = saved["display"]["platforms"]["telegram"]["tool_progress"]
|
||||
assert actual == mode, \
|
||||
f"Expected {mode}, got {actual}"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_defaults_to_all_when_no_tool_progress_set(self, tmp_path, monkeypatch):
|
||||
|
|
@ -122,10 +124,45 @@ class TestVerboseCommand:
|
|||
runner = _make_runner()
|
||||
result = await runner._handle_verbose_command(_make_event())
|
||||
|
||||
# default "all" -> verbose
|
||||
# Telegram default is "all" (high tier) → cycles to verbose
|
||||
assert "VERBOSE" in result
|
||||
saved = yaml.safe_load(config_path.read_text(encoding="utf-8"))
|
||||
assert saved["display"]["tool_progress"] == "verbose"
|
||||
assert saved["display"]["platforms"]["telegram"]["tool_progress"] == "verbose"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_per_platform_isolation(self, tmp_path, monkeypatch):
|
||||
"""Cycling /verbose on Telegram doesn't change Slack's setting.
|
||||
|
||||
Without a global tool_progress, each platform uses its built-in
|
||||
default: Telegram = 'all' (high tier), Slack = 'new' (medium tier).
|
||||
"""
|
||||
hermes_home = tmp_path / "hermes"
|
||||
hermes_home.mkdir()
|
||||
config_path = hermes_home / "config.yaml"
|
||||
# No global tool_progress → built-in platform defaults apply
|
||||
config_path.write_text(
|
||||
"display:\n tool_progress_command: true\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
monkeypatch.setattr(gateway_run, "_hermes_home", hermes_home)
|
||||
runner = _make_runner()
|
||||
|
||||
# Cycle on Telegram
|
||||
await runner._handle_verbose_command(
|
||||
_make_event(platform=Platform.TELEGRAM)
|
||||
)
|
||||
# Cycle on Slack
|
||||
await runner._handle_verbose_command(
|
||||
_make_event(platform=Platform.SLACK)
|
||||
)
|
||||
|
||||
saved = yaml.safe_load(config_path.read_text(encoding="utf-8"))
|
||||
platforms = saved["display"]["platforms"]
|
||||
# Telegram: all -> verbose (high tier default = all)
|
||||
assert platforms["telegram"]["tool_progress"] == "verbose"
|
||||
# Slack: new -> all (medium tier default = new, cycle to all)
|
||||
assert platforms["slack"]["tool_progress"] == "all"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_config_file_returns_disabled(self, tmp_path, monkeypatch):
|
||||
|
|
|
|||
|
|
@ -441,6 +441,6 @@ class TestInterimAssistantMessageConfig:
|
|||
migrate_config(interactive=False, quiet=True)
|
||||
raw = yaml.safe_load(config_path.read_text(encoding="utf-8"))
|
||||
|
||||
assert raw["_config_version"] == 15
|
||||
assert raw["_config_version"] == 16
|
||||
assert raw["display"]["tool_progress"] == "off"
|
||||
assert raw["display"]["interim_assistant_messages"] is True
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue