Files
voiceagent/tests/test_voice_pipeline.py

821 lines
18 KiB
Python

"""
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 voice_assistant.audio_gateway.app.session import SessionState
from voice_assistant.turn_manager import TurnManager, TurnState
from voice_assistant.voice_pipeline import VoicePipeline
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