{"repo":"NousResearch/hermes-agent","public_id":"12542","display_label":"Voice state namespace collision fix across platforms","summary":"These issues and PRs address a bug where voice mode state in the gateway collides across different platforms when chat IDs happen to match. The fix namespaces voice state by platform to prevent cross-platform conflicts.","subsystem":null,"counts":{"members":3,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":1,"attempt_prs":1},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":12844,"merged_at":"2026-04-20T05:36:01+00:00"},"resolution":{"state":"resolved","by":"pr-12844"},"freshness":{"state":"stale","last_verified_at":"2026-04-20T05:36:01+00:00","is_stale":true},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.6666666666666666},"fix_edges":[{"issue_number":12542,"pr_number":12844,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-04-20T05:36:01+00:00"}],"members":[{"kind":"issue","number":12542,"title":"gateway: /voice state collides across platforms when chat IDs match","url":null,"state":"closed"},{"kind":"pr","number":12824,"title":"fix(gateway): namespace voice mode state by platform to prevent cross-platform collision","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#12844","number":12844}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":12844,"title":"fix(gateway): namespace voice mode state by platform (#12542)","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["gateway/run.py","tests/gateway/test_voice_command.py","tests/gateway/test_voice_mode_platform_isolation.py"],"fix_hunks":[{"file":"gateway/run.py","hunks":["@@ -786,6 +786,10 @@ def _has_setup_skill(self) -> bool:\n \n     _VOICE_MODE_PATH = _hermes_home / \"gateway_voice_mode.json\"\n \n+    def _voice_key(self, platform: Platform, chat_id: str) -> str:\n+        \"\"\"Return a platform-namespaced key for voice mode state.\"\"\"\n+        return f\"{platform.value}:{chat_id}\"\n+\n     def _load_voice_modes(self) -> Dict[str, str]:\n         try:\n             data = json.loads(self._VOICE_MODE_PATH.read_text())","@@ -796,11 +800,21 @@ def _load_voice_modes(self) -> Dict[str, str]:\n             return {}\n \n         valid_modes = {\"off\", \"voice_only\", \"all\"}\n-        return {\n-            str(chat_id): mode\n-            for chat_id, mode in data.items()\n-            if mode in valid_modes\n-        }\n+        result = {}\n+        for chat_id, mode in data.items():\n+            if mode not in valid_modes:\n+                continue\n+            key = str(chat_id)\n+            # Skip legacy unprefixed keys (warn and skip)\n+            if \":\" not in key:\n+                logger.warning(\n+                    \"Skipping legacy unprefixed voice mode key %r during migration. \"\n+                    \"Re-enable voice mode on that chat to rebuild the prefixed key.\",\n+                    key,\n+                )\n+                continue\n+            result[key] = mode\n+        return result\n \n     def _save_voice_modes(self) -> None:\n         try:","@@ -826,9 +840,14 @@ def _sync_voice_mode_state_to_adapter(self, adapter) -> None:\n         disabled_chats = getattr(adapter, \"_auto_tts_disabled_chats\", None)\n         if not isinstance(disabled_chats, set):\n             return\n+        platform = getattr(adapter, \"platform\", None)\n+        if not isinstance(platform, Platform):\n+            return\n         disabled_chats.clear()\n+        prefix = f\"{platform.value}:\"\n         disabled_chats.update(\n-            chat_id for chat_id, mode in self._voice_mode.items() if mode == \"off\"\n+            key[len(prefix):] for key, mode in self._voice_mode.items()\n+            if mode == \"off\" and key.startswith(prefix)\n         )\n \n     async def _safe_adapter_disconnect(self, adapter, platform) -> None:","@@ -5781,11 +5800,13 @@ async def _handle_voice_command(self, event: MessageEvent) -> str:\n         \"\"\"Handle /voice [on|off|tts|channel|leave|status] command.\"\"\"\n         args = event.get_command_args().strip().lower()\n         chat_id = event.source.chat_id\n+        platform = event.source.platform\n+        voice_key = self._voice_key(platform, chat_id)\n \n-        adapter = self.adapters.get(event.source.platform)\n+        adapter = self.adapters.get(platform)\n \n         if args in (\"on\", \"enable\"):\n-            self._voice_mode[chat_id] = \"voice_only\"\n+            self._voice_mode[voice_key] = \"voice_only\"\n             self._save_voice_modes()\n             if adapter:\n                 self._set_adapter_auto_tts_disabled(adapter, chat_id, disabled=False)","@@ -5795,13 +5816,13 @@ async def _handle_voice_command(self, event: MessageEvent) -> str:\n                 \"Use /voice tts to get voice replies for all messages.\"\n             )\n         elif args in (\"off\", \"disable\"):\n-            self._voice_mode[chat_id] = \"off\"\n+            self._voice_mode[voice_key] = \"off\"\n             self._save_voice_modes()\n             if adapter:\n                 self._set_adapter_auto_tts_disabled(adapter, chat_id, disabled=True)\n             return \"Voice mode disabled. Text-only replies.\"\n         elif args == \"tts\":\n-            self._voice_mode[chat_id] = \"all\"\n+            self._voice_mode[voice_key] = \"all\"\n             self._save_voice_modes()\n             if adapter:\n                 self._set_adapter_auto_tts_disabled(adapter, chat_id, disabled=False)","@@ -5814,7 +5835,7 @@ async def _handle_voice_command(self, event: MessageEvent) -> str:\n         elif args == \"leave\":\n             return await self._handle_voice_channel_leave(event)\n         elif args == \"status\":\n-            mode = self._voice_mode.get(chat_id, \"off\")\n+            mode = self._voice_mode.get(voice_key, \"off\")\n             labels = {\n                 \"off\": \"Off (text only)\",\n                 \"voice_only\": \"On (voice reply to voice messages)\",","@@ -5838,15 +5859,15 @@ async def _handle_voice_command(self, event: MessageEvent) -> str:\n             return f\"Voice mode: {labels.get(mode, mode)}\"\n         else:\n             # Toggle: off → on, on/all → off\n-            current = self._voice_mode.get(chat_id, \"off\")\n+            current = self._voice_mode.get(voice_key, \"off\")\n             if current == \"off\":\n-                self._voice_mode[chat_id] = \"voice_only\"\n+                self._voice_mode[voice_key] = \"voice_only\"\n                 self._save_voice_modes()\n                 if adapter:\n                     self._set_adapter_auto_tts_disabled(adapter, chat_id, disabled=False)\n                 return \"Voice mode enabled.\"\n             else:\n-                self._voice_mode[chat_id] = \"off\"\n+                self._voice_mode[voice_key] = \"off\"\n                 self._save_voice_modes()\n                 if adapter:\n                     self._set_adapter_auto_tts_disabled(adapter, chat_id, disabled=True)","@@ -5892,7 +5913,7 @@ async def _handle_voice_channel_join(self, event: MessageEvent) -> str:\n             adapter._voice_text_channels[guild_id] = int(event.source.chat_id)\n             if hasattr(adapter, \"_voice_sources\"):\n                 adapter._voice_sources[guild_id] = event.source.to_dict()\n-            self._voice_mode[event.source.chat_id] = \"all\"\n+            self._voice_mode[self._voice_key(event.source.platform, event.source.chat_id)] = \"all\"\n             self._save_voice_modes()\n             self._set_adapter_auto_tts_disabled(adapter, event.source.chat_id, disabled=False)\n             return (","@@ -5919,7 +5940,7 @@ async def _handle_voice_channel_leave(self, event: MessageEvent) -> str:\n         except Exception as e:\n             logger.warning(\"Error leaving voice channel: %s\", e)\n         # Always clean up state even if leave raised an exception\n-        self._voice_mode[event.source.chat_id] = \"off\"\n+        self._voice_mode[self._voice_key(event.source.platform, event.source.chat_id)] = \"off\"\n         self._save_voice_modes()\n         self._set_adapter_auto_tts_disabled(adapter, event.source.chat_id, disabled=True)\n         if hasattr(adapter, \"_voice_input_callback\"):","@@ -5931,7 +5952,7 @@ def _handle_voice_timeout_cleanup(self, chat_id: str) -> None:\n \n         Cleans up runner-side voice_mode state that the adapter cannot reach.\n         \"\"\"\n-        self._voice_mode[chat_id] = \"off\"\n+        self._voice_mode[self._voice_key(Platform.DISCORD, chat_id)] = \"off\"\n         self._save_voice_modes()\n         adapter = self.adapters.get(Platform.DISCORD)\n         self._set_adapter_auto_tts_disabled(adapter, chat_id, disabled=True)","@@ -6017,7 +6038,7 @@ def _should_send_voice_reply(\n             return False\n \n         chat_id = event.source.chat_id\n-        voice_mode = self._voice_mode.get(chat_id, \"off\")\n+        voice_mode = self._voice_mode.get(self._voice_key(event.source.platform, chat_id), \"off\")\n         is_voice_input = (event.message_type == MessageType.VOICE)\n \n         should = ("]},{"file":"tests/gateway/test_voice_command.py","hunks":["@@ -99,22 +99,22 @@ async def test_voice_on(self, runner):\n         event = _make_event(\"/voice on\")\n         result = await runner._handle_voice_command(event)\n         assert \"enabled\" in result.lower()\n-        assert runner._voice_mode[\"123\"] == \"voice_only\"\n+        assert runner._voice_mode[\"telegram:123\"] == \"voice_only\"\n \n     @pytest.mark.asyncio\n     async def test_voice_off(self, runner):\n-        runner._voice_mode[\"123\"] = \"voice_only\"\n+        runner._voice_mode[\"telegram:123\"] = \"voice_only\"\n         event = _make_event(\"/voice off\")\n         result = await runner._handle_voice_command(event)\n         assert \"disabled\" in result.lower()\n-        assert runner._voice_mode[\"123\"] == \"off\"\n+        assert runner._voice_mode[\"telegram:123\"] == \"off\"\n \n     @pytest.mark.asyncio\n     async def test_voice_tts(self, runner):\n         event = _make_event(\"/voice tts\")\n         result = await runner._handle_voice_command(event)\n         assert \"tts\" in result.lower()\n-        assert runner._voice_mode[\"123\"] == \"all\"\n+        assert runner._voice_mode[\"telegram:123\"] == \"all\"\n \n     @pytest.mark.asyncio\n     async def test_voice_status_off(self, runner):","@@ -124,7 +124,7 @@ async def test_voice_status_off(self, runner):\n \n     @pytest.mark.asyncio\n     async def test_voice_status_on(self, runner):\n-        runner._voice_mode[\"123\"] = \"voice_only\"\n+        runner._voice_mode[\"telegram:123\"] = \"voice_only\"\n         event = _make_event(\"/voice status\")\n         result = await runner._handle_voice_command(event)\n         assert \"voice reply\" in result.lower()","@@ -134,15 +134,15 @@ async def test_toggle_off_to_on(self, runner):\n         event = _make_event(\"/voice\")\n         result = await runner._handle_voice_command(event)\n         assert \"enabled\" in result.lower()\n-        assert runner._voice_mode[\"123\"] == \"voice_only\"\n+        assert runner._voice_mode[\"telegram:123\"] == \"voice_only\"\n \n     @pytest.mark.asyncio\n     async def test_toggle_on_to_off(self, runner):\n-        runner._voice_mode[\"123\"] = \"voice_only\"\n+        runner._voice_mode[\"telegram:123\"] = \"voice_only\"\n         event = _make_event(\"/voice\")\n         result = await runner._handle_voice_command(event)\n         assert \"disabled\" in result.lower()\n-        assert runner._voice_mode[\"123\"] == \"off\"\n+        assert runner._voice_mode[\"telegram:123\"] == \"off\"\n \n     @pytest.mark.asyncio\n     async def test_persistence_saved(self, runner):","@@ -150,39 +150,47 @@ async def test_persistence_saved(self, runner):\n         await runner._handle_voice_command(event)\n         assert runner._VOICE_MODE_PATH.exists()\n         data = json.loads(runner._VOICE_MODE_PATH.read_text())\n-        assert data[\"123\"] == \"voice_only\"\n+        assert data[\"telegram:123\"] == \"voice_only\"\n \n     @pytest.mark.asyncio\n     async def test_persistence_loaded(self, runner):\n-        runner._VOICE_MODE_PATH.write_text(json.dumps({\"456\": \"all\"}))\n+        runner._VOICE_MODE_PATH.write_text(json.dumps({\"telegram:456\": \"all\"}))\n         loaded = runner._load_voice_modes()\n-        assert loaded == {\"456\": \"all\"}\n+        assert loaded == {\"telegram:456\": \"all\"}\n \n     @pytest.mark.asyncio\n     async def test_persistence_saved_for_off(self, runner):\n         event = _make_event(\"/voice off\")\n         await runner._handle_voice_command(event)\n         data = json.loads(runner._VOICE_MODE_PATH.read_text())\n-        assert data[\"123\"] == \"off\"\n+        assert data[\"telegram:123\"] == \"off\"\n \n     def test_sync_voice_mode_state_to_adapter_restores_off_chats(self, runner):\n-        runner._voice_mode = {\"123\": \"off\", \"456\": \"all\"}\n-        adapter = SimpleNamespace(_auto_tts_disabled_chats=set())\n+        from gateway.config import Platform\n+        runner._voice_mode = {\"telegram:123\": \"off\", \"telegram:456\": \"all\"}\n+        adapter = SimpleNamespace(\n+            _auto_tts_disabled_chats=set(),\n+            platform=Platform.TELEGRAM,\n+        )\n \n         runner._sync_voice_mode_state_to_adapter(adapter)\n \n         assert adapter._auto_tts_disabled_chats == {\"123\"}\n \n     def test_restart_restores_voice_off_state(self, runner, tmp_path):\n-        runner._VOICE_MODE_PATH.write_text(json.dumps({\"123\": \"off\"}))\n+        from gateway.config import Platform\n+        runner._VOICE_MODE_PATH.write_text(json.dumps({\"telegram:123\": \"off\"}))\n \n         restored_runner = _make_runner(tmp_path)\n         restored_runner._voice_mode = restored_runner._load_voice_modes()\n-        adapter = SimpleNamespace(_auto_tts_disabled_chats=set())\n+        adapter = SimpleNamespace(\n+            _auto_tts_disabled_chats=set(),\n+            platform=Platform.TELEGRAM,\n+        )\n \n         restored_runner._sync_voice_mode_state_to_adapter(adapter)\n \n-        assert restored_runner._voice_mode[\"123\"] == \"off\"\n+        assert restored_runner._voice_mode[\"telegram:123\"] == \"off\"\n         assert adapter._auto_tts_disabled_chats == {\"123\"}\n \n     @pytest.mark.asyncio","@@ -191,8 +199,21 @@ async def test_per_chat_isolation(self, runner):\n         e2 = _make_event(\"/voice tts\", chat_id=\"bbb\")\n         await runner._handle_voice_command(e1)\n         await runner._handle_voice_command(e2)\n-        assert runner._voice_mode[\"aaa\"] == \"voice_only\"\n-        assert runner._voice_mode[\"bbb\"] == \"all\"\n+        assert runner._voice_mode[\"telegram:aaa\"] == \"voice_only\"\n+        assert runner._voice_mode[\"telegram:bbb\"] == \"all\"\n+\n+    @pytest.mark.asyncio\n+    async def test_platform_isolation(self, runner):\n+        \"\"\"Same chat_id on different platforms must not collide (#12542).\"\"\"\n+        telegram_event = _make_event(\"/voice on\", chat_id=\"999\")\n+        slack_event = _make_event(\"/voice off\", chat_id=\"999\")\n+        slack_event.source.platform.value = \"slack\"\n+\n+        await runner._handle_voice_command(telegram_event)\n+        await runner._handle_voice_command(slack_event)\n+\n+        assert runner._voice_mode[\"telegram:999\"] == \"voice_only\"\n+        assert runner._voice_mode[\"slack:999\"] == \"off\"\n \n \n # =====================================================================","@@ -223,9 +244,9 @@ def _call(self, runner, voice_mode, message_type, agent_messages=None,\n         \"\"\"Call real _should_send_voice_reply on a GatewayRunner instance.\"\"\"\n         chat_id = \"123\"\n         if voice_mode != \"off\":\n-            runner._voice_mode[chat_id] = voice_mode\n+            runner._voice_mode[\"telegram:\" + chat_id] = voice_mode\n         else:\n-            runner._voice_mode.pop(chat_id, None)\n+            runner._voice_mode.pop(\"telegram:\" + chat_id, None)\n \n         event = _make_event(message_type=message_type)\n ","@@ -713,7 +734,7 @@ async def test_join_success(self, runner):\n         result = await runner._handle_voice_channel_join(event)\n         assert \"joined\" in result.lower()\n         assert \"General\" in result\n-        assert runner._voice_mode[\"123\"] == \"all\"\n+        assert runner._voice_mode[\"discord:123\"] == \"all\"\n         assert mock_adapter._voice_sources[111][\"chat_id\"] == \"123\"\n         assert mock_adapter._voice_sources[111][\"chat_type\"] == \"group\"\n ","@@ -791,10 +812,10 @@ async def test_leave_success(self, runner):\n         mock_adapter.leave_voice_channel = AsyncMock()\n         event = self._make_discord_event(\"/voice leave\")\n         runner.adapters[event.source.platform] = mock_adapter\n-        runner._voice_mode[\"123\"] = \"all\"\n+        runner._voice_mode[\"discord:123\"] = \"all\"\n         result = await runner._handle_voice_channel_leave(event)\n         assert \"left\" in result.lower()\n-        assert runner._voice_mode[\"123\"] == \"off\"\n+        assert runner._voice_mode[\"discord:123\"] == \"off\"\n         mock_adapter.leave_voice_channel.assert_called_once_with(111)\n \n     # -- _handle_voice_channel_input --","@@ -1298,11 +1319,11 @@ async def test_leave_exception_still_cleans_state(self, runner):\n         event = _make_event(\"/voice leave\")\n         event.raw_message = SimpleNamespace(guild_id=111, guild=None)\n         runner.adapters[event.source.platform] = mock_adapter\n-        runner._voice_mode[\"123\"] = \"all\"\n+        runner._voice_mode[\"telegram:123\"] = \"all\"\n \n         result = await runner._handle_voice_channel_leave(event)\n         assert \"left\" in result.lower()\n-        assert runner._voice_mode[\"123\"] == \"off\"\n+        assert runner._voice_mode[\"telegram:123\"] == \"off\"\n         assert mock_adapter._voice_input_callback is None\n \n     @pytest.mark.asyncio","@@ -1316,7 +1337,7 @@ async def test_leave_clears_callback(self, runner):\n         event = _make_event(\"/voice leave\")\n         event.raw_message = SimpleNamespace(guild_id=111, guild=None)\n         runner.adapters[event.source.platform] = mock_adapter\n-        runner._voice_mode[\"123\"] = \"all\"\n+        runner._voice_mode[\"telegram:123\"] = \"all\"\n \n         await runner._handle_voice_channel_leave(event)\n         assert mock_adapter._voice_input_callback is None","@@ -1763,11 +1784,11 @@ async def test_timeout_calls_disconnect_callback(self, adapter):\n     async def test_runner_cleanup_method_removes_voice_mode(self, tmp_path):\n         \"\"\"_handle_voice_timeout_cleanup removes voice_mode for chat.\"\"\"\n         runner = _make_runner(tmp_path)\n-        runner._voice_mode[\"999\"] = \"all\"\n+        runner._voice_mode[\"discord:999\"] = \"all\"\n \n         runner._handle_voice_timeout_cleanup(\"999\")\n \n-        assert runner._voice_mode[\"999\"] == \"off\", \\\n+        assert runner._voice_mode[\"discord:999\"] == \"off\", \\\n             \"voice_mode must persist explicit off state after timeout cleanup\"\n \n     @pytest.mark.asyncio","@@ -2524,7 +2545,7 @@ def _call_should_reply(self, runner, voice_mode, msg_type, response=\"Hello\",\n                            agent_msgs=None, already_sent=False):\n         from gateway.platforms.base import MessageType, MessageEvent, SessionSource\n         from gateway.config import Platform\n-        runner._voice_mode[\"ch1\"] = voice_mode\n+        runner._voice_mode[\"discord:ch1\"] = voice_mode\n         source = SessionSource(\n             platform=Platform.DISCORD, chat_id=\"ch1\",\n             user_id=\"1\", user_name=\"test\", chat_type=\"channel\","]},{"file":"tests/gateway/test_voice_mode_platform_isolation.py","hunks":["@@ -0,0 +1,218 @@\n+\"\"\"Tests for voice mode platform isolation (bug #12542).\n+\n+Voice mode state stored as {chat_id: mode} without a platform namespace\n+caused collisions: Telegram chat '123' and Slack chat '123' shared the\n+same key. The fix prefixes keys with platform value: 'telegram:123' vs\n+'slack:123'.\n+\"\"\"\n+\n+import json\n+import tempfile\n+from pathlib import Path\n+from unittest.mock import MagicMock, patch\n+\n+import pytest\n+\n+from gateway.config import Platform\n+from gateway.run import GatewayRunner\n+\n+\n+class TestVoiceKeyHelper:\n+    \"\"\"Test the _voice_key helper method.\"\"\"\n+\n+    def test_voice_key_format(self):\n+        \"\"\"_voice_key returns 'platform:chat_id' format.\"\"\"\n+        runner = _make_runner()\n+        assert runner._voice_key(Platform.TELEGRAM, \"123\") == \"telegram:123\"\n+        assert runner._voice_key(Platform.SLACK, \"456\") == \"slack:456\"\n+        assert runner._voice_key(Platform.DISCORD, \"789\") == \"discord:789\"\n+\n+    def test_voice_key_different_platforms_same_chat_id(self):\n+        \"\"\"Same chat_id on different platforms yields different keys.\"\"\"\n+        runner = _make_runner()\n+        key_telegram = runner._voice_key(Platform.TELEGRAM, \"123\")\n+        key_slack = runner._voice_key(Platform.SLACK, \"123\")\n+        key_discord = runner._voice_key(Platform.DISCORD, \"123\")\n+        assert key_telegram != key_slack\n+        assert key_slack != key_discord\n+        assert key_telegram == \"telegram:123\"\n+        assert key_slack == \"slack:123\"\n+        assert key_discord == \"discord:123\"\n+\n+\n+class TestVoiceModePlatformIsolation:\n+    \"\"\"Test that voice mode state is isolated by platform.\"\"\"\n+\n+    def test_telegram_and_slack_voice_mode_independent(self):\n+        \"\"\"Setting voice mode for Telegram chat '123' does not affect Slack chat '123'.\"\"\"\n+        runner = _make_runner()\n+\n+        # Enable voice mode for Telegram chat '123'\n+        runner._voice_mode[runner._voice_key(Platform.TELEGRAM, \"123\")] = \"all\"\n+        # Enable voice mode for Slack chat '123' to a different mode\n+        runner._voice_mode[runner._voice_key(Platform.SLACK, \"123\")] = \"voice_only\"\n+\n+        # Verify they are independent\n+        assert runner._voice_mode.get(runner._voice_key(Platform.TELEGRAM, \"123\")) == \"all\"\n+        assert runner._voice_mode.get(runner._voice_key(Platform.SLACK, \"123\")) == \"voice_only\"\n+\n+        # Disabling Telegram should not affect Slack\n+        runner._voice_mode[runner._voice_key(Platform.TELEGRAM, \"123\")] = \"off\"\n+        assert runner._voice_mode.get(runner._voice_key(Platform.TELEGRAM, \"123\")) == \"off\"\n+        assert runner._voice_mode.get(runner._voice_key(Platform.SLACK, \"123\")) == \"voice_only\"\n+\n+\n+class TestLegacyKeyMigration:\n+    \"\"\"Test migration of legacy unprefixed keys in _load_voice_modes.\"\"\"\n+\n+    def test_load_voice_modes_skips_legacy_keys(self):\n+        \"\"\"_load_voice_modes skips keys without ':' prefix and logs a warning.\"\"\"\n+        runner = _make_runner()\n+\n+        # Simulate legacy persisted data with unprefixed keys\n+        legacy_data = {\n+            \"123\": \"all\",\n+            \"456\": \"voice_only\",\n+            # Also includes a properly prefixed key (from after the fix)\n+            \"telegram:789\": \"off\",\n+        }\n+\n+        with tempfile.TemporaryDirectory() as tmpdir:\n+            voice_path = Path(tmpdir) / \"gateway_voice_mode.json\"\n+            voice_path.write_text(json.dumps(legacy_data))\n+\n+            with patch.object(runner, \"_VOICE_MODE_PATH\", voice_path):\n+                with patch(\"gateway.run.logger\") as mock_logger:\n+                    result = runner._load_voice_modes()\n+\n+            # Legacy keys without ':' should be skipped\n+            assert \"123\" not in result\n+            assert \"456\" not in result\n+            # Prefixed key should be preserved\n+            assert result.get(\"telegram:789\") == \"off\"\n+            # Warning should be logged for each legacy key\n+            assert mock_logger.warning.called\n+            warning_calls = [str(call) for call in mock_logger.warning.call_args_list]\n+            assert any(\"Skipping legacy unprefixed voice mode key\" in str(c) for c in warning_calls)\n+\n+    def test_load_voice_modes_preserves_prefixed_keys(self):\n+        \"\"\"_load_voice_modes correctly loads platform-prefixed keys.\"\"\"\n+        runner = _make_runner()\n+\n+        persisted_data = {\n+            \"telegram:123\": \"all\",\n+            \"slack:456\": \"voice_only\",\n+            \"discord:789\": \"off\",\n+        }\n+\n+        with tempfile.TemporaryDirectory() as tmpdir:\n+            voice_path = Path(tmpdir) / \"gateway_voice_mode.json\"\n+            voice_path.write_text(json.dumps(persisted_data))\n+\n+            with patch.object(runner, \"_VOICE_MODE_PATH\", voice_path):\n+                result = runner._load_voice_modes()\n+\n+        assert result.get(\"telegram:123\") == \"all\"\n+        assert result.get(\"slack:456\") == \"voice_only\"\n+        assert result.get(\"discord:789\") == \"off\"\n+\n+    def test_load_voice_modes_invalid_modes_filtered(self):\n+        \"\"\"_load_voice_modes filters out invalid mode values.\"\"\"\n+        runner = _make_runner()\n+\n+        data = {\n+            \"telegram:123\": \"all\",\n+            \"telegram:456\": \"invalid_mode\",\n+            \"telegram:789\": \"voice_only\",\n+        }\n+\n+        with tempfile.TemporaryDirectory() as tmpdir:\n+            voice_path = Path(tmpdir) / \"gateway_voice_mode.json\"\n+            voice_path.write_text(json.dumps(data))\n+\n+            with patch.object(runner, \"_VOICE_MODE_PATH\", voice_path):\n+                result = runner._load_voice_modes()\n+\n+        assert result.get(\"telegram:123\") == \"all\"\n+        assert \"telegram:456\" not in result\n+        assert result.get(\"telegram:789\") == \"voice_only\"\n+\n+\n+class TestSyncVoiceModeStateToAdapter:\n+    \"\"\"Test _sync_voice_mode_state_to_adapter filters by platform.\"\"\"\n+\n+    def test_sync_only_includes_platform_chats(self):\n+        \"\"\"Only chats matching the adapter's platform are synced.\"\"\"\n+        runner = _make_runner()\n+\n+        # Set up voice mode state with multiple platforms\n+        runner._voice_mode = {\n+            \"telegram:123\": \"off\",      # Should sync\n+            \"telegram:456\": \"all\",       # Should NOT sync (mode is not \"off\")\n+            \"slack:123\": \"off\",          # Should NOT sync (different platform)\n+            \"discord:789\": \"off\",        # Should NOT sync (different platform)\n+        }\n+\n+        # Create a mock Telegram adapter\n+        mock_adapter = MagicMock()\n+        mock_adapter.platform = Platform.TELEGRAM\n+        mock_adapter._auto_tts_disabled_chats = set()\n+\n+        runner._sync_voice_mode_state_to_adapter(mock_adapter)\n+\n+        # Only telegram:123 should be in disabled_chats (mode=\"off\" for telegram)\n+        assert mock_adapter._auto_tts_disabled_chats == {\"123\"}\n+\n+    def test_sync_clears_existing_state(self):\n+        \"\"\"_sync_voice_mode_state_to_adapter clears existing disabled_chats first.\"\"\"\n+        runner = _make_runner()\n+\n+        runner._voice_mode = {\n+            \"telegram:123\": \"off\",\n+        }\n+\n+        mock_adapter = MagicMock()\n+        mock_adapter.platform = Platform.TELEGRAM\n+        mock_adapter._auto_tts_disabled_chats = {\"old_chat_id\", \"another_old\"}\n+\n+        runner._sync_voice_mode_state_to_adapter(mock_adapter)\n+\n+        # Old entries should be cleared\n+        assert mock_adapter._auto_tts_disabled_chats == {\"123\"}\n+\n+    def test_sync_returns_early_without_platform(self):\n+        \"\"\"_sync_voice_mode_state_to_adapter returns early if adapter has no platform.\"\"\"\n+        runner = _make_runner()\n+        runner._voice_mode = {\"telegram:123\": \"off\"}\n+\n+        mock_adapter = MagicMock()\n+        mock_adapter.platform = None\n+        mock_adapter._auto_tts_disabled_chats = {\"old\"}\n+\n+        runner._sync_voice_mode_state_to_adapter(mock_adapter)\n+\n+        # disabled_chats should not be modified\n+        assert mock_adapter._auto_tts_disabled_chats == {\"old\"}\n+\n+    def test_sync_returns_early_without_auto_tts_disabled_chats(self):\n+        \"\"\"_sync_voice_mode_state_to_adapter returns early if adapter lacks _auto_tts_disabled_chats.\"\"\"\n+        runner = _make_runner()\n+        runner._voice_mode = {\"telegram:123\": \"off\"}\n+\n+        mock_adapter = MagicMock(spec=[])  # No _auto_tts_disabled_chats attribute\n+\n+        # Should not raise\n+        runner._sync_voice_mode_state_to_adapter(mock_adapter)\n+\n+\n+# ---------------------------------------------------------------------------\n+# Helper\n+# ---------------------------------------------------------------------------\n+\n+def _make_runner() -> GatewayRunner:\n+    \"\"\"Create a minimal GatewayRunner for testing.\"\"\"\n+    with patch(\"gateway.run.GatewayRunner._load_voice_modes\", return_value={}):\n+        runner = GatewayRunner.__new__(GatewayRunner)\n+        runner._voice_mode = {}\n+        runner.adapters = {}\n+    return runner\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}