Files
hermes-agent/tests/gateway/test_auto_voice_reply_format.py
mooserini fa655b2980 fix(gateway): do not auto-TTS A2A replies
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
2026-09-27 06:54:56 -05:00

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",
)