NousResearch/hermes-agent

Voice state namespace collision fix across platforms

Cluster 12542. Data as of

Nothing open on GitHub. Fix merged in #12844 on 20 Apr 2026. Check it covers your case.

The short answer

0open issues
0open PRs
3items grouped
Known?
Yes. 3 related items grouped here, 1 issue already closed.
Fix event
A fix was merged to main in PR #12844. Check it covers your case. No release data is tracked.
Tracker
The maintainers' tracker marks this resolved, deciding PR #12844. That is separate from the fix event.
Still open
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.

Open issues0

No open issues.

Open PRs0

No open pull requests.

Fixes on record

Merged to main. A stamp counts the issues a fix is linked to.

  • #12844 fix(gateway): namespace voice mode state by platform (#12542)

    merged to main

    files: gateway/run.py, tests/gateway/test_voice_command.py, tests/gateway/test_voice_mode_platform_isolation.py

    Show the diff
    • gateway/run.py
      @@ -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())
      @@ -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:
      @@ -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:
      @@ -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)
      @@ -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)
      @@ -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)",
      @@ -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)
      @@ -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 (
      @@ -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"):
      @@ -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)
      @@ -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
      @@ -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):
      @@ -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()
      @@ -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):
      @@ -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
      @@ -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"
       
       
       # =====================================================================
      @@ -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)
       
      @@ -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"
       
      @@ -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 --
      @@ -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
      @@ -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
      @@ -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
      @@ -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
      @@ -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

1 PR closed without merging

From the close comments: merged via another change 1

  • #12824 fix(gateway): namespace voice mode state by platform to prevent cross-platform collision · merged via another change → #12844
Closed issues · 1
  • Closed#12542gateway: /voice state collides across platforms when chat IDs match
How this cluster was grouped
  • Link evidence (structural links throughout): every member is connected by structural evidence: shared changed files, or a closing/superseded reference.
  • Pairwise link density 0.67: the share of item 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.

Pick up a Lab task

Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.

Open the Lab

At a glance

Merged PRs
#12844
Closed issues
1
Attempts
1 PR closed without merging
Fix link checked
20 Apr 2026. This confirmation is old and may be out of date (a revert or reopen would not have invalidated it here); re-verify.

For your agent

The same facts, typed.