fix: finaliza STT sem bloquear comandos da sessão
This commit is contained in:
@@ -1,4 +1,6 @@
|
||||
import asyncio
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from contextlib import contextmanager
|
||||
import threading
|
||||
import pytest
|
||||
from unittest.mock import patch, AsyncMock, Mock
|
||||
@@ -2528,3 +2530,179 @@ def test_websocket_duplicate_audio_start_preserves_active_turn():
|
||||
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()
|
||||
|
||||
Reference in New Issue
Block a user