"""Behavioral guarantees for streaming and provider cleanup.""" import asyncio from contextlib import aclosing from unittest.mock import AsyncMock import pytest from voice_assistant.agent.adapter import StreamingAgentAdapter from voice_assistant.agent.mock import MockAgentAdapter from voice_assistant.application.events import SpeechChunk from voice_assistant.application.voice_session import VoiceSession from voice_assistant.domain.session import SessionState from voice_assistant.stt.mock import MockSTTAdapter from voice_assistant.tts.adapter import TTSAdapter 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_pipeline(agent, tts): return VoicePipeline( session=SessionState(), turn_manager=TurnManager(), agent=agent, tts=tts, ) @pytest.mark.anyio async def test_streaming_agent_collects_fragments_preserving_spaces(): closed = asyncio.Event() class Agent(StreamingAgentAdapter): async def stream(self, session_id, turn_id, text): try: yield " Olá" yield ", mundo! " finally: closed.set() assert await Agent().process("session", 1, "Olá") == "Olá, mundo!" assert closed.is_set() @pytest.mark.anyio async def test_streaming_failure_propagates_and_releases_stream(): closed = asyncio.Event() class Agent(StreamingAgentAdapter): async def stream(self, session_id, turn_id, text): try: yield "Resposta parcial" raise RuntimeError("Provider failed") finally: closed.set() with pytest.raises(RuntimeError, match="Provider failed"): await Agent().process("session", 1, "Olá") assert closed.is_set() @pytest.mark.anyio async def test_streaming_cancellation_releases_stream(): started = asyncio.Event() closed = asyncio.Event() class Agent(StreamingAgentAdapter): async def stream(self, session_id, turn_id, text): try: yield "Resposta parcial" started.set() await asyncio.Event().wait() finally: closed.set() task = asyncio.create_task(Agent().process("session", 1, "Olá")) try: await asyncio.wait_for(started.wait(), timeout=1.0) task.cancel() with pytest.raises(asyncio.CancelledError): await task assert closed.is_set() finally: if not task.done(): task.cancel() await asyncio.gather(task, return_exceptions=True) @pytest.mark.anyio async def test_stopping_synthesis_early_closes_provider_generator(): closed = asyncio.Event() class TTS(TTSAdapter): async def synthesize(self, text): try: yield b"audio" await asyncio.Event().wait() finally: closed.set() pipeline = make_pipeline(MockAgentAdapter(), TTS()) async with aclosing(pipeline.synthesize_response("Olá")) as stream: assert await anext(stream) == b"audio" assert closed.is_set() @pytest.mark.anyio async def test_interrupt_during_delivery_closes_nonstreaming_tts(): delivery_started = asyncio.Event() synthesis_closed = asyncio.Event() stt = MockSTTAdapter() class TTS(TTSAdapter): async def synthesize(self, text): try: yield b"audio" await asyncio.Event().wait() finally: synthesis_closed.set() async def emit(event): if isinstance(event, SpeechChunk): delivery_started.set() await asyncio.Event().wait() session = VoiceSession( pipeline=make_pipeline(MockAgentAdapter(), TTS()), create_stt=lambda: stt, emit=emit, ) try: turn_id = await session.start_turn() await stt.emit_final("Olá") await asyncio.wait_for(delivery_started.wait(), timeout=1.0) await asyncio.wait_for(session.interrupt(turn_id), timeout=1.0) assert synthesis_closed.is_set() assert session.active_turn_id is None finally: await session.aclose() @pytest.mark.anyio async def test_agent_cleanup_failure_does_not_prevent_tts_cleanup(): agent = MockAgentAdapter() tts = MockTTSAdapter() agent.aclose = AsyncMock(side_effect=RuntimeError("Agent close failed")) tts.aclose = AsyncMock() async def emit(event): pass session = VoiceSession( pipeline=make_pipeline(agent, tts), create_stt=MockSTTAdapter, emit=emit, ) await session.aclose() agent.aclose.assert_awaited_once() tts.aclose.assert_awaited_once() assert session.closed @pytest.mark.anyio @pytest.mark.parametrize("phase", ["tts", "turn_manager"]) @pytest.mark.parametrize("agent_close_fails", [False, True]) async def test_partial_composition_closes_created_providers( monkeypatch, phase, agent_close_fails, ): from voice_assistant.audio_gateway.app import main agent = MockAgentAdapter() tts = MockTTSAdapter() agent.aclose = AsyncMock( side_effect=RuntimeError("Agent close failed") if agent_close_fails else None, ) tts.aclose = AsyncMock() def fail(*args, **kwargs): raise RuntimeError("Composition failed") monkeypatch.setattr(main, "create_agent_adapter", lambda **kwargs: agent) monkeypatch.setattr( main, "create_tts_adapter", fail if phase == "tts" else lambda **kwargs: tts, ) monkeypatch.setattr(main, "create_turn_manager", fail) with pytest.raises(RuntimeError, match="Composition failed"): await main.create_voice_session(AsyncMock(), None) agent.aclose.assert_awaited_once() if phase == "tts": tts.aclose.assert_not_awaited() else: tts.aclose.assert_awaited_once()