{"repo":"NousResearch/hermes-agent","public_id":"1955","display_label":"Gateway per-channel model persistence and routing","summary":"These issues and PRs cluster around two adjacent themes: persisting /model selections per channel or session across gateway restarts, and adding declarative per-channel model routing configuration. A subset addresses bugs where model switches silently fail to persist or cached agents revert to config defaults.","subsystem":"gateway","counts":{"members":16,"open_issues":3,"open_issues_distinct":3,"open_prs":1,"closed_issues":7,"merged_prs":1,"attempt_prs":5},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":56967},"resolution":{"state":"partially_resolved","by":"pr-56967"},"freshness":{"state":"verified","last_verified_at":"2026-07-02T10:08:11+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.08299999999999998},"fix_edges":[{"issue_number":1955,"pr_number":56967,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true}],"members":[{"kind":"issue","number":1955,"title":"feat: per-channel model and system prompt overrides for gateway platforms","url":null,"state":"closed"},{"kind":"issue","number":17834,"title":"Add per-channel model override (`channel_models`) — parallel to `channel_prompts`","url":null,"state":"closed"},{"kind":"issue","number":21637,"title":"Per-channel personality and model routing (extending channel_prompts)","url":null,"state":"closed"},{"kind":"issue","number":26570,"title":"[Feature]: Persist /model selections per chat/thread across gateway restarts","url":null,"state":"closed"},{"kind":"issue","number":30703,"title":"feat: channel-based model routing in gateway config","url":null,"state":"closed"},{"kind":"issue","number":30781,"title":"[Bug]: Gateway /model without --global does not persist, leaves remote users stuck with stale defaults","url":null,"state":"closed"},{"kind":"issue","number":48413,"title":"Feature: persist /model selection per Discord channel","url":null,"state":"open"},{"kind":"issue","number":54601,"title":"feat: declarative per-channel model routing (channel_model_map)","url":null,"state":"open"},{"kind":"issue","number":57664,"title":"[Feature]: Per-topic Model Override for Gateway Sessions (extend topic_configs)","url":null,"state":"open"},{"kind":"issue","number":66107,"title":"[Bug]: Gateway session-only /model switch silently reverts to config default (persistence write failure swallowed at debug level)","url":null,"state":"closed"},{"kind":"pr","number":1991,"title":"feat: per-channel model and system prompt overrides for gateway platforms (Fixes #1955)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#56967","number":56967},{"kind":"pr","value":"#1955","number":1955}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":21476,"title":"feat(gateway): per-channel model binding (Discord)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"0010c14e66cffbed84dfbf1b4a15093f0f8cc76d","number":0},{"kind":"pr","value":"#1955","number":1955},{"kind":"pr","value":"#1991","number":1991}],"category":"uncategorized","implication":"needs_llm"},"merged":false},{"kind":"pr","number":26706,"title":"feat(gateway): persist per-session /model selections across restarts","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"30e947e0a05ef535e4b25a183d8bbe34fd68d1d5","number":0}],"category":"uncategorized","implication":"needs_llm"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":27504,"title":"fix(gateway): persist session model and reasoning overrides","url":null,"state":"open","close_reason":{"refs":[{"kind":"pr","value":"#92187","number":92187},{"kind":"pr","value":"#92187","number":92187}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":56967,"title":"feat(gateway): per-channel model and system prompt overrides (#1991 salvage, fixes #1955)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["gateway/config.py","gateway/run.py","scripts/release.py","tests/gateway/test_channel_overrides.py","tests/gateway/test_config.py"],"fix_hunks":[{"file":"gateway/config.py","hunks":["@@ -324,6 +324,40 @@ def from_dict(cls, data: Dict[str, Any]) -> \"SessionResetPolicy\":\n         )\n \n \n+@dataclass\n+class ChannelOverride:\n+    \"\"\"\n+    Per-channel override for model, provider, and system prompt.\n+\n+    Used in config under platforms.<name>.channel_overrides[channel_id].\n+    Enables different channels (e.g. Discord #daily vs #dev) to use different\n+    models and personas without running separate gateway instances.\n+    \"\"\"\n+    model: Optional[str] = None\n+    provider: Optional[str] = None\n+    system_prompt: Optional[str] = None\n+\n+    def to_dict(self) -> Dict[str, Any]:\n+        out: Dict[str, Any] = {}\n+        if self.model is not None:\n+            out[\"model\"] = self.model\n+        if self.provider is not None:\n+            out[\"provider\"] = self.provider\n+        if self.system_prompt is not None:\n+            out[\"system_prompt\"] = self.system_prompt\n+        return out\n+\n+    @classmethod\n+    def from_dict(cls, data: Dict[str, Any]) -> \"ChannelOverride\":\n+        if not data:\n+            return cls()\n+        return cls(\n+            model=data.get(\"model\"),\n+            provider=data.get(\"provider\"),\n+            system_prompt=data.get(\"system_prompt\"),\n+        )\n+\n+\n @dataclass\n class PlatformConfig:\n     \"\"\"Configuration for a single messaging platform.\"\"\"","@@ -331,7 +365,7 @@ class PlatformConfig:\n     token: [redacted:secret-assignment] = None  # Bot token (Telegram, Discord)\n     api_key: [redacted:secret-assignment] = None  # API key if different from token\n     home_channel: Optional[HomeChannel] = None\n-    \n+\n     # Reply threading mode (Telegram/Slack)\n     # - \"off\": Never thread replies to original message\n     # - \"first\": Only first chunk threads to user's message (default)","@@ -354,6 +388,9 @@ class PlatformConfig:\n     # gateway/platforms/base.py.\n     typing_indicator: bool = True\n \n+    # Per-channel model/provider/system_prompt overrides (channel_id -> ChannelOverride)\n+    channel_overrides: Dict[str, ChannelOverride] = field(default_factory=dict)\n+\n     # Platform-specific settings\n     extra: Dict[str, Any] = field(default_factory=dict)\n ","@@ -371,6 +408,10 @@ def to_dict(self) -> Dict[str, Any]:\n             result[\"api_key\"] = self.api_key\n         if self.home_channel:\n             result[\"home_channel\"] = self.home_channel.to_dict()\n+        if self.channel_overrides:\n+            result[\"channel_overrides\"] = {\n+                cid: ov.to_dict() for cid, ov in self.channel_overrides.items()\n+            }\n         return result\n \n     @classmethod","@@ -394,6 +435,13 @@ def from_dict(cls, data: Dict[str, Any]) -> \"PlatformConfig\":\n         if _typing is None:\n             _typing = data.get(\"extra\", {}).get(\"typing_indicator\")\n \n+        channel_overrides: Dict[str, ChannelOverride] = {}\n+        raw_overrides = data.get(\"channel_overrides\") or {}\n+        if isinstance(raw_overrides, dict):\n+            for cid, ov_data in raw_overrides.items():\n+                if isinstance(ov_data, dict):\n+                    channel_overrides[str(cid)] = ChannelOverride.from_dict(ov_data)\n+\n         return cls(\n             enabled=_coerce_bool(data.get(\"enabled\"), False),\n             token=[redacted:secret-assignment]\"token\"),","@@ -402,6 +450,7 @@ def from_dict(cls, data: Dict[str, Any]) -> \"PlatformConfig\":\n             reply_to_mode=data.get(\"reply_to_mode\", \"first\"),\n             gateway_restart_notification=_coerce_bool(_grn, True),\n             typing_indicator=_coerce_bool(_typing, True),\n+            channel_overrides=channel_overrides,\n             extra=data.get(\"extra\", {}),\n         )\n ","@@ -1054,8 +1103,20 @@ def _merge_platform_map(source_platforms: Any) -> None:\n                     bridged[\"gateway_restart_notification\"] = platform_cfg[\"gateway_restart_notification\"]\n                 if \"typing_indicator\" in platform_cfg:\n                     bridged[\"typing_indicator\"] = platform_cfg[\"typing_indicator\"]\n+                has_channel_overrides = \"channel_overrides\" in platform_cfg\n+                if has_channel_overrides:\n+                    raw_overrides = platform_cfg.get(\"channel_overrides\")\n+                    if isinstance(raw_overrides, dict):\n+                        plat_data, _extra = _ensure_platform_extra_dict(\n+                            platforms_data, plat.value\n+                        )\n+                        plat_data[\"channel_overrides\"] = {\n+                            str(cid): ov_data\n+                            for cid, ov_data in raw_overrides.items()\n+                            if isinstance(ov_data, dict)\n+                        }\n                 enabled_was_explicit = _cfg_toplevel and \"enabled\" in platform_cfg\n-                if not bridged and not enabled_was_explicit:\n+                if not bridged and not enabled_was_explicit and not has_channel_overrides:\n                     continue\n                 plat_data, extra = _ensure_platform_extra_dict(platforms_data, plat.value)\n                 if enabled_was_explicit:"]},{"file":"gateway/run.py","hunks":["@@ -1660,6 +1660,7 @@ def _profile_runtime_scope(profile_home: \"Path\"):\n     os.environ[\"TERMINAL_CWD\"] = _fallback\n \n from gateway.config import (\n+    ChannelOverride,\n     Platform,\n     _BUILTIN_PLATFORM_VALUES,\n     GatewayConfig,","@@ -1825,6 +1826,27 @@ def _resolve_runtime_agent_kwargs() -> dict:\n     }\n \n \n+def _resolve_runtime_agent_kwargs_for_provider(provider: str) -> dict:\n+    \"\"\"Resolve runtime credentials for a specific provider (e.g. from channel override).\"\"\"\n+    from hermes_cli.runtime_provider import (\n+        resolve_runtime_provider,\n+        format_runtime_provider_error,\n+    )\n+    try:\n+        runtime = resolve_runtime_provider(requested=provider)\n+    except Exception as exc:\n+        raise RuntimeError(format_runtime_provider_error(exc)) from exc\n+    return {\n+        \"api_key\": runtime.get(\"api_key\"),\n+        \"base_url\": runtime.get(\"base_url\"),\n+        \"provider\": runtime.get(\"provider\"),\n+        \"api_mode\": runtime.get(\"api_mode\"),\n+        \"command\": runtime.get(\"command\"),\n+        \"args\": list(runtime.get(\"args\") or []),\n+        \"credential_pool\": runtime.get(\"credential_pool\"),\n+    }\n+\n+\n def _try_resolve_fallback_provider() -> dict | None:\n     \"\"\"Attempt to resolve credentials from the fallback_model/fallback_providers config.\"\"\"\n     from hermes_cli.runtime_provider import resolve_runtime_provider","@@ -2284,6 +2306,59 @@ def _resolve_gateway_model(config: dict | None = None) -> str:\n     return \"\"\n \n \n+def _channel_override_lookup_keys(\n+    chat_id: str,\n+    *,\n+    thread_id: Optional[str] = None,\n+    parent_id: Optional[str] = None,\n+) -> list[str]:\n+    \"\"\"Ordered, de-duplicated keys for ``channel_overrides`` lookup.\n+\n+    Matches ``resolve_channel_prompt`` semantics: exact thread/channel id first,\n+    then parent channel/forum id (Discord threads inherit parent overrides).\n+    \"\"\"\n+    keys: list[str] = []\n+    seen: set[str] = set()\n+    for key in (chat_id, thread_id, parent_id):\n+        if not key:\n+            continue\n+        sk = str(key)\n+        if sk in seen:\n+            continue\n+        seen.add(sk)\n+        keys.append(sk)\n+    return keys\n+\n+\n+def _get_channel_override(\n+    config: GatewayConfig,\n+    platform: Platform,\n+    chat_id: str,\n+    *,\n+    thread_id: Optional[str] = None,\n+    parent_id: Optional[str] = None,\n+) -> Optional[ChannelOverride]:\n+    \"\"\"Return per-channel override for this platform/chat_id, or None.\n+\n+    Looks up ``channel_overrides`` by ``chat_id``, then ``thread_id``, then\n+    ``parent_id`` (forum threads / child channels inherit the parent entry).\n+    \"\"\"\n+    platforms = getattr(config, \"platforms\", None)\n+    if not platforms:\n+        return None\n+    platform_config = platforms.get(platform)\n+    if not platform_config or not platform_config.channel_overrides:\n+        return None\n+    overrides = platform_config.channel_overrides\n+    for key in _channel_override_lookup_keys(\n+        chat_id, thread_id=thread_id, parent_id=parent_id\n+    ):\n+        ov = overrides.get(key)\n+        if ov is not None:\n+            return ov\n+    return None\n+\n+\n def _resolve_hermes_bin() -> Optional[list[str]]:\n     \"\"\"Resolve the Hermes update command as argv parts.\n ","@@ -3543,11 +3618,11 @@ def _resolve_session_agent_runtime(\n         session_key: Optional[str] = None,\n         user_config: Optional[dict] = None,\n     ) -> tuple[str, dict]:\n-        \"\"\"Resolve model/runtime for a session, honoring session-scoped /model overrides.\n+        \"\"\"Resolve model/runtime for a session.\n \n-        If the session override already contains a complete provider bundle\n-        (provider/api_key/base_url/api_mode), prefer it directly instead of\n-        resolving fresh global runtime state first.\n+        Priority (highest first): session ``/model`` → ``channel_overrides`` →\n+        global config/env (``_resolve_gateway_model(user_config)`` and default\n+        provider resolution).\n         \"\"\"\n         resolved_session_key = session_key\n         if not resolved_session_key and source is not None:","@@ -3596,6 +3671,38 @@ def _resolve_session_agent_runtime(\n                 runtime_model,\n             )\n             model = runtime_model\n+\n+        cfg = getattr(self, \"config\", None)\n+        if cfg and source is not None:\n+            chat_id = str(source.chat_id) if source.chat_id else \"\"\n+            thread_id = (\n+                str(source.thread_id) if getattr(source, \"thread_id\", None) else None\n+            )\n+            parent_id = (\n+                str(source.parent_chat_id)\n+                if getattr(source, \"parent_chat_id\", None)\n+                else None\n+            )\n+            ch = _get_channel_override(\n+                cfg,\n+                source.platform,\n+                chat_id,\n+                thread_id=thread_id,\n+                parent_id=parent_id,\n+            )\n+            if ch:\n+                if ch.model:\n+                    model = ch.model\n+                if ch.provider:\n+                    runtime_kwargs = _resolve_runtime_agent_kwargs_for_provider(\n+                        ch.provider\n+                    )\n+                    ch_runtime_model = runtime_kwargs.pop(\"model\", None)\n+                    # Only adopt the provider's bundled model when the override\n+                    # did not specify an explicit model.\n+                    if ch_runtime_model and not ch.model:\n+                        model = ch_runtime_model\n+\n         if override and resolved_session_key:\n             model, runtime_kwargs = self._apply_session_model_override(\n                 resolved_session_key, model, runtime_kwargs","@@ -4473,6 +4580,57 @@ def _load_ephemeral_system_prompt() -> str:\n         cfg = _load_gateway_runtime_config()\n         return str(cfg_get(cfg, \"agent\", \"system_prompt\", default=\"\") or \"\").strip()\n \n+    def _resolve_model_for_channel(\n+        self,\n+        platform: Platform,\n+        chat_id: str,\n+        *,\n+        user_config: Optional[dict] = None,\n+        thread_id: Optional[str] = None,\n+        parent_id: Optional[str] = None,\n+    ) -> str:\n+        \"\"\"Resolve model for this channel: channel_overrides else global default.\"\"\"\n+        config = getattr(self, \"config\", None)\n+        if config:\n+            override = _get_channel_override(\n+                config,\n+                platform,\n+                chat_id,\n+                thread_id=thread_id,\n+                parent_id=parent_id,\n+            )\n+            if override and override.model:\n+                return override.model\n+        return _resolve_gateway_model(user_config)\n+\n+    def _get_system_prompt_for_channel(\n+        self,\n+        platform: Platform,\n+        chat_id: str,\n+        *,\n+        thread_id: Optional[str] = None,\n+        parent_id: Optional[str] = None,\n+    ) -> str:\n+        \"\"\"Ephemeral system prompt for this channel/thread.\n+\n+        Uses ``channel_overrides`` when set, else the global gateway prompt.\n+        Legacy ``channel_prompts`` are applied separately via ``event.channel_prompt``\n+        in ``run_sync`` (adapter ``resolve_channel_prompt``), so they are not\n+        duplicated here.\n+        \"\"\"\n+        config = getattr(self, \"config\", None)\n+        if config:\n+            override = _get_channel_override(\n+                config,\n+                platform,\n+                chat_id,\n+                thread_id=thread_id,\n+                parent_id=parent_id,\n+            )\n+            if override and override.system_prompt:\n+                return (override.system_prompt or \"\").strip()\n+        return getattr(self, \"_ephemeral_system_prompt\", None) or \"\"\n+\n     @staticmethod\n     def _load_reasoning_config() -> dict | None:\n         \"\"\"Load reasoning effort from config.yaml.","@@ -16791,14 +16949,21 @@ def run_sync():\n             # Platform.LOCAL (\"local\") maps to \"cli\"; others pass through as-is.\n             platform_key = \"cli\" if source.platform == Platform.LOCAL else source.platform.value\n             \n-            # Combine platform context, per-channel context, and the user-configured\n-            # ephemeral system prompt.\n+            # Combine platform context, YAML channel_prompts hint for this chat,\n+            # channel_overrides system_prompt (or global ephemeral), and gateway\n+            # ephemeral prompt from _get_system_prompt_for_channel.\n             combined_ephemeral = context_prompt or \"\"\n             event_channel_prompt = (channel_prompt or \"\").strip()\n             if event_channel_prompt:\n                 combined_ephemeral = (combined_ephemeral + \"\\n\\n\" + event_channel_prompt).strip()\n-            if self._ephemeral_system_prompt:\n-                combined_ephemeral = (combined_ephemeral + \"\\n\\n\" + self._ephemeral_system_prompt).strip()\n+            cfg_channel_prompt = self._get_system_prompt_for_channel(\n+                source.platform,\n+                source.chat_id or \"\",\n+                thread_id=getattr(source, \"thread_id\", None),\n+                parent_id=getattr(source, \"parent_chat_id\", None),\n+            )\n+            if cfg_channel_prompt:\n+                combined_ephemeral = (combined_ephemeral + \"\\n\\n\" + cfg_channel_prompt).strip()\n \n             max_iterations = _current_max_iterations()\n "]},{"file":"scripts/release.py","hunks":["@@ -917,6 +917,7 @@\n     \"[redacted:email]\": \"ifrederico\",\n     \"[redacted:email]\": \"cdanis\",\n     \"[redacted:email]\": \"samherring99\",\n+    \"[redacted:email]\": \"crazywriter1\",\n     \"[redacted:email]\": \"Aum08Desai\",\n     \"[redacted:email]\": \"shannonsands\",\n     \"[redacted:email]\": \"shannonsands\","]},{"file":"tests/gateway/test_channel_overrides.py","hunks":["@@ -0,0 +1,302 @@\n+\"\"\"Tests for per-channel model and system prompt overrides (Fixes #1955).\"\"\"\n+\n+from unittest.mock import patch\n+\n+import pytest\n+\n+from gateway.config import (\n+    ChannelOverride,\n+    GatewayConfig,\n+    Platform,\n+    PlatformConfig,\n+)\n+from gateway.run import _get_channel_override, GatewayRunner\n+from gateway.session import SessionSource\n+\n+\n+class TestGetChannelOverride:\n+    def test_no_override_when_empty_config(self):\n+        config = GatewayConfig()\n+        assert _get_channel_override(config, Platform.DISCORD, \"123\") is None\n+\n+    def test_no_override_when_platform_not_configured(self):\n+        config = GatewayConfig(platforms={})\n+        assert _get_channel_override(config, Platform.DISCORD, \"123\") is None\n+\n+    def test_no_override_when_channel_not_in_overrides(self):\n+        config = GatewayConfig(\n+            platforms={\n+                Platform.DISCORD: PlatformConfig(\n+                    enabled=True,\n+                    channel_overrides={\n+                        \"999\": ChannelOverride(model=\"openrouter/healer-alpha\"),\n+                    },\n+                ),\n+            },\n+        )\n+        assert _get_channel_override(config, Platform.DISCORD, \"123\") is None\n+\n+    def test_returns_override_when_channel_matches(self):\n+        ov = ChannelOverride(\n+            model=\"openrouter/healer-alpha\",\n+            provider=\"openrouter\",\n+            system_prompt=\"You are a summarizer.\",\n+        )\n+        config = GatewayConfig(\n+            platforms={\n+                Platform.DISCORD: PlatformConfig(\n+                    enabled=True,\n+                    channel_overrides={\"1234567890\": ov},\n+                ),\n+            },\n+        )\n+        result = _get_channel_override(config, Platform.DISCORD, \"1234567890\")\n+        assert result is not None\n+        assert result.model == \"openrouter/healer-alpha\"\n+        assert result.provider == \"openrouter\"\n+        assert result.system_prompt == \"You are a summarizer.\"\n+\n+    def test_returns_override_when_chat_id_is_int_like(self):\n+        \"\"\"Caller may pass str(chat_id); override keys are normalized to str.\"\"\"\n+        config = GatewayConfig(\n+            platforms={\n+                Platform.DISCORD: PlatformConfig(\n+                    enabled=True,\n+                    channel_overrides={\"123\": ChannelOverride(model=\"gpt-4\")},\n+                ),\n+            },\n+        )\n+        assert _get_channel_override(config, Platform.DISCORD, \"123\").model == \"gpt-4\"\n+\n+    def test_thread_id_lookup_when_chat_id_misses(self):\n+        config = GatewayConfig(\n+            platforms={\n+                Platform.DISCORD: PlatformConfig(\n+                    enabled=True,\n+                    channel_overrides={\n+                        \"thread_99\": ChannelOverride(model=\"topic-model\"),\n+                    },\n+                ),\n+            },\n+        )\n+        result = _get_channel_override(\n+            config, Platform.DISCORD, \"parent_chan\", thread_id=\"thread_99\"\n+        )\n+        assert result is not None\n+        assert result.model == \"topic-model\"\n+\n+    def test_parent_id_fallback_when_thread_has_no_entry(self):\n+        config = GatewayConfig(\n+            platforms={\n+                Platform.DISCORD: PlatformConfig(\n+                    enabled=True,\n+                    channel_overrides={\n+                        \"parent_chan\": ChannelOverride(model=\"parent-model\"),\n+                    },\n+                ),\n+            },\n+        )\n+        result = _get_channel_override(\n+            config,\n+            Platform.DISCORD,\n+            \"thread_only\",\n+            parent_id=\"parent_chan\",\n+        )\n+        assert result is not None\n+        assert result.model == \"parent-model\"\n+\n+    def test_exact_thread_overrides_parent(self):\n+        config = GatewayConfig(\n+            platforms={\n+                Platform.DISCORD: PlatformConfig(\n+                    enabled=True,\n+                    channel_overrides={\n+                        \"thread_1\": ChannelOverride(model=\"thread-model\"),\n+                        \"parent_chan\": ChannelOverride(model=\"parent-model\"),\n+                    },\n+                ),\n+            },\n+        )\n+        result = _get_channel_override(\n+            config, Platform.DISCORD, \"thread_1\", parent_id=\"parent_chan\"\n+        )\n+        assert result.model == \"thread-model\"\n+\n+\n+class TestResolveModelForChannel:\n+    def test_uses_channel_override_when_present(self):\n+        config = GatewayConfig(\n+            platforms={\n+                Platform.DISCORD: PlatformConfig(\n+                    enabled=True,\n+                    channel_overrides={\n+                        \"chan_1\": ChannelOverride(model=\"anthropic/claude-opus-4.6\"),\n+                    },\n+                ),\n+            },\n+        )\n+        runner = object.__new__(GatewayRunner)\n+        runner.config = config\n+        model = runner._resolve_model_for_channel(Platform.DISCORD, \"chan_1\")\n+        assert model == \"anthropic/claude-opus-4.6\"\n+\n+    def test_falls_back_to_global_when_no_override(self, monkeypatch):\n+        monkeypatch.setattr(\n+            \"gateway.run._resolve_gateway_model\",\n+            lambda _cfg=None: \"global-model/default\",\n+        )\n+        config = GatewayConfig(\n+            platforms={\n+                Platform.DISCORD: PlatformConfig(enabled=True, channel_overrides={}),\n+            },\n+        )\n+        runner = object.__new__(GatewayRunner)\n+        runner.config = config\n+        model = runner._resolve_model_for_channel(Platform.DISCORD, \"unknown_channel\")\n+        assert model == \"global-model/default\"\n+\n+\n+class TestGetSystemPromptForChannel:\n+    def test_uses_channel_override_when_present(self):\n+        config = GatewayConfig(\n+            platforms={\n+                Platform.DISCORD: PlatformConfig(\n+                    enabled=True,\n+                    channel_overrides={\n+                        \"chan_1\": ChannelOverride(system_prompt=\"You are a coding assistant.\"),\n+                    },\n+                ),\n+            },\n+        )\n+        runner = object.__new__(GatewayRunner)\n+        runner.config = config\n+        runner._ephemeral_system_prompt = \"Global prompt\"\n+        prompt = runner._get_system_prompt_for_channel(Platform.DISCORD, \"chan_1\")\n+        assert prompt == \"You are a coding assistant.\"\n+\n+    def test_falls_back_to_global_when_no_override(self):\n+        config = GatewayConfig(\n+            platforms={Platform.DISCORD: PlatformConfig(enabled=True)},\n+        )\n+        runner = object.__new__(GatewayRunner)\n+        runner.config = config\n+        runner._ephemeral_system_prompt = \"Global prompt\"\n+        prompt = runner._get_system_prompt_for_channel(Platform.DISCORD, \"other\")\n+        assert prompt == \"Global prompt\"\n+\n+\n+class TestResolveSessionAgentRuntimePriority:\n+    \"\"\"Model/runtime priority: session /model → channel_overrides → global.\"\"\"\n+\n+    def test_channel_override_beats_global(self):\n+        runner = object.__new__(GatewayRunner)\n+        runner._session_model_overrides = {}\n+        runner.config = GatewayConfig(\n+            platforms={\n+                Platform.DISCORD: PlatformConfig(\n+                    enabled=True,\n+                    channel_overrides={\n+                        \"chan_1\": ChannelOverride(\n+                            model=\"channel/model\",\n+                            provider=\"openrouter\",\n+                        ),\n+                    },\n+                ),\n+            },\n+        )\n+        source = SessionSource(\n+            platform=Platform.DISCORD,\n+            chat_id=\"chan_1\",\n+            user_id=\"u1\",\n+        )\n+        with patch(\"gateway.run._resolve_gateway_model\", return_value=\"global/model\"), \\\n+             patch(\"gateway.run._resolve_runtime_agent_kwargs\", return_value={\n+                 \"provider\": \"anthropic\",\n+                 \"api_key\": \"k\",\n+                 \"base_url\": \"https://api.anthropic.com\",\n+                 \"api_mode\": \"chat_completions\",\n+             }), \\\n+             patch(\n+                 \"gateway.run._resolve_runtime_agent_kwargs_for_provider\",\n+                 return_value={\n+                     \"provider\": \"openrouter\",\n+                     \"api_key\": \"k2\",\n+                     \"base_url\": \"https://openrouter.ai/api/v1\",\n+                     \"api_mode\": \"chat_completions\",\n+                 },\n+             ):\n+            model, runtime = runner._resolve_session_agent_runtime(\n+                source=source,\n+                user_config={\"model\": {\"default\": \"global/model\"}},\n+            )\n+        assert model == \"channel/model\"\n+        assert runtime[\"provider\"] == \"openrouter\"\n+\n+    def test_session_model_beats_channel_override(self):\n+        runner = object.__new__(GatewayRunner)\n+        runner.config = GatewayConfig(\n+            platforms={\n+                Platform.DISCORD: PlatformConfig(\n+                    enabled=True,\n+                    channel_overrides={\n+                        \"chan_1\": ChannelOverride(model=\"channel/model\"),\n+                    },\n+                ),\n+            },\n+        )\n+        session_key = \"agent:main:discord:channel:chan_1\"\n+        runner._session_model_overrides = {\n+            session_key: {\n+                \"model\": \"session/model\",\n+                \"provider\": \"anthropic\",\n+            },\n+        }\n+        source = SessionSource(\n+            platform=Platform.DISCORD,\n+            chat_id=\"chan_1\",\n+            chat_type=\"channel\",\n+            user_id=\"u1\",\n+        )\n+        with patch(\"gateway.run._resolve_gateway_model\", return_value=\"global/model\"), \\\n+             patch(\"gateway.run._resolve_runtime_agent_kwargs\", return_value={\n+                 \"provider\": \"openrouter\",\n+                 \"api_key\": \"k\",\n+                 \"base_url\": \"https://openrouter.ai/api/v1\",\n+                 \"api_mode\": \"chat_completions\",\n+             }):\n+            model, runtime = runner._resolve_session_agent_runtime(\n+                source=source,\n+                session_key=session_key,\n+            )\n+        assert model == \"session/model\"\n+        assert runtime[\"provider\"] == \"anthropic\"\n+\n+    def test_parent_channel_model_inherited_in_thread(self):\n+        runner = object.__new__(GatewayRunner)\n+        runner._session_model_overrides = {}\n+        runner.config = GatewayConfig(\n+            platforms={\n+                Platform.DISCORD: PlatformConfig(\n+                    enabled=True,\n+                    channel_overrides={\n+                        \"parent_chan\": ChannelOverride(model=\"parent/model\"),\n+                    },\n+                ),\n+            },\n+        )\n+        source = SessionSource(\n+            platform=Platform.DISCORD,\n+            chat_id=\"thread_1\",\n+            chat_type=\"thread\",\n+            parent_chat_id=\"parent_chan\",\n+            user_id=\"u1\",\n+        )\n+        with patch(\"gateway.run._resolve_gateway_model\", return_value=\"global/model\"), \\\n+             patch(\"gateway.run._resolve_runtime_agent_kwargs\", return_value={\n+                 \"provider\": \"anthropic\",\n+                 \"api_key\": \"k\",\n+                 \"base_url\": \"https://api.anthropic.com\",\n+                 \"api_mode\": \"chat_completions\",\n+             }):\n+            model, _runtime = runner._resolve_session_agent_runtime(source=source)\n+        assert model == \"parent/model\""]},{"file":"tests/gateway/test_config.py","hunks":["@@ -5,6 +5,7 @@\n from unittest.mock import patch\n \n from gateway.config import (\n+    ChannelOverride,\n     GatewayConfig,\n     HomeChannel,\n     Platform,","@@ -89,6 +90,54 @@ def test_typing_indicator_resolved_from_extra(self):\n         # extra; from_dict must honor it there too (mirrors _grn fallback).\n         restored = PlatformConfig.from_dict({\"extra\": {\"typing_indicator\": False}})\n         assert restored.typing_indicator is False\n+    def test_channel_overrides_roundtrip(self):\n+        pc = PlatformConfig(\n+            enabled=True,\n+            channel_overrides={\n+                \"1234567890\": ChannelOverride(\n+                    model=\"openrouter/healer-alpha\",\n+                    provider=\"openrouter\",\n+                    system_prompt=\"You are a daily news summarizer.\",\n+                ),\n+                \"9876543210\": ChannelOverride(\n+                    model=\"anthropic/claude-opus-4.6\",\n+                    provider=\"anthropic\",\n+                    system_prompt=\"You are a coding assistant.\",\n+                ),\n+            },\n+        )\n+        d = pc.to_dict()\n+        assert \"channel_overrides\" in d\n+        assert d[\"channel_overrides\"][\"1234567890\"][\"model\"] == \"openrouter/healer-alpha\"\n+        assert d[\"channel_overrides\"][\"9876543210\"][\"system_prompt\"] == \"You are a coding assistant.\"\n+        restored = PlatformConfig.from_dict(d)\n+        assert restored.channel_overrides[\"1234567890\"].model == \"openrouter/healer-alpha\"\n+        assert restored.channel_overrides[\"9876543210\"].provider == \"anthropic\"\n+\n+    def test_channel_overrides_from_dict_normalizes_channel_id_to_str(self):\n+        \"\"\"YAML may have numeric channel IDs; we store as str.\"\"\"\n+        data = {\n+            \"enabled\": True,\n+            \"channel_overrides\": {\n+                1234567890: {\"model\": \"openrouter/healer-alpha\"},\n+            },\n+        }\n+        pc = PlatformConfig.from_dict(data)\n+        assert \"1234567890\" in pc.channel_overrides\n+        assert pc.channel_overrides[\"1234567890\"].model == \"openrouter/healer-alpha\"\n+\n+\n+class TestChannelOverride:\n+    def test_from_dict_empty(self):\n+        assert ChannelOverride.from_dict({}).model is None\n+        assert ChannelOverride.from_dict(None).model is None\n+\n+    def test_to_dict_omits_none(self):\n+        ov = ChannelOverride(model=\"gpt-4\", provider=None, system_prompt=\"Hi\")\n+        d = ov.to_dict()\n+        assert d[\"model\"] == \"gpt-4\"\n+        assert \"provider\" not in d\n+        assert d[\"system_prompt\"] == \"Hi\"\n \n \n class TestGetConnectedPlatforms:","@@ -782,6 +831,31 @@ def test_bridges_quoted_false_always_log_local_from_config_yaml(self, tmp_path,\n \n         assert config.always_log_local is False\n \n+    def test_bridges_discord_channel_overrides_from_top_level_yaml(self, tmp_path, monkeypatch):\n+        hermes_home = tmp_path / \".hermes\"\n+        hermes_home.mkdir()\n+        config_path = hermes_home / \"config.yaml\"\n+        config_path.write_text(\n+            \"discord:\\n\"\n+            \"  channel_overrides:\\n\"\n+            '    \"1234567890\":\\n'\n+            \"      model: openrouter/healer-alpha\\n\"\n+            \"      provider: openrouter\\n\"\n+            \"      system_prompt: Daily news summarizer\\n\",\n+            encoding=\"utf-8\",\n+        )\n+\n+        monkeypatch.setenv(\"HERMES_HOME\", str(hermes_home))\n+\n+        config = load_gateway_config()\n+\n+        discord = config.platforms[Platform.DISCORD]\n+        assert \"1234567890\" in discord.channel_overrides\n+        ov = discord.channel_overrides[\"1234567890\"]\n+        assert ov.model == \"openrouter/healer-alpha\"\n+        assert ov.provider == \"openrouter\"\n+        assert ov.system_prompt == \"Daily news summarizer\"\n+\n     def test_bridges_discord_channel_prompts_from_config_yaml(self, tmp_path, monkeypatch):\n         hermes_home = tmp_path / \".hermes\"\n         hermes_home.mkdir()\n"]}]},{"kind":"pr","number":66586,"title":"fix(gateway): persist session /model override and surface write failures","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#66546","number":66546},{"kind":"pr","value":"#66524","number":66524},{"kind":"pr","value":"#66523","number":66523}],"category":"stale_abandoned","implication":"active"},"merged":false}],"trust":{"default_tier":"verified","includes_suggested":false}}