feat: establish voice assistant V1 baseline

This commit is contained in:
2026-10-09 18:31:21 +00:00
commit cebcb3fd61
57 changed files with 9501 additions and 0 deletions

View File

@@ -0,0 +1,281 @@
"""
Tests for SessionState and TurnManager lifecycle ownership.
Purpose:
Define and verify the separation between session lifecycle state
and semantic turn state.
Responsibilities:
- Verify SessionState owns session and audio sequencing state.
- Verify TurnManager owns semantic turn transitions.
- Verify the expected lifecycle of a complete turn.
- Verify interruption and invalid transitions.
Non-responsibilities:
- Testing STT, Agent, or TTS providers.
- Testing WebSocket transport.
- Testing provider configuration.
"""
import pytest
from voice_assistant.audio_gateway.app.session import SessionState
from voice_assistant.turn_manager import TurnManager, TurnState
def test_session_creates_sequential_turn_ids():
session = SessionState()
first_turn = session.start_turn()
assert first_turn == 1
assert session.active_turn_id == 1
assert session.expected_audio_sequence == 0
session.end_turn(first_turn)
second_turn = session.start_turn()
assert second_turn == 2
assert session.active_turn_id == 2
assert session.expected_audio_sequence == 0
def test_session_owns_audio_sequence():
session = SessionState()
turn_id = session.start_turn()
session.accept_audio_chunk(
turn_id=turn_id,
sequence_number=0,
payload_size=320,
)
assert session.expected_audio_sequence == 1
session.accept_audio_chunk(
turn_id=turn_id,
sequence_number=1,
payload_size=320,
)
assert session.expected_audio_sequence == 2
def test_session_rejects_out_of_order_audio():
session = SessionState()
turn_id = session.start_turn()
with pytest.raises(
ValueError,
match="Expected sequence_number=0",
):
session.accept_audio_chunk(
turn_id=turn_id,
sequence_number=1,
payload_size=320,
)
def test_turn_manager_owns_semantic_turn_state():
manager = TurnManager()
turn = manager.start(turn_id=1)
assert turn.turn_id == 1
assert turn.state == TurnState.LISTENING
manager.on_partial(
turn_id=1,
text="Olá",
)
assert turn.state == TurnState.LISTENING
manager.on_final(
turn_id=1,
text="Olá!",
)
assert turn.state == TurnState.PROCESSING
manager.on_agent_response(turn_id=1)
assert turn.state == TurnState.SPEAKING
manager.on_tts_complete(turn_id=1)
assert turn.state == TurnState.COMPLETED
def test_turn_manager_rejects_invalid_transition():
manager = TurnManager()
manager.start(turn_id=1)
with pytest.raises(
RuntimeError,
match="Cannot start speaking",
):
manager.on_agent_response(turn_id=1)
def test_turn_manager_interrupts_active_turn():
manager = TurnManager()
manager.start(turn_id=1)
turn = manager.interrupt(turn_id=1)
assert turn.state == TurnState.INTERRUPTED
assert manager.active_turn is turn
def test_turn_manager_stop_requires_completed_state():
manager = TurnManager()
manager.start(turn_id=1)
manager.on_final(
turn_id=1,
text="Olá!",
)
with pytest.raises(
RuntimeError,
match="Cannot stop turn while turn is processing",
):
manager.stop(turn_id=1)
def test_turn_manager_stop_allows_completed_turn():
manager = TurnManager()
manager.start(turn_id=1)
manager.on_final(
turn_id=1,
text="Olá!",
)
manager.on_agent_response(turn_id=1)
manager.on_tts_complete(turn_id=1)
turn = manager.stop(turn_id=1)
assert turn.state == TurnState.COMPLETED
assert turn.final_text == "Olá!"
def test_session_and_turn_manager_complete_same_turn():
session = SessionState()
manager = TurnManager()
turn_id = session.start_turn()
turn = manager.start(turn_id)
assert session.active_turn_id == turn_id
assert turn.turn_id == turn_id
assert turn.state == TurnState.LISTENING
manager.on_final(
turn_id,
"Olá!",
)
manager.on_agent_response(turn_id)
manager.on_tts_complete(turn_id)
completed_turn = manager.stop(turn_id)
assert completed_turn.state == TurnState.COMPLETED
manager.clear(turn_id)
session.end_turn(turn_id)
assert manager.active_turn is None
assert session.active_turn_id is None
def test_session_and_turn_manager_must_use_same_turn_id():
session = SessionState()
manager = TurnManager()
session_turn_id = session.start_turn()
manager.start(1)
with pytest.raises(
ValueError,
match="Unexpected turn_id=2",
):
session.accept_audio_chunk(
turn_id=2,
sequence_number=0,
payload_size=320,
)
assert session.active_turn_id == session_turn_id
assert manager.active_turn is not None
assert manager.active_turn.turn_id == 1
def test_session_ends_turn_after_turn_manager_completion():
session = SessionState()
manager = TurnManager()
turn_id = session.start_turn()
manager.start(turn_id)
manager.on_final(
turn_id,
"Olá!",
)
manager.on_agent_response(turn_id)
manager.on_tts_complete(turn_id)
manager.stop(turn_id)
manager.clear(turn_id)
session.end_turn(turn_id)
assert session.active_turn_id is None
def test_session_and_turn_manager_interrupt_same_turn():
session = SessionState()
manager = TurnManager()
turn_id = session.start_turn()
manager.start(turn_id)
session.interrupt(turn_id)
interrupted_turn = manager.interrupt(turn_id)
assert session.active_turn_id is None
assert manager.active_turn is interrupted_turn
assert interrupted_turn.turn_id == turn_id
assert interrupted_turn.state == TurnState.INTERRUPTED
def test_session_can_start_next_turn_after_interrupt():
session = SessionState()
manager = TurnManager()
first_turn_id = session.start_turn()
manager.start(first_turn_id)
session.interrupt(first_turn_id)
manager.interrupt(first_turn_id)
manager.clear(first_turn_id)
second_turn_id = session.start_turn()
manager.start(second_turn_id)
assert second_turn_id == 2
assert session.active_turn_id == 2
assert manager.active_turn is not None
assert manager.active_turn.turn_id == 2
assert manager.active_turn.state == TurnState.LISTENING