feat: establish voice assistant V1 baseline
This commit is contained in:
820
tests/test_voice_pipeline.py
Normal file
820
tests/test_voice_pipeline.py
Normal file
@@ -0,0 +1,820 @@
|
||||
"""
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user