Files
voiceagent/tests/test_gateway.py

2721 lines
78 KiB
Python

from voice_assistant.agent.adapter import StreamingAgentAdapter
import asyncio
from concurrent.futures import ThreadPoolExecutor
from contextlib import contextmanager
import threading
import pytest
from unittest.mock import patch, AsyncMock, Mock
from collections.abc import AsyncIterator
from fastapi.testclient import TestClient
from voice_assistant.stt.mock import MockSTTAdapter
from voice_assistant.turn_manager import (
TurnManager,
TurnState,
)
from voice_assistant.audio_gateway.app import main
from voice_assistant.audio_gateway.app.protocol import (
AUDIO_CHANNELS,
AUDIO_ENCODING,
AUDIO_SAMPLE_RATE,
)
# Teste Agent
from voice_assistant.agent.mock import MockAgentAdapter
# Teste TTS
from voice_assistant.tts.mock import MockTTSAdapter
from voice_assistant.voice_pipeline import VoicePipeline
from voice_assistant.application import voice_session as voice_session_module
from voice_assistant.application.voice_session import VoiceSession
async def forward_stt_events(websocket, stt, pipeline, session_id, turn_id):
# Exercise application event handling with the real WebSocket mapper.
async def emit(event):
await main.send_voice_event(websocket, event)
conversation = VoiceSession(pipeline=pipeline, create_stt=lambda: stt, emit=emit)
await conversation._consume_stt_events(stt, turn_id)
from voice_assistant.stt.models import TranscriptEvent
created_stt: list[MockSTTAdapter] = []
created_turn_managers: list[TurnManager] = []
created_agents: list[MockAgentAdapter] = []
created_tts: list[MockTTSAdapter] = []
def create_test_agent(
config=None,
catalog=None,
) -> MockAgentAdapter:
agent = MockAgentAdapter()
created_agents.append(agent)
return agent
def create_mock_stt(
config=None,
catalog=None,
) -> MockSTTAdapter:
stt = MockSTTAdapter()
created_stt.append(stt)
return stt
def create_test_turn_manager() -> TurnManager:
manager = TurnManager()
created_turn_managers.append(manager)
return manager
def create_test_tts(
config=None,
catalog=None,
) -> MockTTSAdapter:
tts = MockTTSAdapter()
created_tts.append(tts)
return tts
app = main.app
client = TestClient(app)
main.create_stt_adapter = create_mock_stt
main.create_turn_manager = create_test_turn_manager
main.create_agent_adapter = create_test_agent
main.create_tts_adapter = create_test_tts
def test_health():
response = client.get("/health")
assert response.status_code == 200
assert response.json() == {"status": "ok"}
def test_ready():
response = client.get("/ready")
assert response.status_code == 200
assert response.json() == {"status": "ready"}
def test_websocket_session_start():
created_agents.clear()
created_tts.clear()
with client.websocket_connect("/ws/audio") as websocket:
websocket.send_json(
{
"type": "session.start",
"audio": {
"encoding": AUDIO_ENCODING,
"sample_rate": AUDIO_SAMPLE_RATE,
"channels": AUDIO_CHANNELS,
},
}
)
response = websocket.receive_json()
assert response["type"] == "session.ready"
assert response["protocol_version"] == "1.0"
assert response["session_id"]
assert len(created_agents) == 1
assert len(created_tts) == 1
def test_websocket_audio_turn():
created_stt.clear()
created_agents.clear()
created_tts.clear()
with client.websocket_connect("/ws/audio") as websocket:
websocket.send_json(
{
"type": "session.start",
"audio": {
"encoding": AUDIO_ENCODING,
"sample_rate": AUDIO_SAMPLE_RATE,
"channels": AUDIO_CHANNELS,
},
}
)
session_ready = websocket.receive_json()
assert session_ready["type"] == "session.ready"
websocket.send_json(
{
"type": "audio.start",
}
)
turn_started = websocket.receive_json()
assert turn_started["type"] == "turn.started"
assert turn_started["turn_id"] == 1
turn_id = turn_started["turn_id"]
assert len(created_stt) == 1
stt = created_stt[0]
audio = b"\x00\x00" * 160
websocket.send_json(
{
"type": "audio.chunk",
"turn_id": turn_id,
"sequence_number": 0,
}
)
websocket.send_bytes(audio)
stt.emit_partial_now("Olá")
partial = websocket.receive_json()
assert partial == {
"type": "stt.partial",
"session_id": session_ready["session_id"],
"turn_id": turn_id,
"text": "Olá",
}
stt.emit_final_now("Olá")
final = websocket.receive_json()
assert final == {
"type": "stt.final",
"session_id": session_ready["session_id"],
"turn_id": turn_id,
"text": "Olá",
}
agent_response = websocket.receive_json()
assert agent_response == {
"type": "agent.response",
"session_id": session_ready["session_id"],
"turn_id": turn_id,
"text": "Resposta simulada para: Olá",
}
audio_chunk_1 = websocket.receive_bytes()
audio_chunk_2 = websocket.receive_bytes()
assert audio_chunk_1 == b"\x01\x02\x03\x04"
assert audio_chunk_2 == b"\x05\x06\x07\x08"
websocket.send_json(
{
"type": "audio.stop",
"turn_id": turn_id,
}
)
turn_ended = websocket.receive_json()
assert turn_ended["type"] == "turn.ended"
assert turn_ended["turn_id"] == turn_id
def test_websocket_sequence_error():
created_stt.clear()
with client.websocket_connect("/ws/audio") as websocket:
websocket.send_json(
{
"type": "session.start",
"audio": {
"encoding": AUDIO_ENCODING,
"sample_rate": AUDIO_SAMPLE_RATE,
"channels": AUDIO_CHANNELS,
},
}
)
websocket.receive_json()
websocket.send_json(
{
"type": "audio.start",
}
)
turn_started = websocket.receive_json()
turn_id = turn_started["turn_id"]
websocket.send_json(
{
"type": "audio.chunk",
"turn_id": turn_id,
"sequence_number": 1,
}
)
websocket.send_bytes(b"\x00\x00" * 160)
error = websocket.receive_json()
assert error["type"] == "error"
assert error["code"] == "AUDIO_SEQUENCE_ERROR"
def test_websocket_interrupt():
created_stt.clear()
with client.websocket_connect("/ws/audio") as websocket:
websocket.send_json(
{
"type": "session.start",
"audio": {
"encoding": AUDIO_ENCODING,
"sample_rate": AUDIO_SAMPLE_RATE,
"channels": AUDIO_CHANNELS,
},
}
)
session_ready = websocket.receive_json()
assert session_ready["type"] == "session.ready"
websocket.send_json(
{
"type": "audio.start",
}
)
turn_started = websocket.receive_json()
assert turn_started["type"] == "turn.started"
assert turn_started["turn_id"] == 1
turn_id = turn_started["turn_id"]
assert len(created_stt) == 1
websocket.send_json(
{
"type": "interrupt",
"turn_id": turn_id,
}
)
interrupted = websocket.receive_json()
assert interrupted["type"] == "interrupted"
assert interrupted["session_id"] == session_ready["session_id"]
assert interrupted["turn_id"] == turn_id
websocket.send_json(
{
"type": "audio.start",
}
)
next_turn = websocket.receive_json()
assert next_turn["type"] == "turn.started"
assert next_turn["turn_id"] == 2
def test_websocket_session_end():
created_stt.clear()
with client.websocket_connect("/ws/audio") as websocket:
websocket.send_json(
{
"type": "session.start",
"audio": {
"encoding": AUDIO_ENCODING,
"sample_rate": AUDIO_SAMPLE_RATE,
"channels": AUDIO_CHANNELS,
},
}
)
session_ready = websocket.receive_json()
assert session_ready["type"] == "session.ready"
session_id = session_ready["session_id"]
websocket.send_json(
{
"type": "session.end",
}
)
session_ended = websocket.receive_json()
assert session_ended["type"] == "session.ended"
assert session_ended["session_id"] == session_id
def test_websocket_session_end_closes_agent_provider():
"""
Explicit session.end must close the Agent provider.
"""
agent = MockAgentAdapter()
agent.aclose = AsyncMock()
with patch.object(
main,
"create_agent_adapter",
return_value=agent,
):
with client.websocket_connect("/ws/audio") as websocket:
websocket.send_json({
"type": "session.start",
"audio": {
"encoding": AUDIO_ENCODING,
"sample_rate": AUDIO_SAMPLE_RATE,
"channels": AUDIO_CHANNELS,
},
})
ready = websocket.receive_json()
assert ready["type"] == "session.ready"
websocket.send_json({
"type": "session.end",
})
ended = websocket.receive_json()
assert ended["type"] == "session.ended"
agent.aclose.assert_awaited_once()
def test_websocket_disconnect_closes_agent_provider():
"""
Unexpected WebSocket disconnect must close the Agent provider.
"""
agent = MockAgentAdapter()
agent.aclose = AsyncMock()
with patch.object(
main,
"create_agent_adapter",
return_value=agent,
):
with client.websocket_connect("/ws/audio") as websocket:
websocket.send_json({
"type": "session.start",
"audio": {
"encoding": AUDIO_ENCODING,
"sample_rate": AUDIO_SAMPLE_RATE,
"channels": AUDIO_CHANNELS,
},
})
ready = websocket.receive_json()
assert ready["type"] == "session.ready"
# Leave without sending session.end.
# TestClient closes the WebSocket context.
agent.aclose.assert_awaited_once()
def test_websocket_interrupt_preserves_agent_provider():
"""
Interrupt must cancel the turn without closing the Agent provider.
The provider is closed only when the WebSocket session ends.
"""
created_stt.clear()
agent = MockAgentAdapter()
agent.aclose = AsyncMock()
with patch.object(
main,
"create_agent_adapter",
return_value=agent,
):
with client.websocket_connect("/ws/audio") as websocket:
websocket.send_json({
"type": "session.start",
"audio": {
"encoding": AUDIO_ENCODING,
"sample_rate": AUDIO_SAMPLE_RATE,
"channels": AUDIO_CHANNELS,
},
})
ready = websocket.receive_json()
assert ready["type"] == "session.ready"
websocket.send_json({
"type": "audio.start",
})
started = websocket.receive_json()
assert started["type"] == "turn.started"
websocket.send_json({
"type": "interrupt",
"turn_id": started["turn_id"],
})
interrupted = websocket.receive_json()
assert interrupted["type"] == "interrupted"
# Interrupt must not close the provider.
agent.aclose.assert_not_awaited()
# The same WebSocket must accept a new turn.
websocket.send_json({
"type": "audio.start",
})
next_turn = websocket.receive_json()
assert next_turn["type"] == "turn.started"
assert next_turn["turn_id"] != started["turn_id"]
# Provider must remain open.
agent.aclose.assert_not_awaited()
websocket.send_json({
"type": "session.end",
})
ended = websocket.receive_json()
assert ended["type"] == "session.ended"
agent.aclose.assert_awaited_once()
def test_websocket_forwards_stt_events():
created_stt.clear()
created_turn_managers.clear()
created_agents.clear()
created_tts.clear()
with client.websocket_connect("/ws/audio") as websocket:
websocket.send_json(
{
"type": "session.start",
"audio": {
"encoding": AUDIO_ENCODING,
"sample_rate": AUDIO_SAMPLE_RATE,
"channels": AUDIO_CHANNELS,
},
}
)
session_ready = websocket.receive_json()
assert session_ready["type"] == "session.ready"
websocket.send_json(
{
"type": "audio.start",
}
)
turn_started = websocket.receive_json()
assert turn_started["type"] == "turn.started"
turn_id = turn_started["turn_id"]
assert len(created_turn_managers) == 1
turn_manager = created_turn_managers[0]
assert len(created_agents) == 1
agent = created_agents[0]
assert len(created_stt) == 1
stt = created_stt[0]
assert len(created_tts) == 1
tts = created_tts[0]
stt.emit_partial_now(
"Ô Alex tudo bem"
)
partial = websocket.receive_json()
assert partial == {
"type": "stt.partial",
"session_id": session_ready["session_id"],
"turn_id": turn_id,
"text": "Ô Alex tudo bem",
}
assert turn_manager.active_turn is not None
assert turn_manager.active_turn.turn_id == turn_id
assert turn_manager.active_turn.partial_text == (
"Ô Alex tudo bem"
)
assert turn_manager.active_turn.state == TurnState.LISTENING
stt.emit_final_now(
"Ô Alex tudo bem?"
)
final = websocket.receive_json()
assert final == {
"type": "stt.final",
"session_id": session_ready["session_id"],
"turn_id": turn_id,
"text": "Ô Alex tudo bem?",
}
assert turn_manager.active_turn is not None
assert turn_manager.active_turn.turn_id == turn_id
assert turn_manager.active_turn.final_text == (
"Ô Alex tudo bem?"
)
assert turn_manager.active_turn.partial_text == (
"Ô Alex tudo bem?"
)
assert agent.calls == [
(
session_ready["session_id"],
turn_id,
"Ô Alex tudo bem?",
)
]
agent_response = websocket.receive_json()
assert agent_response == {
"type": "agent.response",
"session_id": session_ready["session_id"],
"turn_id": turn_id,
"text": (
"Resposta simulada para: "
"Ô Alex tudo bem?"
),
}
assert tts.calls == [
"Resposta simulada para: Ô Alex tudo bem?"
]
audio_chunk_1 = websocket.receive_bytes()
audio_chunk_2 = websocket.receive_bytes()
assert audio_chunk_1 == b"\x01\x02\x03\x04"
assert audio_chunk_2 == b"\x05\x06\x07\x08"
assert turn_manager.active_turn is not None
assert turn_manager.active_turn.state == TurnState.COMPLETED
websocket.send_json(
{
"type": "audio.stop",
"turn_id": turn_id,
}
)
turn_ended = websocket.receive_json()
assert turn_ended == {
"type": "turn.ended",
"session_id": session_ready["session_id"],
"turn_id": turn_id,
}
def test_websocket_disconnect_cleans_active_turn():
"""
Verify cleanup when a WebSocket disconnects during an active turn.
Responsibilities:
- Confirm the STT session is aborted.
- Confirm the semantic turn is released.
- Confirm the Gateway handles disconnect without an error.
"""
created_stt.clear()
created_turn_managers.clear()
with client.websocket_connect("/ws/audio") as websocket:
websocket.send_json(
{
"type": "session.start",
"audio": {
"encoding": AUDIO_ENCODING,
"sample_rate": AUDIO_SAMPLE_RATE,
"channels": AUDIO_CHANNELS,
},
}
)
session_ready = websocket.receive_json()
assert session_ready["type"] == "session.ready"
websocket.send_json(
{
"type": "audio.start",
}
)
turn_started = websocket.receive_json()
assert turn_started["type"] == "turn.started"
assert turn_started["turn_id"] == 1
assert len(created_stt) == 1
assert len(created_turn_managers) == 1
stt = created_stt[0]
turn_manager = created_turn_managers[0]
assert stt._started is True
assert turn_manager.active_turn is not None
assert turn_manager.active_turn.state == TurnState.LISTENING
# Leaving the context closes the client WebSocket.
# The Gateway's finally block should perform cleanup.
assert stt._started is False
assert turn_manager.active_turn is None
def test_websocket_disconnect_executes_cleanup():
"""
Verify that WebSocket disconnection executes real cleanup.
Purpose:
Confirm cancellation and STT abort are invoked while
preserving their original implementations.
"""
created_stt.clear()
created_turn_managers.clear()
real_cancel = VoicePipeline.cancel_turn_task
real_abort = MockSTTAdapter.abort_session
async def tracked_cancel(pipeline):
return await real_cancel(pipeline)
async def tracked_abort(stt):
"""Record STT abort while preserving the real implementation."""
return await real_abort(stt)
with (
patch.object(
VoicePipeline,
"cancel_turn_task",
autospec=True,
side_effect=tracked_cancel,
) as cancel_mock,
patch.object(
MockSTTAdapter,
"abort_session",
autospec=True,
side_effect=tracked_abort,
) as abort_mock,
):
with client.websocket_connect("/ws/audio") as websocket:
websocket.send_json(
{
"type": "session.start",
"audio": {
"encoding": AUDIO_ENCODING,
"sample_rate": AUDIO_SAMPLE_RATE,
"channels": AUDIO_CHANNELS,
},
}
)
session_ready = websocket.receive_json()
assert session_ready["type"] == "session.ready"
websocket.send_json({"type": "audio.start"})
turn_started = websocket.receive_json()
assert turn_started["type"] == "turn.started"
cancel_mock.assert_awaited()
abort_mock.assert_awaited()
assert created_stt[0]._started is False
assert created_turn_managers[0].active_turn is None
def test_websocket_disconnect_awaits_active_turn_task():
"""
Verify that disconnect cleanup awaits the active turn task.
Purpose:
Ensure asynchronous turn execution terminates before
the Gateway releases the STT provider and semantic state.
Responsibilities:
- Capture the actual turn task created by the Gateway.
- Verify task cancellation on disconnect.
- Verify task termination precedes STT abort.
- Verify semantic turn state is released.
"""
created_stt.clear()
created_turn_managers.clear()
registered_tasks = []
cleanup_order = []
real_register = VoicePipeline.register_turn_task
real_abort = MockSTTAdapter.abort_session
def tracked_register(pipeline, task):
registered_tasks.append(task)
return real_register(pipeline, task)
async def tracked_abort(stt):
cleanup_order.append("stt.abort")
return await real_abort(stt)
with (
patch.object(
VoicePipeline,
"register_turn_task",
autospec=True,
side_effect=tracked_register,
),
patch.object(
MockSTTAdapter,
"abort_session",
autospec=True,
side_effect=tracked_abort,
),
):
with client.websocket_connect("/ws/audio") as websocket:
websocket.send_json(
{
"type": "session.start",
"audio": {
"encoding": AUDIO_ENCODING,
"sample_rate": AUDIO_SAMPLE_RATE,
"channels": AUDIO_CHANNELS,
},
}
)
session_ready = websocket.receive_json()
assert session_ready["type"] == "session.ready"
websocket.send_json({"type": "audio.start"})
turn_started = websocket.receive_json()
assert turn_started["type"] == "turn.started"
assert len(registered_tasks) == 1
# The context manager closes the WebSocket and waits
# for the Gateway's disconnect cleanup.
task = registered_tasks[0]
assert task.done()
assert task.cancelled()
assert cleanup_order == ["stt.abort"]
assert created_stt[0]._started is False
assert created_turn_managers[0].active_turn is None
def test_websocket_disconnect_continues_cleanup_after_stt_abort_failure(
):
"""
Verify that STT abort failures do not prevent session cleanup.
Purpose:
Ensure the Gateway continues releasing semantic state
when the STT provider fails during disconnect cleanup.
Responsibilities:
- Simulate an STT abort failure.
- Verify the failure is logged.
- Verify the semantic turn is released.
"""
created_stt.clear()
created_turn_managers.clear()
real_abort = MockSTTAdapter.abort_session
async def failing_abort(stt):
raise RuntimeError("Simulated STT abort failure.")
with patch.object(
MockSTTAdapter,
"abort_session",
autospec=True,
side_effect=failing_abort,
):
with patch.object(
voice_session_module.logger,
"exception",
) as log_mock:
with client.websocket_connect("/ws/audio") as websocket:
websocket.send_json(
{
"type": "session.start",
"audio": {
"encoding": AUDIO_ENCODING,
"sample_rate": AUDIO_SAMPLE_RATE,
"channels": AUDIO_CHANNELS,
},
}
)
session_ready = websocket.receive_json()
assert session_ready["type"] == "session.ready"
websocket.send_json({"type": "audio.start"})
turn_started = websocket.receive_json()
assert turn_started["type"] == "turn.started"
assert log_mock.called
assert created_turn_managers[0].active_turn is None
def test_websocket_disconnect_continues_cleanup_after_task_cancel_failure():
"""
Verify that task cancellation failures do not stop Gateway cleanup.
Purpose:
Ensure STT abort and semantic state cleanup still execute
when VoicePipeline.cancel_turn_task() raises an exception.
Responsibilities:
- Simulate a cancellation failure.
- Verify the failure is logged.
- Verify STT abort is attempted.
- Verify the active semantic turn is released.
"""
created_stt.clear()
created_turn_managers.clear()
real_cancel = VoicePipeline.cancel_turn_task
real_abort = MockSTTAdapter.abort_session
cancel_calls = 0
abort_calls = 0
async def failing_cancel(pipeline):
nonlocal cancel_calls
cancel_calls += 1
# Simulate failure while still stopping the real task,
# so the test does not leak asynchronous work.
await real_cancel(pipeline)
raise RuntimeError("Simulated turn task cancellation failure.")
async def tracked_abort(stt):
nonlocal abort_calls
abort_calls += 1
await real_abort(stt)
with (
patch.object(
VoicePipeline,
"cancel_turn_task",
autospec=True,
side_effect=failing_cancel,
),
patch.object(
MockSTTAdapter,
"abort_session",
autospec=True,
side_effect=tracked_abort,
),
patch.object(
voice_session_module.logger,
"exception",
) as log_mock,
):
with client.websocket_connect("/ws/audio") as websocket:
websocket.send_json(
{
"type": "session.start",
"audio": {
"encoding": AUDIO_ENCODING,
"sample_rate": AUDIO_SAMPLE_RATE,
"channels": AUDIO_CHANNELS,
},
}
)
session_ready = websocket.receive_json()
assert session_ready["type"] == "session.ready"
websocket.send_json({"type": "audio.start"})
turn_started = websocket.receive_json()
assert turn_started["type"] == "turn.started"
assert cancel_calls >= 1
assert abort_calls >= 1
assert any(
"Failed to cancel voice turn task" in str(call)
for call in log_mock.call_args_list
)
assert created_stt[0]._started is False
assert created_turn_managers[0].active_turn is None
def test_websocket_interrupt_timeout_preserves_active_turn():
"""
Verify Gateway behavior when turn cancellation times out.
Purpose:
Ensure a cancellation timeout does not falsely confirm
interruption or release an active semantic turn.
Responsibilities:
- Simulate cancellation timeout.
- Verify TURN_CANCEL_FAILED response.
- Preserve the active turn.
- Reject overlapping turn creation.
"""
created_stt.clear()
created_turn_managers.clear()
real_cancel = VoicePipeline.cancel_turn_task
async def simulated_timeout(pipeline):
raise TimeoutError(
"Turn task cancellation timed out."
)
with patch.object(
VoicePipeline,
"cancel_turn_task",
autospec=True,
side_effect=simulated_timeout,
):
with client.websocket_connect("/ws/audio") as websocket:
websocket.send_json(
{
"type": "session.start",
"audio": {
"encoding": AUDIO_ENCODING,
"sample_rate": AUDIO_SAMPLE_RATE,
"channels": AUDIO_CHANNELS,
},
}
)
session_ready = websocket.receive_json()
assert session_ready["type"] == "session.ready"
websocket.send_json({"type": "audio.start"})
turn_started = websocket.receive_json()
assert turn_started["type"] == "turn.started"
turn_id = turn_started["turn_id"]
websocket.send_json(
{
"type": "interrupt",
"turn_id": turn_id,
}
)
response = websocket.receive_json()
assert response["type"] == "error"
assert response["code"] == "TURN_CANCEL_FAILED"
assert response["turn_id"] == turn_id
assert "timed out" in response["message"]
assert created_turn_managers[0].active_turn is not None
assert (
created_turn_managers[0].active_turn.turn_id
== turn_id
)
websocket.send_json({"type": "audio.start"})
next_response = websocket.receive_json()
assert next_response["type"] == "error"
assert next_response["code"] == "INVALID_TURN"
def test_websocket_interrupt_during_agent():
"""Interrupt an active Agent without producing a late response."""
created_stt.clear()
created_turn_managers.clear()
agent = BlockingAgentAdapter()
with patch.object(
main,
"create_agent_adapter",
return_value=agent,
):
with client.websocket_connect("/ws/audio") as websocket:
websocket.send_json(
{
"type": "session.start",
"audio": {
"encoding": AUDIO_ENCODING,
"sample_rate": AUDIO_SAMPLE_RATE,
"channels": AUDIO_CHANNELS,
},
}
)
session_ready = websocket.receive_json()
assert session_ready["type"] == "session.ready"
websocket.send_json({"type": "audio.start"})
turn_started = websocket.receive_json()
assert turn_started["type"] == "turn.started"
turn_id = turn_started["turn_id"]
stt = created_stt[0]
stt.emit_final_now("Olá!")
final_event = websocket.receive_json()
assert final_event["type"] == "stt.final"
assert agent.started.wait(timeout=2.0)
websocket.send_json(
{
"type": "interrupt",
"turn_id": turn_id,
}
)
interrupted = websocket.receive_json()
assert interrupted["type"] == "interrupted"
assert interrupted["turn_id"] == turn_id
assert agent.cancelled.is_set()
assert created_turn_managers[0].active_turn is None
# A new turn must remain possible after cancellation.
websocket.send_json({"type": "audio.start"})
next_turn = websocket.receive_json()
assert next_turn["type"] == "turn.started"
assert next_turn["turn_id"] == 2
def test_websocket_interrupt_during_tts():
"""Interrupt TTS while synthesis is blocked."""
created_stt.clear()
created_turn_managers.clear()
tts = BlockingTTSAdapter()
with patch.object(
main,
"create_tts_adapter",
return_value=tts,
):
with client.websocket_connect("/ws/audio") as websocket:
websocket.send_json(
{
"type": "session.start",
"audio": {
"encoding": AUDIO_ENCODING,
"sample_rate": AUDIO_SAMPLE_RATE,
"channels": AUDIO_CHANNELS,
},
}
)
session_ready = websocket.receive_json()
assert session_ready["type"] == "session.ready"
websocket.send_json({"type": "audio.start"})
turn_started = websocket.receive_json()
assert turn_started["type"] == "turn.started"
turn_id = turn_started["turn_id"]
stt = created_stt[0]
stt.emit_final_now("Olá!")
final_event = websocket.receive_json()
assert final_event["type"] == "stt.final"
agent_event = websocket.receive_json()
assert agent_event["type"] == "agent.response"
assert tts.started.wait(timeout=2.0)
websocket.send_json(
{
"type": "interrupt",
"turn_id": turn_id,
}
)
interrupted = websocket.receive_json()
assert interrupted["type"] == "interrupted"
assert interrupted["turn_id"] == turn_id
assert tts.cancelled.is_set()
assert created_turn_managers[0].active_turn is None
class BlockingAgentAdapter(MockAgentAdapter):
"""Agent that waits until cancelled."""
def __init__(self) -> None:
super().__init__()
self.started = threading.Event()
self.cancelled = threading.Event()
async def process(
self,
session_id: str,
turn_id: int,
text: str,
) -> str:
self.started.set()
try:
await asyncio.Event().wait()
except asyncio.CancelledError:
self.cancelled.set()
raise
class BlockingTTSAdapter(MockTTSAdapter):
"""TTS that waits until cancelled before producing audio."""
def __init__(self) -> None:
super().__init__()
self.started = threading.Event()
self.cancelled = threading.Event()
async def synthesize(
self,
text: str,
) -> AsyncIterator[bytes]:
self.started.set()
try:
await asyncio.Event().wait()
yield b"\x01\x02\x03\x04"
except asyncio.CancelledError:
self.cancelled.set()
raise
class StreamingBlockingTTSAdapter(MockTTSAdapter):
"""
Mock TTS that emits one audio chunk and then blocks.
Purpose:
Test interruption during an active audio stream.
Responsibilities:
- Emit the first audio chunk.
- Wait indefinitely before producing the second.
- Record cancellation.
"""
FIRST_CHUNK = b"\x01\x02\x03\x04"
SECOND_CHUNK = b"\x05\x06\x07\x08"
def __init__(self) -> None:
super().__init__()
self.first_chunk_sent = threading.Event()
self.cancelled = threading.Event()
async def synthesize(
self,
text: str,
) -> AsyncIterator[bytes]:
self.calls.append(text)
yield self.FIRST_CHUNK
self.first_chunk_sent.set()
try:
await asyncio.Event().wait()
yield self.SECOND_CHUNK
except asyncio.CancelledError:
self.cancelled.set()
raise
def test_websocket_interrupt_between_tts_chunks():
"""
Verify interruption between streamed TTS audio chunks.
Purpose:
Ensure that an interrupted turn cannot continue
transmitting audio after interruption confirmation.
"""
created_stt.clear()
created_turn_managers.clear()
tts = StreamingBlockingTTSAdapter()
with patch.object(
main,
"create_tts_adapter",
return_value=tts,
):
with client.websocket_connect("/ws/audio") as websocket:
websocket.send_json(
{
"type": "session.start",
"audio": {
"encoding": AUDIO_ENCODING,
"sample_rate": AUDIO_SAMPLE_RATE,
"channels": AUDIO_CHANNELS,
},
}
)
session_ready = websocket.receive_json()
assert session_ready["type"] == "session.ready"
websocket.send_json({"type": "audio.start"})
turn_started = websocket.receive_json()
assert turn_started["type"] == "turn.started"
turn_id = turn_started["turn_id"]
stt = created_stt[0]
stt.emit_final_now("Olá!")
final_event = websocket.receive_json()
assert final_event["type"] == "stt.final"
agent_event = websocket.receive_json()
assert agent_event["type"] == "agent.response"
# First PCM chunk is delivered normally.
first_chunk = websocket.receive_bytes()
assert first_chunk == tts.FIRST_CHUNK
assert tts.first_chunk_sent.wait(timeout=2.0)
# Interrupt while TTS is waiting to emit another chunk.
websocket.send_json(
{
"type": "interrupt",
"turn_id": turn_id,
}
)
interrupted = websocket.receive_json()
assert interrupted["type"] == "interrupted"
assert interrupted["turn_id"] == turn_id
assert tts.cancelled.is_set()
assert created_turn_managers[0].active_turn is None
# A new turn must be possible after cancellation.
websocket.send_json({"type": "audio.start"})
next_turn = websocket.receive_json()
assert next_turn["type"] == "turn.started"
assert next_turn["turn_id"] == 2
def test_websocket_uses_distinct_stt_per_turn():
"""Each audio turn must receive a fresh STT adapter."""
created_stt.clear()
with client.websocket_connect("/ws/audio") as websocket:
websocket.send_json(
{
"type": "session.start",
"audio": {
"encoding": AUDIO_ENCODING,
"sample_rate": AUDIO_SAMPLE_RATE,
"channels": AUDIO_CHANNELS,
},
}
)
assert websocket.receive_json()["type"] == "session.ready"
websocket.send_json({"type": "audio.start"})
first_turn = websocket.receive_json()
assert first_turn["type"] == "turn.started"
assert len(created_stt) == 1
first_stt = created_stt[0]
websocket.send_json(
{
"type": "interrupt",
"turn_id": first_turn["turn_id"],
}
)
assert websocket.receive_json()["type"] == "interrupted"
websocket.send_json({"type": "audio.start"})
second_turn = websocket.receive_json()
assert second_turn["type"] == "turn.started"
assert len(created_stt) == 2
second_stt = created_stt[1]
assert first_stt is not second_stt
assert first_stt._started is False
assert second_stt._started is True
def test_websocket_audio_start_rolls_back_failed_stt():
"""
Verify rollback when STT initialization fails.
The Gateway must abort the partially initialized provider,
release semantic turn state, and allow a new attempt.
"""
created_stt.clear()
created_turn_managers.clear()
class FailingStartSTTAdapter(MockSTTAdapter):
def __init__(self):
super().__init__()
self.abort_called = False
async def start_session(self):
await super().start_session()
raise RuntimeError(
"Simulated STT initialization failure."
)
async def abort_session(self):
self.abort_called = True
await super().abort_session()
failing_stt = FailingStartSTTAdapter()
successful_stt = MockSTTAdapter()
adapters = iter([failing_stt, successful_stt])
def create_test_stt(config=None, catalog=None):
return next(adapters)
with patch.object(
main,
"create_stt_adapter",
side_effect=create_test_stt,
):
with client.websocket_connect("/ws/audio") as websocket:
websocket.send_json(
{
"type": "session.start",
"audio": {
"encoding": AUDIO_ENCODING,
"sample_rate": AUDIO_SAMPLE_RATE,
"channels": AUDIO_CHANNELS,
},
}
)
assert websocket.receive_json()["type"] == "session.ready"
websocket.send_json({"type": "audio.start"})
error = websocket.receive_json()
assert error["type"] == "error"
assert error["code"] == "STT_START_FAILED"
assert "Simulated STT initialization failure" in error["message"]
assert created_turn_managers[0].active_turn is None
assert failing_stt.abort_called is True
assert failing_stt._started is False
websocket.send_json({"type": "audio.start"})
next_turn = websocket.receive_json()
assert next_turn["type"] == "turn.started", (
f"Second audio.start failed: {next_turn!r}"
)
assert next_turn["turn_id"] == 2
assert successful_stt._started is True
def test_websocket_audio_start_rolls_back_task_registration_failure():
"""
Verify rollback when turn task registration fails.
Responsibilities:
- Simulate failure after the STT task is created.
- Verify the unregistered task is terminated.
- Verify STT resources are released.
- Verify semantic turn state is released.
- Verify the next turn can start.
"""
created_stt.clear()
created_turn_managers.clear()
real_register = VoicePipeline.register_turn_task
registered_tasks = []
registration_attempts = 0
def failing_register(pipeline, task):
nonlocal registration_attempts
registration_attempts += 1
registered_tasks.append(task)
if registration_attempts == 1:
raise RuntimeError(
"Simulated turn task registration failure."
)
return real_register(pipeline, task)
with patch.object(
VoicePipeline,
"register_turn_task",
autospec=True,
side_effect=failing_register,
):
with client.websocket_connect("/ws/audio") as websocket:
websocket.send_json(
{
"type": "session.start",
"audio": {
"encoding": AUDIO_ENCODING,
"sample_rate": AUDIO_SAMPLE_RATE,
"channels": AUDIO_CHANNELS,
},
}
)
session_ready = websocket.receive_json()
assert session_ready["type"] == "session.ready"
websocket.send_json({"type": "audio.start"})
error = websocket.receive_json()
assert error["type"] == "error"
assert error["code"] == "STT_START_FAILED"
assert "Simulated turn task registration failure" in (
error["message"]
)
# The task created before registration must be terminated.
assert len(registered_tasks) == 1
first_task = registered_tasks[0]
assert first_task.done()
assert first_task.cancelled()
# The failed STT session must be released.
assert len(created_stt) == 1
assert created_stt[0]._started is False
# The semantic turn must not remain active.
assert created_turn_managers[0].active_turn is None
# A subsequent turn must be able to start normally.
websocket.send_json({"type": "audio.start"})
next_turn = websocket.receive_json()
assert next_turn["type"] == "turn.started", (
f"Second audio.start failed: {next_turn!r}"
)
assert next_turn["turn_id"] == 2
assert registration_attempts == 2
assert len(created_stt) == 2
assert created_stt[1]._started is True
def test_websocket_audio_start_abort_failure_is_not_retryable():
"""
Verify that failed STT cleanup prevents unsafe retry.
Responsibilities:
- Simulate partial STT initialization failure.
- Simulate abort_session() failure.
- Verify STT_START_FAILED is not retryable.
- Verify the semantic turn remains active.
- Verify another audio.start is rejected.
"""
created_turn_managers.clear()
class FailingCleanupSTTAdapter(MockSTTAdapter):
async def start_session(self):
await super().start_session()
raise RuntimeError("Simulated STT startup failure.")
async def abort_session(self):
raise RuntimeError("Simulated STT abort failure.")
failing_stt = FailingCleanupSTTAdapter()
with (
patch.object(
main,
"create_stt_adapter",
return_value=failing_stt,
),
patch.object(
voice_session_module.logger,
"exception",
) as log_mock,
):
with client.websocket_connect("/ws/audio") as websocket:
websocket.send_json(
{
"type": "session.start",
"audio": {
"encoding": AUDIO_ENCODING,
"sample_rate": AUDIO_SAMPLE_RATE,
"channels": AUDIO_CHANNELS,
},
}
)
assert websocket.receive_json()["type"] == "session.ready"
websocket.send_json({"type": "audio.start"})
error = websocket.receive_json()
assert error["type"] == "error"
assert error["code"] == "STT_START_FAILED"
assert error["retryable"] is False
assert created_turn_managers[0].active_turn is not None
assert created_turn_managers[0].active_turn.turn_id == 1
assert log_mock.called
websocket.send_json({"type": "audio.start"})
next_response = websocket.receive_json()
assert next_response["type"] == "error"
assert next_response["code"] == "INVALID_TURN"
@pytest.mark.anyio
async def test_forward_stt_events_discards_stale_turn():
"""
Events belonging to an interrupted turn must not be forwarded
or processed after another turn becomes active.
"""
websocket = AsyncMock()
pipeline = Mock(spec=VoicePipeline)
pipeline.is_active_turn.return_value = False
pipeline.process_transcript = AsyncMock()
class LateSTT:
async def events(self):
yield TranscriptEvent(
type="final",
text="Transcrição atrasada",
)
stt = LateSTT()
await forward_stt_events(
websocket=websocket,
stt=stt,
pipeline=pipeline,
session_id="test-session",
turn_id=1,
)
pipeline.is_active_turn.assert_called_once_with(1)
websocket.send_text.assert_not_awaited()
websocket.send_bytes.assert_not_awaited()
pipeline.on_final.assert_not_called()
pipeline.process_transcript.assert_not_awaited()
pipeline.synthesize_response.assert_not_called()
await forward_stt_events(
websocket=websocket,
stt=stt,
pipeline=pipeline,
session_id="test-session",
turn_id=1,
)
websocket.send_text.assert_not_awaited()
websocket.send_bytes.assert_not_awaited()
pipeline.on_final.assert_not_called()
pipeline.process_transcript.assert_not_awaited()
pipeline.synthesize_response.assert_not_called()
@pytest.mark.anyio
async def test_stt_event_send_cancellation_prevents_late_delivery():
"""
Verify that cancellation stops a blocked STT event send.
The old turn must not deliver its event after cancellation.
"""
send_started = asyncio.Event()
send_cancelled = asyncio.Event()
class BlockingWebSocket:
def __init__(self):
self.delivered = []
async def send_text(self, data):
send_started.set()
try:
await asyncio.Event().wait()
except asyncio.CancelledError:
send_cancelled.set()
raise
self.delivered.append(data)
class SingleEventSTT:
async def events(self):
yield TranscriptEvent(
type="partial",
text="Evento antigo",
)
websocket = BlockingWebSocket()
stt = SingleEventSTT()
session = main.SessionState()
turn_manager = TurnManager()
pipeline = VoicePipeline(
session=session,
turn_manager=turn_manager,
agent=MockAgentAdapter(),
tts=MockTTSAdapter(),
)
turn = pipeline.start_turn()
task = asyncio.create_task(
forward_stt_events(
websocket=websocket,
stt=stt,
pipeline=pipeline,
session_id=session.session_id,
turn_id=turn.turn_id,
)
)
pipeline.register_turn_task(task)
try:
await asyncio.wait_for(
send_started.wait(),
timeout=1.0,
)
await pipeline.cancel_turn_task(timeout=1.0)
assert send_cancelled.is_set()
assert task.done()
assert task.cancelled()
assert pipeline.turn_task is None
assert websocket.delivered == []
pipeline.interrupt_turn(turn.turn_id)
assert turn_manager.active_turn is None
assert session.active_turn_id is None
finally:
if not task.done():
task.cancel()
await asyncio.gather(
task,
return_exceptions=True,
)
def test_websocket_registration_failure_awaits_task_before_abort():
"""
Verify that registration rollback terminates an unregistered
task before aborting its STT provider.
"""
created_stt.clear()
real_register = VoicePipeline.register_turn_task
real_abort = MockSTTAdapter.abort_session
tasks = []
abort_checked = []
def failing_register(pipeline, task):
tasks.append(task)
raise RuntimeError("Simulated registration failure.")
async def tracked_abort(stt):
assert tasks
assert tasks[0].done()
abort_checked.append(True)
await real_abort(stt)
with (
patch.object(
VoicePipeline,
"register_turn_task",
autospec=True,
side_effect=failing_register,
),
patch.object(
MockSTTAdapter,
"abort_session",
autospec=True,
side_effect=tracked_abort,
),
):
with client.websocket_connect("/ws/audio") as websocket:
websocket.send_json(
{
"type": "session.start",
"audio": {
"encoding": AUDIO_ENCODING,
"sample_rate": AUDIO_SAMPLE_RATE,
"channels": AUDIO_CHANNELS,
},
}
)
assert websocket.receive_json()["type"] == "session.ready"
websocket.send_json({"type": "audio.start"})
response = websocket.receive_json()
assert response["type"] == "error"
assert response["code"] == "STT_START_FAILED"
assert len(tasks) == 1
assert tasks[0].done()
assert abort_checked
def test_websocket_streaming_agent_and_tts():
"""
Verify streaming Agent text and TTS audio through WebSocket.
The first audio segment must be delivered before the
Agent's complete response.
"""
created_stt.clear()
class StreamingTestAgent(StreamingAgentAdapter):
async def stream(self, session_id, turn_id, text):
yield "Olá, como posso ajudar?"
yield " Estou à disposição."
class StreamingTestTTS(MockTTSAdapter):
async def synthesize(self, text):
yield text.encode("utf-16-le")
with patch.object(
main,
"create_agent_adapter",
return_value=StreamingTestAgent(),
):
with patch.object(
main,
"create_tts_adapter",
return_value=StreamingTestTTS(),
):
with client.websocket_connect("/ws/audio") as websocket:
websocket.send_json(
{
"type": "session.start",
"audio": {
"encoding": AUDIO_ENCODING,
"sample_rate": AUDIO_SAMPLE_RATE,
"channels": AUDIO_CHANNELS,
},
}
)
session_ready = websocket.receive_json()
assert session_ready["type"] == "session.ready"
websocket.send_json({"type": "audio.start"})
turn_started = websocket.receive_json()
assert turn_started["type"] == "turn.started"
turn_id = turn_started["turn_id"]
stt = created_stt[0]
stt.emit_final_now("Olá")
received_text = []
received_audio = []
received_complete_response = False
# The streaming path emits two deltas and
# two TTS audio segments.
for _ in range(20):
message = websocket.receive()
if message.get("bytes") is not None:
received_audio.append(message["bytes"])
else:
import json
event = json.loads(message["text"])
if event["type"] == "agent.delta":
received_text.append(event["text"])
elif event["type"] == "agent.response":
received_complete_response = True
elif event["type"] == "error":
raise AssertionError(
f"Gateway returned error: {event}"
)
if received_complete_response and len(received_audio) >= 2:
break
assert "".join(received_text) == (
"Olá, como posso ajudar?"
" Estou à disposição."
)
assert received_audio
assert received_complete_response
websocket.send_json(
{
"type": "audio.stop",
"turn_id": turn_id,
}
)
turn_ended = websocket.receive_json()
assert turn_ended["type"] == "turn.ended"
def test_websocket_interrupt_during_streaming_tts():
"""
Verify that interrupt cancels an active streaming TTS turn
while normal turn finalization is pending.
"""
created_stt.clear()
tts_started = threading.Event()
tts_cancelled = threading.Event()
class SlowStreamingAgent(StreamingAgentAdapter):
async def stream(self, session_id, turn_id, text):
yield "Olá, esta é uma resposta longa."
class SlowStreamingTTS(MockTTSAdapter):
async def synthesize(self, text):
try:
tts_started.set()
yield b"\x01\x02\x03\x04"
# Keep synthesis active until cancellation.
await asyncio.Event().wait()
except asyncio.CancelledError:
tts_cancelled.set()
raise
with patch.object(
main,
"create_agent_adapter",
return_value=SlowStreamingAgent(),
):
with patch.object(
main,
"create_tts_adapter",
return_value=SlowStreamingTTS(),
):
with client.websocket_connect("/ws/audio") as websocket:
websocket.send_json({
"type": "session.start",
"audio": {
"encoding": AUDIO_ENCODING,
"sample_rate": AUDIO_SAMPLE_RATE,
"channels": AUDIO_CHANNELS,
},
})
ready = websocket.receive_json()
assert ready["type"] == "session.ready"
websocket.send_json({"type": "audio.start"})
started = websocket.receive_json()
assert started["type"] == "turn.started"
turn_id = started["turn_id"]
stt = created_stt[-1]
stt.emit_final_now("Olá")
# Wait until the Gateway delivers the first TTS audio chunk.
first_audio_received = False
for _ in range(20):
message = websocket.receive()
if message.get("bytes") is not None:
assert message["bytes"] == b"\x01\x02\x03\x04"
first_audio_received = True
break
import json
event = json.loads(message["text"])
if event["type"] == "error":
raise AssertionError(
f"Gateway returned error: {event}"
)
assert first_audio_received
assert tts_started.is_set()
websocket.send_json({
"type": "audio.stop",
"turn_id": turn_id,
})
websocket.send_json({
"type": "interrupt",
"turn_id": turn_id,
})
# Streaming events may precede interruption.
for _ in range(20):
message = websocket.receive()
if message.get("bytes") is not None:
continue
import json
event = json.loads(message["text"])
if event["type"] == "interrupted":
assert event["turn_id"] == turn_id
break
if event["type"] == "error":
raise AssertionError(
f"Gateway returned error: {event}"
)
else:
raise AssertionError(
"Gateway did not confirm interruption."
)
assert tts_cancelled.wait(timeout=2.0)
# A new turn must be accepted in the same session.
websocket.send_json({"type": "audio.start"})
next_turn = websocket.receive_json()
assert next_turn["type"] == "turn.started"
assert next_turn["turn_id"] != turn_id
websocket.send_json({
"type": "interrupt",
"turn_id": next_turn["turn_id"],
})
interrupted = websocket.receive_json()
assert interrupted["type"] == "interrupted"
def test_websocket_session_end_during_streaming_tts():
"""
Verify session.end cancels an active streaming TTS turn.
The Gateway must cancel provider execution before
acknowledging session closure.
"""
import json
created_stt.clear()
tts_cancelled = threading.Event()
class SlowStreamingAgent(StreamingAgentAdapter):
async def stream(self, session_id, turn_id, text):
yield "Esta é uma resposta longa."
class SlowStreamingTTS(MockTTSAdapter):
async def synthesize(self, text):
try:
yield b"\x01\x02\x03\x04"
# Keep synthesis running until cancelled.
await asyncio.Event().wait()
except asyncio.CancelledError:
tts_cancelled.set()
raise
with patch.object(
main,
"create_agent_adapter",
return_value=SlowStreamingAgent(),
):
with patch.object(
main,
"create_tts_adapter",
return_value=SlowStreamingTTS(),
):
with client.websocket_connect("/ws/audio") as websocket:
websocket.send_json({
"type": "session.start",
"audio": {
"encoding": AUDIO_ENCODING,
"sample_rate": AUDIO_SAMPLE_RATE,
"channels": AUDIO_CHANNELS,
},
})
ready = websocket.receive_json()
assert ready["type"] == "session.ready"
websocket.send_json({"type": "audio.start"})
started = websocket.receive_json()
assert started["type"] == "turn.started"
turn_id = started["turn_id"]
stt = created_stt[-1]
stt.emit_final_now("Olá")
# Wait until TTS audio reaches the client.
first_audio_received = False
for _ in range(20):
message = websocket.receive()
if message.get("bytes") is not None:
assert message["bytes"] == b"\x01\x02\x03\x04"
first_audio_received = True
break
event = json.loads(message["text"])
if event["type"] == "error":
raise AssertionError(
f"Gateway returned error: {event}"
)
assert first_audio_received
websocket.send_json({
"type": "audio.stop",
"turn_id": turn_id,
})
websocket.send_json({
"type": "session.end",
})
# Streaming events may already be queued.
for _ in range(20):
message = websocket.receive()
if message.get("bytes") is not None:
continue
event = json.loads(message["text"])
if event["type"] == "session.ended":
assert event["session_id"] == ready["session_id"]
break
if event["type"] == "turn.ended":
raise AssertionError(
"Turn completed normally during session shutdown."
)
if event["type"] == "error":
raise AssertionError(
f"Gateway returned error: {event}"
)
else:
raise AssertionError(
"Gateway did not confirm session closure."
)
assert tts_cancelled.wait(timeout=2.0)
def test_websocket_interrupt_after_turn_completed():
"""
Verify behavior when interrupt arrives after turn.ended.
A completed turn must not be cancelled again.
"""
created_stt.clear()
with client.websocket_connect("/ws/audio") as websocket:
websocket.send_json({
"type": "session.start",
"audio": {
"encoding": AUDIO_ENCODING,
"sample_rate": AUDIO_SAMPLE_RATE,
"channels": AUDIO_CHANNELS,
},
})
ready = websocket.receive_json()
assert ready["type"] == "session.ready"
websocket.send_json({"type": "audio.start"})
started = websocket.receive_json()
assert started["type"] == "turn.started"
turn_id = started["turn_id"]
stt = created_stt[-1]
stt.emit_final_now("Olá")
# Consume events until the first turn finishes.
for _ in range(30):
message = websocket.receive()
if message.get("bytes") is not None:
continue
import json
event = json.loads(message["text"])
if event["type"] == "error":
raise AssertionError(
f"Gateway returned error: {event}"
)
if event["type"] == "stt.final":
websocket.send_json({
"type": "audio.stop",
"turn_id": turn_id,
})
if event["type"] == "turn.ended":
break
else:
raise AssertionError(
"Turn did not complete normally."
)
# Interrupt the already completed turn.
websocket.send_json({
"type": "interrupt",
"turn_id": turn_id,
})
response = websocket.receive_json()
# Existing Gateway behavior may acknowledge stale IDs.
# Capture the current contract before changing it.
assert response["type"] == "error"
assert response["code"] == "INVALID_TURN"
assert response["turn_id"] == turn_id
def test_websocket_interrupt_wins_pending_tts_completion():
"""
Verify interruption wins while TTS completion is pending.
The interrupted turn must not emit turn.ended,
and a new turn must be accepted.
"""
import json
created_stt.clear()
tts_cancelled = threading.Event()
class ControlledAgent(StreamingAgentAdapter):
async def stream(self, session_id, turn_id, text):
yield "Esta resposta será interrompida."
class ControlledTTS(MockTTSAdapter):
async def synthesize(self, text):
try:
yield b"\x01\x02\x03\x04"
# Completion remains pending until cancellation.
await asyncio.Event().wait()
except asyncio.CancelledError:
tts_cancelled.set()
raise
with patch.object(
main,
"create_agent_adapter",
return_value=ControlledAgent(),
):
with patch.object(
main,
"create_tts_adapter",
return_value=ControlledTTS(),
):
with client.websocket_connect("/ws/audio") as websocket:
websocket.send_json({
"type": "session.start",
"audio": {
"encoding": AUDIO_ENCODING,
"sample_rate": AUDIO_SAMPLE_RATE,
"channels": AUDIO_CHANNELS,
},
})
ready = websocket.receive_json()
assert ready["type"] == "session.ready"
websocket.send_json({
"type": "audio.start",
})
started = websocket.receive_json()
assert started["type"] == "turn.started"
turn_id = started["turn_id"]
stt = created_stt[-1]
stt.emit_final_now("Olá")
# Observe the first PCM chunk.
for _ in range(20):
message = websocket.receive()
if message.get("bytes") is not None:
assert message["bytes"] == b"\x01\x02\x03\x04"
break
event = json.loads(message["text"])
if event["type"] == "error":
raise AssertionError(event)
else:
raise AssertionError(
"TTS did not produce its first chunk."
)
websocket.send_json({
"type": "audio.stop",
"turn_id": turn_id,
})
websocket.send_json({
"type": "interrupt",
"turn_id": turn_id,
})
terminal_events = []
for _ in range(20):
message = websocket.receive()
if message.get("bytes") is not None:
continue
event = json.loads(message["text"])
if event["type"] in {
"turn.ended",
"interrupted",
}:
terminal_events.append(event["type"])
if event["type"] == "error":
raise AssertionError(event)
if event["type"] == "interrupted":
break
else:
raise AssertionError(
"Interruption was not acknowledged."
)
assert terminal_events == ["interrupted"]
assert tts_cancelled.wait(timeout=2.0)
# Confirm that the session accepts another turn.
websocket.send_json({
"type": "audio.start",
})
next_turn = websocket.receive_json()
assert next_turn["type"] == "turn.started"
assert next_turn["turn_id"] != turn_id
websocket.send_json({
"type": "interrupt",
"turn_id": next_turn["turn_id"],
})
response = websocket.receive_json()
assert response["type"] == "interrupted"
assert response["turn_id"] == next_turn["turn_id"]
@pytest.mark.parametrize("session_started", [False, True])
@pytest.mark.parametrize(
"payload",
[
None,
[],
"text",
42,
True,
{},
{"type": None},
{"type": 42},
{"type": ""},
{"type": " "},
],
)
def test_websocket_rejects_invalid_message_and_remains_usable(
payload,
session_started,
):
with client.websocket_connect("/ws/audio") as websocket:
if session_started:
websocket.send_json({"type": "session.start"})
assert websocket.receive_json()["type"] == "session.ready"
websocket.send_json(payload)
error = websocket.receive_json()
assert error["type"] == "error"
assert error["code"] == "INVALID_MESSAGE"
if session_started:
websocket.send_json({"type": "session.end"})
assert websocket.receive_json()["type"] == "session.ended"
else:
websocket.send_json({"type": "session.start"})
assert websocket.receive_json()["type"] == "session.ready"
@pytest.mark.parametrize(
"event_type",
[
"audio.start",
"audio.chunk",
"audio.stop",
"interrupt",
"session.end",
],
)
def test_websocket_requires_session_before_control_events(event_type):
with client.websocket_connect("/ws/audio") as websocket:
websocket.send_json(
{
"type": event_type,
"turn_id": 1,
"sequence_number": 0,
}
)
error = websocket.receive_json()
assert error["type"] == "error"
assert error["code"] == "SESSION_NOT_FOUND"
websocket.send_json({"type": "session.start"})
assert websocket.receive_json()["type"] == "session.ready"
@pytest.mark.parametrize(
"text",
["{", "", '{type: "session.start"}'],
)
def test_websocket_rejects_malformed_json_and_remains_usable(text):
with client.websocket_connect("/ws/audio") as websocket:
websocket.send_text(text)
error = websocket.receive_json()
assert error["type"] == "error"
assert error["code"] == "INVALID_MESSAGE"
websocket.send_json({"type": "session.start"})
assert websocket.receive_json()["type"] == "session.ready"
def test_websocket_duplicate_audio_start_preserves_active_turn():
created_stt.clear()
created_turn_managers.clear()
with client.websocket_connect("/ws/audio") as websocket:
websocket.send_json({"type": "session.start"})
assert websocket.receive_json()["type"] == "session.ready"
websocket.send_json({"type": "audio.start"})
started = websocket.receive_json()
assert started["type"] == "turn.started"
turn_id = started["turn_id"]
stt = created_stt[0]
turn = created_turn_managers[0].active_turn
websocket.send_json({"type": "audio.start"})
error = websocket.receive_json()
assert error["type"] == "error"
assert error["code"] == "INVALID_TURN"
assert error["turn_id"] == turn_id
assert len(created_stt) == 1
assert stt._started is True
assert created_turn_managers[0].active_turn is turn
assert turn.state == TurnState.LISTENING
websocket.send_json(
{"type": "interrupt", "turn_id": turn_id}
)
assert websocket.receive_json()["type"] == "interrupted"
assert stt._started is False
assert created_turn_managers[0].active_turn is None
class ControlledStopSTTAdapter(MockSTTAdapter):
def __init__(self, *, ignore_cancellation=False):
super().__init__()
self.ignore_cancellation = ignore_cancellation
self.stop_started = threading.Event()
self.stop_cancelled = threading.Event()
self.stop_release = threading.Event()
self.stop_calls = 0
async def stop_session(self):
self.stop_calls += 1
self.stop_started.set()
try:
while not self.stop_release.is_set():
await asyncio.sleep(0.01)
except asyncio.CancelledError:
self.stop_cancelled.set()
if not self.ignore_cancellation:
raise
while not self.stop_release.is_set():
await asyncio.sleep(0.01)
await super().stop_session()
@contextmanager
def open_turn_with_stt(stt):
created_turn_managers.clear()
with patch.object(
main,
"create_stt_adapter",
side_effect=[stt, MockSTTAdapter()],
):
try:
with client.websocket_connect("/ws/audio") as websocket:
websocket.send_json({"type": "session.start"})
assert websocket.receive_json()["type"] == "session.ready"
websocket.send_json({"type": "audio.start"})
started = websocket.receive_json()
assert started["type"] == "turn.started"
yield websocket, started["turn_id"]
finally:
stt.stop_release.set()
def receive_while_stt_waits(websocket, stt):
with ThreadPoolExecutor(max_workers=1) as executor:
response = executor.submit(websocket.receive_json)
try:
return response.result(timeout=2.0)
except TimeoutError:
stt.stop_release.set()
raise
@pytest.mark.parametrize(
("event_type", "expected"),
[("interrupt", "interrupted"), ("session.end", "session.ended")],
)
def test_websocket_accepts_control_while_stt_stop_waits(event_type, expected):
stt = ControlledStopSTTAdapter()
with open_turn_with_stt(stt) as (websocket, turn_id):
websocket.send_json({"type": "audio.stop", "turn_id": turn_id})
assert stt.stop_started.wait(timeout=2.0)
websocket.send_json({"type": event_type, "turn_id": turn_id})
response = receive_while_stt_waits(websocket, stt)
assert response["type"] == expected
assert stt.stop_cancelled.is_set()
assert stt._started is False
assert created_turn_managers[0].active_turn is None
if event_type == "interrupt":
websocket.send_json({"type": "audio.start"})
assert websocket.receive_json()["type"] == "turn.started"
def test_websocket_disconnect_cancels_stt_stop():
stt = ControlledStopSTTAdapter()
with open_turn_with_stt(stt) as (websocket, turn_id):
websocket.send_json({"type": "audio.stop", "turn_id": turn_id})
assert stt.stop_started.wait(timeout=2.0)
assert stt.stop_cancelled.is_set()
assert stt._started is False
assert created_turn_managers[0].active_turn is None
@pytest.mark.parametrize("failure", ["timeout", "provider"])
@pytest.mark.parametrize("abort_fails", [False, True])
def test_websocket_stt_stop_failure_releases_only_safe_turns(
monkeypatch, failure, abort_fails,
):
stt = ControlledStopSTTAdapter()
monkeypatch.setattr(main, "STT_STOP_TIMEOUT_SECONDS", 0.05)
if failure == "provider":
monkeypatch.setattr(
stt, "stop_session",
AsyncMock(side_effect=RuntimeError("STT stop failed")),
)
if abort_fails:
monkeypatch.setattr(
stt, "abort_session",
AsyncMock(side_effect=RuntimeError("STT abort failed")),
)
with open_turn_with_stt(stt) as (websocket, turn_id):
websocket.send_json({"type": "audio.stop", "turn_id": turn_id})
response = receive_while_stt_waits(websocket, stt)
assert response["type"] == "error"
assert response["code"] == (
"STT_STOP_TIMEOUT" if failure == "timeout" else "STT_STOP_FAILED"
)
assert response["turn_id"] == turn_id
assert response["retryable"] is (not abort_fails)
if abort_fails:
assert created_turn_managers[0].active_turn.turn_id == turn_id
else:
assert created_turn_managers[0].active_turn is None
assert stt._started is False
websocket.send_json({"type": "audio.start"})
next_response = websocket.receive_json()
if abort_fails:
assert next_response["code"] == "INVALID_TURN"
else:
assert next_response["type"] == "turn.started"
@pytest.mark.parametrize(
("event_type", "expected"),
[("audio.stop", "INVALID_TURN"), ("audio.chunk", "AUDIO_SEQUENCE_ERROR")],
)
def test_websocket_rejects_input_while_stt_stop_waits(event_type, expected):
stt = ControlledStopSTTAdapter()
with open_turn_with_stt(stt) as (websocket, turn_id):
websocket.send_json({"type": "audio.stop", "turn_id": turn_id})
assert stt.stop_started.wait(timeout=2.0)
websocket.send_json({
"type": event_type, "turn_id": turn_id, "sequence_number": 0,
})
response = receive_while_stt_waits(websocket, stt)
assert response["code"] == expected
assert stt.stop_calls == 1
websocket.send_json({"type": "interrupt", "turn_id": turn_id})
assert receive_while_stt_waits(websocket, stt)["type"] == "interrupted"
@pytest.mark.parametrize(
("event_type", "expected"),
[("interrupt", "TURN_CANCEL_FAILED"), ("session.end", "SESSION_END_FAILED")],
)
def test_websocket_stt_stop_cancel_timeout_preserves_turn(
monkeypatch, event_type, expected,
):
stt = ControlledStopSTTAdapter(ignore_cancellation=True)
monkeypatch.setattr(main, "FINALIZATION_CANCEL_TIMEOUT_SECONDS", 0.05)
with open_turn_with_stt(stt) as (websocket, turn_id):
websocket.send_json({"type": "audio.stop", "turn_id": turn_id})
assert stt.stop_started.wait(timeout=2.0)
websocket.send_json({"type": event_type, "turn_id": turn_id})
response = receive_while_stt_waits(websocket, stt)
assert response["code"] == expected
assert stt.stop_cancelled.is_set()
assert stt._started is True
assert created_turn_managers[0].active_turn.turn_id == turn_id
websocket.send_json({"type": "audio.start"})
assert websocket.receive_json()["code"] == "INVALID_TURN"
stt.stop_release.set()