1701 lines
46 KiB
Python
1701 lines
46 KiB
Python
import asyncio
|
|
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.audio_gateway.app.main import forward_stt_events
|
|
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_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(
|
|
main.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(
|
|
main.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"] == "STT_START_FAILED"
|
|
|
|
|
|
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(
|
|
main.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"] == "STT_START_FAILED"
|
|
|
|
|
|
|
|
@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
|