feat: 大屏后端(FastAPI+MQTT+sim_engine)

- REST :10085 / MQTT 控制 / SSE 回退 / AI+语音
- 数据源规划:接核心服务端 + 算力引擎真实数据
This commit is contained in:
2026-08-23 22:35:05 +08:00
parent 9700eb652b
commit b681eac2df
236 changed files with 54727 additions and 0 deletions
@@ -0,0 +1,60 @@
from queue import Queue
from threading import Event as ThreadingEvent
import pytest
from openai.types.realtime import RealtimeSessionCreateRequest
from openai.types.realtime.realtime_audio_config import RealtimeAudioConfig
from openai.types.realtime.realtime_audio_config_input import RealtimeAudioConfigInput
from openai.types.realtime.realtime_audio_config_output import RealtimeAudioConfigOutput
from openai.types.realtime.realtime_audio_formats import AudioPCM
from speech_to_speech.api.openai_realtime.runtime_config import RuntimeConfig
from speech_to_speech.api.openai_realtime.service import RealtimeService
def _session_16k() -> RealtimeSessionCreateRequest:
"""Build a test session with 16 kHz audio rates (matches PIPELINE_SAMPLE_RATE)."""
fmt = AudioPCM.model_construct(rate=16000, type="audio/pcm")
return RealtimeSessionCreateRequest.model_construct(
type="realtime",
audio=RealtimeAudioConfig.model_construct(
input=RealtimeAudioConfigInput.model_construct(format=fmt),
output=RealtimeAudioConfigOutput.model_construct(format=fmt),
),
)
@pytest.fixture
def runtime_config():
cfg = RuntimeConfig()
cfg.session = _session_16k()
return cfg
@pytest.fixture
def text_prompt_queue():
return Queue()
@pytest.fixture
def should_listen():
ev = ThreadingEvent()
ev.set()
return ev
@pytest.fixture
def service(runtime_config, text_prompt_queue, should_listen):
svc = RealtimeService(
text_prompt_queue=text_prompt_queue,
should_listen=should_listen,
)
return svc
@pytest.fixture
def conn_id(service, runtime_config):
cid = service.register()
service._state(cid).runtime_config = runtime_config
yield cid
service.unregister(cid)
@@ -0,0 +1,676 @@
"""Tests using the real OpenAI Python SDK client connected to our local server.
``AsyncOpenAI.realtime.connect()`` (non-beta) establishes a WebSocket
connection and returns parsed event objects from ``openai.types.realtime``.
These tests start our FastAPI app on a local port with uvicorn, then drive
a real SDK client against it — exactly as the production client does.
The pipeline side (audio output, text events) is driven through the queues,
while the client side uses ``conn.send()`` and ``async for event in conn``.
We use ``client.realtime.connect()`` (non-beta), **not**
``client.realtime.connect()``. The non-beta path expects the GA type
strings our server emits (e.g. ``response.output_audio.delta``), whereas
the beta path expects the older ``response.audio.delta`` variants.
"""
import asyncio
import base64
import json
import socket
import threading
import time
from queue import Queue
from threading import Event as ThreadingEvent
import pytest
import uvicorn
from openai import AsyncOpenAI
from speech_to_speech.api.openai_realtime.pipeline_unit import PipelineUnit
from speech_to_speech.api.openai_realtime.service import RealtimeService
from speech_to_speech.api.openai_realtime.websocket_router import create_app
from speech_to_speech.pipeline.cancel_scope import CancelScope
from speech_to_speech.pipeline.events import (
AssistantTextEvent,
PartialTranscriptionEvent,
SpeechStartedEvent,
SpeechStoppedEvent,
TranscriptionCompletedEvent,
)
from speech_to_speech.pipeline.messages import AUDIO_RESPONSE_DONE, PIPELINE_END
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _free_port() -> int:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.bind(("192.168.1.9", 0))
return s.getsockname()[1]
def _pcm_bytes(n_samples: int) -> bytes:
return b"\x00" * (n_samples * 2)
class _ServerEnv:
"""Wraps a running uvicorn server + all pipeline queues."""
def __init__(self):
self.text_prompt_queue: Queue = Queue()
self.should_listen = ThreadingEvent()
self.should_listen.set()
self.service = RealtimeService(
text_prompt_queue=self.text_prompt_queue,
should_listen=self.should_listen,
)
self.input_queue: Queue = Queue()
self.output_queue: Queue = Queue()
self.text_output_queue: Queue = Queue()
self.stop_event = ThreadingEvent()
self.response_playing = ThreadingEvent()
self.cancel_scope = CancelScope()
self.unit = PipelineUnit(
index=0,
service=self.service,
cancel_scope=self.cancel_scope,
should_listen=self.should_listen,
response_playing=self.response_playing,
input_queue=self.input_queue,
output_queue=self.output_queue,
text_output_queue=self.text_output_queue,
text_prompt_queue=self.text_prompt_queue,
handlers=[],
)
self.app = create_app(pool=[self.unit], stop_event=self.stop_event)
self.port = _free_port()
self._server_thread: threading.Thread | None = None
def start(self):
config = uvicorn.Config(
self.app,
host="192.168.1.9",
port=self.port,
log_level="warning",
)
server = uvicorn.Server(config)
self._server = server
self._server_thread = threading.Thread(target=server.run, daemon=True)
self._server_thread.start()
for _ in range(50):
try:
with socket.create_connection(("192.168.1.9", self.port), timeout=0.1):
return
except OSError:
time.sleep(0.1)
raise RuntimeError("Server did not start in time")
def stop(self):
self.stop_event.set()
self._server.should_exit = True
if self._server_thread:
self._server_thread.join(timeout=5)
def make_client(self) -> AsyncOpenAI:
return AsyncOpenAI(
api_key="test-key",
base_url=f"http://192.168.1.9:{self.port}/v1",
websocket_base_url=f"ws://192.168.1.9:{self.port}/v1",
)
@pytest.fixture
def server_env():
env = _ServerEnv()
env.start()
yield env
env.stop()
async def _recv(conn, timeout: float = 3.0):
"""Receive next event with a timeout to avoid hanging tests."""
return await asyncio.wait_for(conn.recv(), timeout=timeout)
# Our server uses the openai.types.realtime type strings (e.g.
# "response.output_audio.done"). The production client code matches on
# event.type using both GA and legacy names for compatibility. These
# constants match the Literal values from openai.types.realtime.
SESSION_CREATED = "session.created"
SPEECH_STARTED = "input_audio_buffer.speech_started"
SPEECH_STOPPED = "input_audio_buffer.speech_stopped"
TRANSCRIPTION_DELTA = "conversation.item.input_audio_transcription.delta"
TRANSCRIPTION_COMPLETED = "conversation.item.input_audio_transcription.completed"
ITEM_CREATED = "conversation.item.created"
RESPONSE_CREATED = "response.created"
RESPONSE_DONE = "response.done"
AUDIO_DELTA = "response.output_audio.delta"
AUDIO_DONE = "response.output_audio.done"
TRANSCRIPT_DONE = "response.output_audio_transcript.done"
FUNCTION_CALL_DONE = "response.function_call_arguments.done"
ERROR = "error"
# ===================================================================
# 1. Connection and session.created
# ===================================================================
class TestSDKConnection:
@pytest.mark.asyncio
async def test_connect_receives_session_created(self, server_env):
"""SDK connect yields session.created as the first event."""
client = server_env.make_client()
async with client.realtime.connect(model="test") as conn:
event = await _recv(conn)
assert event.type == SESSION_CREATED
assert event.event_id.startswith("event_")
assert event.session is not None
# ===================================================================
# 2. Session update
# ===================================================================
class TestSDKSessionUpdate:
@pytest.mark.asyncio
async def test_session_update_applies_config(self, server_env):
"""conn.session.update() applies config server-side."""
client = server_env.make_client()
async with client.realtime.connect(model="test") as conn:
await _recv(conn) # session.created
await conn.send(
{
"type": "session.update",
"session": {
"type": "realtime",
"instructions": "You are a helpful robot",
"audio": {
"input": {
"transcription": {"model": "gpt-4o-transcribe", "language": "en"},
"turn_detection": {
"type": "server_vad",
"interrupt_response": True,
},
},
"output": {
"voice": "alloy",
},
},
"tools": [{"type": "function", "name": "get_weather"}],
"tool_choice": "auto",
},
}
)
await asyncio.sleep(0.2)
cid = server_env.service.connection_ids[0]
s = server_env.service._state(cid).runtime_config.session
assert s.audio.output.voice == "alloy"
assert s.instructions == "You are a helpful robot"
assert s.audio.input.turn_detection.type == "server_vad"
assert s.tools is not None
assert s.tool_choice == "auto"
# ===================================================================
# 3. Full voice conversation turn
# ===================================================================
class TestSDKVoiceTurn:
@pytest.mark.asyncio
async def test_full_voice_turn(self, server_env):
"""
Pipeline-driven voice turn through the real SDK:
speech_started → partial transcription → speech_stopped →
transcription_completed → audio response → transcript → done
"""
client = server_env.make_client()
async with client.realtime.connect(model="test") as conn:
await _recv(conn) # session.created
# -- User speech --
server_env.text_output_queue.put(SpeechStartedEvent())
event = await _recv(conn)
assert event.type == SPEECH_STARTED
assert event.audio_start_ms == 0
item_id = event.item_id
server_env.text_output_queue.put(PartialTranscriptionEvent(delta="hel"))
event = await _recv(conn)
assert event.type == TRANSCRIPTION_DELTA
assert event.delta == "hel"
assert event.item_id == item_id
server_env.text_output_queue.put(SpeechStoppedEvent(duration_s=1.9))
event = await _recv(conn)
assert event.type == SPEECH_STOPPED
assert event.audio_end_ms == 0
assert event.item_id == item_id
server_env.text_output_queue.put(TranscriptionCompletedEvent(transcript="hello"))
event = await _recv(conn)
assert event.type == TRANSCRIPTION_COMPLETED
assert event.transcript == "hello"
assert event.usage.seconds == 1.9
# -- Server audio response --
server_env.output_queue.put(_pcm_bytes(256))
event = await _recv(conn)
assert event.type == RESPONSE_CREATED
assert event.response.status == "in_progress"
assert event.response.object == "realtime.response"
conversation_id = event.response.conversation_id
event = await _recv(conn)
assert event.type == AUDIO_DELTA
decoded = base64.b64decode(event.delta)
assert len(decoded) == len(_pcm_bytes(256))
server_env.text_output_queue.put(AssistantTextEvent(text="Hi there!"))
event = await _recv(conn)
assert event.type == TRANSCRIPT_DONE
assert event.transcript == "Hi there!"
server_env.output_queue.put(PIPELINE_END)
event = await _recv(conn)
assert event.type == AUDIO_DONE
event = await _recv(conn)
assert event.type == RESPONSE_DONE
assert event.response.status == "completed"
assert event.response.conversation_id == conversation_id
# ===================================================================
# 4. Interruption (barge-in)
# ===================================================================
class TestSDKBargeIn:
@pytest.mark.asyncio
async def test_speech_interrupts_active_response(self, server_env):
"""User speech during audio streaming cancels with turn_detected."""
client = server_env.make_client()
async with client.realtime.connect(model="test") as conn:
await _recv(conn) # session.created
server_env.output_queue.put(_pcm_bytes(256))
event = await _recv(conn)
assert event.type == RESPONSE_CREATED
await _recv(conn) # audio delta
server_env.text_output_queue.put(SpeechStartedEvent())
events = []
for _ in range(3):
events.append(await _recv(conn))
types = [e.type for e in events]
assert AUDIO_DONE in types
assert RESPONSE_DONE in types
assert SPEECH_STARTED in types
done = next(e for e in events if e.type == RESPONSE_DONE)
assert done.response.status == "cancelled"
assert done.response.status_details.reason == "turn_detected"
@pytest.mark.asyncio
async def test_stale_assistant_text_flushed_on_interruption(self, server_env):
"""Stale assistant_text queued during interruption is flushed, not reopened as a new response."""
client = server_env.make_client()
async with client.realtime.connect(model="test") as conn:
await _recv(conn) # session.created
server_env.output_queue.put(_pcm_bytes(256))
event = await _recv(conn)
assert event.type == RESPONSE_CREATED
await _recv(conn) # audio delta
server_env.text_output_queue.put(SpeechStartedEvent())
server_env.text_output_queue.put(AssistantTextEvent(text="stale response text"))
events = []
for _ in range(3):
events.append(await _recv(conn))
types = [e.type for e in events]
assert AUDIO_DONE in types
assert RESPONSE_DONE in types
assert SPEECH_STARTED in types
done = next(e for e in events if e.type == RESPONSE_DONE)
assert done.response.status == "cancelled"
with pytest.raises(asyncio.TimeoutError):
await asyncio.wait_for(conn.recv(), timeout=0.5)
# ===================================================================
# 4b. Phantom speech & interruption state
# ===================================================================
class TestSDKPhantomSpeech:
@pytest.mark.asyncio
async def test_phantom_speech_does_not_block_pipeline(self, server_env):
"""speech_started + speech_stopped(duration=0) doesn't hang; a normal turn follows."""
client = server_env.make_client()
async with client.realtime.connect(model="test") as conn:
await _recv(conn) # session.created
server_env.text_output_queue.put(SpeechStartedEvent())
event = await _recv(conn)
assert event.type == SPEECH_STARTED
server_env.text_output_queue.put(SpeechStoppedEvent())
event = await _recv(conn)
assert event.type == SPEECH_STOPPED
server_env.text_output_queue.put(SpeechStartedEvent())
event = await _recv(conn)
assert event.type == SPEECH_STARTED
server_env.text_output_queue.put(SpeechStoppedEvent(duration_s=2.0))
event = await _recv(conn)
assert event.type == SPEECH_STOPPED
server_env.output_queue.put(_pcm_bytes(256))
event = await _recv(conn)
assert event.type == RESPONSE_CREATED
await _recv(conn) # audio delta
server_env.output_queue.put(AUDIO_RESPONSE_DONE)
event = await _recv(conn)
assert event.type == AUDIO_DONE
event = await _recv(conn)
assert event.type == RESPONSE_DONE
assert event.response.status == "completed"
class TestSDKInterruptionState:
@pytest.mark.asyncio
async def test_interruption_resets_pipeline_state(self, server_env):
"""After interruption, response_playing is cleared and cancel_scope
enters discarding mode until __RESPONSE_DONE__ arrives."""
client = server_env.make_client()
async with client.realtime.connect(model="test") as conn:
await _recv(conn) # session.created
assert not server_env.response_playing.is_set()
assert not server_env.cancel_scope.discarding
server_env.output_queue.put(_pcm_bytes(256))
await _recv(conn) # response.created
await _recv(conn) # audio delta
assert server_env.response_playing.is_set()
server_env.text_output_queue.put(SpeechStartedEvent())
events = []
for _ in range(3):
events.append(await _recv(conn))
types = [e.type for e in events]
assert SPEECH_STARTED in types
assert RESPONSE_DONE in types
await asyncio.sleep(0.1)
assert not server_env.response_playing.is_set()
assert server_env.cancel_scope.discarding
# ===================================================================
# 5. Tool calling
# ===================================================================
class TestSDKToolCalling:
@pytest.mark.asyncio
async def test_tool_call_events(self, server_env):
"""Tool calls produce events with name, call_id, arguments."""
client = server_env.make_client()
async with client.realtime.connect(model="test") as conn:
await _recv(conn)
server_env.text_output_queue.put(
AssistantTextEvent(
text="Checking weather",
tools=[
{
"type": "function_call",
"call_id": "call_xyz",
"name": "get_weather",
"arguments": '{"city": "Tokyo"}',
}
],
)
)
event = await _recv(conn)
assert event.type == TRANSCRIPT_DONE
assert event.transcript == "Checking weather"
event = await _recv(conn)
assert event.type == FUNCTION_CALL_DONE
assert event.name == "get_weather"
assert event.call_id == "call_xyz"
assert json.loads(event.arguments) == {"city": "Tokyo"}
@pytest.mark.asyncio
async def test_multiple_tool_calls_output_index(self, server_env):
"""Multiple tool calls have incrementing output_index."""
client = server_env.make_client()
async with client.realtime.connect(model="test") as conn:
await _recv(conn)
server_env.text_output_queue.put(
AssistantTextEvent(
text="",
tools=[
{"type": "function_call", "call_id": "c1", "name": "tool_a", "arguments": "{}"},
{"type": "function_call", "call_id": "c2", "name": "tool_b", "arguments": '{"x": 1}'},
],
)
)
e1 = await _recv(conn)
e2 = await _recv(conn)
assert e1.type == FUNCTION_CALL_DONE
assert e2.type == FUNCTION_CALL_DONE
assert e1.output_index == 0
assert e2.output_index == 1
# ===================================================================
# 6. Text input via SDK
# ===================================================================
class TestSDKTextInput:
@pytest.mark.asyncio
async def test_send_conversation_item_create(self, server_env):
"""Sending conversation.item.create produces an item.created event."""
client = server_env.make_client()
async with client.realtime.connect(model="test") as conn:
await _recv(conn)
await conn.send(
{
"type": "conversation.item.create",
"item": {
"id": "msg_sdk_1",
"type": "message",
"role": "user",
"content": [{"type": "input_text", "text": "Hello from SDK"}],
},
}
)
event = await _recv(conn)
assert event.type == ITEM_CREATED
assert event.item.role == "user"
assert event.item.content[0].text == "Hello from SDK"
assert event.previous_item_id is None
@pytest.mark.asyncio
async def test_text_input_previous_item_id_chain(self, server_env):
"""Sequential text items chain via previous_item_id."""
client = server_env.make_client()
async with client.realtime.connect(model="test") as conn:
await _recv(conn)
await conn.send(
{
"type": "conversation.item.create",
"item": {
"id": "msg_a",
"type": "message",
"role": "user",
"content": [{"type": "input_text", "text": "first"}],
},
}
)
e1 = await _recv(conn)
assert e1.previous_item_id is None
await conn.send(
{
"type": "conversation.item.create",
"item": {
"id": "msg_b",
"type": "message",
"role": "user",
"content": [{"type": "input_text", "text": "second"}],
},
}
)
e2 = await _recv(conn)
assert e2.previous_item_id == e1.item.id
# ===================================================================
# 7. Error handling
# ===================================================================
class TestSDKErrorHandling:
@pytest.mark.asyncio
async def test_unknown_event_returns_error(self, server_env):
"""Unknown event type returns an error event."""
client = server_env.make_client()
async with client.realtime.connect(model="test") as conn:
await _recv(conn)
await conn.send({"type": "bogus.nonexistent"})
event = await _recv(conn)
assert event.type == ERROR
assert event.error is not None
@pytest.mark.asyncio
async def test_duplicate_response_create_error(self, server_env):
"""response.create while response is active returns error."""
client = server_env.make_client()
async with client.realtime.connect(model="test") as conn:
await _recv(conn)
server_env.output_queue.put(_pcm_bytes(256))
await _recv(conn) # response.created
await _recv(conn) # audio delta
await conn.send({"type": "response.create"})
event = await _recv(conn)
assert event.type == ERROR
assert event.error.type == "conversation_already_has_active_response"
# ===================================================================
# 8. Response cancel
# ===================================================================
class TestSDKResponseCancel:
@pytest.mark.asyncio
async def test_cancel_active_response(self, server_env):
"""response.cancel produces done events with cancelled status."""
client = server_env.make_client()
async with client.realtime.connect(model="test") as conn:
await _recv(conn)
server_env.output_queue.put(_pcm_bytes(256))
await _recv(conn) # response.created
await _recv(conn) # audio delta
await conn.send({"type": "response.cancel"})
event = await _recv(conn)
assert event.type == AUDIO_DONE
event = await _recv(conn)
assert event.type == RESPONSE_DONE
assert event.response.status == "cancelled"
assert event.response.status_details.reason == "client_cancelled"
# ===================================================================
# 9. Multi-turn conversation_id consistency
# ===================================================================
class TestSDKMultiTurn:
@pytest.mark.asyncio
async def test_two_turns_same_conversation(self, server_env):
"""Two voice turns share the same conversation_id."""
client = server_env.make_client()
async with client.realtime.connect(model="test") as conn:
await _recv(conn) # session.created
# Turn 1
server_env.text_output_queue.put(SpeechStartedEvent())
await _recv(conn)
server_env.text_output_queue.put(SpeechStoppedEvent())
await _recv(conn)
server_env.text_output_queue.put(TranscriptionCompletedEvent(transcript="hi"))
await _recv(conn)
server_env.output_queue.put(_pcm_bytes(128))
t1_created = await _recv(conn)
assert t1_created.type == RESPONSE_CREATED
await _recv(conn) # audio delta
# Barge-in
server_env.text_output_queue.put(SpeechStartedEvent())
events = []
for _ in range(3):
events.append(await _recv(conn))
t1_done = next(e for e in events if e.type == RESPONSE_DONE)
# Simulate pipeline acknowledging cancellation so discard guard clears
server_env.output_queue.put(AUDIO_RESPONSE_DONE)
await asyncio.sleep(0.15)
# Turn 2
server_env.text_output_queue.put(SpeechStoppedEvent())
await _recv(conn)
server_env.text_output_queue.put(TranscriptionCompletedEvent(transcript="bye"))
await _recv(conn)
server_env.output_queue.put(_pcm_bytes(128))
t2_created = await _recv(conn)
assert t2_created.type == RESPONSE_CREATED
await _recv(conn) # audio delta
server_env.output_queue.put(PIPELINE_END)
await _recv(conn) # audio done
t2_done = await _recv(conn)
assert t2_done.type == RESPONSE_DONE
assert t1_done.response.conversation_id == t2_done.response.conversation_id
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,160 @@
"""Unit tests for RuntimeConfig.apply_session_update / _apply_update.
Verifies the merge semantics: only explicitly-set fields (model_fields_set)
are written, nested BaseModels recurse without clobbering siblings, and
explicit None clears a previously-set value.
"""
from openai.types.realtime import RealtimeSessionCreateRequest
from openai.types.realtime.session_update_event import SessionUpdateEvent
from speech_to_speech.api.openai_realtime.runtime_config import RuntimeConfig
def _parse_session(**session_fields) -> RealtimeSessionCreateRequest:
"""Parse a session dict the same way the SDK does (tracks model_fields_set)."""
session_fields.setdefault("type", "realtime")
evt = SessionUpdateEvent.model_validate(
{
"type": "session.update",
"session": session_fields,
}
)
return evt.session # type: ignore[return-value]
class TestApplySessionUpdate:
def test_partial_update_preserves_untouched_fields(self):
cfg = RuntimeConfig()
cfg.apply_session_update(
_parse_session(
instructions="Be a pirate",
audio={"output": {"voice": "coral"}},
tool_choice="auto",
)
)
assert cfg.session.instructions == "Be a pirate"
assert cfg.session.audio.output.voice == "coral"
assert cfg.session.tool_choice == "auto"
cfg.apply_session_update(_parse_session(instructions="Be an astronaut"))
assert cfg.session.instructions == "Be an astronaut"
assert cfg.session.audio.output.voice == "coral"
assert cfg.session.tool_choice == "auto"
def test_explicit_none_clears_field(self):
"""Sending ``"turn_detection": null`` clears a previously-set value."""
cfg = RuntimeConfig()
cfg.apply_session_update(
_parse_session(
audio={"input": {"turn_detection": {"type": "server_vad"}}},
)
)
assert cfg.session.audio.input.turn_detection is not None
assert cfg.session.audio.input.turn_detection.type == "server_vad"
cfg.apply_session_update(
_parse_session(
audio={"input": {"turn_detection": None}},
)
)
assert cfg.session.audio.input.turn_detection is None
def test_nested_sibling_preserved(self):
"""Updating audio.output.voice must not touch audio.input.turn_detection."""
cfg = RuntimeConfig()
cfg.apply_session_update(
_parse_session(
audio={
"input": {"turn_detection": {"type": "server_vad", "threshold": 0.6}},
"output": {"voice": "echo"},
},
)
)
assert cfg.session.audio.output.voice == "echo"
assert cfg.session.audio.input.turn_detection.threshold == 0.6
cfg.apply_session_update(
_parse_session(
audio={"output": {"voice": "shimmer"}},
)
)
assert cfg.session.audio.output.voice == "shimmer"
assert cfg.session.audio.input.turn_detection.type == "server_vad"
assert cfg.session.audio.input.turn_detection.threshold == 0.6
def test_sequential_updates_accumulate(self):
cfg = RuntimeConfig()
cfg.apply_session_update(_parse_session(instructions="Step 1"))
cfg.apply_session_update(_parse_session(audio={"output": {"voice": "alloy"}}))
cfg.apply_session_update(_parse_session(tool_choice="required"))
assert cfg.session.instructions == "Step 1"
assert cfg.session.audio.output.voice == "alloy"
assert cfg.session.tool_choice == "required"
def test_deep_nested_leaf_update(self):
"""Changing only turn_detection.threshold preserves the rest."""
cfg = RuntimeConfig()
cfg.apply_session_update(
_parse_session(
audio={
"input": {
"turn_detection": {
"type": "server_vad",
"threshold": 0.5,
"silence_duration_ms": 800,
},
},
},
)
)
assert cfg.session.audio.input.turn_detection.threshold == 0.5
assert cfg.session.audio.input.turn_detection.silence_duration_ms == 800
cfg.apply_session_update(
_parse_session(
audio={"input": {"turn_detection": {"type": "server_vad", "threshold": 0.8}}},
)
)
assert cfg.session.audio.input.turn_detection.threshold == 0.8
assert cfg.session.audio.input.turn_detection.silence_duration_ms == 800
def test_tools_replaced_wholesale(self):
"""Tools is a list, not a BaseModel — the whole list is replaced."""
cfg = RuntimeConfig()
cfg.apply_session_update(
_parse_session(
tools=[{"type": "function", "name": "get_weather"}],
)
)
assert len(cfg.session.tools) == 1
cfg.apply_session_update(
_parse_session(
tools=[
{"type": "function", "name": "get_weather"},
{"type": "function", "name": "get_time"},
],
)
)
assert len(cfg.session.tools) == 2
def test_update_after_fresh_init(self):
"""apply_session_update works on a freshly-constructed RuntimeConfig."""
cfg = RuntimeConfig()
cfg.apply_session_update(
_parse_session(
instructions="Hello",
audio={"output": {"voice": "sage"}},
)
)
assert cfg.session.instructions == "Hello"
assert cfg.session.audio.output.voice == "sage"
def test_validator_ensures_audio_structure_on_init(self):
"""Default construction guarantees audio.input and audio.output are not None."""
cfg = RuntimeConfig()
assert cfg.session.audio is not None
assert cfg.session.audio.input is not None
assert cfg.session.audio.output is not None
@@ -0,0 +1,778 @@
"""Integration tests for api.openai_realtime.websocket_router.
Uses Starlette's synchronous TestClient with WebSocket support to exercise
the full FastAPI app produced by ``create_app``. Each test gets a fresh
PipelineUnit pool (size 1, matching the single-session semantics of the
old tests) so there is no cross-test state.
"""
import asyncio
import base64
import time
from queue import Empty, Queue
from threading import Event as ThreadingEvent
import pytest
from starlette.testclient import TestClient
from starlette.websockets import WebSocketState
import speech_to_speech.api.openai_realtime.websocket_router as router_module
from speech_to_speech.api.openai_realtime.pipeline_unit import PipelineUnit
from speech_to_speech.api.openai_realtime.service import CHUNK_SIZE_BYTES, RealtimeService
from speech_to_speech.api.openai_realtime.websocket_router import create_app
from speech_to_speech.pipeline.cancel_scope import CancelScope
from speech_to_speech.pipeline.control import SESSION_END, PipelineControlMessage, is_control_message
from speech_to_speech.pipeline.events import AssistantTextEvent, SpeechStartedEvent, TokenUsageEvent
from speech_to_speech.pipeline.messages import AUDIO_RESPONSE_DONE, PIPELINE_END, AudioOutput
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture(autouse=True)
def short_drain_timeout(monkeypatch):
"""Shorten the SESSION_END drain warning threshold so tests don't wait 10s.
The constant only controls when the release task logs a warning about a
slow-draining unit — there is no longer a release-anyway timeout, so the
unit stays unavailable until SESSION_END actually drains.
"""
monkeypatch.setattr(router_module, "SESSION_END_DRAIN_TIMEOUT_S", 0.1)
@pytest.fixture
def setup():
"""Return (app, service, input_queue, output_queue, text_output_queue,
should_listen, stop_event, response_playing, cancel_scope) for a pool of one.
There is no real handler chain in this fixture, so SESSION_END enqueued by
the route handler on disconnect never reaches output_queue. Tests that need
the release task to complete (verifying unit.session is cleared and the
service unregistered) must drain SESSION_END themselves — see
`_simulate_session_end_drain` below.
"""
text_prompt_queue: Queue = Queue()
should_listen = ThreadingEvent()
should_listen.set()
service = RealtimeService(
text_prompt_queue=text_prompt_queue,
should_listen=should_listen,
)
input_queue: Queue = Queue()
output_queue: Queue = Queue()
text_output_queue: Queue = Queue()
stop_event = ThreadingEvent()
response_playing = ThreadingEvent()
cancel_scope = CancelScope()
unit = PipelineUnit(
index=0,
service=service,
cancel_scope=cancel_scope,
should_listen=should_listen,
response_playing=response_playing,
input_queue=input_queue,
output_queue=output_queue,
text_output_queue=text_output_queue,
text_prompt_queue=text_prompt_queue,
handlers=[],
)
app = create_app(pool=[unit], stop_event=stop_event)
return (
app,
service,
input_queue,
output_queue,
text_output_queue,
should_listen,
stop_event,
response_playing,
cancel_scope,
)
def _simulate_session_end_drain(input_queue: Queue, output_queue: Queue, timeout: float = 1.0) -> None:
"""Wait for SESSION_END to land in input_queue (from the route handler's
release path) and forward it to output_queue — simulating the handler chain.
The send loop will then observe SESSION_END and set `session.drained`,
letting the release task complete (unregister + clear `unit.session`).
"""
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
try:
item = input_queue.get(timeout=0.05)
except Empty:
continue
if isinstance(item, PipelineControlMessage) and is_control_message(item, SESSION_END.kind):
output_queue.put(item)
return
raise AssertionError("SESSION_END did not appear on input_queue within timeout")
def _pcm_bytes(n_samples: int) -> bytes:
return b"\x00" * (n_samples * 2)
class _FakeWebSocket:
application_state = WebSocketState.CONNECTED
def __init__(self):
self.sent: list[dict] = []
async def send_json(self, payload: dict) -> None:
self.sent.append(payload)
# ===================================================================
# Connection
# ===================================================================
class TestConnection:
def test_connect_receives_session_created(self, setup):
app, *_ = setup
with TestClient(app) as client:
with client.websocket_connect("/v1/realtime") as ws:
msg = ws.receive_json()
assert msg["type"] == "session.created"
assert msg["event_id"].startswith("event_")
assert "session" in msg
def test_second_connection_rejected(self, setup):
app, *_ = setup
with TestClient(app) as client:
with client.websocket_connect("/v1/realtime") as ws1:
ws1.receive_json() # session.created
with client.websocket_connect("/v1/realtime") as ws2:
msg = ws2.receive_json()
assert msg["type"] == "error"
# Rejection uses the stateless build_error_event helper —
# the error type identifies pool exhaustion specifically.
assert msg["error"]["type"] == "session_limit_reached"
# ===================================================================
# Client event dispatch
# ===================================================================
class TestClientEventDispatch:
def test_audio_append_forwarded_to_input_queue(self, setup):
app, _, input_queue, *_ = setup
audio_b64 = base64.b64encode(_pcm_bytes(512)).decode("ascii")
with TestClient(app) as client:
with client.websocket_connect("/v1/realtime") as ws:
ws.receive_json() # session.created
ws.send_json(
{
"type": "input_audio_buffer.append",
"audio": audio_b64,
}
)
time.sleep(0.1)
item = input_queue.get(timeout=1)
assert isinstance(item, tuple) and len(item) == 2
chunk, rt_cfg = item
assert isinstance(chunk, bytes)
assert len(chunk) == CHUNK_SIZE_BYTES
def test_session_update_applied(self, setup):
app, service, *_ = setup
with TestClient(app) as client:
with client.websocket_connect("/v1/realtime") as ws:
ws.receive_json()
ws.send_json(
{
"type": "session.update",
"session": {
"type": "realtime",
"audio": {"output": {"voice": "coral"}},
},
}
)
time.sleep(0.1)
cid = service.connection_ids[0]
assert service._state(cid).runtime_config.session.audio.output.voice == "coral"
def test_conversation_item_create_returns_events(self, setup):
app, *_ = setup
with TestClient(app) as client:
with client.websocket_connect("/v1/realtime") as ws:
ws.receive_json() # session.created
ws.send_json(
{
"type": "conversation.item.create",
"item": {
"type": "message",
"role": "user",
"content": [{"type": "input_text", "text": "ping"}],
},
}
)
msg = ws.receive_json()
assert msg["type"] == "conversation.item.created"
assert msg["item"]["content"][0]["text"] == "ping"
def test_response_create_error_when_active(self, setup):
app, service, *_ = setup
with TestClient(app) as client:
with client.websocket_connect("/v1/realtime") as ws:
ws.receive_json()
conn_id = list(service._conns.keys())[0]
service.response._ensure_response(conn_id)
ws.send_json({"type": "response.create"})
msg = ws.receive_json()
assert msg["type"] == "error"
assert "another response is in progress" in msg["error"]["message"].lower()
def test_response_cancel_returns_events(self, setup):
app, service, *_ = setup
with TestClient(app) as client:
with client.websocket_connect("/v1/realtime") as ws:
ws.receive_json()
conn_id = list(service._conns.keys())[0]
service.response._ensure_response(conn_id)
ws.send_json({"type": "response.cancel"})
msg1 = ws.receive_json()
msg2 = ws.receive_json()
types = {msg1["type"], msg2["type"]}
assert "response.output_audio.done" in types
assert "response.done" in types
def test_response_cancel_flushes_queues(self, setup):
app, service, _, output_queue, text_output_queue, _, _, response_playing, cancel_scope = setup
with TestClient(app) as client:
with client.websocket_connect("/v1/realtime") as ws:
ws.receive_json()
conn_id = list(service._conns.keys())[0]
service.response._ensure_response(conn_id)
response_playing.set()
output_queue.put(_pcm_bytes(256))
output_queue.put(_pcm_bytes(256))
text_output_queue.put(AssistantTextEvent(text="stale"))
ws.send_json({"type": "response.cancel"})
ws.receive_json() # response.output_audio.done
ws.receive_json() # response.done
time.sleep(0.1)
assert output_queue.empty()
assert text_output_queue.empty()
assert not response_playing.is_set()
assert cancel_scope.discarding
def test_response_cancel_spurious_does_not_set_discarding(self, setup):
"""response.cancel when no response is active must NOT enable discarding,
otherwise it would stick True forever (no __RESPONSE_DONE__ to clear it)."""
app, service, _, _, _, _, _, _, cancel_scope = setup
with TestClient(app) as client:
with client.websocket_connect("/v1/realtime") as ws:
ws.receive_json() # session.created
assert not service._state(list(service._conns.keys())[0]).in_response
ws.send_json({"type": "response.cancel"})
time.sleep(0.1)
assert not cancel_scope.discarding
def test_response_cancel_late_audio_is_discarded(self, setup):
"""Audio arriving after response.cancel is silently dropped (discard guard)."""
app, service, _, output_queue, _, _, _, response_playing, cancel_scope = setup
with TestClient(app) as client:
with client.websocket_connect("/v1/realtime") as ws:
ws.receive_json() # session.created
conn_id = list(service._conns.keys())[0]
service.response._ensure_response(conn_id)
response_playing.set()
ws.send_json({"type": "response.cancel"})
ws.receive_json() # response.output_audio.done
ws.receive_json() # response.done
time.sleep(0.1)
assert cancel_scope.discarding
output_queue.put(_pcm_bytes(256))
time.sleep(0.15)
# No response.created or audio delta should appear; only
# __RESPONSE_DONE__ will eventually clear the guard.
output_queue.put(AUDIO_RESPONSE_DONE)
time.sleep(0.15)
assert not cancel_scope.discarding
def test_unknown_event_returns_error(self, setup):
app, *_ = setup
with TestClient(app) as client:
with client.websocket_connect("/v1/realtime") as ws:
ws.receive_json()
ws.send_json({"type": "bogus.event"})
msg = ws.receive_json()
assert msg["type"] == "error"
# ===================================================================
# Send loop (pipeline -> client)
# ===================================================================
class TestSendLoop:
def test_audio_output_ignores_session_end_control_message(self, setup):
app, _, _, output_queue, *_ = setup
with TestClient(app) as client:
with client.websocket_connect("/v1/realtime") as ws:
ws.receive_json() # session.created
output_queue.put(SESSION_END)
output_queue.put(_pcm_bytes(256))
msg1 = ws.receive_json()
assert msg1["type"] == "response.created"
msg2 = ws.receive_json()
assert msg2["type"] == "response.output_audio.delta"
def test_audio_output_sends_response_created_and_delta(self, setup):
app, _, _, output_queue, *_ = setup
with TestClient(app) as client:
with client.websocket_connect("/v1/realtime") as ws:
ws.receive_json() # session.created
output_queue.put(_pcm_bytes(256))
msg1 = ws.receive_json()
assert msg1["type"] == "response.created"
assert msg1["response"]["status"] == "in_progress"
msg2 = ws.receive_json()
assert msg2["type"] == "response.output_audio.delta"
assert "delta" in msg2
def test_audio_output_batches_immediately_available_chunks(self, setup):
app, _, _, output_queue, *_ = setup
with TestClient(app) as client:
with client.websocket_connect("/v1/realtime") as ws:
ws.receive_json() # session.created
output_queue.put(_pcm_bytes(256))
output_queue.put(_pcm_bytes(256))
output_queue.put(PIPELINE_END)
msg1 = ws.receive_json()
assert msg1["type"] == "response.created"
msg2 = ws.receive_json()
assert msg2["type"] == "response.output_audio.delta"
decoded = base64.b64decode(msg2["delta"])
assert len(decoded) == len(_pcm_bytes(512))
msg3 = ws.receive_json()
msg4 = ws.receive_json()
types = {msg3["type"], msg4["type"]}
assert "response.output_audio.done" in types
assert "response.done" in types
def test_end_marker_sends_finish_events(self, setup):
app, _, _, output_queue, *_ = setup
with TestClient(app) as client:
with client.websocket_connect("/v1/realtime") as ws:
ws.receive_json()
output_queue.put(_pcm_bytes(256))
ws.receive_json() # response.created
ws.receive_json() # audio delta
output_queue.put(PIPELINE_END)
msg1 = ws.receive_json()
msg2 = ws.receive_json()
types = {msg1["type"], msg2["type"]}
assert "response.output_audio.done" in types
assert "response.done" in types
def test_text_output_sends_pipeline_events(self, setup):
app, _, _, _, text_output_queue, *_ = setup
with TestClient(app) as client:
with client.websocket_connect("/v1/realtime") as ws:
ws.receive_json()
text_output_queue.put(SpeechStartedEvent())
msg = ws.receive_json()
assert msg["type"] == "input_audio_buffer.speech_started"
assert msg["audio_start_ms"] == 0
def test_barge_in_discard_clears_after_response_done(self, setup):
"""After barge-in sets discarding=True, __RESPONSE_DONE__ must clear it back to False."""
app, service, _, output_queue, text_output_queue, _, _, response_playing, cancel_scope = setup
with TestClient(app) as client:
with client.websocket_connect("/v1/realtime") as ws:
ws.receive_json() # session.created
conn_id = list(service._conns.keys())[0]
service.response._ensure_response(conn_id)
response_playing.set()
# Trigger barge-in
text_output_queue.put(SpeechStartedEvent())
ws.receive_json() # input_audio_buffer.speech_started
ws.receive_json() # response.output_audio.done
ws.receive_json() # response.done
time.sleep(0.1)
assert cancel_scope.discarding
output_queue.put(AUDIO_RESPONSE_DONE)
time.sleep(0.15)
assert not cancel_scope.discarding
def test_speech_started_cancels_pending_implicit_response(self, setup):
app, service, _, output_queue, text_output_queue, _, _, response_playing, cancel_scope = setup
with TestClient(app) as client:
with client.websocket_connect("/v1/realtime") as ws:
ws.receive_json() # session.created
conn_id = list(service._conns.keys())[0]
stale_generation = cancel_scope.generation
service._state(conn_id).response_pending = True
text_output_queue.put(SpeechStartedEvent())
msg = ws.receive_json()
assert msg["type"] == "input_audio_buffer.speech_started"
time.sleep(0.15)
assert cancel_scope.discarding
assert cancel_scope.generation == stale_generation + 1
assert service._state(conn_id).response_pending is False
assert service._state(conn_id).in_response is False
assert not response_playing.is_set()
output_queue.put(AudioOutput(audio=AUDIO_RESPONSE_DONE, cancel_generation=stale_generation))
time.sleep(0.15)
assert not cancel_scope.discarding
def test_speech_started_does_not_cancel_pending_when_internal_non_interrupt(self, setup):
app, service, _, _, text_output_queue, _, _, _, cancel_scope = setup
with TestClient(app) as client:
with client.websocket_connect("/v1/realtime") as ws:
ws.receive_json() # session.created
conn_id = list(service._conns.keys())[0]
service._state(conn_id).response_pending = True
text_output_queue.put(SpeechStartedEvent(interrupt_response=False))
msg = ws.receive_json()
assert msg["type"] == "input_audio_buffer.speech_started"
time.sleep(0.15)
assert not cancel_scope.discarding
assert service._state(conn_id).response_pending is True
def test_stale_tagged_audio_is_dropped_after_interruption(self, setup):
app, _, _, output_queue, _, _, _, _, cancel_scope = setup
with TestClient(app) as client:
with client.websocket_connect("/v1/realtime") as ws:
ws.receive_json() # session.created
stale_generation = cancel_scope.generation
cancel_scope.cancel()
current_generation = cancel_scope.generation
output_queue.put(AudioOutput(audio=_pcm_bytes(64), cancel_generation=stale_generation))
output_queue.put(AudioOutput(audio=_pcm_bytes(512), cancel_generation=current_generation))
assert ws.receive_json()["type"] == "response.created"
delta = ws.receive_json()
assert delta["type"] == "response.output_audio.delta"
assert len(base64.b64decode(delta["delta"])) == len(_pcm_bytes(512))
def test_current_generation_text_survives_stuck_discarding(self, setup):
"""Regression: a fresh response's transcript must survive a stuck discard guard.
A superseded speculative turn can leave ``cancel_scope.discarding`` stuck True
(its TTS dropped the stale ``EndOfResponse`` without emitting AUDIO_RESPONSE_DONE,
so ``response_done()`` never cleared the flag). The next response's audio is tagged
with the current generation and streams fine, but the assistant text used to be
blanket-dropped while discarding — leaving audio + ``response.done`` with no
``response.output_audio_transcript.done``. The text is now discarded by the same
generation-aware rule as audio, so a current-generation transcript is kept.
"""
app, _, _, output_queue, text_output_queue, _, _, _, cancel_scope = setup
with TestClient(app) as client:
with client.websocket_connect("/v1/realtime") as ws:
ws.receive_json() # session.created
cancel_scope.cancel() # discarding=True, generation bumped; sentinel never arrived
current_generation = cancel_scope.generation
assert cancel_scope.discarding
text_output_queue.put(AssistantTextEvent(text="hello there", cancel_generation=current_generation))
output_queue.put(AudioOutput(audio=_pcm_bytes(256), cancel_generation=current_generation))
output_queue.put(AudioOutput(audio=AUDIO_RESPONSE_DONE, cancel_generation=current_generation))
types: list[str] = []
transcript = None
for _ in range(8):
msg = ws.receive_json()
types.append(msg["type"])
if msg["type"] == "response.output_audio_transcript.done":
transcript = msg["transcript"]
if msg["type"] == "response.done":
break
assert "response.output_audio_transcript.done" in types
assert transcript == "hello there"
def test_stale_tagged_response_done_does_not_finish_current_response(self, setup):
app, service, _, output_queue, _, _, _, _, cancel_scope = setup
with TestClient(app) as client:
with client.websocket_connect("/v1/realtime") as ws:
ws.receive_json() # session.created
conn_id = list(service._conns.keys())[0]
stale_generation = cancel_scope.generation
service.response._ensure_response(conn_id)
service.finish_response(conn_id, status="cancelled")
cancel_scope.cancel()
current_response_id, _ = service.response._ensure_response(conn_id)
output_queue.put(AudioOutput(audio=AUDIO_RESPONSE_DONE, cancel_generation=stale_generation))
time.sleep(0.15)
state = service._state(conn_id)
assert state.in_response
assert state.current_response_id == current_response_id
def test_response_done_drains_pending_token_usage_before_finish(self, setup):
app, service, _, output_queue, text_output_queue, *_ = setup
with TestClient(app) as client:
with client.websocket_connect("/v1/realtime") as ws:
ws.receive_json() # session.created
conn_id = list(service._conns.keys())[0]
text_output_queue.put(
AssistantTextEvent(
text="",
tools=[{"type": "function_call", "call_id": "c1", "name": "f1", "arguments": "{}"}],
)
)
text_output_queue.put(TokenUsageEvent(input_tokens=10, output_tokens=5))
output_queue.put(AUDIO_RESPONSE_DONE)
assert ws.receive_json()["type"] == "response.function_call_arguments.done"
msg1 = ws.receive_json()
msg2 = ws.receive_json()
assert {msg1["type"], msg2["type"]} == {"response.output_audio.done", "response.done"}
assert service.total_usage.input_tokens == 10
assert service.total_usage.output_tokens == 5
assert service._state(conn_id).response_usage.input_tokens == 0
assert service._state(conn_id).response_usage.output_tokens == 0
def test_response_completion_drain_sends_pending_tool_before_done(self, setup):
_, service, input_queue, output_queue, text_output_queue, should_listen, _, response_playing, cancel_scope = (
setup
)
unit = PipelineUnit(
index=0,
service=service,
cancel_scope=cancel_scope,
should_listen=should_listen,
response_playing=response_playing,
input_queue=input_queue,
output_queue=output_queue,
text_output_queue=text_output_queue,
text_prompt_queue=Queue(),
handlers=[],
)
conn_id = service.register()
response_id, _ = service.response._ensure_response(conn_id)
text_output_queue.put(
AssistantTextEvent(
text="",
tools=[
{
"type": "function_call",
"call_id": "c1",
"name": "play_emotion",
"arguments": '{"emotion":"loving"}',
}
],
)
)
text_output_queue.put(TokenUsageEvent(input_tokens=10, output_tokens=5))
ws = _FakeWebSocket()
asyncio.run(router_module._drain_pending_response_events(ws, unit, conn_id))
done_events = service.finish_response(conn_id)
assert [payload["type"] for payload in ws.sent] == ["response.function_call_arguments.done"]
assert [event.type for event in done_events] == ["response.output_audio.done", "response.done"]
assert ws.sent[0]["response_id"] == response_id
assert done_events[1].response.id == response_id
assert done_events[1].response.usage.input_tokens == 10
assert done_events[1].response.usage.output_tokens == 5
assert text_output_queue.empty()
def test_response_completion_drain_preserves_usage_across_non_response_boundary(self, setup):
_, service, input_queue, output_queue, text_output_queue, should_listen, _, response_playing, cancel_scope = (
setup
)
unit = PipelineUnit(
index=0,
service=service,
cancel_scope=cancel_scope,
should_listen=should_listen,
response_playing=response_playing,
input_queue=input_queue,
output_queue=output_queue,
text_output_queue=text_output_queue,
text_prompt_queue=Queue(),
handlers=[],
)
conn_id = service.register()
response_id, _ = service.response._ensure_response(conn_id)
text_output_queue.put(
AssistantTextEvent(
text="",
tools=[{"type": "function_call", "call_id": "c1", "name": "play_emotion", "arguments": "{}"}],
)
)
text_output_queue.put(SpeechStartedEvent())
text_output_queue.put(TokenUsageEvent(input_tokens=10, output_tokens=5))
text_output_queue.put(AssistantTextEvent(text="queued after boundary"))
ws = _FakeWebSocket()
asyncio.run(router_module._drain_pending_response_events(ws, unit, conn_id))
done_events = service.finish_response(conn_id)
assert [payload["type"] for payload in ws.sent] == ["response.function_call_arguments.done"]
assert ws.sent[0]["response_id"] == response_id
assert done_events[1].response.usage.input_tokens == 10
assert done_events[1].response.usage.output_tokens == 5
boundary = text_output_queue.get_nowait()
queued_assistant = text_output_queue.get_nowait()
assert isinstance(boundary, SpeechStartedEvent)
assert isinstance(queued_assistant, AssistantTextEvent)
assert queued_assistant.text == "queued after boundary"
assert text_output_queue.empty()
def test_speech_started_does_not_cancel_when_interrupt_disabled(self, setup):
"""With interrupt_response=False, speech during playback should NOT cancel or flush."""
from openai.types.realtime.realtime_audio_input_turn_detection import ServerVad
app, service, _, output_queue, text_output_queue, _, _, response_playing, cancel_scope = setup
with TestClient(app) as client:
with client.websocket_connect("/v1/realtime") as ws:
ws.receive_json() # session.created
conn_id = list(service._conns.keys())[0]
service._state(conn_id).runtime_config.session.audio.input.turn_detection = ServerVad(
type="server_vad",
interrupt_response=False,
)
_, response_item_id = service.response._ensure_response(conn_id)
response_playing.set()
text_output_queue.put(SpeechStartedEvent())
msg = ws.receive_json()
assert msg["type"] == "input_audio_buffer.speech_started"
time.sleep(0.15)
assert response_playing.is_set(), "response_playing should remain set"
assert not cancel_scope.discarding, "cancel_scope should not be discarding"
assert service._state(conn_id).in_response, "response should still be active"
assert service._state(conn_id).current_item_id == response_item_id
# ===================================================================
# Cleanup
# ===================================================================
class TestCleanup:
def test_new_connection_resets_discard_after_invalidating_generation(self, setup):
"""connect-time _clean_unit cancels+resets: stale work is invalidated, discarding cleared."""
app, _, *_rest, cancel_scope = setup
cancel_scope.cancel()
assert cancel_scope.discarding
assert cancel_scope.generation == 1
with TestClient(app) as client:
with client.websocket_connect("/v1/realtime") as ws:
ws.receive_json()
assert not cancel_scope.discarding
assert cancel_scope.generation == 2
def test_disconnect_bumps_cancel_scope_generation(self, setup):
"""_clean_unit() on disconnect calls cancel() so in-flight generations go stale."""
app, _, _, _, _, _, _, _, cancel_scope = setup
assert cancel_scope.generation == 0
with TestClient(app) as client:
with client.websocket_connect("/v1/realtime") as ws:
ws.receive_json()
assert cancel_scope.generation == 1
# disconnect triggers _clean_unit again + drain (short timeout in tests)
time.sleep(0.3)
assert cancel_scope.generation == 2
def test_disconnect_unregisters(self, setup):
app, service, input_queue, output_queue, *_ = setup
with TestClient(app) as client:
with client.websocket_connect("/v1/realtime") as ws:
ws.receive_json()
assert len(service._conns) == 1
# Simulate the handler chain consuming SESSION_END so the release
# task can complete and unregister the session.
_simulate_session_end_drain(input_queue, output_queue)
time.sleep(0.3)
assert len(service._conns) == 0
def test_last_disconnect_cancels_and_clears_response_state(self, setup):
app, service, input_queue, output_queue, text_output_queue, _, _, response_playing, cancel_scope = setup
with TestClient(app) as client:
with client.websocket_connect("/v1/realtime") as ws:
ws.receive_json()
conn_id = list(service._conns.keys())[0]
service.response._ensure_response(conn_id)
response_playing.set()
output_queue.put(_pcm_bytes(256))
text_output_queue.put(AssistantTextEvent(text="stale"))
_simulate_session_end_drain(input_queue, output_queue)
time.sleep(0.3)
assert not cancel_scope.discarding
assert cancel_scope.generation == 2
assert not response_playing.is_set()
assert text_output_queue.empty()
# ===================================================================
# Pool semantics (new in pool refactor)
# ===================================================================
def _make_unit(index: int) -> PipelineUnit:
text_prompt_queue: Queue = Queue()
should_listen = ThreadingEvent()
should_listen.set()
return PipelineUnit(
index=index,
service=RealtimeService(text_prompt_queue=text_prompt_queue, should_listen=should_listen),
cancel_scope=CancelScope(),
should_listen=should_listen,
response_playing=ThreadingEvent(),
input_queue=Queue(),
output_queue=Queue(),
text_output_queue=Queue(),
text_prompt_queue=text_prompt_queue,
handlers=[],
)
class TestPool:
def test_pool_endpoint_reports_idle_state(self):
pool = [_make_unit(0), _make_unit(1)]
app = create_app(pool=pool, stop_event=ThreadingEvent())
with TestClient(app) as client:
r = client.get("/v1/pool")
assert r.status_code == 200
data = r.json()
assert data["size"] == 2
assert data["in_use"] == 0
assert [u["session_id"] for u in data["units"]] == [None, None]
def test_two_clients_claim_two_slots_third_rejected(self):
pool = [_make_unit(0), _make_unit(1)]
app = create_app(pool=pool, stop_event=ThreadingEvent())
with TestClient(app) as client:
with client.websocket_connect("/v1/realtime") as ws1:
ws1.receive_json() # session.created
with client.websocket_connect("/v1/realtime") as ws2:
ws2.receive_json() # session.created (different unit)
with client.websocket_connect("/v1/realtime") as ws3:
msg = ws3.receive_json()
assert msg["type"] == "error"
assert msg["error"]["type"] == "session_limit_reached"
# Pool now reports 2 in_use
r = client.get("/v1/pool")
assert r.json()["in_use"] == 2
def test_usage_aggregates_errors_by_type_across_units(self):
pool = [_make_unit(0), _make_unit(1)]
pool[0].service.total_usage.record_error("foo")
pool[0].service.total_usage.record_error("foo")
pool[1].service.total_usage.record_error("bar")
app = create_app(pool=pool, stop_event=ThreadingEvent())
with TestClient(app) as client:
data = client.get("/v1/usage").json()
assert data["errors_by_type"] == {"foo": 2, "bar": 1}
assert data["total_errors"] == 3