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:
Teknium
2026-04-11 17:20:34 -07:00
committed by GitHub
parent 14ccd32cee
commit 723b5bec85
6 changed files with 695 additions and 49 deletions

View 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

View File

@@ -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):

View File

@@ -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