821 lines
18 KiB
Python
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
|
|
|