from voice_assistant.agent.adapter import StreamingAgentAdapter """ Tests for the Voice Pipeline lifecycle orchestration. Purpose: Verify that the VoicePipeline coordinates SessionState and TurnManager without coupling either component to transport details. Responsibilities: - Validate turn creation. - Validate semantic turn completion. - Validate coordinated interruption. - Verify SessionState and TurnManager remain independently modeled. Non-responsibilities: - Testing WebSocket transport. - Testing STT, Agent, or TTS providers. """ import pytest import asyncio from contextlib import aclosing from voice_assistant.voice_pipeline import ( PipelineAudioChunk, PipelineAgentResponse, PipelineTextDelta, VoicePipeline, ) from voice_assistant.domain.session import SessionState from voice_assistant.turn_manager import TurnManager, TurnState from voice_assistant.agent.mock import MockAgentAdapter from voice_assistant.tts.mock import MockTTSAdapter def create_pipeline() -> tuple[ VoicePipeline, SessionState, TurnManager, MockAgentAdapter, MockTTSAdapter, ]: session = SessionState() turn_manager = TurnManager() agent = MockAgentAdapter() tts = MockTTSAdapter() pipeline = VoicePipeline( session=session, turn_manager=turn_manager, agent=agent, tts=tts, ) return ( pipeline, session, turn_manager, agent, tts, ) def test_pipeline_starts_turn(): pipeline, session, turn_manager, _, _ = create_pipeline() turn = pipeline.start_turn() assert turn.turn_id == 1 assert turn.state == TurnState.LISTENING assert session.active_turn_id == 1 assert turn_manager.active_turn is turn def test_pipeline_completes_turn(): pipeline, session, turn_manager, _, _ = create_pipeline() turn = pipeline.start_turn() turn_manager.on_final( turn.turn_id, "Olá!", ) turn_manager.on_agent_response(turn.turn_id) turn_manager.on_tts_complete(turn.turn_id) completed_turn = pipeline.complete_turn( turn.turn_id, ) assert completed_turn.state == TurnState.COMPLETED assert session.active_turn_id is None assert turn_manager.active_turn is None def test_pipeline_rejects_incomplete_turn(): pipeline, session, turn_manager, _, _ = create_pipeline() turn = pipeline.start_turn() with pytest.raises( RuntimeError, match="Cannot stop turn while turn is listening", ): pipeline.complete_turn(turn.turn_id) # Failed completion must not destroy the active turn. assert session.active_turn_id == turn.turn_id assert turn_manager.active_turn is turn def test_pipeline_interrupts_turn(): pipeline, session, turn_manager, _, _ = create_pipeline() turn = pipeline.start_turn() interrupted_turn = pipeline.interrupt_turn( turn.turn_id, ) assert interrupted_turn.state == TurnState.INTERRUPTED assert session.active_turn_id is None assert turn_manager.active_turn is None def test_pipeline_can_start_turn_after_interrupt(): pipeline, session, turn_manager, _, _ = create_pipeline() first_turn = pipeline.start_turn() pipeline.interrupt_turn( first_turn.turn_id, ) second_turn = pipeline.start_turn() assert second_turn.turn_id == 2 assert second_turn.state == TurnState.LISTENING assert session.active_turn_id == 2 assert turn_manager.active_turn is second_turn @pytest.mark.anyio async def test_pipeline_processes_transcript_through_agent(): ( pipeline, session, turn_manager, agent, _, ) = create_pipeline() turn = pipeline.start_turn() turn_manager.on_final( turn.turn_id, "Olá!", ) result = await pipeline.process_transcript( session_id=session.session_id, turn_id=turn.turn_id, text="Olá!", ) assert result.response_text == ( "Resposta simulada para: Olá!" ) assert agent.calls == [ ( session.session_id, turn.turn_id, "Olá!", ) ] assert turn_manager.active_turn is not None assert ( turn_manager.active_turn.state == TurnState.SPEAKING ) @pytest.mark.anyio async def test_pipeline_streams_tts_audio(): ( pipeline, session, turn_manager, _, tts, ) = create_pipeline() turn = pipeline.start_turn() turn_manager.on_final( turn.turn_id, "Olá!", ) result = await pipeline.process_transcript( session_id=session.session_id, turn_id=turn.turn_id, text="Olá!", ) chunks = [ chunk async for chunk in pipeline.synthesize_response( result.response_text, ) ] assert chunks == [ b"\x01\x02\x03\x04", b"\x05\x06\x07\x08", ] assert tts.calls == [ "Resposta simulada para: Olá!", ] @pytest.mark.anyio async def test_pipeline_processes_complete_voice_turn(): ( pipeline, session, turn_manager, agent, tts, ) = create_pipeline() turn = pipeline.start_turn() turn_manager.on_final( turn.turn_id, "Olá!", ) result = await pipeline.process_transcript( session_id=session.session_id, turn_id=turn.turn_id, text="Olá!", ) audio_chunks = [ chunk async for chunk in pipeline.synthesize_response( result.response_text, ) ] turn_manager.on_tts_complete( turn.turn_id, ) assert result.response_text == ( "Resposta simulada para: Olá!" ) assert audio_chunks == [ b"\x01\x02\x03\x04", b"\x05\x06\x07\x08", ] assert turn_manager.active_turn is not None assert ( turn_manager.active_turn.state == TurnState.COMPLETED ) assert agent.calls == [ ( session.session_id, turn.turn_id, "Olá!", ) ] assert tts.calls == [ "Resposta simulada para: Olá!", ] def test_pipeline_handles_partial_transcript(): pipeline, _, _, _, _ = create_pipeline() turn = pipeline.start_turn() pipeline.on_partial( turn.turn_id, "Olá", ) assert turn.partial_text == "Olá" assert turn.state == TurnState.LISTENING def test_pipeline_handles_final_transcript(): pipeline, _, _, _, _ = create_pipeline() turn = pipeline.start_turn() pipeline.on_final( turn.turn_id, "Olá!", ) assert turn.final_text == "Olá!" assert turn.partial_text == "Olá!" assert turn.state == TurnState.PROCESSING def test_pipeline_handles_tts_completion(): pipeline, _, _, _, _ = create_pipeline() turn = pipeline.start_turn() pipeline.on_final( turn.turn_id, "Olá!", ) # The Agent transition is part of the normal semantic lifecycle. pipeline.on_agent_response( turn.turn_id, ) pipeline.on_tts_complete( turn.turn_id, ) assert turn.state == TurnState.COMPLETED def test_pipeline_closes_session_without_active_turn(): pipeline, session, turn_manager, _, _ = create_pipeline() pipeline.close_session() assert session.closed is True assert session.active_turn_id is None assert turn_manager.active_turn is None def test_pipeline_closes_session_with_active_turn(): pipeline, session, turn_manager, _, _ = create_pipeline() turn = pipeline.start_turn() pipeline.close_session() assert session.closed is True assert session.active_turn_id is None assert turn_manager.active_turn is None assert turn.state == TurnState.INTERRUPTED def test_pipeline_cannot_start_turn_after_session_close(): pipeline, session, turn_manager, _, _ = create_pipeline() pipeline.close_session() with pytest.raises( RuntimeError, match="Session is closed", ): pipeline.start_turn() def test_pipeline_interrupt_if_active(): pipeline, session, turn_manager, _, _ = create_pipeline() turn = pipeline.start_turn() interrupted = pipeline.interrupt_if_active( turn.turn_id, ) assert interrupted is True assert session.active_turn_id is None assert turn_manager.active_turn is None assert turn.state == TurnState.INTERRUPTED def test_pipeline_interrupt_if_active_unknown_turn(): pipeline, session, turn_manager, _, _ = create_pipeline() turn = pipeline.start_turn() interrupted = pipeline.interrupt_if_active( turn.turn_id + 1, ) assert interrupted is False assert session.active_turn_id == turn.turn_id assert turn_manager.active_turn is turn assert turn.state == TurnState.LISTENING @pytest.mark.anyio async def test_pipeline_registers_turn_task(): pipeline, _, _, _, _ = create_pipeline() pipeline.start_turn() async def worker(): await asyncio.sleep(0) task = asyncio.create_task(worker()) pipeline.register_turn_task(task) assert pipeline.turn_task is task await task @pytest.mark.anyio async def test_pipeline_rejects_duplicate_running_task(): pipeline, _, _, _, _ = create_pipeline() pipeline.start_turn() started = asyncio.Event() release = asyncio.Event() async def worker(): started.set() await release.wait() task = asyncio.create_task(worker()) try: await started.wait() pipeline.register_turn_task(task) with pytest.raises( RuntimeError, match="A turn execution task is already active", ): pipeline.register_turn_task(task) finally: release.set() await task @pytest.mark.anyio async def test_pipeline_rejects_task_without_active_turn(): pipeline, _, _, _, _ = create_pipeline() async def worker(): await asyncio.sleep(0) task = asyncio.create_task(worker()) try: with pytest.raises( RuntimeError, match="Cannot register task without an active turn", ): pipeline.register_turn_task(task) finally: await task @pytest.mark.anyio async def test_pipeline_cancels_running_turn_task(): pipeline, _, _, _, _ = create_pipeline() pipeline.start_turn() started = asyncio.Event() cancelled = asyncio.Event() async def worker(): started.set() try: await asyncio.Event().wait() finally: cancelled.set() task = asyncio.create_task(worker()) await started.wait() pipeline.register_turn_task(task) await pipeline.cancel_turn_task() assert cancelled.is_set() assert task.done() assert task.cancelled() assert pipeline.turn_task is None @pytest.mark.anyio async def test_pipeline_cancel_without_registered_task(): pipeline, _, _, _, _ = create_pipeline() await pipeline.cancel_turn_task() assert pipeline.turn_task is None @pytest.mark.anyio async def test_pipeline_cancel_completed_task(): pipeline, _, _, _, _ = create_pipeline() pipeline.start_turn() async def worker(): return None task = asyncio.create_task(worker()) pipeline.register_turn_task(task) await task await pipeline.cancel_turn_task() assert task.done() assert pipeline.turn_task is None @pytest.mark.anyio async def test_pipeline_rejects_self_cancellation(): """ Verify that a turn task cannot cancel itself. The task reference must remain available so another task can subsequently perform cleanup. """ pipeline, _, _, _, _ = create_pipeline() pipeline.start_turn() async def worker(): await pipeline.cancel_turn_task() task = asyncio.create_task(worker()) pipeline.register_turn_task(task) # Self-cancellation must be rejected. with pytest.raises( RuntimeError, match="A turn task cannot cancel itself", ): await task # The failed task remains registered until explicit cleanup. assert pipeline.turn_task is task # Cleanup is performed from the test task, not the worker. with pytest.raises( RuntimeError, match="A turn task cannot cancel itself", ): await pipeline.cancel_turn_task() # The reference must be released after the completed task # has been awaited, even when its exception is propagated. assert pipeline.turn_task is None @pytest.mark.anyio async def test_pipeline_propagates_turn_task_failure(): pipeline, _, _, _, _ = create_pipeline() pipeline.start_turn() async def worker(): raise ValueError( "Provider execution failed." ) task = asyncio.create_task(worker()) pipeline.register_turn_task(task) # Allow the worker to execute and finish with its error. await asyncio.sleep(0) assert task.done() with pytest.raises( ValueError, match="Provider execution failed", ): await pipeline.cancel_turn_task() assert task.done() assert pipeline.turn_task is None @pytest.mark.anyio async def test_pipeline_propagates_waiter_cancellation(): pipeline, _, _, _, _ = create_pipeline() pipeline.start_turn() worker_started = asyncio.Event() allow_cleanup = asyncio.Event() async def worker(): worker_started.set() try: await asyncio.Event().wait() finally: await allow_cleanup.wait() task = asyncio.create_task(worker()) await worker_started.wait() pipeline.register_turn_task(task) waiter = asyncio.create_task( pipeline.cancel_turn_task() ) try: # Allow cancellation to reach the worker. await asyncio.sleep(0) waiter.cancel() with pytest.raises(asyncio.CancelledError): await waiter # The worker is still completing its cancellation cleanup. assert pipeline.turn_task is task finally: allow_cleanup.set() await pipeline.cancel_turn_task() assert task.done() assert pipeline.turn_task is None def test_pipeline_identifies_active_turn(): pipeline, _, _, _, _ = create_pipeline() turn = pipeline.start_turn() assert pipeline.is_active_turn(turn.turn_id) is True def test_pipeline_rejects_inactive_turn_id(): pipeline, _, _, _, _ = create_pipeline() turn = pipeline.start_turn() assert pipeline.is_active_turn(turn.turn_id + 1) is False pipeline.interrupt_turn(turn.turn_id) assert pipeline.is_active_turn(turn.turn_id) is False @pytest.mark.anyio async def test_pipeline_waits_for_completed_turn_task(): pipeline, _, _, _, _ = create_pipeline() pipeline.start_turn() completed = asyncio.Event() async def worker(): completed.set() task = asyncio.create_task(worker()) pipeline.register_turn_task(task) await pipeline.wait_turn_task() assert completed.is_set() assert task.done() assert pipeline.turn_task is None @pytest.mark.anyio async def test_pipeline_wait_propagates_task_failure(): pipeline, _, _, _, _ = create_pipeline() pipeline.start_turn() async def worker(): raise ValueError("Agent processing failed.") task = asyncio.create_task(worker()) pipeline.register_turn_task(task) with pytest.raises( ValueError, match="Agent processing failed", ): await pipeline.wait_turn_task() assert pipeline.turn_task is None @pytest.mark.anyio async def test_pipeline_wait_without_registered_task(): pipeline, _, _, _, _ = create_pipeline() await pipeline.wait_turn_task() assert pipeline.turn_task is None @pytest.mark.anyio async def test_pipeline_cancel_rejects_invalid_timeout(): pipeline, _, _, _, _ = create_pipeline() with pytest.raises( ValueError, match="Turn cancellation timeout must be positive", ): await pipeline.cancel_turn_task(timeout=0) @pytest.mark.anyio async def test_pipeline_cancel_completes_within_timeout(): pipeline, _, _, _, _ = create_pipeline() pipeline.start_turn() started = asyncio.Event() async def worker(): started.set() await asyncio.Event().wait() task = asyncio.create_task(worker()) await started.wait() pipeline.register_turn_task(task) await pipeline.cancel_turn_task(timeout=1.0) assert task.done() assert task.cancelled() assert pipeline.turn_task is None @pytest.mark.anyio async def test_pipeline_cancel_timeout_preserves_task_ownership(): pipeline, _, _, _, _ = create_pipeline() pipeline.start_turn() started = asyncio.Event() allow_cleanup = asyncio.Event() cancellation_received = asyncio.Event() async def worker(): started.set() try: await asyncio.Event().wait() finally: cancellation_received.set() await allow_cleanup.wait() task = asyncio.create_task(worker()) await started.wait() pipeline.register_turn_task(task) try: with pytest.raises( TimeoutError, match="Turn task cancellation timed out", ): await pipeline.cancel_turn_task(timeout=0.01) assert cancellation_received.is_set() assert not task.done() assert pipeline.turn_task is task finally: allow_cleanup.set() await pipeline.cancel_turn_task(timeout=1.0) assert task.done() assert pipeline.turn_task is None @pytest.mark.anyio async def test_pipeline_rejects_new_turn_while_previous_task_runs(): pipeline, session, _, _, _ = create_pipeline() first_turn = pipeline.start_turn() started = asyncio.Event() release = asyncio.Event() async def worker(): started.set() await release.wait() task = asyncio.create_task(worker()) await started.wait() pipeline.register_turn_task(task) # Simulate semantic interruption without task cancellation. pipeline.interrupt_turn(first_turn.turn_id) try: with pytest.raises( RuntimeError, match="previous turn task is still running", ): pipeline.start_turn() assert session.active_turn_id is None finally: release.set() await pipeline.wait_turn_task() second_turn = pipeline.start_turn() assert second_turn.turn_id == 2 assert session.active_turn_id == 2 @pytest.mark.anyio async def test_stream_turn_response_produces_audio_before_agent_finishes(): """ Verify that TTS audio can be delivered while the Agent is still generating its response. """ class StreamingAgent(StreamingAgentAdapter): def __init__(self): self.first_delta_sent = asyncio.Event() self.release_generation = asyncio.Event() self.finished = False async def stream(self, session_id, turn_id, text): yield "Olá, como posso ajudar?" self.first_delta_sent.set() await self.release_generation.wait() yield " Estou à disposição." self.finished = True class StreamingTTS(MockTTSAdapter): async def synthesize(self, text): yield text.encode("utf-16-le") agent = StreamingAgent() pipeline = VoicePipeline( session=SessionState(session_id="stream-session"), turn_manager=TurnManager(), agent=agent, tts=StreamingTTS(), ) turn = pipeline.start_turn() pipeline.on_final(turn.turn_id, "Olá") first_audio_received = asyncio.Event() received_events = [] async def consume(): async with aclosing( pipeline.stream_turn_response( session_id="stream-session", turn_id=turn.turn_id, text="Olá", ) ) as stream: async for event in stream: received_events.append(event) if isinstance(event, PipelineAudioChunk): first_audio_received.set() task = asyncio.create_task(consume()) try: await asyncio.wait_for( first_audio_received.wait(), timeout=2.0, ) assert agent.finished is False assert any( isinstance(event, PipelineTextDelta) for event in received_events ) assert any( isinstance(event, PipelineAudioChunk) for event in received_events ) agent.release_generation.set() await asyncio.wait_for(task, timeout=2.0) assert agent.finished is True assert any( isinstance(event, PipelineAgentResponse) for event in received_events ) assert ( pipeline.turn_manager.active_turn.state == TurnState.COMPLETED ) finally: agent.release_generation.set() if not task.done(): task.cancel() await asyncio.gather( task, return_exceptions=True, ) @pytest.mark.anyio async def test_stream_turn_response_propagates_agent_failure(): class FailingStreamingAgent(StreamingAgentAdapter): async def stream(self, session_id, turn_id, text): yield "Olá, como posso ajudar?" raise RuntimeError("Simulated OCI streaming failure.") class StreamingTTS(MockTTSAdapter): async def synthesize(self, text): yield text.encode("utf-16-le") pipeline = VoicePipeline( session=SessionState(session_id="agent-failure-session"), turn_manager=TurnManager(), agent=FailingStreamingAgent(), tts=StreamingTTS(), ) turn = pipeline.start_turn() pipeline.on_final(turn.turn_id, "Olá") async def consume(): async with aclosing( pipeline.stream_turn_response( session_id="agent-failure-session", turn_id=turn.turn_id, text="Olá", ) ) as stream: return [event async for event in stream] with pytest.raises(ExceptionGroup) as exc_info: await asyncio.wait_for(consume(), timeout=2.0) assert any( isinstance(error, RuntimeError) and "Simulated OCI streaming failure" in str(error) for error in exc_info.value.exceptions ) @pytest.mark.anyio async def test_stream_turn_response_cancels_agent_on_tts_failure(): class BlockingStreamingAgent(StreamingAgentAdapter): def __init__(self): self.cancelled = asyncio.Event() async def stream(self, session_id, turn_id, text): try: yield "Olá, como posso ajudar?" await asyncio.Event().wait() finally: self.cancelled.set() class FailingTTS(MockTTSAdapter): async def synthesize(self, text): raise RuntimeError("Simulated TTS failure.") yield b"" # Keep this method an async generator. agent = BlockingStreamingAgent() pipeline = VoicePipeline( session=SessionState(session_id="tts-failure-session"), turn_manager=TurnManager(), agent=agent, tts=FailingTTS(), ) turn = pipeline.start_turn() pipeline.on_final(turn.turn_id, "Olá") async def consume(): async with aclosing( pipeline.stream_turn_response( session_id="tts-failure-session", turn_id=turn.turn_id, text="Olá", ) ) as stream: async for _ in stream: pass with pytest.raises(ExceptionGroup) as exc_info: await asyncio.wait_for(consume(), timeout=2.0) assert any( isinstance(error, RuntimeError) and "Simulated TTS failure" in str(error) for error in exc_info.value.exceptions ) assert agent.cancelled.is_set() @pytest.mark.anyio async def test_stream_turn_response_external_cancellation(): class BlockingAgent(StreamingAgentAdapter): def __init__(self): self.closed = asyncio.Event() async def stream(self, session_id, turn_id, text): try: yield "Olá, como posso ajudar?" await asyncio.Event().wait() finally: self.closed.set() class BlockingTTS(MockTTSAdapter): def __init__(self): self.started = asyncio.Event() self.closed = asyncio.Event() async def synthesize(self, text): try: self.started.set() await asyncio.Event().wait() yield b"audi" finally: self.closed.set() agent = BlockingAgent() tts = BlockingTTS() pipeline = VoicePipeline( session=SessionState(session_id="cancel-session"), turn_manager=TurnManager(), agent=agent, tts=tts, ) turn = pipeline.start_turn() pipeline.on_final(turn.turn_id, "Olá") async def consume(): async with aclosing( pipeline.stream_turn_response( session_id="cancel-session", turn_id=turn.turn_id, text="Olá", ) ) as stream: async for _ in stream: pass task = asyncio.create_task(consume()) try: await asyncio.wait_for(tts.started.wait(), timeout=2.0) task.cancel() with pytest.raises(asyncio.CancelledError): await asyncio.wait_for(task, timeout=2.0) assert agent.closed.is_set() assert tts.closed.is_set() finally: if not task.done(): task.cancel() await asyncio.gather(task, return_exceptions=True)