fix: finaliza STT sem bloquear comandos da sessão

This commit is contained in:
alex alves
2026-11-03 11:12:40 -03:00
parent 153b2986b7
commit 4523910077
2 changed files with 271 additions and 16 deletions

View File

@@ -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()