voice.auto_tts (flipped globally by Desktop "Read replies aloud") made the runner synthesize a spoken reply for every A2A text task. The A2A adapter has no native send_voice, so delivery fell back to the media notice — the peer received "Couldn't deliver the audio attachment." instead of the text the agent had already produced (#90103). Inbound A2A is MessageType.TEXT; the base adapter's own auto-TTS gate keys on MessageType.VOICE, but the runner's fallback branch (voice_mode is None and adapter_auto_tts) had no platform gate at all. Two aligned gates, both field-tested by the reporter: - _should_send_voice_reply returns False for platform 'a2a' before any mode/config consultation — the text reply lands normally. - _sync_voice_mode_state_to_adapter never pushes the global speak default onto the A2A adapter, so the adapter-side path cannot regress it either. /voice on|tts|off scoped behavior is untouched (persisted per platform:chat_id, still honored for human platforms). Tests pin the pair the issue asked for: A2A + global auto-TTS skips, Telegram with the same default still voices, and the sync-side skip. Re-implemented from PR #90121 on the GatewayVoiceMixin split (author credited). The Desktop-scoped read-aloud preference is a separate design change, not folded in. Fixes #90103
173 lines
7.0 KiB
Python
173 lines
7.0 KiB
Python
"""Tests for gateway auto-TTS voice reply audio format selection."""
|
|
|
|
import json
|
|
from pathlib import Path
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
from gateway.config import Platform
|
|
from gateway.platforms.event import MessageEvent, MessageType
|
|
from gateway.run import GatewayRunner
|
|
from gateway.session import SessionSource
|
|
|
|
|
|
class TestAutoVoiceReplyFormat:
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize(
|
|
"platform",
|
|
[Platform.MATRIX, Platform.FEISHU, Platform.WHATSAPP, Platform.SIGNAL],
|
|
)
|
|
async def test_opus_platform_auto_voice_reply_requests_ogg(self, platform):
|
|
"""Every OPUS_VOICE_PLATFORMS member gets an explicit .ogg output path.
|
|
|
|
Regression for #14841 (Matrix) / #45557 (Feishu): _send_voice_reply
|
|
hardcoded .ogg for Telegram only, so Matrix/Feishu voice replies were
|
|
synthesized as MP3 and delivered as plain attachments instead of
|
|
native voice bubbles.
|
|
"""
|
|
runner = _make_runner()
|
|
adapter = _make_adapter(platform)
|
|
runner.adapters[platform] = adapter
|
|
event = _make_event(platform)
|
|
requested_paths = []
|
|
|
|
def fake_tts(*, text, output_path):
|
|
requested_paths.append(output_path)
|
|
Path(output_path).parent.mkdir(parents=True, exist_ok=True)
|
|
Path(output_path).write_bytes(b"fake ogg opus")
|
|
return json.dumps({
|
|
"success": True,
|
|
"file_path": output_path,
|
|
"provider": "gemini",
|
|
"voice_compatible": True,
|
|
})
|
|
|
|
with patch("tools.tts_tool.text_to_speech_tool", side_effect=fake_tts):
|
|
await runner._send_voice_reply(event, "hello from auto tts")
|
|
|
|
assert requested_paths and requested_paths[0].endswith(".ogg")
|
|
adapter.send_voice.assert_awaited_once()
|
|
assert adapter.send_voice.await_args.kwargs["audio_path"].endswith(".ogg")
|
|
|
|
def test_should_send_voice_reply_streamed_global_auto_tts_fires(self):
|
|
"""Streamed reply + global voice.auto_tts (no /voice opt-in) sends voice.
|
|
|
|
Regression for the #51867/#23983 remainder: when streaming consumed
|
|
the text, the base adapter's auto-TTS gets text_content=None, and the
|
|
runner path used to consult only self._voice_mode — so a chat relying
|
|
purely on the global voice.auto_tts default silently lost its voice
|
|
reply.
|
|
"""
|
|
runner = _make_runner()
|
|
adapter = _make_adapter(Platform.TELEGRAM)
|
|
adapter._should_auto_tts_for_chat = MagicMock(return_value=True)
|
|
runner.adapters[Platform.TELEGRAM] = adapter
|
|
voice_event = _make_event(
|
|
Platform.TELEGRAM, chat_id="123", message_type=MessageType.VOICE
|
|
)
|
|
|
|
assert runner._should_send_voice_reply(
|
|
voice_event, "hello", [], already_sent=True
|
|
) is True
|
|
|
|
def test_should_send_voice_reply_voice_only_still_requires_voice_input(self):
|
|
"""Explicit voice_only must not widen to text input (#73508 regression).
|
|
|
|
Persisted voice_only mode is synced into the adapter as an explicit
|
|
auto-TTS opt-in, so adapter_auto_tts is True for this chat. The
|
|
chat-level mode stays authoritative: text input gets no voice reply,
|
|
voice input still does.
|
|
"""
|
|
runner = _make_runner()
|
|
runner._voice_mode["telegram:123"] = "voice_only"
|
|
adapter = _make_adapter(Platform.TELEGRAM)
|
|
adapter._should_auto_tts_for_chat = MagicMock(return_value=True)
|
|
runner.adapters[Platform.TELEGRAM] = adapter
|
|
event = _make_event(Platform.TELEGRAM, chat_id="123")
|
|
|
|
assert runner._should_send_voice_reply(event, "hello", []) is False
|
|
|
|
voice_event = _make_event(Platform.TELEGRAM, chat_id="123", message_type=MessageType.VOICE)
|
|
assert runner._should_send_voice_reply(voice_event, "hello", [], already_sent=True) is True
|
|
|
|
def test_should_send_voice_reply_a2a_ignores_global_auto_tts(self):
|
|
"""A2A text tasks must stay text even when voice.auto_tts is on.
|
|
|
|
Desktop Read-replies-aloud writes the global default. The runner used
|
|
that default for every adapter with no /voice mode, including A2A,
|
|
which cannot deliver native audio: the synthesized MP3 failed delivery
|
|
and the peer saw "Couldn't deliver the audio attachment." instead of
|
|
the agent's text reply (#90103).
|
|
"""
|
|
runner = _make_runner()
|
|
a2a = Platform("a2a")
|
|
adapter = _make_adapter(a2a)
|
|
adapter._should_auto_tts_for_chat = MagicMock(return_value=True)
|
|
runner.adapters[a2a] = adapter
|
|
event = _make_event(a2a, chat_id="ctx-peer")
|
|
|
|
assert runner._should_send_voice_reply(event, "audit findings", []) is False
|
|
|
|
# The same global default still voices a human platform.
|
|
telegram_adapter = _make_adapter(Platform.TELEGRAM)
|
|
telegram_adapter._should_auto_tts_for_chat = MagicMock(return_value=True)
|
|
runner.adapters[Platform.TELEGRAM] = telegram_adapter
|
|
telegram = _make_event(Platform.TELEGRAM, chat_id="999")
|
|
|
|
assert runner._should_send_voice_reply(telegram, "hello", []) is True
|
|
|
|
def test_sync_voice_mode_state_never_inherits_global_auto_tts_for_a2a(self):
|
|
"""The adapter-side default must match the runner-side skip (#90103).
|
|
|
|
voice.auto_tts is synced onto every adapter at connect; for A2A the
|
|
base adapter's own auto-TTS gate would otherwise read a speak default
|
|
the platform cannot honor.
|
|
"""
|
|
runner = _make_runner()
|
|
a2a = Platform("a2a")
|
|
a2a_adapter = _make_adapter(a2a)
|
|
a2a_adapter._auto_tts_disabled_chats = set()
|
|
a2a_adapter._auto_tts_enabled_chats = set()
|
|
|
|
telegram_adapter = _make_adapter(Platform.TELEGRAM)
|
|
telegram_adapter._auto_tts_disabled_chats = set()
|
|
telegram_adapter._auto_tts_enabled_chats = set()
|
|
|
|
with patch("hermes_cli.config.load_config", return_value={"voice": {"auto_tts": True}}):
|
|
runner._sync_voice_mode_state_to_adapter(a2a_adapter)
|
|
runner._sync_voice_mode_state_to_adapter(telegram_adapter)
|
|
|
|
assert a2a_adapter._auto_tts_default is False
|
|
assert telegram_adapter._auto_tts_default is True
|
|
|
|
def _make_runner() -> GatewayRunner:
|
|
with patch("gateway.run.GatewayRunner._load_voice_modes", return_value={}):
|
|
runner = GatewayRunner.__new__(GatewayRunner)
|
|
runner._voice_mode = {}
|
|
runner.adapters = {}
|
|
return runner
|
|
|
|
|
|
def _make_adapter(platform: Platform) -> MagicMock:
|
|
adapter = MagicMock()
|
|
adapter.platform = platform
|
|
adapter.send_voice = AsyncMock()
|
|
return adapter
|
|
|
|
|
|
def _make_event(platform: Platform, chat_id: str = "123", message_type: MessageType = MessageType.TEXT) -> MessageEvent:
|
|
return MessageEvent(
|
|
text="trigger",
|
|
source=SessionSource(
|
|
platform=platform,
|
|
chat_id=chat_id,
|
|
user_id="u1",
|
|
user_name="User",
|
|
),
|
|
message_type=message_type,
|
|
message_id="456",
|
|
)
|