194 lines
5.8 KiB
Python
194 lines
5.8 KiB
Python
"""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.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(MockTTSAdapter):
|
|
async def synthesize(self, text):
|
|
try:
|
|
yield b"audi"
|
|
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"audi"
|
|
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(MockTTSAdapter):
|
|
async def synthesize(self, text):
|
|
try:
|
|
yield b"audi"
|
|
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()
|