"""Tests for TTS plugin dispatch in tools/tts_tool.py (issue #30398). Covers the three core invariants of the plugin dispatcher: 1. Built-in provider names short-circuit — plugins NEVER win over a built-in. Even if a plugin somehow ended up in the registry with a built-in name (which the registry already blocks), the dispatcher re-checks defensively. 2. Command-type providers declared under ``tts.providers.: type: command`` (PR #17843) win over a plugin with the same name. Config is more local than plugin install. 3. Plugin dispatch fires only when the configured provider is neither a built-in nor a command-type entry, AND a plugin is registered under that name. Unknown names fall through. Also exercises: - Plugin exceptions surface to the outer error envelope (don't crash) - Plugin returning a different path is honored - voice_compatible: True triggers ffmpeg opus conversion path - voice_compatible: False keeps the file as-is The dispatcher is exercised in isolation — we don't actually call ``text_to_speech_tool`` because that would require real audio file writes. Each test directly calls ``tools.tts_tool._dispatch_to_plugin_provider`` / the predicate helpers. """ from __future__ import annotations from typing import Optional import pytest from agent import tts_registry from agent.tts_provider import TTSProvider from tools import tts_tool class _FakeTTSProvider(TTSProvider): def __init__( self, name: str, voice_compat: bool = False, raise_exc: Optional[BaseException] = None, return_path: Optional[str] = None, ): self._name = name self._voice_compat = voice_compat self._raise_exc = raise_exc self._return_path = return_path # Recorded for assertions self.last_call: Optional[dict] = None @property def name(self) -> str: return self._name @property def voice_compatible(self) -> bool: return self._voice_compat def synthesize(self, text, output_path, **kw): self.last_call = { "text": text, "output_path": output_path, "kwargs": dict(kw), } if self._raise_exc is not None: raise self._raise_exc return self._return_path if self._return_path is not None else output_path @pytest.fixture(autouse=True) def _reset_registry(): tts_registry._reset_for_tests() yield tts_registry._reset_for_tests() # --------------------------------------------------------------------------- # Resolution invariants # --------------------------------------------------------------------------- class TestBuiltinAlwaysWins: """Built-in TTS provider names short-circuit the dispatcher. Even with a plugin registered (which the registry would reject — but the dispatcher is defensive), built-in names return None so the caller's elif chain handles them natively. """ @pytest.mark.parametrize( "builtin", ["edge", "openai", "elevenlabs", "minimax", "gemini", "mistral", "xai", "piper", "kittentts", "neutts"], ) def test_dispatcher_short_circuits_builtin(self, builtin): result = tts_tool._dispatch_to_plugin_provider( text="hello", output_path="/tmp/out.mp3", provider=builtin, tts_config={}, ) assert result is None, ( f"Built-in {builtin!r} must short-circuit plugin dispatch. " "If this test fails, the dispatcher would silently let a " "plugin with a built-in name shadow the native handler — " "violating the precedence rule from PR #17843." ) def test_dispatcher_short_circuits_builtin_case_insensitive(self): for variant in ("EDGE", "Edge", " edge ", "eDgE"): assert ( tts_tool._dispatch_to_plugin_provider( text="hello", output_path="/tmp/x.mp3", provider=variant, tts_config={}, ) is None ) class TestCommandProviderWins: """A same-name ``tts.providers.: type: command`` config beats a plugin. Locality: a user's command-provider config is more specific than whichever plugin happens to be installed. """ def test_command_config_beats_plugin(self): tts_registry.register_provider(_FakeTTSProvider(name="my-tts")) result = tts_tool._dispatch_to_plugin_provider( text="hello", output_path="/tmp/out.mp3", provider="my-tts", tts_config={ "providers": { "my-tts": { "type": "command", "command": "echo 'hi' > {output_path}", }, }, }, ) # Plugin path returns None → caller falls back to command # provider dispatch (handled by the outer text_to_speech_tool # via _resolve_command_provider_config). assert result is None class TestPluginDispatch: """Happy path: configured name matches a registered plugin, dispatcher fires.""" def test_registered_plugin_called(self): provider = _FakeTTSProvider(name="cartesia") tts_registry.register_provider(provider) result = tts_tool._dispatch_to_plugin_provider( text="hello world", output_path="/tmp/out.mp3", provider="cartesia", tts_config={}, ) assert result == "/tmp/out.mp3" assert provider.last_call is not None assert provider.last_call["text"] == "hello world" assert provider.last_call["output_path"] == "/tmp/out.mp3" def test_unregistered_name_returns_none(self): result = tts_tool._dispatch_to_plugin_provider( text="hello", output_path="/tmp/out.mp3", provider="unknown-tts", tts_config={}, ) assert result is None def test_voice_model_speed_format_forwarded(self): provider = _FakeTTSProvider(name="cartesia") tts_registry.register_provider(provider) result = tts_tool._dispatch_to_plugin_provider( text="hello", output_path="/tmp/out.opus", provider="cartesia", tts_config={ "voice": "voice-aria", "model": "sonic-2", "speed": 1.2, "output_format": "opus", }, ) assert result == "/tmp/out.opus" kwargs = provider.last_call["kwargs"] assert kwargs["voice"] == "voice-aria" assert kwargs["model"] == "sonic-2" assert kwargs["speed"] == 1.2 assert kwargs["format"] == "opus" def test_empty_string_voice_passed_as_none(self): """Empty-string config values are normalized to None so providers can fall back to their own defaults (matches the ABC contract).""" provider = _FakeTTSProvider(name="cartesia") tts_registry.register_provider(provider) tts_tool._dispatch_to_plugin_provider( text="hello", output_path="/tmp/out.mp3", provider="cartesia", tts_config={"voice": "", "model": ""}, ) kwargs = provider.last_call["kwargs"] assert kwargs["voice"] is None assert kwargs["model"] is None def test_provider_returning_different_path_honored(self): """If a provider rewrites the output path (e.g. format-driven extension change), the dispatcher returns the new path.""" provider = _FakeTTSProvider(name="cartesia", return_path="/tmp/rewritten.opus") tts_registry.register_provider(provider) result = tts_tool._dispatch_to_plugin_provider( text="hi", output_path="/tmp/out.mp3", provider="cartesia", tts_config={}, ) assert result == "/tmp/rewritten.opus" def test_provider_returning_none_falls_back_to_output_path(self): """Defensive: a provider returning None means the dispatcher should report the caller-supplied output_path (matches the ABC contract — the provider is supposed to write to output_path).""" provider = _FakeTTSProvider(name="cartesia", return_path=None) # Override the default-output-path behavior to return None explicitly provider._return_path = None class _ReturnsNone(_FakeTTSProvider): def synthesize(self, text, output_path, **kw): return None # type: ignore[return-value] provider2 = _ReturnsNone(name="weird") tts_registry.register_provider(provider2) result = tts_tool._dispatch_to_plugin_provider( text="hi", output_path="/tmp/out.mp3", provider="weird", tts_config={}, ) assert result == "/tmp/out.mp3" def test_provider_exception_bubbles_up(self): """Plugin exceptions are NOT swallowed by the dispatcher — they bubble up so the outer ``text_to_speech_tool`` try/except converts them to the standard error envelope. Matches command-provider failure behavior.""" provider = _FakeTTSProvider( name="cartesia", raise_exc=RuntimeError("network down"), ) tts_registry.register_provider(provider) with pytest.raises(RuntimeError, match="network down"): tts_tool._dispatch_to_plugin_provider( text="hi", output_path="/tmp/out.mp3", provider="cartesia", tts_config={}, ) # --------------------------------------------------------------------------- # voice_compatible flag # --------------------------------------------------------------------------- class TestVoiceCompatibleHelper: def test_voice_compatible_true(self): tts_registry.register_provider( _FakeTTSProvider(name="cartesia", voice_compat=True) ) assert tts_tool._plugin_provider_is_voice_compatible("cartesia") is True def test_voice_compatible_false_by_default(self): tts_registry.register_provider(_FakeTTSProvider(name="cartesia")) assert tts_tool._plugin_provider_is_voice_compatible("cartesia") is False def test_unregistered_provider_returns_false(self): assert tts_tool._plugin_provider_is_voice_compatible("unknown") is False def test_empty_provider_name_returns_false(self): assert tts_tool._plugin_provider_is_voice_compatible("") is False @pytest.mark.parametrize( "builtin", ["edge", "openai", "elevenlabs", "minimax", "gemini", "mistral", "xai", "piper", "kittentts", "neutts"], ) def test_builtin_names_return_false(self, builtin): """voice_compatible helper short-circuits built-ins so they go through the legacy code path that handles their format quirks.""" assert tts_tool._plugin_provider_is_voice_compatible(builtin) is False def test_voice_compatible_case_insensitive(self): tts_registry.register_provider( _FakeTTSProvider(name="cartesia", voice_compat=True) ) assert tts_tool._plugin_provider_is_voice_compatible("CARTESIA") is True assert tts_tool._plugin_provider_is_voice_compatible(" cartesia ") is True def test_provider_property_exception_returns_false(self): """A buggy ``voice_compatible`` property raising must not crash the TTS pipeline.""" class _ExplodingProvider(_FakeTTSProvider): @property def voice_compatible(self) -> bool: raise RuntimeError("boom") tts_registry.register_provider(_ExplodingProvider(name="cartesia")) assert tts_tool._plugin_provider_is_voice_compatible("cartesia") is False