feat(voice): unify Discord voice↔text session (squash of voice/text-unify)
Voice utterances and text messages on the same Discord channel now share one Claude session, and Echo's voice replies are mirrored back into the text channel. Replaces the old voice:<id> session-key split. Changes: - src/adapters/_text_chunks.py: new leaf module for split_message (used by both discord_bot and voice pipeline) - src/router.py: drop voice: prefix from session_key; add [voice] marker; strip leading [speaker:/[voice] tokens from user input (anti-jailbreak); remove dead double-clear of voice: key - src/claude_session.py: include personality/VOICE_MODE.md unconditionally (rules become per-turn-aware via [speaker:] prefix instead of session flag) - src/voice/pipeline.py: VoiceSession splits text_channel_id + voice_channel_id; resolve text channel per-send (no stale refs); mirror Echo's reply text into the text channel after route_message returns - src/adapters/discord_voice.py: /voice join passes both channel ids - src/adapters/discord_bot.py: import split_message from leaf module - personality/VOICE_MODE.md: rewrite as per-turn dynamic rules; add synthesis instructions for text turns after voice turns Tests: - tests/test_router.py: 4 new cases (plain channel_id, anti-jailbreak, text-adapter regression, no-double-clear) - tests/test_pipeline_mirror.py: new — Echo reply mirror chunking, empty guard, mirror_enabled=False, send-raises resilience - tests/test_voice_session_channel_ids.py: new — split-attr contract + metrics payload schema - tests/test_voice_session_cleanup.py: update for new kwargs Plan: /home/moltbot/.claude/plans/vreau-ca-tot-textul-greedy-rivest.md Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
124
tests/test_pipeline_mirror.py
Normal file
124
tests/test_pipeline_mirror.py
Normal file
@@ -0,0 +1,124 @@
|
||||
"""Echo-reply text mirror: VoiceSession.on_segment_done forwards Claude's
|
||||
reply back into the originating text channel, chunked to Discord's 2000-char
|
||||
limit, gated on mirror_enabled, and resilient to send failures.
|
||||
|
||||
The pipeline calls router.route_message via the injected
|
||||
`router_route_message` seam so tests can drive the reply text without
|
||||
monkey-patching modules or invoking the real Claude subprocess.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from src.voice.pipeline import VoiceSession
|
||||
|
||||
|
||||
def _make_text_channel(send_mock: AsyncMock) -> MagicMock:
|
||||
tc = MagicMock(name="text_channel")
|
||||
tc.send = send_mock
|
||||
return tc
|
||||
|
||||
|
||||
def _make_session(
|
||||
*,
|
||||
reply_text: str,
|
||||
text_channel,
|
||||
mirror_enabled: bool = True,
|
||||
) -> VoiceSession:
|
||||
bot = MagicMock(name="bot")
|
||||
bot.get_channel = MagicMock(return_value=text_channel)
|
||||
bot.get_user = MagicMock(return_value=None)
|
||||
ttsq = MagicMock(name="ttsq")
|
||||
ttsq.push_text = MagicMock()
|
||||
ttsq.clear = MagicMock()
|
||||
route_mock = MagicMock(name="route_message", return_value=(reply_text, False))
|
||||
return VoiceSession(
|
||||
text_channel_id=1001,
|
||||
voice_channel_id=2002,
|
||||
guild_id=42,
|
||||
voice_client=MagicMock(name="voice_client"),
|
||||
bot=bot,
|
||||
ttsq=ttsq,
|
||||
whitelist=set(),
|
||||
record_enabled=False,
|
||||
mirror_enabled=mirror_enabled,
|
||||
transcripts_jsonl_path=None,
|
||||
loop=asyncio.get_event_loop_policy().new_event_loop(),
|
||||
router_route_message=route_mock,
|
||||
)
|
||||
|
||||
|
||||
def _reply_chunks(send_mock: AsyncMock) -> list[str]:
|
||||
# Drop the user-mirror call (starts with the 🎤 microphone emoji); the
|
||||
# rest are reply chunks.
|
||||
return [
|
||||
call.args[0]
|
||||
for call in send_mock.call_args_list
|
||||
if not call.args[0].startswith("\U0001f3a4")
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_long_reply_splits_into_multiple_chunks():
|
||||
long_reply = "răspuns lung " * 200 # ~2600 chars → ≥2 chunks at 2000-char limit
|
||||
send_mock = AsyncMock(name="text_send")
|
||||
text_channel = _make_text_channel(send_mock)
|
||||
session = _make_session(reply_text=long_reply, text_channel=text_channel)
|
||||
|
||||
await session.on_segment_done(speaker_id=123, text="salut", no_speech_prob=0.1)
|
||||
|
||||
chunks = _reply_chunks(send_mock)
|
||||
assert len(chunks) >= 2
|
||||
assert "".join(chunks).replace("\n", "").strip().startswith("răspuns lung")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_empty_reply_emits_no_reply_chunks():
|
||||
send_mock = AsyncMock(name="text_send")
|
||||
text_channel = _make_text_channel(send_mock)
|
||||
session = _make_session(reply_text="", text_channel=text_channel)
|
||||
|
||||
await session.on_segment_done(speaker_id=123, text="salut", no_speech_prob=0.1)
|
||||
|
||||
assert _reply_chunks(send_mock) == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_whitespace_only_reply_emits_no_reply_chunks():
|
||||
send_mock = AsyncMock(name="text_send")
|
||||
text_channel = _make_text_channel(send_mock)
|
||||
session = _make_session(reply_text=" \n\t ", text_channel=text_channel)
|
||||
|
||||
await session.on_segment_done(speaker_id=123, text="salut", no_speech_prob=0.1)
|
||||
|
||||
assert _reply_chunks(send_mock) == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mirror_disabled_sends_nothing():
|
||||
send_mock = AsyncMock(name="text_send")
|
||||
text_channel = _make_text_channel(send_mock)
|
||||
session = _make_session(
|
||||
reply_text="orice răspuns", text_channel=text_channel, mirror_enabled=False,
|
||||
)
|
||||
|
||||
await session.on_segment_done(speaker_id=123, text="salut", no_speech_prob=0.1)
|
||||
|
||||
assert send_mock.call_count == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_failure_is_swallowed(caplog):
|
||||
send_mock = AsyncMock(name="text_send", side_effect=RuntimeError("discord 500"))
|
||||
text_channel = _make_text_channel(send_mock)
|
||||
session = _make_session(reply_text="răspuns scurt", text_channel=text_channel)
|
||||
|
||||
with caplog.at_level("WARNING"):
|
||||
# Must not raise — both user-mirror and reply-mirror trap exceptions.
|
||||
await session.on_segment_done(speaker_id=123, text="salut", no_speech_prob=0.1)
|
||||
|
||||
# At least one warning was logged for a mirror send failure.
|
||||
assert any("mirror" in rec.message.lower() for rec in caplog.records)
|
||||
Reference in New Issue
Block a user