{"repo":"NousResearch/hermes-agent","public_id":"16007","display_label":"Gateway auto-TTS ignores voice.auto_tts config setting","summary":"A bug report and related fix attempts addressing the gateway's auto-TTS feature not respecting the voice.auto_tts configuration setting for inbound voice messages.","subsystem":null,"counts":{"members":4,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":1,"attempt_prs":2},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":16039,"merged_at":"2026-04-26T12:52:05+00:00"},"resolution":{"state":"resolved","by":"pr-16039"},"freshness":{"state":"stale","last_verified_at":"2026-04-26T12:52:05+00:00","is_stale":true},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.5},"fix_edges":[{"issue_number":16007,"pr_number":16039,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-04-26T12:52:05+00:00"}],"members":[{"kind":"issue","number":16007,"title":"[Bug]: Gateway auto-TTS ignores voice.auto_tts config setting","url":null,"state":"closed"},{"kind":"pr","number":15591,"title":"fix(gateway/base): respect voice.auto_tts config for inbound voice messages","url":null,"state":"closed","close_reason":{"category":"silent_close","implication":"active"},"merged":false},{"kind":"pr","number":16014,"title":"fix(gateway): honor voice.auto_tts config setting in adapter auto-TTS gate (#16007)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#16039","number":16039}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":16039,"title":"fix(gateway): honor voice.auto_tts config in auto-TTS gate (#16007)","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["gateway/platforms/base.py","gateway/run.py","tests/gateway/test_voice_command.py"],"fix_hunks":[{"file":"gateway/platforms/base.py","hunks":["@@ -1025,7 +1025,20 @@ def __init__(self, config: PlatformConfig, platform: Platform):\n         self._post_delivery_callbacks: Dict[str, Any] = {}\n         self._expected_cancelled_tasks: set[asyncio.Task] = set()\n         self._busy_session_handler: Optional[Callable[[MessageEvent, str], Awaitable[bool]]] = None\n-        # Chats where auto-TTS on voice input is disabled (set by /voice off)\n+        # Auto-TTS on voice input: ``_auto_tts_default`` is the global default\n+        # (``voice.auto_tts`` in config.yaml, pushed by GatewayRunner on connect).\n+        # Per-chat overrides live in two sets populated from ``_voice_mode``:\n+        #   - ``_auto_tts_enabled_chats``: chat explicitly opted in via ``/voice on``\n+        #     or ``/voice tts`` (mode is ``voice_only`` or ``all``). Fires even when\n+        #     the global default is False.\n+        #   - ``_auto_tts_disabled_chats``: chat explicitly opted out via\n+        #     ``/voice off`` (mode is ``off``). Suppresses auto-TTS even when the\n+        #     global default is True.\n+        # The gate in _process_message() is:\n+        #   fire if chat in _auto_tts_enabled_chats\n+        #     OR (_auto_tts_default and chat not in _auto_tts_disabled_chats)\n+        self._auto_tts_default: bool = False\n+        self._auto_tts_enabled_chats: set = set()\n         self._auto_tts_disabled_chats: set = set()\n         # Chats where typing indicator is paused (e.g. during approval waits).\n         # _keep_typing skips send_typing when the chat_id is in this set.","@@ -1047,6 +1060,21 @@ def fatal_error_code(self) -> Optional[str]:\n     def fatal_error_retryable(self) -> bool:\n         return self._fatal_error_retryable\n \n+    def _should_auto_tts_for_chat(self, chat_id: str) -> bool:\n+        \"\"\"Whether auto-TTS on voice input should fire for ``chat_id``.\n+\n+        Decision layers (Issue #16007):\n+          1. Explicit ``/voice on`` or ``/voice tts`` → always fire (even if\n+             ``voice.auto_tts`` is False).\n+          2. Explicit ``/voice off`` → never fire.\n+          3. Fall back to the global ``voice.auto_tts`` config default.\n+        \"\"\"\n+        if chat_id in self._auto_tts_enabled_chats:\n+            return True\n+        if chat_id in self._auto_tts_disabled_chats:\n+            return False\n+        return bool(self._auto_tts_default)\n+\n     def set_fatal_error_handler(self, handler: Callable[[\"BasePlatformAdapter\"], Awaitable[None] | None]) -> None:\n         self._fatal_error_handler = handler\n ","@@ -2214,12 +2242,14 @@ def _record_delivery(result):\n                     logger.info(\"[%s] extract_local_files found %d file(s) in response\", self.name, len(local_files))\n                 \n                 # Auto-TTS: if voice message, generate audio FIRST (before sending text)\n-                # Skipped when the chat has voice mode disabled (/voice off)\n+                # Gated via ``_should_auto_tts_for_chat``: fires when the chat has\n+                # an explicit ``/voice on|tts`` opt-in OR when ``voice.auto_tts`` is\n+                # True globally and no ``/voice off`` has been issued.\n                 _tts_path = None\n-                if (event.message_type == MessageType.VOICE\n+                if (self._should_auto_tts_for_chat(event.source.chat_id)\n+                        and event.message_type == MessageType.VOICE\n                         and text_content\n-                        and not media_files\n-                        and event.source.chat_id not in self._auto_tts_disabled_chats):\n+                        and not media_files):\n                     try:\n                         from tools.tts_tool import text_to_speech_tool, check_tts_requirements\n                         if check_tts_requirements():"]},{"file":"gateway/run.py","hunks":["@@ -881,23 +881,74 @@ def _set_adapter_auto_tts_disabled(self, adapter, chat_id: str, disabled: bool)\n             return\n         if disabled:\n             disabled_chats.add(chat_id)\n+            # ``/voice off`` also clears any explicit enable — it's a hard override.\n+            enabled_chats = getattr(adapter, \"_auto_tts_enabled_chats\", None)\n+            if isinstance(enabled_chats, set):\n+                enabled_chats.discard(chat_id)\n         else:\n             disabled_chats.discard(chat_id)\n \n-    def _sync_voice_mode_state_to_adapter(self, adapter) -> None:\n-        \"\"\"Restore persisted /voice off state into a live platform adapter.\"\"\"\n-        disabled_chats = getattr(adapter, \"_auto_tts_disabled_chats\", None)\n-        if not isinstance(disabled_chats, set):\n+    def _set_adapter_auto_tts_enabled(self, adapter, chat_id: str, enabled: bool) -> None:\n+        \"\"\"Update an adapter's per-chat auto-TTS opt-in set if present.\n+\n+        Used for ``/voice on``/``/voice tts`` where the user explicitly wants\n+        auto-TTS even when ``voice.auto_tts`` is False globally.\n+        \"\"\"\n+        enabled_chats = getattr(adapter, \"_auto_tts_enabled_chats\", None)\n+        if not isinstance(enabled_chats, set):\n             return\n+        if enabled:\n+            enabled_chats.add(chat_id)\n+            # An explicit opt-in clears any stale /voice off for this chat.\n+            disabled_chats = getattr(adapter, \"_auto_tts_disabled_chats\", None)\n+            if isinstance(disabled_chats, set):\n+                disabled_chats.discard(chat_id)\n+        else:\n+            enabled_chats.discard(chat_id)\n+\n+    def _sync_voice_mode_state_to_adapter(self, adapter) -> None:\n+        \"\"\"Restore persisted /voice state into a live platform adapter.\n+\n+        Populates three fields from config + ``self._voice_mode``:\n+          - ``_auto_tts_default``: global default from ``voice.auto_tts``\n+          - ``_auto_tts_enabled_chats``: chats with mode ``voice_only``/``all``\n+          - ``_auto_tts_disabled_chats``: chats with mode ``off``\n+        \"\"\"\n         platform = getattr(adapter, \"platform\", None)\n         if not isinstance(platform, Platform):\n             return\n-        disabled_chats.clear()\n+\n+        disabled_chats = getattr(adapter, \"_auto_tts_disabled_chats\", None)\n+        enabled_chats = getattr(adapter, \"_auto_tts_enabled_chats\", None)\n+        if not isinstance(disabled_chats, set) and not isinstance(enabled_chats, set):\n+            return\n+\n+        # Push the global voice.auto_tts default (config.yaml) onto the adapter.\n+        # Lazy import to avoid adding a module-level dep from gateway → hermes_cli.\n+        try:\n+            from hermes_cli.config import load_config as _load_full_config\n+            _full_cfg = _load_full_config()\n+            _auto_tts_default = bool(\n+                (_full_cfg.get(\"voice\") or {}).get(\"auto_tts\", False)\n+            )\n+        except Exception:\n+            _auto_tts_default = False\n+        if hasattr(adapter, \"_auto_tts_default\"):\n+            adapter._auto_tts_default = _auto_tts_default\n+\n         prefix = f\"{platform.value}:\"\n-        disabled_chats.update(\n-            key[len(prefix):] for key, mode in self._voice_mode.items()\n-            if mode == \"off\" and key.startswith(prefix)\n-        )\n+        if isinstance(disabled_chats, set):\n+            disabled_chats.clear()\n+            disabled_chats.update(\n+                key[len(prefix):] for key, mode in self._voice_mode.items()\n+                if mode == \"off\" and key.startswith(prefix)\n+            )\n+        if isinstance(enabled_chats, set):\n+            enabled_chats.clear()\n+            enabled_chats.update(\n+                key[len(prefix):] for key, mode in self._voice_mode.items()\n+                if mode in (\"voice_only\", \"all\") and key.startswith(prefix)\n+            )\n \n     async def _safe_adapter_disconnect(self, adapter, platform) -> None:\n         \"\"\"Call adapter.disconnect() defensively, swallowing any error.","@@ -5977,7 +6028,7 @@ async def _handle_voice_command(self, event: MessageEvent) -> str:\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+                self._set_adapter_auto_tts_enabled(adapter, chat_id, enabled=True)\n             return (\n                 \"Voice mode enabled.\\n\"\n                 \"I'll reply with voice when you send voice messages.\\n\"","@@ -5993,7 +6044,7 @@ async def _handle_voice_command(self, event: MessageEvent) -> str:\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)\n+                self._set_adapter_auto_tts_enabled(adapter, chat_id, enabled=True)\n             return (\n                 \"Auto-TTS enabled.\\n\"\n                 \"All replies will include a voice message.\"","@@ -6032,7 +6083,7 @@ async def _handle_voice_command(self, event: MessageEvent) -> str:\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+                    self._set_adapter_auto_tts_enabled(adapter, chat_id, enabled=True)\n                 return \"Voice mode enabled.\"\n             else:\n                 self._voice_mode[voice_key] = \"off\"","@@ -6083,7 +6134,7 @@ async def _handle_voice_channel_join(self, event: MessageEvent) -> str:\n                 adapter._voice_sources[guild_id] = event.source.to_dict()\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+            self._set_adapter_auto_tts_enabled(adapter, event.source.chat_id, enabled=True)\n             return (\n                 f\"Joined voice channel **{voice_channel.name}**.\\n\"\n                 f\"I'll speak my replies and listen to you. Use /voice leave to disconnect.\""]},{"file":"tests/gateway/test_voice_command.py","hunks":["@@ -177,6 +177,53 @@ def test_sync_voice_mode_state_to_adapter_restores_off_chats(self, runner):\n \n         assert adapter._auto_tts_disabled_chats == {\"123\"}\n \n+    def test_sync_populates_enabled_chats_from_voice_modes(self, runner):\n+        \"\"\"Issue #16007: sync also restores per-chat /voice on|tts opt-ins.\n+\n+        The adapter's ``_auto_tts_enabled_chats`` must mirror chats whose\n+        persisted voice_mode is ``voice_only`` or ``all`` — without this,\n+        ``/voice on`` was relying on a \"not in disabled set\" default that\n+        silently enabled auto-TTS for every chat.\n+        \"\"\"\n+        from gateway.config import Platform\n+        runner._voice_mode = {\n+            \"telegram:off_chat\": \"off\",\n+            \"telegram:on_chat\": \"voice_only\",\n+            \"telegram:tts_chat\": \"all\",\n+            \"slack:999\": \"voice_only\",  # wrong platform, must be ignored\n+        }\n+        adapter = SimpleNamespace(\n+            _auto_tts_default=False,\n+            _auto_tts_disabled_chats=set(),\n+            _auto_tts_enabled_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 == {\"off_chat\"}\n+        assert adapter._auto_tts_enabled_chats == {\"on_chat\", \"tts_chat\"}\n+\n+    def test_sync_pushes_config_default_onto_adapter(self, runner, monkeypatch):\n+        \"\"\"Issue #16007: ``voice.auto_tts`` must propagate to ``_auto_tts_default``.\"\"\"\n+        from gateway.config import Platform\n+\n+        fake_cfg = {\"voice\": {\"auto_tts\": True}}\n+        monkeypatch.setattr(\n+            \"hermes_cli.config.load_config\",\n+            lambda: fake_cfg,\n+        )\n+        adapter = SimpleNamespace(\n+            _auto_tts_default=False,\n+            _auto_tts_disabled_chats=set(),\n+            _auto_tts_enabled_chats=set(),\n+            platform=Platform.TELEGRAM,\n+        )\n+\n+        runner._sync_voice_mode_state_to_adapter(adapter)\n+\n+        assert adapter._auto_tts_default is True\n+\n     def test_restart_restores_voice_off_state(self, runner, tmp_path):\n         from gateway.config import Platform\n         runner._VOICE_MODE_PATH.write_text(json.dumps({\"telegram:123\": \"off\"}))","@@ -2706,3 +2753,56 @@ async def test_keepalive_sends_silence_frame(self):\n             mock_conn.send_packet.assert_called_with(b'\\xf8\\xff\\xfe')\n         finally:\n             DiscordAdapter._KEEPALIVE_INTERVAL = original_interval\n+\n+\n+# =====================================================================\n+# BasePlatformAdapter._should_auto_tts_for_chat — gate for auto-TTS\n+# on voice input. Regression test for Issue #16007.\n+# =====================================================================\n+\n+class TestShouldAutoTtsForChat:\n+    \"\"\"Three-layer gate: per-chat enable > per-chat disable > config default.\"\"\"\n+\n+    def _make_adapter(self, *, default: bool, enabled=(), disabled=()):\n+        \"\"\"Build a bare adapter with only the attrs the gate reads.\"\"\"\n+        adapter = SimpleNamespace(\n+            _auto_tts_default=default,\n+            _auto_tts_enabled_chats=set(enabled),\n+            _auto_tts_disabled_chats=set(disabled),\n+        )\n+        # Bind the unbound method — _should_auto_tts_for_chat only reads the\n+        # three attrs above via ``self.``, so an unbound call works.\n+        from gateway.platforms.base import BasePlatformAdapter\n+        return BasePlatformAdapter._should_auto_tts_for_chat, adapter\n+\n+    def test_default_false_no_override_suppresses(self):\n+        \"\"\"Issue #16007: voice.auto_tts=False and no per-chat state → no TTS.\"\"\"\n+        fn, adapter = self._make_adapter(default=False)\n+        assert fn(adapter, \"chat1\") is False\n+\n+    def test_default_true_no_override_fires(self):\n+        fn, adapter = self._make_adapter(default=True)\n+        assert fn(adapter, \"chat1\") is True\n+\n+    def test_explicit_enable_overrides_false_default(self):\n+        \"\"\"``/voice on`` with config auto_tts=False still fires.\"\"\"\n+        fn, adapter = self._make_adapter(default=False, enabled={\"chat1\"})\n+        assert fn(adapter, \"chat1\") is True\n+\n+    def test_explicit_disable_overrides_true_default(self):\n+        \"\"\"``/voice off`` with config auto_tts=True still suppresses.\"\"\"\n+        fn, adapter = self._make_adapter(default=True, disabled={\"chat1\"})\n+        assert fn(adapter, \"chat1\") is False\n+\n+    def test_enabled_wins_over_disabled(self):\n+        \"\"\"An explicit enable beats an explicit disable (enable takes priority).\"\"\"\n+        fn, adapter = self._make_adapter(\n+            default=False, enabled={\"chat1\"}, disabled={\"chat1\"}\n+        )\n+        assert fn(adapter, \"chat1\") is True\n+\n+    def test_per_chat_isolation(self):\n+        \"\"\"Enable for chat1 doesn't leak to chat2.\"\"\"\n+        fn, adapter = self._make_adapter(default=False, enabled={\"chat1\"})\n+        assert fn(adapter, \"chat1\") is True\n+        assert fn(adapter, \"chat2\") is False\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}