# NousResearch/hermes-agent · Voice state namespace collision fix across platforms

## Open now

- Nothing open on GitHub.

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.

## TL;DR

- 3 member(s): 0 open issue(s), 1 closed issue(s), 0 open PR(s), 1 merged PR(s), 1 PR(s) closed without merging.
- A fix was merged to main in PR #12844. Verify it addresses your case.
- **Recommended:** read PR #12844 (and any competing PRs below) before filing. Confirm the merged change covers your symptom.

## Maintainer resolution verdict

- Internal-tracker verdict over this cluster's issue members: `resolved` · deciding PR(s): `pr-12844`. This is distinct from the fix status above. Weigh it yourself.

## How this cluster was grouped

- Link evidence (`STRUCTURAL_SPANNING`): every member is connected by structural evidence: shared changed files, or a closing/superseded reference.
- Pairwise link density `0.67`: the share of member pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. `1.00` means every pair is linked.

## Closed / duplicate issues

- #12542: gateway: /voice state collides across platforms when chat IDs match

## Prior & competing PRs

- **#12844: fix(gateway): namespace voice mode state by platform (#12542)**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`
  - files: `gateway/run.py`, `tests/gateway/test_voice_command.py`, `tests/gateway/test_voice_mode_platform_isolation.py`
  - fix hunks:
  - `gateway/run.py`

    ```diff
    @@ -786,6 +786,10 @@ def _has_setup_skill(self) -> bool:
     
         _VOICE_MODE_PATH = _hermes_home / "gateway_voice_mode.json"
     
    +    def _voice_key(self, platform: Platform, chat_id: str) -> str:
    +        """Return a platform-namespaced key for voice mode state."""
    +        return f"{platform.value}:{chat_id}"
    +
         def _load_voice_modes(self) -> Dict[str, str]:
             try:
                 data = json.loads(self._VOICE_MODE_PATH.read_text())
    ```

    ```diff
    @@ -796,11 +800,21 @@ def _load_voice_modes(self) -> Dict[str, str]:
                 return {}
     
             valid_modes = {"off", "voice_only", "all"}
    -        return {
    -            str(chat_id): mode
    -            for chat_id, mode in data.items()
    -            if mode in valid_modes
    -        }
    +        result = {}
    +        for chat_id, mode in data.items():
    +            if mode not in valid_modes:
    +                continue
    +            key = str(chat_id)
    +            # Skip legacy unprefixed keys (warn and skip)
    +            if ":" not in key:
    +                logger.warning(
    +                    "Skipping legacy unprefixed voice mode key %r during migration. "
    +                    "Re-enable voice mode on that chat to rebuild the prefixed key.",
    +                    key,
    +                )
    +                continue
    +            result[key] = mode
    +        return result
     
         def _save_voice_modes(self) -> None:
             try:
    ```

    ```diff
    @@ -826,9 +840,14 @@ def _sync_voice_mode_state_to_adapter(self, adapter) -> None:
             disabled_chats = getattr(adapter, "_auto_tts_disabled_chats", None)
             if not isinstance(disabled_chats, set):
                 return
    +        platform = getattr(adapter, "platform", None)
    +        if not isinstance(platform, Platform):
    +            return
             disabled_chats.clear()
    +        prefix = f"{platform.value}:"
             disabled_chats.update(
    -            chat_id for chat_id, mode in self._voice_mode.items() if mode == "off"
    +            key[len(prefix):] for key, mode in self._voice_mode.items()
    +            if mode == "off" and key.startswith(prefix)
             )
     
         async def _safe_adapter_disconnect(self, adapter, platform) -> None:
    ```

    ```diff
    @@ -5781,11 +5800,13 @@ async def _handle_voice_command(self, event: MessageEvent) -> str:
             """Handle /voice [on|off|tts|channel|leave|status] command."""
             args = event.get_command_args().strip().lower()
             chat_id = event.source.chat_id
    +        platform = event.source.platform
    +        voice_key = self._voice_key(platform, chat_id)
     
    -        adapter = self.adapters.get(event.source.platform)
    +        adapter = self.adapters.get(platform)
     
             if args in ("on", "enable"):
    -            self._voice_mode[chat_id] = "voice_only"
    +            self._voice_mode[voice_key] = "voice_only"
                 self._save_voice_modes()
                 if adapter:
                     self._set_adapter_auto_tts_disabled(adapter, chat_id, disabled=False)
    ```

    ```diff
    @@ -5795,13 +5816,13 @@ async def _handle_voice_command(self, event: MessageEvent) -> str:
                     "Use /voice tts to get voice replies for all messages."
                 )
             elif args in ("off", "disable"):
    -            self._voice_mode[chat_id] = "off"
    +            self._voice_mode[voice_key] = "off"
                 self._save_voice_modes()
                 if adapter:
                     self._set_adapter_auto_tts_disabled(adapter, chat_id, disabled=True)
                 return "Voice mode disabled. Text-only replies."
             elif args == "tts":
    -            self._voice_mode[chat_id] = "all"
    +            self._voice_mode[voice_key] = "all"
                 self._save_voice_modes()
                 if adapter:
                     self._set_adapter_auto_tts_disabled(adapter, chat_id, disabled=False)
    ```

    ```diff
    @@ -5814,7 +5835,7 @@ async def _handle_voice_command(self, event: MessageEvent) -> str:
             elif args == "leave":
                 return await self._handle_voice_channel_leave(event)
             elif args == "status":
    -            mode = self._voice_mode.get(chat_id, "off")
    +            mode = self._voice_mode.get(voice_key, "off")
                 labels = {
                     "off": "Off (text only)",
                     "voice_only": "On (voice reply to voice messages)",
    ```

    ```diff
    @@ -5838,15 +5859,15 @@ async def _handle_voice_command(self, event: MessageEvent) -> str:
                 return f"Voice mode: {labels.get(mode, mode)}"
             else:
                 # Toggle: off → on, on/all → off
    -            current = self._voice_mode.get(chat_id, "off")
    +            current = self._voice_mode.get(voice_key, "off")
                 if current == "off":
    -                self._voice_mode[chat_id] = "voice_only"
    +                self._voice_mode[voice_key] = "voice_only"
                     self._save_voice_modes()
                     if adapter:
                         self._set_adapter_auto_tts_disabled(adapter, chat_id, disabled=False)
                     return "Voice mode enabled."
                 else:
    -                self._voice_mode[chat_id] = "off"
    +                self._voice_mode[voice_key] = "off"
                     self._save_voice_modes()
                     if adapter:
                         self._set_adapter_auto_tts_disabled(adapter, chat_id, disabled=True)
    ```

    ```diff
    @@ -5892,7 +5913,7 @@ async def _handle_voice_channel_join(self, event: MessageEvent) -> str:
                 adapter._voice_text_channels[guild_id] = int(event.source.chat_id)
                 if hasattr(adapter, "_voice_sources"):
                     adapter._voice_sources[guild_id] = event.source.to_dict()
    -            self._voice_mode[event.source.chat_id] = "all"
    +            self._voice_mode[self._voice_key(event.source.platform, event.source.chat_id)] = "all"
                 self._save_voice_modes()
                 self._set_adapter_auto_tts_disabled(adapter, event.source.chat_id, disabled=False)
                 return (
    ```

    ```diff
    @@ -5919,7 +5940,7 @@ async def _handle_voice_channel_leave(self, event: MessageEvent) -> str:
             except Exception as e:
                 logger.warning("Error leaving voice channel: %s", e)
             # Always clean up state even if leave raised an exception
    -        self._voice_mode[event.source.chat_id] = "off"
    +        self._voice_mode[self._voice_key(event.source.platform, event.source.chat_id)] = "off"
             self._save_voice_modes()
             self._set_adapter_auto_tts_disabled(adapter, event.source.chat_id, disabled=True)
             if hasattr(adapter, "_voice_input_callback"):
    ```

    ```diff
    @@ -5931,7 +5952,7 @@ def _handle_voice_timeout_cleanup(self, chat_id: str) -> None:
     
             Cleans up runner-side voice_mode state that the adapter cannot reach.
             """
    -        self._voice_mode[chat_id] = "off"
    +        self._voice_mode[self._voice_key(Platform.DISCORD, chat_id)] = "off"
             self._save_voice_modes()
             adapter = self.adapters.get(Platform.DISCORD)
             self._set_adapter_auto_tts_disabled(adapter, chat_id, disabled=True)
    ```

    ```diff
    @@ -6017,7 +6038,7 @@ def _should_send_voice_reply(
                 return False
     
             chat_id = event.source.chat_id
    -        voice_mode = self._voice_mode.get(chat_id, "off")
    +        voice_mode = self._voice_mode.get(self._voice_key(event.source.platform, chat_id), "off")
             is_voice_input = (event.message_type == MessageType.VOICE)
     
             should = (
    ```
  - `tests/gateway/test_voice_command.py`

    ```diff
    @@ -99,22 +99,22 @@ async def test_voice_on(self, runner):
             event = _make_event("/voice on")
             result = await runner._handle_voice_command(event)
             assert "enabled" in result.lower()
    -        assert runner._voice_mode["123"] == "voice_only"
    +        assert runner._voice_mode["telegram:123"] == "voice_only"
     
         @pytest.mark.asyncio
         async def test_voice_off(self, runner):
    -        runner._voice_mode["123"] = "voice_only"
    +        runner._voice_mode["telegram:123"] = "voice_only"
             event = _make_event("/voice off")
             result = await runner._handle_voice_command(event)
             assert "disabled" in result.lower()
    -        assert runner._voice_mode["123"] == "off"
    +        assert runner._voice_mode["telegram:123"] == "off"
     
         @pytest.mark.asyncio
         async def test_voice_tts(self, runner):
             event = _make_event("/voice tts")
             result = await runner._handle_voice_command(event)
             assert "tts" in result.lower()
    -        assert runner._voice_mode["123"] == "all"
    +        assert runner._voice_mode["telegram:123"] == "all"
     
         @pytest.mark.asyncio
         async def test_voice_status_off(self, runner):
    ```

    ```diff
    @@ -124,7 +124,7 @@ async def test_voice_status_off(self, runner):
     
         @pytest.mark.asyncio
         async def test_voice_status_on(self, runner):
    -        runner._voice_mode["123"] = "voice_only"
    +        runner._voice_mode["telegram:123"] = "voice_only"
             event = _make_event("/voice status")
             result = await runner._handle_voice_command(event)
             assert "voice reply" in result.lower()
    ```

    ```diff
    @@ -134,15 +134,15 @@ async def test_toggle_off_to_on(self, runner):
             event = _make_event("/voice")
             result = await runner._handle_voice_command(event)
             assert "enabled" in result.lower()
    -        assert runner._voice_mode["123"] == "voice_only"
    +        assert runner._voice_mode["telegram:123"] == "voice_only"
     
         @pytest.mark.asyncio
         async def test_toggle_on_to_off(self, runner):
    -        runner._voice_mode["123"] = "voice_only"
    +        runner._voice_mode["telegram:123"] = "voice_only"
             event = _make_event("/voice")
             result = await runner._handle_voice_command(event)
             assert "disabled" in result.lower()
    -        assert runner._voice_mode["123"] == "off"
    +        assert runner._voice_mode["telegram:123"] == "off"
     
         @pytest.mark.asyncio
         async def test_persistence_saved(self, runner):
    ```

    ```diff
    @@ -150,39 +150,47 @@ async def test_persistence_saved(self, runner):
             await runner._handle_voice_command(event)
             assert runner._VOICE_MODE_PATH.exists()
             data = json.loads(runner._VOICE_MODE_PATH.read_text())
    -        assert data["123"] == "voice_only"
    +        assert data["telegram:123"] == "voice_only"
     
         @pytest.mark.asyncio
         async def test_persistence_loaded(self, runner):
    -        runner._VOICE_MODE_PATH.write_text(json.dumps({"456": "all"}))
    +        runner._VOICE_MODE_PATH.write_text(json.dumps({"telegram:456": "all"}))
             loaded = runner._load_voice_modes()
    -        assert loaded == {"456": "all"}
    +        assert loaded == {"telegram:456": "all"}
     
         @pytest.mark.asyncio
         async def test_persistence_saved_for_off(self, runner):
             event = _make_event("/voice off")
             await runner._handle_voice_command(event)
             data = json.loads(runner._VOICE_MODE_PATH.read_text())
    -        assert data["123"] == "off"
    +        assert data["telegram:123"] == "off"
     
         def test_sync_voice_mode_state_to_adapter_restores_off_chats(self, runner):
    -        runner._voice_mode = {"123": "off", "456": "all"}
    -        adapter = SimpleNamespace(_auto_tts_disabled_chats=set())
    +        from gateway.config import Platform
    +        runner._voice_mode = {"telegram:123": "off", "telegram:456": "all"}
    +        adapter = SimpleNamespace(
    +            _auto_tts_disabled_chats=set(),
    +            platform=Platform.TELEGRAM,
    +        )
     
             runner._sync_voice_mode_state_to_adapter(adapter)
     
             assert adapter._auto_tts_disabled_chats == {"123"}
     
         def test_restart_restores_voice_off_state(self, runner, tmp_path):
    -        runner._VOICE_MODE_PATH.write_text(json.dumps({"123": "off"}))
    +        from gateway.config import Platform
    +        runner._VOICE_MODE_PATH.write_text(json.dumps({"telegram:123": "off"}))
     
             restored_runner = _make_runner(tmp_path)
             restored_runner._voice_mode = restored_runner._load_voice_modes()
    -        adapter = SimpleNamespace(_auto_tts_disabled_chats=set())
    +        adapter = SimpleNamespace(
    +            _auto_tts_disabled_chats=set(),
    +            platform=Platform.TELEGRAM,
    +        )
     
             restored_runner._sync_voice_mode_state_to_adapter(adapter)
     
    -        assert restored_runner._voice_mode["123"] == "off"
    +        assert restored_runner._voice_mode["telegram:123"] == "off"
             assert adapter._auto_tts_disabled_chats == {"123"}
     
         @pytest.mark.asyncio
    ```

    ```diff
    @@ -191,8 +199,21 @@ async def test_per_chat_isolation(self, runner):
             e2 = _make_event("/voice tts", chat_id="bbb")
             await runner._handle_voice_command(e1)
             await runner._handle_voice_command(e2)
    -        assert runner._voice_mode["aaa"] == "voice_only"
    -        assert runner._voice_mode["bbb"] == "all"
    +        assert runner._voice_mode["telegram:aaa"] == "voice_only"
    +        assert runner._voice_mode["telegram:bbb"] == "all"
    +
    +    @pytest.mark.asyncio
    +    async def test_platform_isolation(self, runner):
    +        """Same chat_id on different platforms must not collide (#12542)."""
    +        telegram_event = _make_event("/voice on", chat_id="999")
    +        slack_event = _make_event("/voice off", chat_id="999")
    +        slack_event.source.platform.value = "slack"
    +
    +        await runner._handle_voice_command(telegram_event)
    +        await runner._handle_voice_command(slack_event)
    +
    +        assert runner._voice_mode["telegram:999"] == "voice_only"
    +        assert runner._voice_mode["slack:999"] == "off"
     
     
     # =====================================================================
    ```

    ```diff
    @@ -223,9 +244,9 @@ def _call(self, runner, voice_mode, message_type, agent_messages=None,
             """Call real _should_send_voice_reply on a GatewayRunner instance."""
             chat_id = "123"
             if voice_mode != "off":
    -            runner._voice_mode[chat_id] = voice_mode
    +            runner._voice_mode["telegram:" + chat_id] = voice_mode
             else:
    -            runner._voice_mode.pop(chat_id, None)
    +            runner._voice_mode.pop("telegram:" + chat_id, None)
     
             event = _make_event(message_type=message_type)
     
    ```

    ```diff
    @@ -713,7 +734,7 @@ async def test_join_success(self, runner):
             result = await runner._handle_voice_channel_join(event)
             assert "joined" in result.lower()
             assert "General" in result
    -        assert runner._voice_mode["123"] == "all"
    +        assert runner._voice_mode["discord:123"] == "all"
             assert mock_adapter._voice_sources[111]["chat_id"] == "123"
             assert mock_adapter._voice_sources[111]["chat_type"] == "group"
     
    ```

    ```diff
    @@ -791,10 +812,10 @@ async def test_leave_success(self, runner):
             mock_adapter.leave_voice_channel = AsyncMock()
             event = self._make_discord_event("/voice leave")
             runner.adapters[event.source.platform] = mock_adapter
    -        runner._voice_mode["123"] = "all"
    +        runner._voice_mode["discord:123"] = "all"
             result = await runner._handle_voice_channel_leave(event)
             assert "left" in result.lower()
    -        assert runner._voice_mode["123"] == "off"
    +        assert runner._voice_mode["discord:123"] == "off"
             mock_adapter.leave_voice_channel.assert_called_once_with(111)
     
         # -- _handle_voice_channel_input --
    ```

    ```diff
    @@ -1298,11 +1319,11 @@ async def test_leave_exception_still_cleans_state(self, runner):
             event = _make_event("/voice leave")
             event.raw_message = SimpleNamespace(guild_id=111, guild=None)
             runner.adapters[event.source.platform] = mock_adapter
    -        runner._voice_mode["123"] = "all"
    +        runner._voice_mode["telegram:123"] = "all"
     
             result = await runner._handle_voice_channel_leave(event)
             assert "left" in result.lower()
    -        assert runner._voice_mode["123"] == "off"
    +        assert runner._voice_mode["telegram:123"] == "off"
             assert mock_adapter._voice_input_callback is None
     
         @pytest.mark.asyncio
    ```

    ```diff
    @@ -1316,7 +1337,7 @@ async def test_leave_clears_callback(self, runner):
             event = _make_event("/voice leave")
             event.raw_message = SimpleNamespace(guild_id=111, guild=None)
             runner.adapters[event.source.platform] = mock_adapter
    -        runner._voice_mode["123"] = "all"
    +        runner._voice_mode["telegram:123"] = "all"
     
             await runner._handle_voice_channel_leave(event)
             assert mock_adapter._voice_input_callback is None
    ```

    ```diff
    @@ -1763,11 +1784,11 @@ async def test_timeout_calls_disconnect_callback(self, adapter):
         async def test_runner_cleanup_method_removes_voice_mode(self, tmp_path):
             """_handle_voice_timeout_cleanup removes voice_mode for chat."""
             runner = _make_runner(tmp_path)
    -        runner._voice_mode["999"] = "all"
    +        runner._voice_mode["discord:999"] = "all"
     
             runner._handle_voice_timeout_cleanup("999")
     
    -        assert runner._voice_mode["999"] == "off", \
    +        assert runner._voice_mode["discord:999"] == "off", \
                 "voice_mode must persist explicit off state after timeout cleanup"
     
         @pytest.mark.asyncio
    ```

    ```diff
    @@ -2524,7 +2545,7 @@ def _call_should_reply(self, runner, voice_mode, msg_type, response="Hello",
                                agent_msgs=None, already_sent=False):
             from gateway.platforms.base import MessageType, MessageEvent, SessionSource
             from gateway.config import Platform
    -        runner._voice_mode["ch1"] = voice_mode
    +        runner._voice_mode["discord:ch1"] = voice_mode
             source = SessionSource(
                 platform=Platform.DISCORD, chat_id="ch1",
                 user_id="1", user_name="test", chat_type="channel",
    ```
  - `tests/gateway/test_voice_mode_platform_isolation.py`

    ```diff
    @@ -0,0 +1,218 @@
    +"""Tests for voice mode platform isolation (bug #12542).
    +
    +Voice mode state stored as {chat_id: mode} without a platform namespace
    +caused collisions: Telegram chat '123' and Slack chat '123' shared the
    +same key. The fix prefixes keys with platform value: 'telegram:123' vs
    +'slack:123'.
    +"""
    +
    +import json
    +import tempfile
    +from pathlib import Path
    +from unittest.mock import MagicMock, patch
    +
    +import pytest
    +
    +from gateway.config import Platform
    +from gateway.run import GatewayRunner
    +
    +
    +class TestVoiceKeyHelper:
    +    """Test the _voice_key helper method."""
    +
    +    def test_voice_key_format(self):
    +        """_voice_key returns 'platform:chat_id' format."""
    +        runner = _make_runner()
    +        assert runner._voice_key(Platform.TELEGRAM, "123") == "telegram:123"
    +        assert runner._voice_key(Platform.SLACK, "456") == "slack:456"
    +        assert runner._voice_key(Platform.DISCORD, "789") == "discord:789"
    +
    +    def test_voice_key_different_platforms_same_chat_id(self):
    +        """Same chat_id on different platforms yields different keys."""
    +        runner = _make_runner()
    +        key_telegram = runner._voice_key(Platform.TELEGRAM, "123")
    +        key_slack = runner._voice_key(Platform.SLACK, "123")
    +        key_discord = runner._voice_key(Platform.DISCORD, "123")
    +        assert key_telegram != key_slack
    +        assert key_slack != key_discord
    +        assert key_telegram == "telegram:123"
    +        assert key_slack == "slack:123"
    +        assert key_discord == "discord:123"
    +
    +
    +class TestVoiceModePlatformIsolation:
    +    """Test that voice mode state is isolated by platform."""
    +
    +    def test_telegram_and_slack_voice_mode_independent(self):
    +        """Setting voice mode for Telegram chat '123' does not affect Slack chat '123'."""
    +        runner = _make_runner()
    +
    +        # Enable voice mode for Telegram chat '123'
    +        runner._voice_mode[runner._voice_key(Platform.TELEGRAM, "123")] = "all"
    +        # Enable voice mode for Slack chat '123' to a different mode
    +        runner._voice_mode[runner._voice_key(Platform.SLACK, "123")] = "voice_only"
    +
    +        # Verify they are independent
    +        assert runner._voice_mode.get(runner._voice_key(Platform.TELEGRAM, "123")) == "all"
    +        assert runner._voice_mode.get(runner._voice_key(Platform.SLACK, "123")) == "voice_only"
    +
    +        # Disabling Telegram should not affect Slack
    +        runner._voice_mode[runner._voice_key(Platform.TELEGRAM, "123")] = "off"
    +        assert runner._voice_mode.get(runner._voice_key(Platform.TELEGRAM, "123")) == "off"
    +        assert runner._voice_mode.get(runner._voice_key(Platform.SLACK, "123")) == "voice_only"
    +
    +
    +class TestLegacyKeyMigration:
    +    """Test migration of legacy unprefixed keys in _load_voice_modes."""
    +
    +    def test_load_voice_modes_skips_legacy_keys(self):
    +        """_load_voice_modes skips keys without ':' prefix and logs a warning."""
    +        runner = _make_runner()
    +
    +        # Simulate legacy persisted data with unprefixed keys
    +        legacy_data = {
    +            "123": "all",
    +            "456": "voice_only",
    +            # Also includes a properly prefixed key (from after the fix)
    +            "telegram:789": "off",
    +        }
    +
    +        with tempfile.TemporaryDirectory() as tmpdir:
    +            voice_path = Path(tmpdir) / "gateway_voice_mode.json"
    +            voice_path.write_text(json.dumps(legacy_data))
    +
    +            with patch.object(runner, "_VOICE_MODE_PATH", voice_path):
    +                with patch("gateway.run.logger") as mock_logger:
    +                    result = runner._load_voice_modes()
    +
    +            # Legacy keys without ':' should be skipped
    +            assert "123" not in result
    +            assert "456" not in result
    +            # Prefixed key should be preserved
    +            assert result.get("telegram:789") == "off"
    +            # Warning should be logged for each legacy key
    +            assert mock_logger.warning.called
    +            warning_calls = [str(call) for call in mock_logger.warning.call_args_list]
    +            assert any("Skipping legacy unprefixed voice mode key" in str(c) for c in warning_calls)
    +
    +    def test_load_voice_modes_preserves_prefixed_keys(self):
    +        """_load_voice_modes correctly loads platform-prefixed keys."""
    +        runner = _make_runner()
    +
    +        persisted_data = {
    +            "telegram:123": "all",
    +            "slack:456": "voice_only",
    +            "discord:789": "off",
    +        }
    +
    +        with tempfile.TemporaryDirectory() as tmpdir:
    +            voice_path = Path(tmpdir) / "gateway_voice_mode.json"
    +            voice_path.write_text(json.dumps(persisted_data))
    +
    +            with patch.object(runner, "_VOICE_MODE_PATH", voice_path):
    +                result = runner._load_voice_modes()
    +
    +        assert result.get("telegram:123") == "all"
    +        assert result.get("slack:456") == "voice_only"
    +        assert result.get("discord:789") == "off"
    +
    +    def test_load_voice_modes_invalid_modes_filtered(self):
    +        """_load_voice_modes filters out invalid mode values."""
    +        runner = _make_runner()
    +
    +        data = {
    +            "telegram:123": "all",
    +            "telegram:456": "invalid_mode",
    +            "telegram:789": "voice_only",
    +        }
    +
    +        with tempfile.TemporaryDirectory() as tmpdir:
    +            voice_path = Path(tmpdir) / "gateway_voice_mode.json"
    +            voice_path.write_text(json.dumps(data))
    +
    +            with patch.object(runner, "_VOICE_MODE_PATH", voice_path):
    +                result = runner._load_voice_modes()
    +
    +        assert result.get("telegram:123") == "all"
    +        assert "telegram:456" not in result
    +        assert result.get("telegram:789") == "voice_only"
    +
    +
    +class TestSyncVoiceModeStateToAdapter:
    +    """Test _sync_voice_mode_state_to_adapter filters by platform."""
    +
    +    def test_sync_only_includes_platform_chats(self):
    +        """Only chats matching the adapter's platform are synced."""
    +        runner = _make_runner()
    +
    +        # Set up voice mode state with multiple platforms
    +        runner._voice_mode = {
    +            "telegram:123": "off",      # Should sync
    +            "telegram:456": "all",       # Should NOT sync (mode is not "off")
    +            "slack:123": "off",          # Should NOT sync (different platform)
    +            "discord:789": "off",        # Should NOT sync (different platform)
    +        }
    +
    +        # Create a mock Telegram adapter
    +        mock_adapter = MagicMock()
    +        mock_adapter.platform = Platform.TELEGRAM
    +        mock_adapter._auto_tts_disabled_chats = set()
    +
    +        runner._sync_voice_mode_state_to_adapter(mock_adapter)
    +
    +        # Only telegram:123 should be in disabled_chats (mode="off" for telegram)
    +        assert mock_adapter._auto_tts_disabled_chats == {"123"}
    +
    +    def test_sync_clears_existing_state(self):
    +        """_sync_voice_mode_state_to_adapter clears existing disabled_chats first."""
    +        runner = _make_runner()
    +
    +        runner._voice_mode = {
    +            "telegram:123": "off",
    +        }
    +
    +        mock_adapter = MagicMock()
    +        mock_adapter.platform = Platform.TELEGRAM
    +        mock_adapter._auto_tts_disabled_chats = {"old_chat_id", "another_old"}
    +
    +        runner._sync_voice_mode_state_to_adapter(mock_adapter)
    +
    +        # Old entries should be cleared
    +        assert mock_adapter._auto_tts_disabled_chats == {"123"}
    +
    +    def test_sync_returns_early_without_platform(self):
    +        """_sync_voice_mode_state_to_adapter returns early if adapter has no platform."""
    +        runner = _make_runner()
    +        runner._voice_mode = {"telegram:123": "off"}
    +
    +        mock_adapter = MagicMock()
    +        mock_adapter.platform = None
    +        mock_adapter._auto_tts_disabled_chats = {"old"}
    +
    +        runner._sync_voice_mode_state_to_adapter(mock_adapter)
    +
    +        # disabled_chats should not be modified
    +        assert mock_adapter._auto_tts_disabled_chats == {"old"}
    +
    +    def test_sync_returns_early_without_auto_tts_disabled_chats(self):
    +        """_sync_voice_mode_state_to_adapter returns early if adapter lacks _auto_tts_disabled_chats."""
    +        runner = _make_runner()
    +        runner._voice_mode = {"telegram:123": "off"}
    +
    +        mock_adapter = MagicMock(spec=[])  # No _auto_tts_disabled_chats attribute
    +
    +        # Should not raise
    +        runner._sync_voice_mode_state_to_adapter(mock_adapter)
    +
    +
    +# ---------------------------------------------------------------------------
    +# Helper
    +# ---------------------------------------------------------------------------
    +
    +def _make_runner() -> GatewayRunner:
    +    """Create a minimal GatewayRunner for testing."""
    +    with patch("gateway.run.GatewayRunner._load_voice_modes", return_value={}):
    +        runner = GatewayRunner.__new__(GatewayRunner)
    +        runner._voice_mode = {}
    +        runner.adapters = {}
    +    return runner
    ```
- **#12824: fix(gateway): namespace voice mode state by platform to prevent cross-platform collision**. closed without merge
  - maintainer direction: category `merged_via`; implication `superseded`; refs `#12844`

## Fix status

- Merged to main in PR #12844 (cited above). No release data is tracked. Verify downstream.
- Fix last verified (merged) `2026-04-20`. This confirmation is old and may be out of date (a revert/reopen would not have invalidated it here); re-verify.
