refactor: extrai coordenação da conversa para VoiceSession

This commit is contained in:
alex alves
2026-11-03 14:43:14 -03:00
parent a5ae9c1013
commit dc45dbdb6d
8 changed files with 828 additions and 1120 deletions

151
tests/test_voice_session.py Normal file
View File

@@ -0,0 +1,151 @@
"""Exercise the conversation use case without a transport or protocol."""
import asyncio
import pytest
from voice_assistant.agent.mock import MockAgentAdapter
from voice_assistant.application.events import (
AgentReply, SpeechChunk, TranscriptFinal, TranscriptPartial, TurnCompleted,
)
from voice_assistant.application.voice_session import VoiceSession, VoiceSessionError
from voice_assistant.domain.session import SessionState
from voice_assistant.stt.mock import MockSTTAdapter
from voice_assistant.tts.mock import MockTTSAdapter
from voice_assistant.turn_manager import TurnManager
from voice_assistant.voice_pipeline import VoicePipeline
@pytest.fixture
def anyio_backend():
return "asyncio"
def make_session(create_stt, emit):
return VoiceSession(
pipeline=VoicePipeline(
session=SessionState(), turn_manager=TurnManager(),
agent=MockAgentAdapter(), tts=MockTTSAdapter(),
),
create_stt=create_stt, emit=emit,
)
@pytest.mark.anyio
async def test_conversation_completes_without_websocket():
events = []
completed = asyncio.Event()
stt = MockSTTAdapter()
async def emit(event):
events.append(event)
if isinstance(event, TurnCompleted):
completed.set()
session = make_session(lambda: stt, emit)
try:
turn_id = await session.start_turn()
await session.send_audio(turn_id, 0, b"\x00\x00" * 160)
await stt.emit_partial("Bom")
await stt.emit_final("Bom dia")
session.stop_turn(turn_id)
await asyncio.wait_for(completed.wait(), timeout=1.0)
assert isinstance(events[0], TranscriptPartial)
assert isinstance(events[1], TranscriptFinal)
assert any(isinstance(event, AgentReply) for event in events)
assert any(isinstance(event, SpeechChunk) for event in events)
assert isinstance(events[-1], TurnCompleted)
assert all(event.session_id == session.session_id for event in events)
assert all(event.turn_id == turn_id for event in events)
assert session.active_turn_id is None
await session.end()
assert session.closed
finally:
await session.aclose()
@pytest.mark.anyio
async def test_interrupt_releases_turn_and_creates_fresh_stt():
providers = []
def create_stt():
provider = MockSTTAdapter()
providers.append(provider)
return provider
async def emit(event):
pass
session = make_session(create_stt, emit)
try:
first = await session.start_turn()
await session.interrupt(first)
assert session.active_turn_id is None
second = await session.start_turn()
assert second > first
assert providers[0] is not providers[1]
assert not providers[0]._started
await session.send_audio(second, 0, b"\x00\x00")
finally:
await session.aclose()
@pytest.mark.anyio
async def test_stt_finalization_does_not_block_interrupt():
started = asyncio.Event()
cancelled = asyncio.Event()
class WaitingSTT(MockSTTAdapter):
async def stop_session(self):
started.set()
try:
await asyncio.Event().wait()
except asyncio.CancelledError:
cancelled.set()
raise
async def emit(event):
pass
stt = WaitingSTT()
session = make_session(lambda: stt, emit)
try:
turn_id = await session.start_turn()
session.stop_turn(turn_id)
await asyncio.wait_for(started.wait(), timeout=1.0)
with pytest.raises(ValueError, match="Audio input is closed"):
await session.send_audio(turn_id, 0, b"\x00\x00")
await asyncio.wait_for(session.interrupt(turn_id), timeout=1.0)
assert cancelled.is_set()
assert session.active_turn_id is None
assert not stt._started
finally:
await session.aclose()
@pytest.mark.anyio
@pytest.mark.parametrize("abort_fails", [False, True])
async def test_startup_rollback_only_releases_safe_turns(abort_fails):
class FailingSTT(MockSTTAdapter):
async def start_session(self):
await super().start_session()
raise RuntimeError("Failed startup")
async def abort_session(self):
if abort_fails:
raise RuntimeError("Failed cleanup")
await super().abort_session()
async def emit(event):
pass
session = make_session(FailingSTT, emit)
try:
with pytest.raises(VoiceSessionError) as failure:
await session.start_turn()
assert failure.value.code == "STT_START_FAILED"
assert failure.value.retryable is (not abort_fails)
assert (session.active_turn_id is not None) is abort_fails
finally:
await session.aclose()