152 lines
4.7 KiB
Python
152 lines
4.7 KiB
Python
"""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()
|