diff --git a/gateway/display_config.py b/gateway/display_config.py new file mode 100644 index 000000000..e148be910 --- /dev/null +++ b/gateway/display_config.py @@ -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..`` — explicit per-platform user override + 2. ``display.`` — global user setting + 3. ``_PLATFORM_DEFAULTS[][]`` — built-in sensible default + 4. ``_GLOBAL_DEFAULTS[]`` — 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..) + 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. + 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.) + 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 diff --git a/gateway/run.py b/gateway/run.py index 5ca1a7d7f..00c486c99 100644 --- a/gateway/run.py +++ b/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 `" ) - # 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..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..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.. first, then + # display. 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..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: diff --git a/hermes_cli/config.py b/hermes_cli/config.py index a4eee56f9..5faa767a3 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -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}") diff --git a/tests/gateway/test_display_config.py b/tests/gateway/test_display_config.py new file mode 100644 index 000000000..4dd73ebd2 --- /dev/null +++ b/tests/gateway/test_display_config.py @@ -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.. 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. 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..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 diff --git a/tests/gateway/test_verbose_command.py b/tests/gateway/test_verbose_command.py index 857d0744e..c34167b2e 100644 --- a/tests/gateway/test_verbose_command.py +++ b/tests/gateway/test_verbose_command.py @@ -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): diff --git a/tests/hermes_cli/test_config.py b/tests/hermes_cli/test_config.py index 2bb63767a..d934a8012 100644 --- a/tests/hermes_cli/test_config.py +++ b/tests/hermes_cli/test_config.py @@ -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