refactor: formaliza contratos de streaming e ciclo de vida dos provedores

This commit is contained in:
alex alves
2026-11-03 16:15:14 -03:00
parent dc45dbdb6d
commit 4d7ff46a36
14 changed files with 337 additions and 57 deletions

View File

@@ -1,3 +1,5 @@
from voice_assistant.agent.adapter import StreamingAgentAdapter
from voice_assistant.tts.adapter import TTSAdapter
import asyncio
from concurrent.futures import ThreadPoolExecutor
from contextlib import contextmanager
@@ -1858,12 +1860,12 @@ def test_websocket_streaming_agent_and_tts():
"""
created_stt.clear()
class StreamingTestAgent:
class StreamingTestAgent(StreamingAgentAdapter):
async def stream(self, session_id, turn_id, text):
yield "Olá, como posso ajudar?"
yield " Estou à disposição."
class StreamingTestTTS:
class StreamingTestTTS(TTSAdapter):
async def synthesize(self, text):
yield text.encode("utf-8")
@@ -1961,11 +1963,11 @@ def test_websocket_interrupt_during_streaming_tts():
tts_started = threading.Event()
tts_cancelled = threading.Event()
class SlowStreamingAgent:
class SlowStreamingAgent(StreamingAgentAdapter):
async def stream(self, session_id, turn_id, text):
yield "Olá, esta é uma resposta longa."
class SlowStreamingTTS:
class SlowStreamingTTS(TTSAdapter):
async def synthesize(self, text):
try:
tts_started.set()
@@ -2100,11 +2102,11 @@ def test_websocket_session_end_during_streaming_tts():
tts_cancelled = threading.Event()
class SlowStreamingAgent:
class SlowStreamingAgent(StreamingAgentAdapter):
async def stream(self, session_id, turn_id, text):
yield "Esta é uma resposta longa."
class SlowStreamingTTS:
class SlowStreamingTTS(TTSAdapter):
async def synthesize(self, text):
try:
yield b"\x01\x02\x03\x04"
@@ -2297,11 +2299,11 @@ def test_websocket_interrupt_wins_pending_tts_completion():
tts_cancelled = threading.Event()
class ControlledAgent:
class ControlledAgent(StreamingAgentAdapter):
async def stream(self, session_id, turn_id, text):
yield "Esta resposta será interrompida."
class ControlledTTS:
class ControlledTTS(TTSAdapter):
async def synthesize(self, text):
try:
yield b"\x01\x02\x03\x04"

View File

@@ -0,0 +1,194 @@
"""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()

View File

@@ -1,3 +1,5 @@
from voice_assistant.agent.adapter import StreamingAgentAdapter
from voice_assistant.tts.adapter import TTSAdapter
"""
Tests for the Voice Pipeline lifecycle orchestration.
@@ -832,7 +834,7 @@ async def test_stream_turn_response_produces_audio_before_agent_finishes():
is still generating its response.
"""
class StreamingAgent:
class StreamingAgent(StreamingAgentAdapter):
def __init__(self):
self.first_delta_sent = asyncio.Event()
self.release_generation = asyncio.Event()
@@ -848,7 +850,7 @@ async def test_stream_turn_response_produces_audio_before_agent_finishes():
yield " Estou à disposição."
self.finished = True
class StreamingTTS:
class StreamingTTS(TTSAdapter):
async def synthesize(self, text):
yield text.encode("utf-8")
@@ -931,12 +933,12 @@ async def test_stream_turn_response_produces_audio_before_agent_finishes():
@pytest.mark.anyio
async def test_stream_turn_response_propagates_agent_failure():
class FailingStreamingAgent:
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:
class StreamingTTS(TTSAdapter):
async def synthesize(self, text):
yield text.encode("utf-8")
@@ -971,7 +973,7 @@ async def test_stream_turn_response_propagates_agent_failure():
@pytest.mark.anyio
async def test_stream_turn_response_cancels_agent_on_tts_failure():
class BlockingStreamingAgent:
class BlockingStreamingAgent(StreamingAgentAdapter):
def __init__(self):
self.cancelled = asyncio.Event()
@@ -982,7 +984,7 @@ async def test_stream_turn_response_cancels_agent_on_tts_failure():
finally:
self.cancelled.set()
class FailingTTS:
class FailingTTS(TTSAdapter):
async def synthesize(self, text):
raise RuntimeError("Simulated TTS failure.")
yield b"" # Keep this method an async generator.
@@ -1023,7 +1025,7 @@ async def test_stream_turn_response_cancels_agent_on_tts_failure():
@pytest.mark.anyio
async def test_stream_turn_response_external_cancellation():
class BlockingAgent:
class BlockingAgent(StreamingAgentAdapter):
def __init__(self):
self.closed = asyncio.Event()
@@ -1034,7 +1036,7 @@ async def test_stream_turn_response_external_cancellation():
finally:
self.closed.set()
class BlockingTTS:
class BlockingTTS(TTSAdapter):
def __init__(self):
self.started = asyncio.Event()
self.closed = asyncio.Event()