1094 lines
26 KiB
Python
1094 lines
26 KiB
Python
from voice_assistant.agent.adapter import StreamingAgentAdapter
|
|
"""
|
|
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 contextlib import aclosing
|
|
|
|
from voice_assistant.voice_pipeline import (
|
|
PipelineAudioChunk,
|
|
PipelineAgentResponse,
|
|
PipelineTextDelta,
|
|
VoicePipeline,
|
|
)
|
|
|
|
from voice_assistant.domain.session import SessionState
|
|
from voice_assistant.turn_manager import TurnManager, TurnState
|
|
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
|
|
|
|
@pytest.mark.anyio
|
|
async def test_stream_turn_response_produces_audio_before_agent_finishes():
|
|
"""
|
|
Verify that TTS audio can be delivered while the Agent
|
|
is still generating its response.
|
|
"""
|
|
|
|
class StreamingAgent(StreamingAgentAdapter):
|
|
def __init__(self):
|
|
self.first_delta_sent = asyncio.Event()
|
|
self.release_generation = asyncio.Event()
|
|
self.finished = False
|
|
|
|
async def stream(self, session_id, turn_id, text):
|
|
yield "Olá, como posso ajudar?"
|
|
|
|
self.first_delta_sent.set()
|
|
|
|
await self.release_generation.wait()
|
|
|
|
yield " Estou à disposição."
|
|
self.finished = True
|
|
|
|
class StreamingTTS(MockTTSAdapter):
|
|
async def synthesize(self, text):
|
|
yield text.encode("utf-16-le")
|
|
|
|
agent = StreamingAgent()
|
|
|
|
pipeline = VoicePipeline(
|
|
session=SessionState(session_id="stream-session"),
|
|
turn_manager=TurnManager(),
|
|
agent=agent,
|
|
tts=StreamingTTS(),
|
|
)
|
|
|
|
turn = pipeline.start_turn()
|
|
pipeline.on_final(turn.turn_id, "Olá")
|
|
|
|
first_audio_received = asyncio.Event()
|
|
received_events = []
|
|
|
|
async def consume():
|
|
async with aclosing(
|
|
pipeline.stream_turn_response(
|
|
session_id="stream-session",
|
|
turn_id=turn.turn_id,
|
|
text="Olá",
|
|
)
|
|
) as stream:
|
|
async for event in stream:
|
|
received_events.append(event)
|
|
|
|
if isinstance(event, PipelineAudioChunk):
|
|
first_audio_received.set()
|
|
|
|
task = asyncio.create_task(consume())
|
|
|
|
try:
|
|
await asyncio.wait_for(
|
|
first_audio_received.wait(),
|
|
timeout=2.0,
|
|
)
|
|
|
|
assert agent.finished is False
|
|
|
|
assert any(
|
|
isinstance(event, PipelineTextDelta)
|
|
for event in received_events
|
|
)
|
|
|
|
assert any(
|
|
isinstance(event, PipelineAudioChunk)
|
|
for event in received_events
|
|
)
|
|
|
|
agent.release_generation.set()
|
|
|
|
await asyncio.wait_for(task, timeout=2.0)
|
|
|
|
assert agent.finished is True
|
|
|
|
assert any(
|
|
isinstance(event, PipelineAgentResponse)
|
|
for event in received_events
|
|
)
|
|
|
|
assert (
|
|
pipeline.turn_manager.active_turn.state
|
|
== TurnState.COMPLETED
|
|
)
|
|
|
|
finally:
|
|
agent.release_generation.set()
|
|
|
|
if not task.done():
|
|
task.cancel()
|
|
|
|
await asyncio.gather(
|
|
task,
|
|
return_exceptions=True,
|
|
)
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_stream_turn_response_propagates_agent_failure():
|
|
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(MockTTSAdapter):
|
|
async def synthesize(self, text):
|
|
yield text.encode("utf-16-le")
|
|
|
|
pipeline = VoicePipeline(
|
|
session=SessionState(session_id="agent-failure-session"),
|
|
turn_manager=TurnManager(),
|
|
agent=FailingStreamingAgent(),
|
|
tts=StreamingTTS(),
|
|
)
|
|
|
|
turn = pipeline.start_turn()
|
|
pipeline.on_final(turn.turn_id, "Olá")
|
|
|
|
async def consume():
|
|
async with aclosing(
|
|
pipeline.stream_turn_response(
|
|
session_id="agent-failure-session",
|
|
turn_id=turn.turn_id,
|
|
text="Olá",
|
|
)
|
|
) as stream:
|
|
return [event async for event in stream]
|
|
|
|
with pytest.raises(ExceptionGroup) as exc_info:
|
|
await asyncio.wait_for(consume(), timeout=2.0)
|
|
|
|
assert any(
|
|
isinstance(error, RuntimeError)
|
|
and "Simulated OCI streaming failure" in str(error)
|
|
for error in exc_info.value.exceptions
|
|
)
|
|
|
|
@pytest.mark.anyio
|
|
async def test_stream_turn_response_cancels_agent_on_tts_failure():
|
|
class BlockingStreamingAgent(StreamingAgentAdapter):
|
|
def __init__(self):
|
|
self.cancelled = asyncio.Event()
|
|
|
|
async def stream(self, session_id, turn_id, text):
|
|
try:
|
|
yield "Olá, como posso ajudar?"
|
|
await asyncio.Event().wait()
|
|
finally:
|
|
self.cancelled.set()
|
|
|
|
class FailingTTS(MockTTSAdapter):
|
|
async def synthesize(self, text):
|
|
raise RuntimeError("Simulated TTS failure.")
|
|
yield b"" # Keep this method an async generator.
|
|
|
|
agent = BlockingStreamingAgent()
|
|
|
|
pipeline = VoicePipeline(
|
|
session=SessionState(session_id="tts-failure-session"),
|
|
turn_manager=TurnManager(),
|
|
agent=agent,
|
|
tts=FailingTTS(),
|
|
)
|
|
|
|
turn = pipeline.start_turn()
|
|
pipeline.on_final(turn.turn_id, "Olá")
|
|
|
|
async def consume():
|
|
async with aclosing(
|
|
pipeline.stream_turn_response(
|
|
session_id="tts-failure-session",
|
|
turn_id=turn.turn_id,
|
|
text="Olá",
|
|
)
|
|
) as stream:
|
|
async for _ in stream:
|
|
pass
|
|
|
|
with pytest.raises(ExceptionGroup) as exc_info:
|
|
await asyncio.wait_for(consume(), timeout=2.0)
|
|
|
|
assert any(
|
|
isinstance(error, RuntimeError)
|
|
and "Simulated TTS failure" in str(error)
|
|
for error in exc_info.value.exceptions
|
|
)
|
|
|
|
assert agent.cancelled.is_set()
|
|
|
|
@pytest.mark.anyio
|
|
async def test_stream_turn_response_external_cancellation():
|
|
class BlockingAgent(StreamingAgentAdapter):
|
|
def __init__(self):
|
|
self.closed = asyncio.Event()
|
|
|
|
async def stream(self, session_id, turn_id, text):
|
|
try:
|
|
yield "Olá, como posso ajudar?"
|
|
await asyncio.Event().wait()
|
|
finally:
|
|
self.closed.set()
|
|
|
|
class BlockingTTS(MockTTSAdapter):
|
|
def __init__(self):
|
|
self.started = asyncio.Event()
|
|
self.closed = asyncio.Event()
|
|
|
|
async def synthesize(self, text):
|
|
try:
|
|
self.started.set()
|
|
await asyncio.Event().wait()
|
|
yield b"audi"
|
|
finally:
|
|
self.closed.set()
|
|
|
|
agent = BlockingAgent()
|
|
tts = BlockingTTS()
|
|
|
|
pipeline = VoicePipeline(
|
|
session=SessionState(session_id="cancel-session"),
|
|
turn_manager=TurnManager(),
|
|
agent=agent,
|
|
tts=tts,
|
|
)
|
|
|
|
turn = pipeline.start_turn()
|
|
pipeline.on_final(turn.turn_id, "Olá")
|
|
|
|
async def consume():
|
|
async with aclosing(
|
|
pipeline.stream_turn_response(
|
|
session_id="cancel-session",
|
|
turn_id=turn.turn_id,
|
|
text="Olá",
|
|
)
|
|
) as stream:
|
|
async for _ in stream:
|
|
pass
|
|
|
|
task = asyncio.create_task(consume())
|
|
|
|
try:
|
|
await asyncio.wait_for(tts.started.wait(), timeout=2.0)
|
|
|
|
task.cancel()
|
|
|
|
with pytest.raises(asyncio.CancelledError):
|
|
await asyncio.wait_for(task, timeout=2.0)
|
|
|
|
assert agent.closed.is_set()
|
|
assert tts.closed.is_set()
|
|
|
|
finally:
|
|
if not task.done():
|
|
task.cancel()
|
|
|
|
await asyncio.gather(task, return_exceptions=True)
|
|
|