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