Files
vllm-omni/tests/entrypoints/openai_api/test_duplex_handler.py
T

7611 lines
269 KiB
Python

# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
from __future__ import annotations
import asyncio
import base64
import json
import struct
from types import SimpleNamespace
from typing import Any
import numpy as np
import pytest
from starlette.websockets import WebSocketDisconnect
from vllm_omni.config.stage_config import DuplexSessionRuntimeConfig
from vllm_omni.experimental.fullduplex.engine.duplex_control_client import DuplexControlRequestError
from vllm_omni.experimental.fullduplex.engine.duplex_runtime import duplex_resource_request_id
from vllm_omni.experimental.fullduplex.engine.lease import DuplexLeaseActivity
from vllm_omni.experimental.fullduplex.engine.messages import DuplexFence, DuplexSessionLifecycleMessage
from vllm_omni.experimental.fullduplex.minicpmo45 import (
MiniCPMO45NativeDuplexServingAdapter,
MiniCPMO45PcmAppendBuffer,
)
from vllm_omni.experimental.fullduplex.minicpmo45.data_plane import (
MiniCPMO45DataPlaneContext,
MiniCPMO45DataPlaneSession,
)
from vllm_omni.experimental.fullduplex.minicpmo45.runtime import (
MiniCPMO45DuplexRuntimeExtension,
)
from vllm_omni.experimental.fullduplex.minicpmo45.session import (
MiniCPMO45ServingSessionState,
)
from vllm_omni.experimental.fullduplex.openai import vad as realtime_vad
from vllm_omni.experimental.fullduplex.openai.protocol import (
DuplexCapabilities,
DuplexOverlapPolicy,
DuplexPlaybackCommitPolicy,
DuplexSession,
DuplexSessionConfig,
ResponseCreateOptions,
)
from vllm_omni.experimental.fullduplex.openai.realtime_session import NativeRealtimeSessionProtocol
from vllm_omni.experimental.fullduplex.openai.runtime_adapter import ServingRuntimeConfigError
from vllm_omni.experimental.fullduplex.openai.serving import (
OmniDuplexSessionHandler,
should_enable_duplex_endpoint,
)
from vllm_omni.experimental.fullduplex.openai.websocket import DuplexWebSocketActor
from vllm_omni.experimental.fullduplex.output import attach_duplex_output_decision
from vllm_omni.experimental.fullduplex.personaplex.serving_adapter import (
PersonaPlexServingRuntimeAdapter,
)
from vllm_omni.outputs import OmniRequestOutput
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
def test_native_input_append_supports_explicit_session_opt_out():
session = DuplexSession(
"sid-explicit-tts",
DuplexSessionConfig(model="test-model"),
capabilities=DuplexCapabilities.minicpmo45_native(),
)
assert OmniDuplexSessionHandler._uses_native_input_append(session) is True
session.config.extra_body["minicpmo45_native_duplex"] = False
assert OmniDuplexSessionHandler._uses_native_input_append(session) is False
class _ModelConfig:
model = "test-model"
class FakeEngineClient:
output_modalities = ["text", "audio"]
def __init__(
self,
*,
fail_open: bool = False,
fail_signal: bool = False,
fail_touch: bool = False,
fail_signal_events: set[str] | None = None,
fail_close: bool = False,
fail_abort: bool = False,
control_result: dict[str, object] | None = None,
open_result: dict[str, object] | None = None,
append_result: dict[str, object] | None = None,
append_results: list[dict[str, object]] | None = None,
collect_outputs: list[list[object]] | None = None,
collect_delay_s: float = 0.0,
signal_result: dict[str, object] | None = None,
close_result: dict[str, object] | None = None,
) -> None:
self.fail_open = fail_open
self.fail_signal = fail_signal
self.fail_touch = fail_touch
self.fail_signal_events = set(fail_signal_events or ())
self.fail_close = fail_close
self.fail_abort = fail_abort
self.control_result = control_result
self.open_result = open_result
self.append_result = append_result
self.append_results = list(append_results or [])
self.collect_outputs = list(collect_outputs or [])
self.collect_delay_s = collect_delay_s
self.signal_result = signal_result
self.close_result = close_result
self.opened: list[str] = []
self.opened_fences: list[DuplexFence | None] = []
self.appended: list[tuple[str, str, Any, bool]] = []
self.append_operation_ids: list[str | None] = []
self.appended_fences: list[DuplexFence | None] = []
self.opened_configs: list[dict[str, Any]] = []
self.opened_runtime_configs: list[dict[str, Any]] = []
self.signals: list[tuple[str, str]] = []
self.signal_fences: list[DuplexFence | None] = []
self.signal_next_fences: list[DuplexFence | None] = []
self.signal_session_configs: list[dict[str, Any] | None] = []
self.signal_runtime_configs: list[dict[str, Any] | None] = []
self.closed: list[tuple[str, str]] = []
self.touched: list[tuple[str, DuplexLeaseActivity]] = []
self.touch_fences: list[DuplexFence] = []
self.resumed: list[tuple[str, int]] = []
self.resume_fences: list[DuplexFence] = []
self.aborted: list[str] = []
self.abort_batches: list[list[str]] = []
self.internal_abort_batches: list[list[str]] = []
self.collected: list[tuple[str, int | None]] = []
self.duplex_lifecycle_events: asyncio.Queue[DuplexSessionLifecycleMessage] = asyncio.Queue()
async def open_duplex_session_async(
self,
session_id: str,
*,
session_mode: str = "duplex",
capabilities: dict[str, object] | None = None,
session_config: dict[str, object] | None = None,
runtime_config: dict[str, object] | None = None,
timeout: float | None = None,
fence: DuplexFence | None = None,
) -> Any:
del timeout
if self.fail_open:
raise RuntimeError("open failed")
self.opened.append(session_id)
self.opened_fences.append(fence)
self.opened_configs.append(dict(session_config or {}))
self.opened_runtime_configs.append(dict(runtime_config or {}))
return self.open_result if self.open_result is not None else self.control_result
async def append_duplex_input_async(
self,
session_id: str,
*,
mode: str,
payload: object,
operation_id: str | None = None,
final: bool = False,
timeout: float | None = None,
collect_outputs: bool = True,
fence: DuplexFence | None = None,
) -> Any:
del timeout, collect_outputs
self.appended.append((session_id, mode, payload, final))
self.append_operation_ids.append(operation_id)
self.appended_fences.append(fence)
if self.append_results:
return self.append_results.pop(0)
return self.append_result if self.append_result is not None else self.control_result
async def collect_duplex_data_plane_outputs_async(
self,
request_id: str,
*,
response_stage_id: int | None = None,
timeout: float | None = None,
) -> list[object]:
del timeout
self.collected.append((request_id, response_stage_id))
if self.collect_delay_s > 0:
await asyncio.sleep(self.collect_delay_s)
if not self.collect_outputs:
return []
return self.collect_outputs.pop(0)
async def signal_duplex_turn_async(
self,
session_id: str,
*,
event: str,
timeout: float | None = None,
fence: DuplexFence | None = None,
next_fence: DuplexFence | None = None,
session_config: dict[str, object] | None = None,
runtime_config: dict[str, object] | None = None,
) -> Any:
del timeout
if self.fail_signal or event in self.fail_signal_events:
raise RuntimeError("signal failed")
self.signals.append((session_id, event))
self.signal_fences.append(fence)
self.signal_next_fences.append(next_fence)
self.signal_session_configs.append(session_config)
self.signal_runtime_configs.append(runtime_config)
return self.signal_result if self.signal_result is not None else self.control_result
async def close_duplex_session_async(
self,
session_id: str,
*,
reason: str = "client_close",
timeout: float | None = None,
fence: DuplexFence | None = None,
) -> Any:
del timeout
if self.fail_close:
raise RuntimeError("close failed")
self.closed.append((session_id, reason))
return self.close_result if self.close_result is not None else self.control_result
async def touch_duplex_session_async(
self,
session_id: str,
*,
fence: DuplexFence,
activity: DuplexLeaseActivity,
timeout: float | None = None,
) -> dict[str, object]:
del timeout
if self.fail_touch:
raise RuntimeError("touch failed")
self.touched.append((session_id, activity))
self.touch_fences.append(fence)
return {
"operation": "touch",
"session_id": session_id,
"ok": True,
"stage_results": [
{
"stage_id": -1,
"replica_id": -1,
"result": {"supported": True, "lease_generation": 0},
}
],
}
async def resume_duplex_session_async(
self,
session_id: str,
*,
fence: DuplexFence,
expected_lease_generation: int,
timeout: float | None = None,
) -> dict[str, object]:
del timeout
self.resumed.append((session_id, expected_lease_generation))
self.resume_fences.append(fence)
return {
"operation": "resume",
"session_id": session_id,
"ok": True,
"stage_results": [
{
"stage_id": -1,
"replica_id": -1,
"result": {
"supported": True,
"lease_generation": expected_lease_generation + 1,
},
}
],
}
async def abort(self, request_ids: list[str]) -> None:
if self.fail_abort:
raise RuntimeError("abort failed")
assert isinstance(request_ids, list)
self.abort_batches.append(list(request_ids))
self.aborted.extend(request_ids)
async def _abort_internal_requests(self, request_ids: list[str]) -> None:
if self.fail_abort:
raise RuntimeError("abort failed")
assert isinstance(request_ids, list)
self.internal_abort_batches.append(list(request_ids))
self.aborted.extend(request_ids)
class FakeChatService:
duplex_serving_adapter_path = (
"vllm_omni.experimental.fullduplex.minicpmo45.serving_adapter.MiniCPMO45ServingRuntimeAdapter"
)
def __init__(self, engine_client: FakeEngineClient) -> None:
self.engine_client = engine_client
self.model_config = _ModelConfig()
self.seen_request_ids: list[str] = []
async def create_chat_completion(self, request, raw_request=None):
self.seen_request_ids.append(request.request_id)
async def _gen():
await asyncio.sleep(999)
yield "data: [DONE]\n\n"
return _gen()
def create_audio(self, audio_obj):
return SimpleNamespace(audio_data=f"wav-{int(audio_obj.audio_tensor.shape[0])}")
def _test_data_plane() -> MiniCPMO45DataPlaneSession:
def encode_audio(
audio: object,
sample_rate_hz: int,
response_format: str,
speed: float | None,
) -> str | None:
del sample_rate_hz, response_format, speed
if audio is None:
return None
if hasattr(audio, "numel"):
return f"wav-{int(audio.numel())}"
return f"wav-{int(np.asarray(audio).size)}"
return MiniCPMO45DataPlaneSession(encode_audio)
def _data_plane_context(session: DuplexSession | None = None) -> MiniCPMO45DataPlaneContext:
if session is None:
return MiniCPMO45DataPlaneContext()
return MiniCPMO45DataPlaneContext(
epoch=session.epoch,
turn_id=session.turn_id,
active_response_turn_id=session.active_response_turn_id,
active_response_id=session.active_response_id,
auto_responds=bool(session.config.extra_body.get("auto_response", False)),
response_format=session.config.response_format,
speed=session.config.speed,
modalities=tuple(session.config.modalities),
)
def _project_data_plane(
data_plane: MiniCPMO45DataPlaneSession,
result: object,
*,
session: DuplexSession | None = None,
) -> list[dict[str, Any]]:
return list(data_plane.project(result, context=_data_plane_context(session)))
class TimedWebSocket:
def __init__(self, *, on_send=None, receive_timeout_s: float = 1.0):
self._q: asyncio.Queue[str | BaseException] = asyncio.Queue()
self.sent: list[dict[str, Any]] = []
self.accepted = False
self._on_send = on_send
self._receive_timeout_s = receive_timeout_s
self.query_params: dict[str, str] = {}
async def accept(self):
self.accepted = True
async def receive_text(self) -> str:
try:
item = await asyncio.wait_for(self._q.get(), timeout=self._receive_timeout_s)
except asyncio.TimeoutError as exc:
raise WebSocketDisconnect(code=1000) from exc
if isinstance(item, BaseException):
raise item
return item
async def send_json(self, data: dict[str, Any]):
self.sent.append(data)
if self._on_send is not None:
self._on_send(self, data)
def put(self, payload: dict[str, Any]) -> None:
self._q.put_nowait(json.dumps(payload))
async def close(self, code: int = 1000, reason: str | None = None) -> None:
del reason
self._q.put_nowait(WebSocketDisconnect(code=code))
def sent_types(self) -> list[str]:
return [m.get("type", "") for m in self.sent]
def test_native_realtime_protocol_emits_speak_once_per_response():
protocol = NativeRealtimeSessionProtocol({})
response_id = "resp-speak-once"
projected = protocol.encode_outbound_event(
{
"type": "response.created",
"response_id": response_id,
"modalities": ["audio", "text"],
}
)
projected.extend(
protocol.encode_outbound_event(
{
"type": "response.speak",
"response_id": response_id,
"text": "hello",
"model_speak": True,
}
)
)
projected.extend(
protocol.encode_outbound_event(
{
"type": "response.output_audio.delta",
"response_id": response_id,
"audio": base64.b64encode(b"\x00\x00").decode("ascii"),
"format": "pcm16",
"sample_rate_hz": 24000,
"model_speak": True,
}
)
)
assert [event["type"] for event in projected].count("response.speak") == 1
speak = next(event for event in projected if event["type"] == "response.speak")
assert "text" not in speak
assert speak["metadata"] == {"model_speak": True}
def test_native_realtime_protocol_speak_does_not_expose_internal_event():
protocol = NativeRealtimeSessionProtocol({})
projected = protocol.encode_outbound_event(
{
"type": "response.speak",
"response_id": "resp-curated-speak",
"session_id": "sid-curated-speak",
"epoch": 3,
"text": "transcript belongs on the delta channel",
"model_speak": True,
"data_plane_request_id": "internal-request-id",
"uses_model_runner_scheduler": True,
}
)
assert len(projected) == 1
projected[0].pop("event_id")
assert projected == [
{
"type": "response.speak",
"response_id": "resp-curated-speak",
"item_id": "item_resp-curated-speak",
"output_index": 0,
"content_index": 0,
"metadata": {
"session_id": "sid-curated-speak",
"epoch": 3,
"model_speak": True,
},
}
]
@pytest.mark.asyncio
async def test_native_realtime_protocol_drains_internal_conversation_item_control_event():
ws = TimedWebSocket()
protocol = NativeRealtimeSessionProtocol(ws) # type: ignore[arg-type]
protocol.bind_sender(ws.send_json)
ws.put({"type": "session.update", "model": "test-model", "session_id": "rt-delete"})
session_create = json.loads(await protocol.receive_internal_event_text(ws))
assert session_create["type"] == "session.create"
ws.put(
{
"type": "conversation.item.create",
"item": {
"id": "item-a",
"type": "message",
"role": "user",
"content": [{"type": "input_text", "text": "hello"}],
},
}
)
assert json.loads(await protocol.receive_internal_event_text(ws)) == {
"type": "turn.signal",
"event": "conversation.item.create",
"payload": {
"item": {
"id": "item-a",
"object": "realtime.item",
"type": "message",
"status": "completed",
"role": "user",
"content": [{"type": "input_text", "text": "hello"}],
}
},
}
ws.put({"type": "conversation.item.delete", "item_id": "item-a"})
translated = json.loads(await protocol.receive_internal_event_text(ws))
assert translated == {
"type": "turn.signal",
"event": "conversation.item.delete",
"payload": {"item_id": "item-a"},
}
@pytest.mark.asyncio
async def test_native_realtime_protocol_conversation_item_create_commits_user_text():
ws = TimedWebSocket()
protocol = NativeRealtimeSessionProtocol(ws) # type: ignore[arg-type]
protocol.bind_sender(ws.send_json)
ws.put({"type": "session.update", "model": "test-model", "session_id": "rt-item-create"})
session_create = json.loads(await protocol.receive_internal_event_text(ws))
assert session_create["type"] == "session.create"
ws.put(
{
"type": "conversation.item.create",
"item": {
"id": "item-user-text",
"type": "message",
"role": "user",
"content": [{"type": "input_text", "text": "hello"}],
},
}
)
translated = json.loads(await protocol.receive_internal_event_text(ws))
assert translated == {
"type": "turn.signal",
"event": "conversation.item.create",
"payload": {
"item": {
"id": "item-user-text",
"object": "realtime.item",
"type": "message",
"status": "completed",
"role": "user",
"content": [{"type": "input_text", "text": "hello"}],
}
},
}
@pytest.mark.asyncio
async def test_function_output_is_recorded_only_after_runtime_delivery():
ws = TimedWebSocket()
protocol = NativeRealtimeSessionProtocol(ws) # type: ignore[arg-type]
protocol.bind_sender(ws.send_json)
protocol.encode_outbound_event(
{
"type": "conversation.item.created",
"item": {
"id": "item-call",
"type": "function_call",
"call_id": "call-1",
"name": "weather",
"arguments": "{}",
"status": "completed",
},
}
)
event = {
"type": "conversation.item.create",
"item": {
"id": "item-output",
"type": "function_call_output",
"call_id": "call-1",
"output": '{"temperature":20}',
},
}
first = await protocol._to_duplex_event(event)
retry = await protocol._to_duplex_event(event)
assert first is not None
assert retry is not None
assert "item-output" not in protocol._conversation_items
delivered_item = first["payload"]["item"]
protocol.encode_outbound_event(
{
"type": "conversation.item.created",
"item": delivered_item,
}
)
assert protocol._conversation_items["item-output"] == delivered_item
assert await protocol._to_duplex_event(event) is None
assert ws.sent[-1]["error"]["code"] == "invalid_function_call_output"
@pytest.mark.asyncio
async def test_native_realtime_protocol_audio_commit_requires_non_empty_buffer():
ws = TimedWebSocket()
protocol = NativeRealtimeSessionProtocol(ws) # type: ignore[arg-type]
protocol.bind_sender(ws.send_json)
ws.put({"type": "session.update", "model": "test-model", "session_id": "rt-audio-commit"})
session_create = json.loads(await protocol.receive_internal_event_text(ws))
assert session_create["type"] == "session.create"
ws.put({"type": "input_audio_buffer.commit", "final": True})
with pytest.raises(WebSocketDisconnect):
await protocol.receive_internal_event_text(ws)
assert ws.sent[-1]["type"] == "error"
assert ws.sent[-1]["error"]["code"] == "input_audio_buffer_empty"
@pytest.mark.asyncio
async def test_native_realtime_protocol_audio_commit_does_not_auto_create_response():
ws = TimedWebSocket()
protocol = NativeRealtimeSessionProtocol(ws) # type: ignore[arg-type]
protocol.bind_sender(ws.send_json)
ws.put({"type": "session.update", "model": "test-model", "session_id": "rt-audio-commit-full"})
session_create = json.loads(await protocol.receive_internal_event_text(ws))
assert session_create["type"] == "session.create"
pcm = struct.pack("<2h", 1024, -1024)
ws.put({"type": "input_audio_buffer.append", "audio": base64.b64encode(pcm).decode("ascii"), "format": "pcm16"})
append_event = json.loads(await protocol.receive_internal_event_text(ws))
assert append_event["type"] == "input_audio_buffer.append"
ws.put({"type": "input_audio_buffer.commit", "final": True})
commit_event = json.loads(await protocol.receive_internal_event_text(ws))
assert commit_event["type"] == "input_audio_buffer.commit"
assert commit_event["response_create"] is False
@pytest.mark.asyncio
async def test_native_realtime_protocol_rejects_invalid_sample_rate_without_closing():
ws = TimedWebSocket()
protocol = NativeRealtimeSessionProtocol(ws) # type: ignore[arg-type]
protocol.bind_sender(ws.send_json)
ws.put({"type": "session.update", "model": "test-model", "session_id": "rt-invalid-rate"})
session_create = json.loads(await protocol.receive_internal_event_text(ws))
assert session_create["type"] == "session.create"
ws.put(
{
"type": "input_audio_buffer.append",
"audio": _pcm16_b64(1),
"format": "pcm16",
"sample_rate_hz": 192_001,
}
)
ws.put({"type": "input_audio_buffer.clear"})
translated = json.loads(await protocol.receive_internal_event_text(ws))
assert translated == {"type": "input_audio_buffer.clear", "reason": "input_audio_buffer.clear"}
error = next(event for event in ws.sent if event.get("type") == "error")
assert error["error"]["code"] == "bad_event"
assert error["error"]["param"] == "sample_rate_hz"
@pytest.mark.asyncio
async def test_native_realtime_protocol_audio_clear_is_not_barge_in_cancel():
ws = TimedWebSocket()
protocol = NativeRealtimeSessionProtocol(ws) # type: ignore[arg-type]
protocol.bind_sender(ws.send_json)
ws.put({"type": "session.update", "model": "test-model", "session_id": "rt-audio-clear"})
session_create = json.loads(await protocol.receive_internal_event_text(ws))
assert session_create["type"] == "session.create"
ws.put({"type": "input_audio_buffer.clear"})
clear_event = json.loads(await protocol.receive_internal_event_text(ws))
assert clear_event == {"type": "input_audio_buffer.clear", "reason": "input_audio_buffer.clear"}
@pytest.mark.asyncio
async def test_native_realtime_protocol_preserves_input_turn_policy_hints():
ws = TimedWebSocket()
protocol = NativeRealtimeSessionProtocol(ws) # type: ignore[arg-type]
protocol.bind_sender(ws.send_json)
translated = await protocol._to_duplex_event(
{
"type": "input_audio_buffer.append",
"audio": "AAAA",
"format": "pcm_f32le",
"duration_ms": 240,
"vad": {"is_speech": True, "speech_probability": 0.9},
"overlap_action": "listen",
}
)
assert translated is not None
assert translated["duration_ms"] == 240
assert translated["vad"] == {"is_speech": True, "speech_probability": 0.9}
assert translated["overlap_action"] == "listen"
@pytest.mark.asyncio
async def test_native_realtime_protocol_rejects_server_vad_without_interrupt():
ws = TimedWebSocket()
protocol = NativeRealtimeSessionProtocol(ws) # type: ignore[arg-type]
protocol.bind_sender(ws.send_json)
translated = await protocol._to_duplex_event(
{
"type": "session.update",
"model": "test-model",
"session_id": "rt-turn-detection",
"turn_detection": {
"type": "server_vad",
"interrupt_response": False,
"silence_duration_ms": 900,
"threshold": 0.4,
},
}
)
assert translated is None
error = ws.sent[-1]
assert error["type"] == "error"
assert error["error"]["code"] == "unsupported_turn_detection"
assert "interrupt_response=false" in error["error"]["message"]
threshold_error = protocol._validate_realtime_turn_detection(
{"turn_detection": {"type": "server_vad", "threshold": 0.15}}
)
assert threshold_error == "turn_detection.threshold must be greater than 0.15 and at most 1"
@pytest.mark.asyncio
async def test_native_realtime_protocol_accepts_disabled_turn_detection():
ws = TimedWebSocket()
protocol = NativeRealtimeSessionProtocol(ws) # type: ignore[arg-type]
protocol.bind_sender(ws.send_json)
ws.put(
{
"type": "session.update",
"model": "test-model",
"session_id": "rt-no-turn-detection",
"turn_detection": None,
}
)
translated = json.loads(await protocol.receive_internal_event_text(ws))
assert translated["type"] == "session.create"
assert translated["session"]["extra_body"]["realtime_session_payload"]["turn_detection"] is None
@pytest.mark.asyncio
async def test_native_realtime_protocol_prefers_nested_vad_over_top_level():
ws = TimedWebSocket()
protocol = NativeRealtimeSessionProtocol(ws) # type: ignore[arg-type]
protocol.bind_sender(ws.send_json)
translated = await protocol._to_duplex_event(
{
"type": "session.update",
"model": "test-model",
"turn_detection": None,
"audio": {
"input": {
"turn_detection": {
"type": "server_vad",
}
}
},
}
)
assert translated["session"]["extra_body"]["realtime_session_payload"]["overlap_policy"] == "barge_in_on_speech"
@pytest.mark.asyncio
async def test_realtime_vad_edge_order_and_tiny_chunk_resampling(monkeypatch):
ws = TimedWebSocket()
protocol = NativeRealtimeSessionProtocol(ws) # type: ignore[arg-type]
protocol.bind_sender(ws.send_json)
protocol._server_vad = realtime_vad.SileroStreamingVAD(realtime_vad.SileroVADConfig())
result = realtime_vad.StreamingVADResult(True, True, True, True)
monkeypatch.setattr(protocol._server_vad, "process_base64", lambda *args, **kwargs: result)
protocol._input_speech_started = True
protocol._hold_realtime_output_until_session_created = False
await protocol._to_duplex_event(
{"type": "input_audio_buffer.append", "audio": _pcm_f32_b64(1), "format": "pcm_f32le"}
)
assert [event["type"] for event in ws.sent] == [
"input_audio_buffer.speech_stopped",
"input_audio_buffer.speech_started",
]
ws.sent.clear()
protocol._input_speech_started = False
result = realtime_vad.StreamingVADResult(True, False, True, True)
await protocol._to_duplex_event({"type": "input_audio_buffer.append", "audio": "AAAA", "format": "pcm_f32le"})
assert [event["type"] for event in ws.sent] == [
"input_audio_buffer.speech_started",
"input_audio_buffer.speech_stopped",
]
detector = realtime_vad.SileroStreamingVAD(realtime_vad.SileroVADConfig(), frame_scorer=lambda _: 0.0)
for _ in range(1024):
detector.process_base64(_pcm_f32_b64(1), fmt="pcm_f32le", sample_rate_hz=48_000)
assert detector._processed_samples + detector._pending.size == 341
@pytest.mark.asyncio
async def test_realtime_server_vad_runtime_failures_are_terminal(monkeypatch):
handler = OmniDuplexSessionHandler(chat_service=FakeChatService(FakeEngineClient()))
ws = TimedWebSocket()
ws.put(_server_vad_update("generic-vad", model="test-model", session_id="generic-vad"))
await handler.handle_session(ws, realtime_protocol=NativeRealtimeSessionProtocol({}))
assert ws.sent[-1]["error"]["code"] == "server_vad_requires_native_duplex"
monkeypatch.setattr(realtime_vad.SileroStreamingVAD, "process_base64", lambda *args, **kwargs: 1 / 0)
ws = TimedWebSocket()
create = _native_realtime_session_update("vad-inference-failure")
create["session"]["turn_detection"] = {"type": "server_vad"}
ws.put(create)
ws.put({"type": "input_audio_buffer.append", "audio": _pcm_f32_b64(1), "format": "pcm_f32le"})
await asyncio.wait_for(handler.handle_session(ws, realtime_protocol=NativeRealtimeSessionProtocol({})), timeout=2)
assert any(e.get("error", {}).get("code") == "realtime_input_failed" for e in ws.sent)
def test_native_duplex_handler_has_no_fixed_session_admission_cap():
handler = OmniDuplexSessionHandler(chat_service=FakeChatService(FakeEngineClient()))
client_config = DuplexSessionConfig(
extra_body={"max_duplex_sessions": 99, "duplex_max_sessions": 99},
)
assert not hasattr(handler, "_max_native_duplex_sessions")
assert not hasattr(handler, "_max_duplex_sessions")
assert client_config.extra_body["max_duplex_sessions"] == 99
def test_native_response_options_ignore_private_runtime_config():
session = DuplexSession(
session_id="sid-response-private-config",
config=DuplexSessionConfig(),
capabilities=DuplexCapabilities.minicpmo45_native(),
)
handler = OmniDuplexSessionHandler(chat_service=FakeChatService(FakeEngineClient()))
handler._apply_response_create_options(
session,
{
"extra_body": {
"duplex_stage_max_tokens": {"0": 999},
"client_trace_id": "trace-1",
}
},
)
session.begin_response()
assert "duplex_stage_max_tokens" not in session.config.extra_body
assert "client_trace_id" not in session.config.extra_body
assert session.response_config.extra_body["client_trace_id"] == "trace-1"
@pytest.mark.parametrize(
"payload",
[
{"instructions": "not wired"},
{"voice": "not-wired"},
{"temperature": 0.2},
{"max_output_tokens": 8},
{"tools": [{"type": "function", "name": "lookup"}]},
],
)
def test_native_response_create_rejects_unwired_generation_options(payload):
session = DuplexSession(
session_id="sid-response-unwired-options",
config=DuplexSessionConfig(),
capabilities=DuplexCapabilities.minicpmo45_native(),
)
handler = OmniDuplexSessionHandler(chat_service=FakeChatService(FakeEngineClient()))
error = handler._apply_response_create_options(session, payload)
assert error == "unsupported_native_response_options"
session.begin_response()
assert session.response_config == session.config
def test_chat_fallback_excludes_realtime_response_metadata_from_model_request():
handler = OmniDuplexSessionHandler(chat_service=FakeChatService(FakeEngineClient()))
session = DuplexSession(
session_id="sid-response-protocol-metadata",
config=DuplexSessionConfig(),
)
session.reserve_response_options(
ResponseCreateOptions(
extra_body={
"user": "trace-1",
"realtime_response_conversation": "none",
"realtime_response_metadata": {"source": "test"},
"realtime_response_prompt": {"id": "prompt-1"},
"realtime_response_tools": [
{
"type": "function",
"function": {"name": "lookup", "parameters": {}},
}
],
"realtime_response_tool_choice": "auto",
}
)
)
session.begin_response()
request = handler._build_chat_request(session, "request-1")
created = handler._response_created_payload(session, session.active_response_id or "response-1", epoch=0)
dumped = request.model_dump()
assert dumped["user"] == "trace-1"
assert dumped["tools"][0]["function"]["name"] == "lookup"
assert dumped["tool_choice"] == "auto"
assert not any(key.startswith("realtime_response_") for key in dumped)
assert created["conversation"] == "none"
assert created["metadata"] == {"source": "test"}
assert created["prompt"] == {"id": "prompt-1"}
assert session.response_config.extra_body["realtime_response_conversation"] == "none"
@pytest.mark.asyncio
async def test_native_response_create_surfaces_unwired_generation_options_error():
handler = OmniDuplexSessionHandler(
chat_service=FakeChatService(FakeEngineClient()),
config_timeout_s=0.1,
idle_timeout_s=1,
)
ws = TimedWebSocket()
ws.put(_native_session_create("sid-response-unwired-options-event"))
ws.put(
{
"type": "response.create",
"response": {"instructions": "not wired"},
}
)
ws.put({"type": "session.close"})
await handler.handle_session(ws)
error = next(message for message in ws.sent if message.get("type") == "error")
assert error["code"] == "unsupported_native_response_options"
assert "response_create_without_input" not in {message.get("code") for message in ws.sent}
@pytest.mark.asyncio
async def test_realtime_session_update_preserves_tools_and_metadata():
engine = FakeEngineClient()
chat_service = FakeChatService(engine)
handler = OmniDuplexSessionHandler(chat_service=chat_service, config_timeout_s=0.1, idle_timeout_s=1)
ws = TimedWebSocket()
ws.put(_session_create("sid-rt-update-fields"))
ws.put(
{
"type": "turn.signal",
"event": "session.update",
"payload": {
"tools": [{"type": "function", "name": "lookup"}],
"tool_choice": "auto",
"metadata": {"demo": "yes"},
},
}
)
ws.put({"type": "session.close"})
await handler.handle_session(ws)
updated = next(m for m in ws.sent if m.get("type") == "session.updated")
session = updated["session"]
assert session["tools"] == [{"type": "function", "name": "lookup"}]
assert session["tool_choice"] == "auto"
assert session["metadata"] == {"demo": "yes"}
assert "turn_detection" not in session
runtime_config = engine.signal_session_configs[-1]
assert runtime_config is not None
assert runtime_config["extra_body"]["realtime_tools"] == [{"type": "function", "name": "lookup"}]
assert runtime_config["extra_body"]["realtime_metadata"] == {"demo": "yes"}
@pytest.mark.asyncio
async def test_native_realtime_protocol_non_speech_append_does_not_emit_speech_started():
ws = TimedWebSocket()
protocol = NativeRealtimeSessionProtocol(ws) # type: ignore[arg-type]
protocol.bind_sender(ws.send_json)
translated = await protocol._to_duplex_event(
{
"type": "input_audio_buffer.append",
"audio": "AAAA",
"format": "pcm_f32le",
"vad": {"is_speech": False},
}
)
assert translated is not None
assert translated["vad"] == {"is_speech": False}
assert not any(event.get("type") == "input_audio_buffer.speech_started" for event in ws.sent)
@pytest.mark.asyncio
async def test_native_session_update_rebuilds_server_runtime_policy():
engine = FakeEngineClient()
handler = OmniDuplexSessionHandler(
chat_service=FakeChatService(engine),
config_timeout_s=0.1,
idle_timeout_s=1,
)
ws = TimedWebSocket()
ws.put(_native_session_create("sid-runtime-update"))
ws.put(
{
"type": "turn.signal",
"event": "session.update",
"payload": {"temperature": 0.2, "max_output_tokens": 7},
}
)
ws.put({"type": "session.close"})
await handler.handle_session(ws)
assert "session.updated" in ws.sent_types()
public_config = engine.signal_session_configs[-1]
runtime_config = engine.signal_runtime_configs[-1]
assert public_config is not None
assert runtime_config is not None
assert "duplex_stage_sampling_params" not in public_config["extra_body"]
assert runtime_config["duplex_stage_max_tokens"]["0"] == 7
assert runtime_config["duplex_stage_sampling_params"]["0"]["temperature"] == 0.2
assert runtime_config["duplex_stage_sampling_params"]["0"]["top_k"] == 20
assert runtime_config["duplex_stage_sampling_params"]["0"]["top_p"] == 0.8
@pytest.mark.asyncio
async def test_native_session_update_rejects_client_runtime_config():
engine = FakeEngineClient()
handler = OmniDuplexSessionHandler(
chat_service=FakeChatService(engine),
config_timeout_s=0.1,
idle_timeout_s=1,
)
ws = TimedWebSocket()
ws.put(_native_session_create("sid-runtime-update-reject"))
ws.put(
{
"type": "turn.signal",
"event": "session.update",
"payload": {
"extra_body": {"duplex_stage_max_tokens": {"0": 999}},
},
}
)
ws.put({"type": "session.close"})
await handler.handle_session(ws)
error = next(message for message in ws.sent if message.get("type") == "error")
assert error["code"] == "invalid_duplex_runtime_config"
assert ("sid-runtime-update-reject", "session.update") not in engine.signals
@pytest.mark.asyncio
async def test_minicpmo_native_session_update_requires_ref_audio_before_enabling_audio():
engine = FakeEngineClient()
handler = OmniDuplexSessionHandler(
chat_service=FakeChatService(engine),
config_timeout_s=0.1,
idle_timeout_s=1,
)
ws = TimedWebSocket()
ws.put(_native_session_create("sid-runtime-update-ref-required", modalities=["text"]))
ws.put(
{
"type": "turn.signal",
"event": "session.update",
"payload": {"modalities": ["text", "audio"]},
}
)
ws.put({"type": "session.close"})
await handler.handle_session(ws)
error = next(message for message in ws.sent if message.get("type") == "error")
assert error["code"] == "ref_audio_required"
assert "session.updated" not in ws.sent_types()
assert ("sid-runtime-update-ref-required", "session.update") not in engine.signals
def test_personaplex_candidate_update_does_not_leak_minicpmo_ref_audio_check():
handler = OmniDuplexSessionHandler(
chat_service=SimpleNamespace(engine_client=SimpleNamespace()),
serving_runtime_adapter=PersonaPlexServingRuntimeAdapter(lambda *_: None),
)
session = DuplexSession(
session_id="sid-personaplex-voice-change",
config=DuplexSessionConfig(modalities=["audio"], voice="NATF2.pt"),
capabilities=PersonaPlexServingRuntimeAdapter.capabilities(max_sessions=1),
)
candidate_config = DuplexSessionConfig(modalities=["audio"], voice="OtherVoice.pt")
error = handler._runtime_session_candidate_update_error(session, candidate_config)
assert error is None
@pytest.mark.asyncio
async def test_minicpmo_native_session_update_rejects_instructions_after_native_context_locked():
engine = FakeEngineClient()
handler = OmniDuplexSessionHandler(
chat_service=FakeChatService(engine),
config_timeout_s=0.1,
idle_timeout_s=1,
)
ws = TimedWebSocket()
ws.put(_native_session_create("sid-instructions-locked", modalities=["text"]))
ws.put(
{
"type": "input_audio_buffer.append",
"audio": _pcm_f32_b64(16000, value=0.05),
"format": "pcm_f32le",
"sample_rate_hz": 16000,
"duration_ms": 1000,
"is_speech": True,
}
)
ws.put(
{
"type": "turn.signal",
"event": "session.update",
"payload": {"instructions": "You are now a pirate."},
}
)
ws.put({"type": "session.close"})
await handler.handle_session(ws)
error = next(message for message in ws.sent if message.get("type") == "error")
assert error["code"] == "instructions_update_unsupported"
assert "session.updated" not in ws.sent_types()
assert ("sid-instructions-locked", "session.update") not in engine.signals
@pytest.mark.asyncio
async def test_minicpmo_native_session_update_allows_unchanged_instructions_after_lock():
engine = FakeEngineClient()
handler = OmniDuplexSessionHandler(
chat_service=FakeChatService(engine),
config_timeout_s=0.1,
idle_timeout_s=1,
)
ws = TimedWebSocket()
ws.put(_native_session_create("sid-instructions-unchanged", modalities=["text"]))
ws.put(
{
"type": "input_audio_buffer.append",
"audio": _pcm_f32_b64(16000, value=0.05),
"format": "pcm_f32le",
"sample_rate_hz": 16000,
"duration_ms": 1000,
"is_speech": True,
}
)
ws.put(
{
"type": "turn.signal",
"event": "session.update",
"payload": {"instructions": "You are a concise assistant.", "temperature": 0.3},
}
)
ws.put({"type": "session.close"})
await handler.handle_session(ws)
assert "session.updated" in ws.sent_types()
assert not any(
message.get("type") == "error" and message.get("code") == "instructions_update_unsupported"
for message in ws.sent
)
@pytest.mark.asyncio
async def test_minicpmo_native_session_update_rejects_instructions_before_first_real_append():
engine = FakeEngineClient()
handler = OmniDuplexSessionHandler(
chat_service=FakeChatService(engine),
config_timeout_s=0.1,
idle_timeout_s=1,
)
ws = TimedWebSocket()
ws.put(_native_session_create("sid-buffered-before-lock", modalities=["text"]))
ws.put(
{
"type": "input_audio_buffer.append",
"audio": _pcm_f32_b64(3200, value=0.05),
"format": "pcm_f32le",
"sample_rate_hz": 16000,
"duration_ms": 200,
"is_speech": True,
}
)
ws.put(
{
"type": "turn.signal",
"event": "session.update",
"payload": {
"instructions": (
"You are now a swashbuckling pirate captain who speaks entirely in "
"nautical slang, references buried treasure constantly, and never "
"breaks character no matter what the user asks."
)
},
}
)
ws.put({"type": "input_audio_buffer.commit", "final": True, "response_create": True})
ws.put({"type": "session.close"})
await handler.handle_session(ws)
error = next(message for message in ws.sent if message.get("type") == "error")
assert error["code"] == "instructions_update_unsupported"
assert "session.updated" not in ws.sent_types()
assert engine.appended, "the commit must still flush the buffered chunk despite the rejected update"
@pytest.mark.asyncio
async def test_minicpmo_native_session_update_rejects_native_duplex_mode_flip():
engine = FakeEngineClient()
handler = OmniDuplexSessionHandler(
chat_service=FakeChatService(engine),
config_timeout_s=0.1,
idle_timeout_s=1,
)
ws = TimedWebSocket()
ws.put(_native_session_create("sid-native-duplex-flag-flip", modalities=["text"]))
ws.put(
{
"type": "turn.signal",
"event": "session.update",
"payload": {"extra_body": {"minicpmo45_native_duplex": False}},
}
)
ws.put(
{
"type": "turn.signal",
"event": "session.update",
"payload": {"instructions": "You are now a pirate."},
}
)
ws.put({"type": "session.close"})
await handler.handle_session(ws)
errors = [message for message in ws.sent if message.get("type") == "error"]
assert [error["code"] for error in errors] == [
"native_duplex_mode_update_unsupported",
"instructions_update_unsupported",
]
assert "session.updated" not in ws.sent_types()
def test_native_realtime_protocol_audio_delta_preserves_sample_rate_hz():
ws = TimedWebSocket()
protocol = NativeRealtimeSessionProtocol(ws) # type: ignore[arg-type]
payloads = protocol._from_duplex_event(
{
"type": "response.output_audio.delta",
"response_id": "resp-a",
"audio": "AAAA",
"format": "pcm",
"sample_rate_hz": 24000,
}
)
audio_events = [
payload for payload in payloads if payload["type"] in {"response.output_audio.delta", "response.audio.delta"}
]
assert {payload["type"] for payload in audio_events} == {"response.audio.delta"}
assert {payload["format"] for payload in audio_events} == {"pcm16"}
assert {payload["sample_rate_hz"] for payload in audio_events} == {24000}
def test_native_realtime_protocol_ignores_removed_legacy_event_switches():
ws = TimedWebSocket()
ws.query_params = {
"legacy_audio_events": "true",
"vllm_omni_legacy_events": "true",
"output_audio_events": "true",
"vllm_omni_output_audio_events": "true",
}
protocol = NativeRealtimeSessionProtocol(ws) # type: ignore[arg-type]
payloads = protocol._from_duplex_event(
{
"type": "response.output_audio.delta",
"response_id": "resp-canonical",
"audio": "AAAA",
"text": "hello",
"format": "pcm",
"sample_rate_hz": 24000,
"end_of_turn": True,
}
)
event_types = {payload["type"] for payload in payloads}
assert "response.audio.delta" in event_types
assert "response.audio.done" in event_types
assert "response.audio_transcript.delta" in event_types
assert "response.audio_transcript.done" in event_types
assert "response.output_audio.delta" not in event_types
assert "response.output_audio.done" not in event_types
assert "response.output_audio_transcript.delta" not in event_types
assert "response.output_audio_transcript.done" not in event_types
assert "response.output_item.created" not in event_types
assert "response.text.delta" not in event_types
def test_native_realtime_protocol_emits_terminal_audio_transcript_events():
ws = TimedWebSocket()
protocol = NativeRealtimeSessionProtocol(ws) # type: ignore[arg-type]
payloads = protocol._from_duplex_event(
{
"type": "response.output_audio.delta",
"response_id": "resp-a",
"audio": "AAAA",
"text": "hello",
"format": "pcm",
"sample_rate_hz": 24000,
"end_of_turn": True,
}
)
by_type = {payload["type"]: payload for payload in payloads}
assert "response.audio.done" in by_type
assert "response.audio_transcript.delta" in by_type
assert "response.audio_transcript.done" in by_type
assert by_type["response.content_part.done"]["part"]["transcript"] == "hello"
assert by_type["response.output_item.done"]["item"]["object"] == "realtime.item"
assert by_type["response.done"]["response"]["output"][0]["object"] == "realtime.item"
def test_native_realtime_protocol_terminal_transcript_equals_joined_deltas():
protocol = NativeRealtimeSessionProtocol(TimedWebSocket()) # type: ignore[arg-type]
response_id = "resp-transcript-contract"
events: list[dict[str, object]] = []
for text, end_of_turn in (("It's Canberra.", False), (" Next question.", True)):
events.extend(
protocol._from_duplex_event(
{
"type": "response.output_audio.delta",
"response_id": response_id,
"audio": "AAAA",
"text": text,
"format": "pcm",
"sample_rate_hz": 24000,
"end_of_turn": end_of_turn,
}
)
)
transcript = "".join(str(event["delta"]) for event in events if event["type"] == "response.audio_transcript.delta")
done = [str(event["transcript"]) for event in events if event["type"] == "response.audio_transcript.done"]
assert transcript == "It's Canberra. Next question."
assert done == [transcript]
def test_native_realtime_protocol_updates_in_progress_item_for_audio_truncate():
ws = TimedWebSocket()
protocol = NativeRealtimeSessionProtocol(ws) # type: ignore[arg-type]
protocol._from_duplex_event({"type": "response.created", "response_id": "resp-truncate"})
protocol._from_duplex_event(
{
"type": "response.output_audio.delta",
"response_id": "resp-truncate",
"audio": "AAAA",
"text": "hello",
"format": "pcm",
"sample_rate_hz": 24000,
"audio_duration_ms": 100,
"audio_text_marks": [{"text_chars": 5, "audio_end_ms": 100}],
}
)
item = protocol._conversation_items["item_resp-truncate"]
assert item["content"][0]["type"] == "output_audio"
assert item["content"][0]["transcript"] == "hello"
assert item["content"][0]["audio_duration_ms"] == 100
translated = asyncio.run(
protocol._to_duplex_event(
{
"type": "conversation.item.truncate",
"item_id": "item_resp-truncate",
"content_index": 0,
"audio_end_ms": 50,
}
)
)
assert translated is None
assert protocol._conversation_items["item_resp-truncate"]["content"][0]["transcript"] == "he"
@pytest.mark.asyncio
async def test_native_realtime_protocol_rejects_truncate_for_user_item():
ws = TimedWebSocket()
protocol = NativeRealtimeSessionProtocol(ws) # type: ignore[arg-type]
protocol.bind_sender(ws.send_json)
protocol._conversation_items["item-user"] = {
"id": "item-user",
"object": "realtime.item",
"type": "message",
"role": "user",
"status": "completed",
"content": [{"type": "input_audio", "transcript": "hello"}],
}
translated = await protocol._to_duplex_event(
{
"type": "conversation.item.truncate",
"item_id": "item-user",
"content_index": 0,
"audio_end_ms": 10,
}
)
assert translated is None
error = next(event for event in ws.sent if event.get("type") == "error")
assert error["error"]["code"] == "bad_event"
def test_native_realtime_protocol_response_done_emits_audio_done_before_terminal_events():
ws = TimedWebSocket()
protocol = NativeRealtimeSessionProtocol(ws) # type: ignore[arg-type]
protocol._from_duplex_event({"type": "response.created", "response_id": "resp-done"})
protocol._from_duplex_event(
{
"type": "response.output_audio.delta",
"response_id": "resp-done",
"audio": "AAAA",
"format": "pcm",
"sample_rate_hz": 24000,
}
)
payloads = protocol._from_duplex_event({"type": "response.done", "response_id": "resp-done"})
event_types = [payload["type"] for payload in payloads]
assert event_types.index("response.audio.done") < event_types.index("response.content_part.done")
assert event_types.index("response.content_part.done") < event_types.index("response.output_item.done")
assert event_types.index("conversation.item.done") < event_types.index("response.done")
assert event_types.index("response.output_item.done") < event_types.index("response.done")
def test_native_realtime_protocol_audio_cancelled_uses_active_response_id():
ws = TimedWebSocket()
protocol = NativeRealtimeSessionProtocol(ws) # type: ignore[arg-type]
protocol._from_duplex_event({"type": "response.created", "response_id": "resp-cancel"})
payloads = protocol._from_duplex_event(
{
"type": "audio.cancelled",
"reason": "barge_in",
"committed_ms": 0,
}
)
done = [payload for payload in payloads if payload.get("type") == "response.done"]
assert len(done) == 1
assert done[0]["response_id"] == "resp-cancel"
assert done[0]["response"]["id"] == "resp-cancel"
def test_native_realtime_protocol_audio_cancelled_does_not_reopen_completed_response():
ws = TimedWebSocket()
protocol = NativeRealtimeSessionProtocol(ws) # type: ignore[arg-type]
protocol._from_duplex_event({"type": "response.created", "response_id": "resp-complete"})
protocol._from_duplex_event(
{
"type": "response.output_audio.delta",
"response_id": "resp-complete",
"audio": "AAAA",
"format": "pcm",
"sample_rate_hz": 24000,
}
)
protocol._from_duplex_event({"type": "response.done", "response_id": "resp-complete"})
payloads = protocol._from_duplex_event(
{
"type": "audio.cancelled",
"reason": "session_close",
"committed_ms": 999999,
}
)
assert [payload.get("type") for payload in payloads] == []
def test_native_audio_text_marks_are_normalized_to_session_cumulative_offsets():
marks = OmniDuplexSessionHandler._normalize_native_audio_text_marks(
[{"text_chars": 3, "audio_end_ms": 200}],
audio_offset_ms=800,
text_offset_chars=5,
)
assert marks == [{"text_chars": 8, "audio_end_ms": 1000}]
def test_duplex_session_playback_commit_uses_multi_delta_audio_text_marks():
session = DuplexSession(
session_id="sid-marks",
config=DuplexSessionConfig(playback_commit_policy=DuplexPlaybackCommitPolicy.ACK_ONLY.value),
)
session.begin_response()
session.append_assistant_text("hello ")
session.mark_audio_sent(1000, text_chars=6)
session.append_assistant_text("world")
session.mark_audio_sent(2000, text_chars=11)
session.acknowledge_playback(played_ms=1500, committed_ms=1500)
committed = session.end_response(commit_text=True)
assert committed == {"role": "assistant", "content": "hello wo"}
assert session.history == (committed,)
@pytest.mark.asyncio
async def test_duplex_session_actor_preserves_wire_order_before_control():
ws = TimedWebSocket()
actor = DuplexWebSocketActor(ws)
await actor.enqueue_event({"type": "input_audio_buffer.append", "audio": "AAAA"})
await actor.enqueue_event({"type": "response.cancel"})
first = await actor.next_event()
second = await actor.next_event()
assert first["type"] == "input_audio_buffer.append"
assert second["type"] == "response.cancel"
def _pcm_f32_b64(samples: int, *, value: float = 0.05) -> str:
return base64.b64encode(struct.pack(f"<{samples}f", *([value] * samples))).decode("ascii")
def _pcm16_b64(samples: int = 1, *, value: int = 1000) -> str:
return base64.b64encode(struct.pack(f"<{samples}h", *([value] * samples))).decode("ascii")
def _session_create(session_id: str = "duplex-test") -> dict[str, Any]:
return {
"type": "session.create",
"session_id": session_id,
"session": {
"model": "test-model",
"modalities": ["text", "audio"],
"idle_timeout_s": 1,
},
}
def _native_session_create(
session_id: str = "duplex-native",
*,
modalities: list[str] | None = None,
) -> dict[str, Any]:
event = _session_create(session_id)
event["session"]["model"] = "openbmb/MiniCPM-o-4_5"
event["session"]["modalities"] = list(modalities or ["text"])
event["session"]["instructions"] = "You are a concise assistant."
event["session"]["extra_body"] = {"minicpmo45_native_duplex": True}
return event
def _native_realtime_session_update(
session_id: str,
*,
modalities: list[str] | None = None,
) -> dict[str, Any]:
return {
"type": "session.update",
"session": {
"session_id": session_id,
"model": "openbmb/MiniCPM-o-4_5",
"modalities": list(modalities or ["text"]),
"instructions": "You are a concise assistant.",
"idle_timeout_s": 1,
"extra_body": {"minicpmo45_native_duplex": True},
},
}
def _server_vad_update(event_id: str, **session: object) -> dict[str, object]:
return {
"type": "session.update",
"event_id": event_id,
"session": {"turn_detection": {"type": "server_vad"}, **session},
}
def test_native_stage0_request_id_matches_engine_after_same_session_id_reopen():
handler = OmniDuplexSessionHandler(chat_service=FakeChatService(FakeEngineClient()))
first = handler._registry.create(session_id="sid-reused")
handler._registry.close(first.session_id)
reopened = handler._registry.create(session_id="sid-reused")
expected = duplex_resource_request_id(
DuplexFence(
reopened.session_id,
epoch=reopened.epoch,
incarnation=reopened.incarnation,
),
"stage0",
)
assert reopened.incarnation == 1
assert handler._native_stage0_request_id(reopened, reopened.epoch) == expected
def _native_audio_payload(
*,
samples: int = 16000,
value: float = 0.05,
is_speech: bool | None = True,
force_listen: bool | None = None,
) -> dict[str, Any]:
payload: dict[str, Any] = {
"type": "audio",
"audio": _pcm_f32_b64(samples, value=value),
"format": "pcm_f32le",
"sample_rate_hz": 16000,
}
if is_speech is not None:
payload["is_speech"] = is_speech
if force_listen is not None:
payload["force_listen"] = force_listen
return payload
def _auto_response_context(
session_id: str,
*,
playback_active: bool = False,
) -> tuple[OmniDuplexSessionHandler, DuplexSession]:
handler = OmniDuplexSessionHandler(
chat_service=FakeChatService(FakeEngineClient()),
config_timeout_s=0.1,
idle_timeout_s=1,
)
session = DuplexSession(
session_id=session_id,
config=DuplexSessionConfig(extra_body={"auto_response": True}),
)
if playback_active:
session.config.playback_commit_policy = DuplexPlaybackCommitPolicy.ACK_ONLY.value
session.begin_response()
session.mark_audio_sent(duration_ms=1000)
return handler, session
def _install_direct_silence_scheduler(
handler: OmniDuplexSessionHandler,
session: DuplexSession,
) -> None:
native = handler._minicpmo_session_state(session)
async def _schedule(payload: object, **kwargs: Any) -> bool:
if handler._native_silence_continuation_is_stale(
session,
request_id=kwargs["request_id"],
response_id=kwargs["response_id"],
response_owned=kwargs["response_owned"],
expected_epoch=kwargs["expected_epoch"],
expected_incarnation=kwargs["expected_incarnation"],
expected_model_turn_id=kwargs["expected_model_turn_id"],
):
return False
append_ok, _ = await handler._append_runtime_input(
session,
payload,
final=False,
send_json=kwargs["send_json"],
mode="append_audio_chunk",
expected_epoch=kwargs["expected_epoch"],
)
return append_ok
native.silence_continuation_scheduler = _schedule
def test_minicpmo_pcm_append_buffer_flush_preserves_accumulated_speech_marker():
buffer = MiniCPMO45PcmAppendBuffer()
payload = _native_audio_payload(samples=8000)
assert buffer.append(payload, chunk_period_ms=1000) is None
flushed = buffer.flush(chunk_period_ms=1000)
assert flushed is not None
assert flushed["is_speech"] is True
def test_minicpmo_pcm_append_buffer_drops_serving_new_user_turn_marker():
buffer = MiniCPMO45PcmAppendBuffer()
first = _native_audio_payload(samples=8000)
first.update(
new_user_turn=True,
new_user_turn_prefix_variant="clean_response_done",
force_speak=True,
)
second = _native_audio_payload(samples=8000)
assert buffer.append(first, chunk_period_ms=1000) is None
emitted = buffer.append(second, chunk_period_ms=1000)
assert emitted is not None
assert emitted["is_speech"] is True
assert "new_user_turn" not in emitted
assert "new_user_turn_prefix_variant" not in emitted
assert "force_speak" not in emitted
def test_minicpmo_merge_native_audio_payloads_preserves_speech_marker():
first = _native_audio_payload(samples=8000)
second = _native_audio_payload(samples=8000, value=0.0, is_speech=False)
merged = OmniDuplexSessionHandler._merge_native_audio_payloads(first, second)
assert merged["is_speech"] is True
def test_minicpmo_merge_drops_serving_new_user_turn_marker():
first = _native_audio_payload(samples=8000)
first.update(
new_user_turn=True,
new_user_turn_prefix_variant="clean_response_done",
force_speak=True,
)
second = _native_audio_payload(samples=8000, is_speech=False)
merged = OmniDuplexSessionHandler._merge_native_audio_payloads(first, second)
assert merged["is_speech"] is True
assert "new_user_turn" not in merged
assert "new_user_turn_prefix_variant" not in merged
assert "force_speak" not in merged
@pytest.mark.asyncio
async def test_minicpmo_clear_continuation_does_not_cancel_pending_silence_task():
async def _pending() -> bool:
await asyncio.sleep(3600)
return True
native = MiniCPMO45ServingSessionState()
task = asyncio.create_task(_pending())
native.continuation_owner_id = "owner"
native.continuation_units = 1
native.pending_silence_task = task
native.pending_silence_owner_id = "owner"
native.clear_continuation()
assert native.continuation_owner_id is None
assert native.continuation_units == 0
assert native.pending_silence_task is None
assert native.pending_silence_owner_id is None
assert not task.cancelled()
task.cancel()
with pytest.raises(asyncio.CancelledError):
await task
def test_auto_response_playback_overlap_keeps_model_owned_listen_speak_decision():
handler, session = _auto_response_context("sid-auto-silent-overlap", playback_active=True)
session.mark_audio_sent(duration_ms=1000)
silent_payload = _native_audio_payload(value=0.0, is_speech=None, force_listen=False)
speech_payload = _native_audio_payload(is_speech=None, force_listen=False)
assert handler._should_force_listen_for_auto_response_overlap(session, {}, silent_payload) is False
assert handler._should_force_listen_for_auto_response_overlap(session, {}, speech_payload) is False
assert (
handler._should_force_listen_for_auto_response_overlap(
session,
{"force_listen": True},
speech_payload,
)
is True
)
assert (
handler._should_force_listen_for_auto_response_overlap(
session,
{"force_barge_in": True},
speech_payload,
)
is False
)
session.acknowledge_playback(played_ms=1000, committed_ms=1000)
assert handler._should_force_listen_for_auto_response_overlap(session, {}, speech_payload) is False
def test_auto_response_overlap_silence_advances_model_unit_and_preserves_realtime_input():
handler, session = _auto_response_context(
"sid-auto-silent-wire-buffer",
playback_active=True,
)
decision = handler._overlap_decision(
session,
{"is_speech": False},
_native_audio_payload(samples=3200, is_speech=False),
)
assert decision["action"] == "listen"
assert decision["buffer_audio"] is True
assert decision["defer_runtime_append"] is False
assert decision["force_listen"] is False
assert decision["preserve_realtime_input"] is True
def test_auto_response_playback_overlap_admits_model_units_and_tracks_speech():
handler, session = _auto_response_context("sid-auto-deferred-overlap", playback_active=True)
payload = _native_audio_payload(samples=8640)
decision = handler._overlap_decision(
session,
{"duration_ms": 540, "is_speech": True},
payload,
)
assert decision["action"] == "listen"
assert decision["defer_runtime_append"] is False
assert decision["force_listen"] is False
assert decision["buffer_audio"] is True
assert session.overlap_speech_ms == 540
@pytest.mark.asyncio
async def test_auto_response_overlap_exact_unit_commit_does_not_block_or_replay_next_unit():
class ModelUnitEngine(FakeEngineClient):
def __init__(self) -> None:
super().__init__()
self.append_started = asyncio.Event()
self.active_response_ids_at_append: list[str | None] = []
self.response_done_seen_at_append = False
self.session: DuplexSession | None = None
self.websocket: TimedWebSocket | None = None
async def append_duplex_input_async(self, session_id: str, **kwargs):
assert self.session is not None
assert self.websocket is not None
self.active_response_ids_at_append.append(self.session.active_response_id)
self.response_done_seen_at_append = "response.done" in self.websocket.sent_types()
kwargs.pop("expected_epoch", None)
result = await super().append_duplex_input_async(session_id, **kwargs)
self.append_started.set()
return result
engine = ModelUnitEngine()
handler = OmniDuplexSessionHandler(
chat_service=FakeChatService(engine),
config_timeout_s=0.1,
idle_timeout_s=1,
)
ws = TimedWebSocket()
engine.websocket = ws
create = _native_session_create("sid-auto-overlap-model-unit")
create["session"]["extra_body"]["auto_response"] = True
ws.put(create)
handler_task = asyncio.create_task(handler.handle_session(ws))
try:
for _ in range(100):
session = handler._registry.get("sid-auto-overlap-model-unit")
if session is not None and "session.created" in ws.sent_types():
break
await asyncio.sleep(0.01)
else:
pytest.fail("native duplex session did not open")
engine.session = session
session.config.playback_commit_policy = DuplexPlaybackCommitPolicy.ACK_ONLY.value
response_id = session.begin_response()
session.mark_audio_sent(duration_ms=2000)
request_id = handler._native_stage0_request_id(session, session.epoch)
session.bind_request(request_id)
expected_fence = DuplexFence(
session.session_id,
epoch=session.epoch,
turn_id=session.turn_id,
incarnation=session.incarnation,
)
chunk = {
"type": "input_audio_buffer.append",
"audio": _pcm_f32_b64(3200, value=0.05),
"format": "pcm_f32le",
"sample_rate_hz": 16000,
"duration_ms": 200,
"is_speech": True,
}
for _ in range(4):
ws.put(chunk)
await asyncio.sleep(0.05)
assert engine.appended == []
ws.put(chunk)
await asyncio.wait_for(engine.append_started.wait(), timeout=0.5)
ws.put({"type": "input_audio_buffer.commit", "final": True})
for _ in range(100):
if "input.committed" in ws.sent_types():
break
await asyncio.sleep(0.01)
else:
pytest.fail("exact model unit input did not commit")
engine.append_started.clear()
for _ in range(5):
ws.put(chunk)
await asyncio.wait_for(engine.append_started.wait(), timeout=0.5)
await asyncio.sleep(0.05)
assert "error" not in ws.sent_types()
finally:
ws.put({"type": "session.close"})
await asyncio.wait_for(handler_task, timeout=2)
assert len(engine.appended) == 2
for appended_session_id, mode, payload, final in engine.appended:
assert appended_session_id == session.session_id
assert mode == "append_audio_chunk"
assert final is False
assert isinstance(payload, dict)
assert np.frombuffer(base64.b64decode(payload["audio"]), dtype="<f4").shape == (16000,)
assert payload.get("force_listen") is not True
assert engine.appended_fences == [expected_fence, expected_fence]
assert engine.active_response_ids_at_append == [response_id, response_id]
assert engine.response_done_seen_at_append is False
assert not any(event in {"barge_in", "input.cancel"} for _, event in engine.signals)
assert ws.sent_types().count("response.speak") <= 1
@pytest.mark.asyncio
async def test_stale_terminal_event_does_not_reset_current_session_overlap():
handler, session = _auto_response_context("sid-stale-terminal")
session.epoch = 2
session.accumulate_overlap_speech(540)
actor = DuplexWebSocketActor(TimedWebSocket(), current_epoch=lambda: session.epoch)
native = MiniCPMO45ServingSessionState()
accepted, deferred = await handler._apply_outbound_session_event(
{"type": "response.done", "epoch": 1},
session=session,
actor=actor,
native=native,
realtime_protocol=None,
)
assert accepted is False
assert deferred is None
assert session.overlap_speech_ms == 540
@pytest.mark.asyncio
async def test_current_terminal_event_resets_session_owned_overlap():
handler, session = _auto_response_context("sid-current-terminal")
session.accumulate_overlap_speech(320)
actor = DuplexWebSocketActor(TimedWebSocket(), current_epoch=lambda: session.epoch)
native = MiniCPMO45ServingSessionState()
accepted, deferred = await handler._apply_outbound_session_event(
{"type": "response.done", "epoch": session.epoch},
session=session,
actor=actor,
native=native,
realtime_protocol=None,
)
assert accepted is True
assert deferred is None
assert session.overlap_speech_ms == 0
@pytest.mark.asyncio
async def test_response_listen_terminal_does_not_drive_serving_generation_state():
handler, session = _auto_response_context("sid-listen-terminal")
actor = DuplexWebSocketActor(TimedWebSocket(), current_epoch=lambda: session.epoch)
native = MiniCPMO45ServingSessionState()
accepted, deferred = await handler._apply_outbound_session_event(
{"type": "response.listen", "epoch": session.epoch},
session=session,
actor=actor,
native=native,
realtime_protocol=None,
)
assert accepted is True
assert deferred is None
assert session.turn_id == 0
@pytest.mark.asyncio
async def test_stale_audio_cancelled_does_not_reset_current_session_overlap():
handler, session = _auto_response_context("sid-stale-audio-cancelled")
session.epoch = 3
session.accumulate_overlap_speech(640)
actor = DuplexWebSocketActor(TimedWebSocket(), current_epoch=lambda: session.epoch)
native = MiniCPMO45ServingSessionState()
accepted, deferred = await handler._apply_outbound_session_event(
{"type": "audio.cancelled", "epoch": 2},
session=session,
actor=actor,
native=native,
realtime_protocol=None,
)
assert accepted is False
assert deferred is None
assert session.overlap_speech_ms == 640
def test_auto_response_post_response_silence_remains_model_owned_input():
handler, session = _auto_response_context("sid-auto-waiting-silence", playback_active=True)
silent_payload = _native_audio_payload(value=0.0, is_speech=False, force_listen=False)
speech_payload = _native_audio_payload(force_listen=False)
assert handler._assistant_playback_active(session) is True
assert handler._should_force_listen_for_auto_response_overlap(session, {}, silent_payload) is False
assert handler._should_force_listen_for_auto_response_overlap(session, {}, speech_payload) is False
@pytest.mark.asyncio
async def test_auto_response_terminal_advances_open_realtime_input_without_commit():
handler, session = _auto_response_context("sid-auto-realtime-open-input", playback_active=True)
session.capabilities = DuplexCapabilities.minicpmo45_native()
response_id = session.active_response_id
assert response_id is not None
native = handler._minicpmo_session_state(session)
native.input_since_commit = True
native.speech_since_commit = True
session.accumulate_overlap_speech(800)
assert (
native.audio_buffer.append(
_native_audio_payload(samples=8000),
chunk_period_ms=session.capabilities.chunk_period_ms or 1000,
)
is None
)
session.end_response(commit_text=False, preserve_request=True)
allowed, promoted = await handler._apply_outbound_session_event(
{
"type": "response.done",
"response_id": response_id,
"epoch": session.epoch,
"status": "completed",
},
session=session,
actor=DuplexWebSocketActor(TimedWebSocket()),
native=native,
realtime_protocol=NativeRealtimeSessionProtocol({}),
)
assert allowed is True
assert promoted is None
assert native.audio_buffer.has_pending()
assert native.committed_audio_payload is None
assert native.deferred_response_create is False
assert native.input_since_commit is True
assert native.speech_since_commit is True
next_speech = _native_audio_payload(samples=8000)
assert "new_user_turn" not in next_speech
@pytest.mark.asyncio
async def test_auto_response_terminal_allows_next_model_unit_while_playback_drains():
handler, session = _auto_response_context(
"sid-auto-realtime-open-before-overlap",
playback_active=True,
)
session.capabilities = DuplexCapabilities.minicpmo45_native()
response_id = session.active_response_id
assert response_id is not None
native = handler._minicpmo_session_state(session)
native.input_since_commit = True
native.speech_since_commit = True
session.end_response(commit_text=False, preserve_request=True)
allowed, promoted = await handler._apply_outbound_session_event(
{
"type": "response.done",
"response_id": response_id,
"epoch": session.epoch,
"status": "completed",
},
session=session,
actor=DuplexWebSocketActor(TimedWebSocket()),
native=native,
realtime_protocol=NativeRealtimeSessionProtocol({}),
)
assert allowed is True
assert promoted is None
assert native.input_since_commit is True
assert handler._assistant_playback_active(session) is True
next_speech = _native_audio_payload(samples=16_000)
assert "new_user_turn" not in next_speech
@pytest.mark.asyncio
async def test_realtime_committed_overlap_promotes_after_response_done():
handler, session = _auto_response_context("sid-auto-realtime-committed-input", playback_active=True)
session.capabilities = DuplexCapabilities.minicpmo45_native()
response_id = session.active_response_id
assert response_id is not None
native = handler._minicpmo_session_state(session)
native.committed_audio_payload = _native_audio_payload()
native.deferred_response_create = True
session.accumulate_overlap_speech(1000)
session.end_response(commit_text=False, preserve_request=True)
allowed, promoted = await handler._apply_outbound_session_event(
{
"type": "response.done",
"response_id": response_id,
"epoch": session.epoch,
"status": "completed",
},
session=session,
actor=DuplexWebSocketActor(TimedWebSocket()),
native=native,
realtime_protocol=NativeRealtimeSessionProtocol({}),
)
assert allowed is True
assert promoted is not None
assert promoted["force_listen"] is False
assert native.committed_audio_payload is promoted
assert native.deferred_response_create is False
assert "new_user_turn" not in promoted
@pytest.mark.asyncio
async def test_auto_response_committed_overlap_does_not_precreate_empty_response():
session_id = "sid-auto-overlap-no-empty-response"
request_id = f"duplex-{session_id}-e0-stage0"
def _stage_output(samples: int, *, turn_end: bool = False):
return SimpleNamespace(
request_id=request_id,
finished=False,
outputs=[
SimpleNamespace(
text="hello",
multimodal_output={
"audio": np.zeros(samples, dtype=np.float32),
"sr": 24000,
"meta": {
"turn_end": turn_end,
"duplex_turn_id": 0,
"duplex_epoch": 0,
},
},
)
],
)
first_append = {
"operation": "append",
"session_id": session_id,
"ok": True,
"unsupported_count": 0,
"error_count": 0,
"stage_results": [
{
"stage_id": 0,
"replica_id": 0,
"result": {
"supported": True,
"implementation_level": "model_native_duplex",
"data_plane_append": True,
"request_id": request_id,
"response_stage_id": 1,
},
}
],
"data_plane_outputs": [_stage_output(2400)],
}
promoted_append = {
"operation": "append",
"session_id": session_id,
"ok": True,
"unsupported_count": 0,
"error_count": 0,
"stage_results": [],
"data_plane_outputs": [],
}
engine = FakeEngineClient(
append_results=[first_append, promoted_append],
collect_outputs=[[_stage_output(4800, turn_end=True)]],
collect_delay_s=0.1,
)
handler = OmniDuplexSessionHandler(
chat_service=FakeChatService(engine),
config_timeout_s=0.1,
idle_timeout_s=2,
)
overlap_sent = False
def commit_overlap_after_first_response(ws: TimedWebSocket, data: dict[str, Any]) -> None:
nonlocal overlap_sent
if data.get("type") != "response.audio.delta" or overlap_sent:
return
overlap_sent = True
chunk = {
"type": "input_audio_buffer.append",
"audio": _pcm_f32_b64(3200, value=0.05),
"format": "pcm_f32le",
"sample_rate_hz": 16000,
"duration_ms": 200,
"is_speech": True,
}
for _ in range(4):
ws.put(chunk)
ws.put({"type": "input_audio_buffer.commit", "final": True})
ws = TimedWebSocket(on_send=commit_overlap_after_first_response, receive_timeout_s=3)
create = _native_realtime_session_update(session_id)
create["session"]["extra_body"]["auto_response"] = True
ws.put(create)
ws.put(
{
"type": "input_audio_buffer.append",
"audio": _pcm_f32_b64(16000, value=0.05),
"format": "pcm_f32le",
"sample_rate_hz": 16000,
"duration_ms": 1000,
"is_speech": True,
}
)
ws.put({"type": "input_audio_buffer.commit", "final": True})
handler_task = asyncio.create_task(handler.handle_realtime_session(ws))
try:
for _ in range(300):
if len(engine.appended) >= 2:
break
await asyncio.sleep(0.01)
else:
pytest.fail(
f"committed overlap was not promoted after response.done: "
f"appends={len(engine.appended)}, events={ws.sent_types()}"
)
assert overlap_sent is True
assert len(engine.appended) == 2
assert engine.appended[1][3] is True
assert ws.sent_types().count("response.created") == 1
finally:
ws.put({"type": "session.close"})
await asyncio.wait_for(handler_task, timeout=3)
assert ws.sent_types().count("response.done") >= 1
def test_auto_response_barge_in_does_not_mark_serving_new_user_turn_payload():
handler, session = _auto_response_context("sid-auto-barge-new-turn", playback_active=True)
payload = _native_audio_payload()
assert handler._should_force_listen_for_auto_response_overlap(session, {"force_barge_in": True}, payload) is False
assert "new_user_turn" not in payload
assert "new_user_turn_prefix_variant" not in payload
assert session.turn_id == 0
assert payload.get("force_speak") is not True
def test_native_realtime_barge_in_payload_does_not_create_serving_turn_boundary():
payload: dict[str, object] = {
"type": "audio",
"audio": _pcm_f32_b64(16000, value=0.05),
"format": "pcm_f32le",
"sample_rate_hz": 16000,
"is_speech": True,
}
session = DuplexSession(session_id="sid-realtime-barge", config=DuplexSessionConfig())
session.turn_id = 2
assert "new_user_turn" not in payload
assert "new_user_turn_prefix_variant" not in payload
assert session.turn_id == 2
def test_auto_response_force_barge_in_has_no_waiting_turn_variant():
handler, session = _auto_response_context("sid-auto-barge-keeps-variant")
payload = _native_audio_payload()
assert (
handler._should_force_listen_for_auto_response_overlap(
session,
{"force_barge_in": True},
payload,
)
is False
)
def test_duplex_endpoint_requires_explicit_session_mode_duplex():
assert should_enable_duplex_endpoint(None) is False
assert should_enable_duplex_endpoint([]) is False
assert should_enable_duplex_endpoint([SimpleNamespace(session_mode="turn")]) is False
assert should_enable_duplex_endpoint([{"session_mode": "turn"}]) is False
assert (
should_enable_duplex_endpoint(
[
SimpleNamespace(session_mode="turn"),
SimpleNamespace(session_mode="duplex"),
]
)
is True
)
assert should_enable_duplex_endpoint([{"session_mode": "duplex"}]) is True
def test_duplex_endpoint_supports_top_level_session_mode(tmp_path):
config_path = tmp_path / "stage_config.yaml"
config_path.write_text(
"""
session_mode: duplex
stage_args:
- stage_id: 0
engine_args: {}
""",
encoding="utf-8",
)
assert should_enable_duplex_endpoint([], config_path=str(config_path)) is True
def test_duplex_handler_splits_data_plane_audio_list_into_deltas():
import torch
data_plane = _test_data_plane()
output = SimpleNamespace(
finished=True,
outputs=[
SimpleNamespace(
text="hello",
multimodal_output={
"audio": [
torch.zeros(10, dtype=torch.float32),
torch.zeros(20, dtype=torch.float32),
],
"sr": 24000,
},
)
],
)
native_results = list(data_plane.project_output(output))
assert [result["audio_data"] for result in native_results] == ["wav-10", "wav-20"]
assert [result["text"] for result in native_results] == ["hello", ""]
assert [result["end_of_turn"] for result in native_results] == [False, True]
def test_duplex_listen_latent_does_not_poison_cumulative_audio_offset():
import torch
data_plane = _test_data_plane()
request_id = "duplex-duplex-sess-stage0"
# A model-listen decision wraps the segment with a latent tensor that is
# NOT reply audio; it must not advance the cumulative audio offset.
listen_output = SimpleNamespace(
request_id=request_id,
finished=True,
outputs=[],
multimodal_output={
"duplex_native_decision": "listen",
"model_listen": True,
"latent": torch.zeros(331776, dtype=torch.float32),
"meta": {"sr": 24000},
},
)
listen_results = list(data_plane.project_output(listen_output))
assert [result.get("is_listen") for result in listen_results] == [True]
# The first speak unit carries cumulative stage-1 audio far smaller than
# the listen latent; it must still be delivered from sample 0.
speak_output = SimpleNamespace(
request_id=request_id,
finished=False,
outputs=[
SimpleNamespace(
text=" It was a very",
multimodal_output={},
)
],
multimodal_output={
"audio": torch.zeros(32768, dtype=torch.float32),
"sr": 24000,
},
)
speak_results = list(data_plane.project_output(speak_output))
assert [result.get("audio_data") for result in speak_results] == ["wav-32768"]
assert speak_results[0]["text"] == " It was a very"
def test_direct_listen_decision_survives_inner_completion_metadata():
inner_output = SimpleNamespace(
outputs=[
SimpleNamespace(
multimodal_output={"special_token_ids": {"listen_token_id": 151705}},
)
]
)
decision = MiniCPMO45DuplexRuntimeExtension().decide_output(
stage_id=0,
final_stage_id=1,
segment_finished=True,
segment_token_ids=(151705,),
segment_output_metadata={"special_token_ids": {"listen_token_id": 151705}},
output=inner_output,
)
assert decision is not None
output = attach_duplex_output_decision(
OmniRequestOutput.from_stage_output(
inner_output,
request_id="duplex-direct-listen",
finished=True,
stage_id=0,
final_output_type=decision.final_output_type,
metrics={
"stage_metrics": {
"0": {
"vllm_ttft_ms": 125.0,
"vllm_tpot_ms": 18.0,
"vllm_itl_ms": 17.5,
"vllm_itls_ms": [17.0, 18.0],
}
}
},
),
decision,
)
results = list(_test_data_plane().project_output(output))
assert len(results) == 1
assert results[0]["is_listen"] is True
assert results[0]["model_listen"] is True
assert results[0]["listen_source"] == "model_listen"
assert results[0]["stage_metrics"] == output.metrics["stage_metrics"]
def test_duplex_segment_text_is_attached_once_across_streaming_batches():
import torch
class _ChatService:
model_config = _ModelConfig()
def create_audio(self, audio_obj):
return SimpleNamespace(audio_data=f"wav-{int(audio_obj.audio_tensor.shape[0])}")
data_plane = _test_data_plane()
request_id = "duplex-duplex-sess-stage0"
session = DuplexSession(
session_id="sid-auto-respond",
config=DuplexSessionConfig(extra_body={"auto_response": True}),
)
def _speak(total_samples: int, text: str, *, finished: bool):
output = SimpleNamespace(
request_id=request_id,
finished=finished,
outputs=[SimpleNamespace(text=text, multimodal_output={})],
multimodal_output={
"audio": torch.zeros(total_samples, dtype=torch.float32),
"sr": 24000,
},
)
return _project_data_plane(data_plane, {"data_plane_outputs": [output]}, session=session)
def _audio(results):
return [r for r in results if r.get("is_listen") is False]
def _listen(results):
return [r for r in results if r.get("is_listen") is True]
# One talker segment streams several cumulative-audio batches, each
# carrying the SAME segment text; only the first may attach it.
assert [r["text"] for r in _audio(_speak(100, " movie called", finished=False))] == [" movie called"]
assert [r["text"] for r in _audio(_speak(220, " movie called", finished=False))] == [""]
finished_results = _speak(300, " movie called", finished=True)
assert [r["text"] for r in _audio(finished_results)] == [""]
assert _listen(finished_results) == []
# Continuation units re-run the talker with the SAME handed text past a
# finished boundary (every engine segment ends finished=True); the text
# must stay suppressed or each continuation duplicates the transcript.
assert [r["text"] for r in _audio(_speak(400, " movie called", finished=False))] == [""]
assert [r["text"] for r in _audio(_speak(450, " movie called", finished=True))] == [""]
# A genuinely new segment text is attached in full.
assert [r["text"] for r in _audio(_speak(500, " Titanic", finished=True))] == [" Titanic"]
# Text growing within a segment is delivered as suffix deltas.
assert [r["text"] for r in _audio(_speak(600, " by", finished=False))] == [" by"]
assert [r["text"] for r in _audio(_speak(700, " by James", finished=True))] == [" James"]
# A segment whose finished batch slices to an EMPTY audio delta (all
# samples already delivered) must not block the next segment's text.
assert [r["text"] for r in _audio(_speak(800, " James Cameron. The", finished=False))] == [" James Cameron. The"]
assert _speak(800, " James Cameron. The", finished=True) == []
assert [r["text"] for r in _audio(_speak(900, " 997.", finished=False))] == [" 997."]
def test_duplex_data_plane_output_prefers_audio_segment_text_metadata():
data_plane = _test_data_plane()
request_id = "duplex-sid-native-segment-text-e0-stage0"
segment_text = "你好,有什么想聊的吗?"
cumulative_text = segment_text * 2
output = SimpleNamespace(
request_id=request_id,
finished=False,
outputs=[SimpleNamespace(text=cumulative_text, multimodal_output={})],
multimodal_output={
"audio": np.zeros(24000, dtype=np.float32),
"sr": 24000,
"meta.llm_output_text_utf8": np.frombuffer(segment_text.encode("utf-8"), dtype=np.uint8),
},
)
results = list(data_plane.project_output(output))
assert len(results) == 1
assert results[0]["audio_data"] == "wav-24000"
assert results[0]["text"] == segment_text
next_output_without_segment_metadata = SimpleNamespace(
request_id=request_id,
finished=False,
outputs=[SimpleNamespace(text=segment_text * 3, multimodal_output={})],
multimodal_output={
"audio": np.zeros(30720, dtype=np.float32),
"sr": 24000,
},
)
next_results = list(data_plane.project_output(next_output_without_segment_metadata))
assert len(next_results) == 1
assert next_results[0]["audio_data"] == "wav-6720"
assert next_results[0]["text"] == ""
@pytest.mark.parametrize(
("request_id", "second_samples", "expected_second_audio"),
[
("duplex-sid-model-turn-audio-e0-stage0", 36000, "wav-12000"),
("duplex-sid-model-turn-audio-restart-e0-stage0", 12000, "wav-12000"),
],
ids=["cumulative-across-model-turns", "upstream-cumulative-audio-restarts"],
)
def test_duplex_audio_cursor_across_model_turns(
request_id: str,
second_samples: int,
expected_second_audio: str,
):
data_plane = _test_data_plane()
def output(turn_id: int, samples: int):
return SimpleNamespace(
request_id=request_id,
finished=False,
outputs=[SimpleNamespace(text="", multimodal_output={})],
multimodal_output={
"audio": np.zeros(samples, dtype=np.float32),
"sr": 24000,
"meta.duplex_turn_id": np.array([turn_id], dtype=np.int32),
},
)
first = list(data_plane.project_output(output(0, 24000)))
second = list(data_plane.project_output(output(1, second_samples)))
assert first[0]["audio_data"] == "wav-24000"
assert second[0]["audio_data"] == expected_second_audio
def test_duplex_data_plane_accepts_model_turn_output_across_client_commits():
data_plane = _test_data_plane()
session = DuplexSession(
session_id="sid-auto-stale-turn",
config=DuplexSessionConfig(extra_body={"auto_response": True}),
)
session.commit_native_audio_input()
session.commit_native_audio_input()
session.turn_id = 1
session.epoch = 0
request_id = "duplex-sid-auto-stale-turn-stage0"
output = SimpleNamespace(
request_id=request_id,
finished=False,
outputs=[SimpleNamespace(text="old tail", multimodal_output={})],
multimodal_output={
"audio": np.zeros(24000, dtype=np.float32),
"sr": 24000,
"meta.duplex_turn_id": np.array([1], dtype=np.int32),
"meta.llm_output_text_utf8": np.frombuffer("旧尾巴".encode(), dtype=np.uint8),
},
)
results = list(data_plane.project_output(output, context=_data_plane_context(session)))
assert len(results) == 1
assert results[0]["audio_data"] == "wav-24000"
assert results[0]["model_turn_id"] == 1
def test_duplex_data_plane_accepts_active_response_turn_identity():
data_plane = _test_data_plane()
session = DuplexSession(
session_id="sid-active-response-turn",
config=DuplexSessionConfig(extra_body={"auto_response": True}),
)
session.turn_id = 1
session.epoch = 0
session.begin_response(turn_id=0)
output = SimpleNamespace(
request_id="duplex-sid-active-response-turn-stage0",
finished=True,
outputs=[SimpleNamespace(text="", multimodal_output={})],
multimodal_output={
"audio": np.zeros(10, dtype=np.float32),
"sr": 24000,
"meta.duplex_turn_id": np.array([0], dtype=np.int32),
"meta.duplex_epoch": np.array([0], dtype=np.int32),
},
)
results = _project_data_plane(data_plane, {"data_plane_outputs": [output]}, session=session)
assert len(results) == 1
def test_duplex_data_plane_listen_preserves_model_turn_identity():
data_plane = _test_data_plane()
session = DuplexSession(
session_id="sid-listen-turn",
config=DuplexSessionConfig(extra_body={"auto_response": True}),
)
output = SimpleNamespace(
request_id="duplex-sid-listen-turn-stage0",
finished=True,
outputs=[SimpleNamespace(text="", token_ids=[], multimodal_output={})],
multimodal_output={
"duplex_native_decision": "listen",
"meta.duplex_turn_id": np.array([2], dtype=np.int32),
"meta.duplex_epoch": np.array([0], dtype=np.int32),
},
)
results = _project_data_plane(data_plane, {"data_plane_outputs": [output]}, session=session)
assert len(results) == 1
assert results[0]["is_listen"] is True
assert results[0]["model_turn_id"] == 2
def test_duplex_data_plane_does_not_drop_future_model_turn_while_response_active():
data_plane = _test_data_plane()
session = DuplexSession(
session_id="sid-future-response-turn",
config=DuplexSessionConfig(extra_body={"auto_response": True}),
)
session.turn_id = 0
session.epoch = 0
session.begin_response(turn_id=0)
output = SimpleNamespace(
request_id="duplex-sid-future-response-turn-stage0",
finished=False,
outputs=[SimpleNamespace(text="", multimodal_output={})],
multimodal_output={
"audio": np.zeros(10, dtype=np.float32),
"sr": 24000,
"meta.duplex_turn_id": np.array([1], dtype=np.int32),
"meta.duplex_epoch": np.array([0], dtype=np.int32),
},
)
results = _project_data_plane(data_plane, {"data_plane_outputs": [output]}, session=session)
assert len(results) == 1
assert results[0]["model_turn_id"] == 1
assert results[0]["audio_data"] == "wav-10"
def _duplex_tts_output(
*,
request_id: str,
samples: int,
finished: bool,
text: str = "hello",
tts_is_last_chunk: bool = False,
turn_end: bool = False,
turn_id: int = 0,
token_ids: list[int] | None = None,
):
return SimpleNamespace(
request_id=request_id,
finished=finished,
outputs=[SimpleNamespace(text=text, token_ids=list(token_ids or []), multimodal_output={})],
multimodal_output={
"audio": np.zeros(samples, dtype=np.float32),
"sr": 24000,
"meta.tts_is_last_chunk": np.array([int(tts_is_last_chunk)], dtype=np.int32),
"meta.turn_end": np.array([int(turn_end)], dtype=np.int32),
"meta.duplex_turn_id": np.array([turn_id], dtype=np.int32),
"meta.duplex_epoch": np.array([0], dtype=np.int32),
},
)
@pytest.mark.parametrize(
(
"finished",
"tts_is_last_chunk",
"turn_end",
"consume_audio_first",
"expected_end_of_turn",
"expected_audio",
),
[
(False, True, False, False, False, "wav-24000"),
(True, True, False, True, False, ""),
(True, True, True, True, True, ""),
],
ids=[
"last-audio-batch-does-not-end-response",
"scheduler-eos-only-ends-tts-segment",
"explicit-turn-end-metadata-ends-response",
],
)
def test_duplex_auto_response_tts_boundary(
finished: bool,
tts_is_last_chunk: bool,
turn_end: bool,
consume_audio_first: bool,
expected_end_of_turn: bool,
expected_audio: str,
):
data_plane = _test_data_plane()
request_id = "duplex-sid-tts-boundary-e0-stage0"
session = DuplexSession(
session_id="sid-tts-boundary",
config=DuplexSessionConfig(extra_body={"auto_response": True}),
)
session.bind_response_turn(0)
if consume_audio_first:
data_plane.slice_cumulative_audio(request_id, np.zeros(24000, dtype=np.float32))
output = _duplex_tts_output(
request_id=request_id,
samples=24000,
finished=finished,
tts_is_last_chunk=tts_is_last_chunk,
turn_end=turn_end,
token_ids=[151645] if finished else None,
)
results = _project_data_plane(data_plane, {"data_plane_outputs": [output]}, session=session)
assert len(results) == 1
boundary = results[0]
assert boundary["stage_role"] == "tts"
assert boundary["end_of_turn"] is expected_end_of_turn
assert boundary["abort_data_plane_request"] is True
assert boundary["audio_data"] == expected_audio
def test_duplex_auto_response_tts_scheduler_eos_fallback():
data_plane = _test_data_plane()
request_id = "duplex-sid-tts-eos-no-profile-e0-stage0"
session = DuplexSession(
session_id="sid-tts-eos-no-profile",
config=DuplexSessionConfig(extra_body={"auto_response": True}),
)
session.bind_response_turn(0)
data_plane.slice_cumulative_audio(request_id, np.zeros(24000, dtype=np.float32))
output = _duplex_tts_output(
request_id=request_id,
samples=24000,
finished=True,
tts_is_last_chunk=False,
token_ids=[151645],
)
results = _project_data_plane(data_plane, {"data_plane_outputs": [output]}, session=session)
assert len(results) == 1
assert results[0]["stage_role"] == "tts"
assert results[0]["abort_data_plane_request"] is True
def test_duplex_auto_response_rearms_tts_boundary_for_next_segment_in_same_turn():
data_plane = _test_data_plane()
request_id = "duplex-sid-next-segment-same-turn-e0-stage0"
session = DuplexSession(
session_id="sid-next-segment-same-turn",
config=DuplexSessionConfig(extra_body={"auto_response": True}),
)
session.bind_response_turn(0)
data_plane.begin_request(request_id)
first_segment = _duplex_tts_output(
request_id=request_id,
samples=24000,
finished=False,
tts_is_last_chunk=True,
turn_id=0,
)
first_results = _project_data_plane(
data_plane,
{"data_plane_outputs": [first_segment]},
session=session,
)
assert len(first_results) == 1
assert first_results[0]["abort_data_plane_request"] is True
data_plane.begin_request(request_id)
second_segment = _duplex_tts_output(
request_id=request_id,
samples=48000,
finished=False,
text="hello again",
tts_is_last_chunk=True,
turn_id=0,
)
second_results = _project_data_plane(
data_plane,
{"data_plane_outputs": [second_segment]},
session=session,
)
assert len(second_results) == 1
assert second_results[0]["audio_data"] == "wav-24000"
assert second_results[0]["abort_data_plane_request"] is True
def test_duplex_turn_end_is_not_swallowed_by_finished_segment_fallback():
data_plane = _test_data_plane()
request_id = "duplex-sid-turn-end-after-segment-e0-stage0"
session = DuplexSession(
session_id="sid-turn-end-after-segment",
config=DuplexSessionConfig(extra_body={"auto_response": True}),
)
session.begin_response(turn_id=0)
session.bind_request(request_id)
_project_data_plane(
data_plane,
{
"data_plane_outputs": [
_duplex_tts_output(
request_id=request_id,
samples=24000,
finished=False,
tts_is_last_chunk=True,
)
]
},
session=session,
)
output = _duplex_tts_output(
request_id=request_id,
samples=24000,
finished=True,
text="",
tts_is_last_chunk=False,
turn_end=True,
token_ids=[151645],
)
results = _project_data_plane(data_plane, {"data_plane_outputs": [output]}, session=session)
assert len(results) == 1
assert results[0]["stage_role"] == "tts"
assert results[0]["end_of_turn"] is True
duplicate = _project_data_plane(data_plane, {"data_plane_outputs": [output]}, session=session)
assert not any(result.get("end_of_turn") is True for result in duplicate)
def test_duplex_auto_response_discards_terminal_only_audio_before_response_creation():
data_plane = _test_data_plane()
request_id = "duplex-sid-terminal-only-audio-e0-stage0"
session = DuplexSession(
session_id="sid-terminal-only-audio",
config=DuplexSessionConfig(extra_body={"auto_response": True}),
)
session.bind_request(request_id)
first_audio = _duplex_tts_output(
request_id=request_id,
samples=3840,
finished=False,
text="",
)
assert _project_data_plane(data_plane, {"data_plane_outputs": [first_audio]}, session=session) == []
terminal = _duplex_tts_output(
request_id=request_id,
samples=3840,
finished=True,
text="",
tts_is_last_chunk=True,
turn_end=True,
token_ids=[151645],
)
results = _project_data_plane(data_plane, {"data_plane_outputs": [terminal]}, session=session)
assert len(results) == 1
assert results[0]["audio_data"] == ""
assert results[0]["end_of_turn"] is True
assert session.active_request_id == request_id
assert not data_plane.is_terminal(request_id)
def test_duplex_auto_response_releases_buffered_audio_when_transcript_arrives():
data_plane = _test_data_plane()
request_id = "duplex-sid-delayed-transcript-e0-stage0"
session = DuplexSession(
session_id="sid-delayed-transcript",
config=DuplexSessionConfig(extra_body={"auto_response": True}),
)
audio_before_text = _duplex_tts_output(
request_id=request_id,
samples=100,
finished=False,
text="",
)
assert _project_data_plane(data_plane, {"data_plane_outputs": [audio_before_text]}, session=session) == []
audio_with_text = _duplex_tts_output(
request_id=request_id,
samples=200,
finished=False,
text="hello",
)
results = _project_data_plane(data_plane, {"data_plane_outputs": [audio_with_text]}, session=session)
assert [result["audio_data"] for result in results] == ["wav-100", "wav-100"]
assert [result["text"] for result in results] == ["", "hello"]
def test_duplex_auto_response_releases_buffered_audio_on_text_only_terminal():
data_plane = _test_data_plane()
request_id = "duplex-sid-delayed-terminal-text-e0-stage0"
session = DuplexSession(
session_id="sid-delayed-terminal-text",
config=DuplexSessionConfig(extra_body={"auto_response": True}),
)
audio_before_text = _duplex_tts_output(
request_id=request_id,
samples=100,
finished=False,
text="",
turn_id=1,
)
assert _project_data_plane(data_plane, {"data_plane_outputs": [audio_before_text]}, session=session) == []
textless_turn_terminal = _duplex_tts_output(
request_id=request_id,
samples=100,
finished=True,
text="",
tts_is_last_chunk=True,
turn_end=True,
turn_id=1,
token_ids=[151645],
)
terminal_results = list(
data_plane.project(
{"data_plane_outputs": [textless_turn_terminal]},
context=_data_plane_context(session),
)
)
assert len(terminal_results) == 1
assert terminal_results[0]["audio_data"] == ""
assert terminal_results[0]["end_of_turn"] is True
text_only_terminal = SimpleNamespace(
request_id=request_id,
finished=True,
outputs=[SimpleNamespace(text="hello", token_ids=[151645], multimodal_output={})],
multimodal_output={
"sr": 24000,
"meta.tts_is_last_chunk": np.array([1], dtype=np.int32),
"meta.turn_end": np.array([1], dtype=np.int32),
"meta.duplex_turn_id": np.array([2], dtype=np.int32),
"meta.duplex_epoch": np.array([0], dtype=np.int32),
},
)
results = _project_data_plane(data_plane, {"data_plane_outputs": [text_only_terminal]}, session=session)
assert len(results) == 1
assert results[0]["audio_data"] == "wav-100"
assert results[0]["text"] == "hello"
assert results[0]["end_of_turn"] is True
assert results[0]["abort_data_plane_request"] is True
assert not data_plane.has_pending_audio(request_id)
@pytest.mark.asyncio
async def test_native_append_propagates_current_turn_fence_to_engine():
engine = FakeEngineClient()
handler = OmniDuplexSessionHandler(chat_service=FakeChatService(engine))
session = DuplexSession(
session_id="sid-fenced-append",
config=DuplexSessionConfig(extra_body={"auto_response": True}),
)
session.capabilities = DuplexCapabilities.minicpmo45_native()
session.turn_id = 2
session.begin_response(turn_id=2)
ws = TimedWebSocket()
await handler._append_runtime_input(
session,
{"duplex_turn_id": 2, "audio": "", "format": "pcm_f32le"},
final=True,
send_json=ws.send_json,
mode="append_audio_chunk",
expected_epoch=0,
)
assert engine.appended_fences == [DuplexFence("sid-fenced-append", epoch=0, turn_id=2)]
@pytest.mark.asyncio
async def test_minicpmo_auto_response_tts_segment_boundary_appends_silence_unit():
request_id = "duplex-sid-segment-boundary-e0-stage0"
engine = FakeEngineClient()
handler = OmniDuplexSessionHandler(
chat_service=FakeChatService(engine),
config_timeout_s=0.1,
idle_timeout_s=1,
)
session = DuplexSession(
session_id="sid-segment-boundary",
config=DuplexSessionConfig(extra_body={"auto_response": True}),
)
session.capabilities = DuplexCapabilities.minicpmo45_native()
session.begin_response(turn_id=0)
session.turn_id = 1
session.bind_request(request_id)
session.mark_audio_sent(100)
_install_direct_silence_scheduler(handler, session)
ws = TimedWebSocket()
await handler._send_one_native_duplex_event(
ws.send_json,
{
"supported": True,
"stage_role": "tts",
"is_listen": False,
"data_plane_request_id": request_id,
"text": "",
"audio_data": "",
"audio_format": "pcm16",
"end_of_turn": False,
"abort_data_plane_request": True,
"uses_model_runner_scheduler": True,
"runner_kv_backed": True,
"runtime_impl": "scheduler_data_plane",
},
session=session,
expected_epoch=session.epoch,
)
await asyncio.sleep(0.01)
assert len(engine.appended) == 1
_, mode, payload, final = engine.appended[0]
assert mode == "append_audio_chunk"
assert payload["format"] == "pcm_f32le"
assert payload["duplex_turn_id"] == 0
assert "force_speak" not in payload
assert final is False
assert session.active_response_id is not None
assert "response.done" not in ws.sent_types()
@pytest.mark.asyncio
async def test_minicpmo_auto_response_pre_response_tts_boundary_continues_model_turn():
request_id = "duplex-sid-pre-response-boundary-e0-stage0"
engine = FakeEngineClient()
handler = OmniDuplexSessionHandler(chat_service=FakeChatService(engine))
session = DuplexSession(
session_id="sid-pre-response-boundary",
config=DuplexSessionConfig(extra_body={"auto_response": True}),
)
session.capabilities = DuplexCapabilities.minicpmo45_native()
session.bind_request(request_id)
_install_direct_silence_scheduler(handler, session)
ws = TimedWebSocket()
close_reason, emitted = await handler._send_one_native_duplex_event(
ws.send_json,
{
"supported": True,
"stage_role": "tts",
"is_listen": False,
"data_plane_request_id": request_id,
"model_turn_id": session.turn_id,
"text": "",
"audio_data": "",
"audio_format": "pcm16",
"end_of_turn": False,
"abort_data_plane_request": True,
"uses_model_runner_scheduler": True,
"runner_kv_backed": True,
"runtime_impl": "scheduler_data_plane",
},
session=session,
expected_epoch=session.epoch,
)
await asyncio.sleep(0.01)
assert close_reason is None
assert emitted is False
assert session.active_response_id is None
assert ws.sent == []
assert len(engine.appended) == 1
_, mode, payload, final = engine.appended[0]
assert mode == "append_audio_chunk"
assert payload["duplex_turn_id"] == session.turn_id
assert final is False
@pytest.mark.asyncio
async def test_minicpmo_pre_response_continuation_drops_after_model_turn_ends():
request_id = "duplex-sid-stale-pre-response-boundary-e0-stage0"
engine = FakeEngineClient()
handler = OmniDuplexSessionHandler(chat_service=FakeChatService(engine))
session = DuplexSession(
session_id="sid-stale-pre-response-boundary",
config=DuplexSessionConfig(extra_body={"auto_response": True}),
)
session.capabilities = DuplexCapabilities.minicpmo45_native()
session.bind_request(request_id)
model_turn_id = session.turn_id
native = handler._minicpmo_session_state(session)
async def _stale_before_append(payload: object, **kwargs: Any) -> bool:
session.complete_model_turn(model_turn_id)
if handler._native_silence_continuation_is_stale(
session,
request_id=kwargs["request_id"],
response_id=kwargs["response_id"],
response_owned=kwargs["response_owned"],
expected_epoch=kwargs["expected_epoch"],
expected_incarnation=kwargs["expected_incarnation"],
expected_model_turn_id=kwargs["expected_model_turn_id"],
):
return False
append_ok, _ = await handler._append_runtime_input(
session,
payload,
final=False,
send_json=kwargs["send_json"],
mode="append_audio_chunk",
expected_epoch=kwargs["expected_epoch"],
)
return append_ok
native.silence_continuation_scheduler = _stale_before_append
await handler._maybe_continue_native_response(
TimedWebSocket().send_json,
session=session,
expected_epoch=session.epoch,
expected_model_turn_id=model_turn_id,
)
await asyncio.sleep(0.01)
assert engine.appended == []
@pytest.mark.asyncio
async def test_minicpmo_auto_response_continuation_stops_at_large_safety_boundary():
request_id = "duplex-sid-long-response-e0-stage0"
engine = FakeEngineClient()
handler = OmniDuplexSessionHandler(chat_service=FakeChatService(engine))
session = DuplexSession(
session_id="sid-long-response",
config=DuplexSessionConfig(extra_body={"auto_response": True}),
)
session.capabilities = DuplexCapabilities.minicpmo45_native()
session.begin_response(turn_id=0)
session.turn_id = 1
session.bind_request(request_id)
session.mark_audio_sent(100)
_install_direct_silence_scheduler(handler, session)
ws = TimedWebSocket()
for _ in range(handler._NATIVE_AUTO_RESPONSE_MAX_CONTINUATION_UNITS):
await handler._maybe_continue_native_response(
ws.send_json,
session=session,
expected_epoch=session.epoch,
)
await asyncio.sleep(0.02)
response_id = session.active_response_id
assert response_id is not None
assert len(engine.appended) == handler._NATIVE_AUTO_RESPONSE_MAX_CONTINUATION_UNITS
assert all(payload["duplex_turn_id"] == 0 for _, _, payload, _ in engine.appended)
assert all(not {"force_speak", "force_listen"} & payload.keys() for _, _, payload, _ in engine.appended[:-1])
assert engine.appended[-1][2]["force_listen"] is True
assert handler._native_response_continuations_remaining(session, response_id) is False
await handler._maybe_continue_native_response(
ws.send_json,
session=session,
expected_epoch=session.epoch,
)
assert len(engine.appended) == handler._NATIVE_AUTO_RESPONSE_MAX_CONTINUATION_UNITS
assert ws.sent_types()[-2:] == ["response.listen", "response.done"]
assert session.active_response_id is None
assert session.active_request_id == request_id
@pytest.mark.asyncio
async def test_minicpmo_auto_response_boundary_listen_closes_response():
request_id = "duplex-sid-boundary-listen-e0-stage0"
engine = FakeEngineClient()
handler = OmniDuplexSessionHandler(chat_service=FakeChatService(engine))
session = DuplexSession(
session_id="sid-boundary-listen",
config=DuplexSessionConfig(extra_body={"auto_response": True}),
)
session.capabilities = DuplexCapabilities.minicpmo45_native()
response_id = session.begin_response(turn_id=0)
session.bind_request(request_id)
native = handler._minicpmo_session_state(session)
native.continuation_owner_id = f"response:{response_id}"
native.continuation_units = handler._NATIVE_AUTO_RESPONSE_MAX_CONTINUATION_UNITS
_install_direct_silence_scheduler(handler, session)
ws = TimedWebSocket()
close_reason, emitted = await handler._send_one_native_duplex_event(
ws.send_json,
{
"supported": True,
"stage_role": "llm",
"is_listen": True,
"model_listen": True,
"data_plane_request_id": request_id,
"end_of_turn": False,
"uses_model_runner_scheduler": True,
"runner_kv_backed": True,
"runtime_impl": "scheduler_data_plane",
},
session=session,
expected_epoch=session.epoch,
)
assert close_reason is None
assert emitted is True
assert engine.appended == []
assert ws.sent_types() == ["response.listen", "response.done"]
assert session.active_response_id is None
assert session.active_request_id == request_id
assert native.continuation_owner_id is None
@pytest.mark.asyncio
async def test_minicpmo_auto_response_boundary_does_not_close_new_epoch_response():
request_id = "duplex-sid-boundary-race-e0-stage0"
handler = OmniDuplexSessionHandler(chat_service=FakeChatService(FakeEngineClient()))
session = DuplexSession(
session_id="sid-boundary-race",
config=DuplexSessionConfig(extra_body={"auto_response": True}),
)
session.capabilities = DuplexCapabilities.minicpmo45_native()
old_response_id = session.begin_response(turn_id=0)
session.bind_request(request_id)
native = handler._minicpmo_session_state(session)
native.continuation_owner_id = f"response:{old_response_id}"
native.continuation_units = handler._NATIVE_AUTO_RESPONSE_MAX_CONTINUATION_UNITS
sent: list[dict[str, Any]] = []
new_response_ids: list[str] = []
async def _barge_in_on_listen(payload: dict[str, Any]) -> None:
sent.append(payload)
if payload["type"] == "response.listen":
session.barge_in()
session.bind_request("duplex-sid-boundary-race-e1-stage0")
new_response_ids.append(session.begin_response(turn_id=1))
await handler._maybe_continue_native_response(
_barge_in_on_listen,
session=session,
expected_epoch=session.epoch,
)
assert [payload["type"] for payload in sent] == ["response.listen"]
assert len(new_response_ids) == 1
assert session.active_response_id == new_response_ids[0]
@pytest.mark.asyncio
async def test_minicpmo_auto_response_pre_speak_listen_continues_same_response():
request_id = "duplex-sid-pre-speak-listen-e0-stage0"
engine = FakeEngineClient()
handler = OmniDuplexSessionHandler(chat_service=FakeChatService(engine))
session = DuplexSession(
session_id="sid-pre-speak-listen",
config=DuplexSessionConfig(extra_body={"auto_response": True}),
)
session.capabilities = DuplexCapabilities.minicpmo45_native()
response_id = session.begin_response(turn_id=0)
session.turn_id = 1
session.bind_request(request_id)
_install_direct_silence_scheduler(handler, session)
ws = TimedWebSocket()
await handler._send_one_native_duplex_event(
ws.send_json,
{
"supported": True,
"stage_role": "llm",
"is_listen": True,
"model_listen": True,
"data_plane_request_id": request_id,
"end_of_turn": False,
"uses_model_runner_scheduler": True,
"runner_kv_backed": True,
"runtime_impl": "scheduler_data_plane",
},
session=session,
expected_epoch=session.epoch,
)
await asyncio.sleep(0.01)
assert len(engine.appended) == 1
assert session.active_response_id == response_id
assert session.active_request_id == request_id
assert "response.listen" not in ws.sent_types()
assert "response.done" not in ws.sent_types()
@pytest.mark.asyncio
async def test_minicpmo_auto_response_listen_before_response_keeps_resumable_request():
request_id = "duplex-sid-listen-before-response-e0-stage0"
handler = OmniDuplexSessionHandler(chat_service=FakeChatService(FakeEngineClient()))
session = DuplexSession(
session_id="sid-listen-before-response",
config=DuplexSessionConfig(extra_body={"auto_response": True}),
)
session.capabilities = DuplexCapabilities.minicpmo45_native()
session.bind_request(request_id)
ws = TimedWebSocket()
close_reason, emitted = await handler._send_one_native_duplex_event(
ws.send_json,
{
"supported": True,
"stage_role": "llm",
"is_listen": True,
"model_listen": True,
"data_plane_request_id": request_id,
"end_of_turn": False,
"uses_model_runner_scheduler": True,
"runner_kv_backed": True,
"runtime_impl": "scheduler_data_plane",
},
session=session,
expected_epoch=session.epoch,
)
assert close_reason is None
assert emitted is True
assert session.active_request_id == request_id
assert not handler._minicpmo_data_plane.is_terminal(request_id)
assert ws.sent_types().count("response.listen") == 1
@pytest.mark.asyncio
async def test_minicpmo_auto_response_listen_without_response_does_not_defer_commit():
session_id = "sid-listen-before-response-commit"
request_id = duplex_resource_request_id(DuplexFence(session_id), "stage0")
inner_output = SimpleNamespace(
outputs=[
SimpleNamespace(
multimodal_output={"special_token_ids": {"listen_token_id": 151705}},
)
]
)
decision = MiniCPMO45DuplexRuntimeExtension().decide_output(
stage_id=0,
final_stage_id=1,
segment_finished=True,
segment_token_ids=(151705,),
segment_output_metadata={"special_token_ids": {"listen_token_id": 151705}},
output=inner_output,
)
assert decision is not None
listen_output = attach_duplex_output_decision(
OmniRequestOutput.from_stage_output(
inner_output,
request_id=request_id,
finished=True,
stage_id=0,
final_output_type=decision.final_output_type,
),
decision,
)
append_result = {
"operation": "append",
"session_id": session_id,
"ok": True,
"unsupported_count": 0,
"error_count": 0,
"stage_results": [
{
"stage_id": 0,
"replica_id": 0,
"result": {
"supported": True,
"implementation_level": "model_native_duplex",
"data_plane_append": True,
"request_id": request_id,
"response_stage_id": 1,
},
}
],
"data_plane_outputs": [listen_output],
}
engine = FakeEngineClient(append_result=append_result)
handler = OmniDuplexSessionHandler(
chat_service=FakeChatService(engine),
config_timeout_s=0.1,
idle_timeout_s=1,
)
listen_count = 0
def append_residual_after_listen(ws: TimedWebSocket, data: dict[str, Any]) -> None:
nonlocal listen_count
if data.get("type") == "response.listen":
listen_count += 1
if listen_count == 1:
ws.put(
{
"type": "input_audio_buffer.append",
"audio": _pcm_f32_b64(3_200),
"format": "pcm_f32le",
"sample_rate_hz": 16_000,
"duration_ms": 200,
"is_speech": True,
}
)
ws.put({"type": "input_audio_buffer.commit", "final": True})
elif data.get("type") == "input_audio_buffer.committed":
asyncio.get_running_loop().call_later(0.05, ws.put, {"type": "session.close"})
ws = TimedWebSocket(on_send=append_residual_after_listen, receive_timeout_s=2)
create = _native_realtime_session_update(session_id)
create["session"]["extra_body"]["auto_response"] = True
ws.put(create)
ws.put(
{
"type": "input_audio_buffer.append",
"audio": _pcm_f32_b64(16_000),
"format": "pcm_f32le",
"sample_rate_hz": 16_000,
"duration_ms": 1_000,
"is_speech": True,
}
)
await handler.handle_realtime_session(ws)
committed = [event for event in ws.sent if event.get("type") == "input_audio_buffer.committed"]
assert len(engine.appended) == 2
assert engine.appended[1][3] is True
assert len(committed) == 1
assert committed[0].get("event", {}).get("overlap_deferred") is not True
assert committed[0].get("event", {}).get("response_create_deferred") is not True
assert "response.created" not in ws.sent_types()
@pytest.mark.asyncio
async def test_minicpmo_pcm_f32_residual_commit_releases_pending_input_bytes():
session_id = "sid-pcm-residual-byte-accounting"
control_result = {
"operation": "append",
"session_id": session_id,
"ok": True,
"unsupported_count": 0,
"error_count": 0,
"stage_results": [],
}
engine = FakeEngineClient(control_result=control_result)
handler = OmniDuplexSessionHandler(
chat_service=FakeChatService(engine),
config_timeout_s=0.1,
idle_timeout_s=1,
)
ws = TimedWebSocket(receive_timeout_s=2)
create = _native_realtime_session_update(session_id)
create["session"]["extra_body"]["auto_response"] = True
ws.put(create)
handler_task = asyncio.create_task(handler.handle_realtime_session(ws))
try:
for _ in range(100):
session = handler._registry.get(session_id)
if session is not None and "session.created" in ws.sent_types():
break
await asyncio.sleep(0.01)
else:
pytest.fail("native duplex session did not open")
pending_bytes_after_turn: list[int] = []
for turn_index in range(3):
ws.put(
{
"type": "input_audio_buffer.append",
"audio": _pcm_f32_b64(19_200),
"format": "pcm_f32le",
"sample_rate_hz": 16_000,
"duration_ms": 1_200,
"is_speech": True,
}
)
ws.put({"type": "input_audio_buffer.commit", "final": True})
expected_appends = (turn_index + 1) * 2
for _ in range(100):
if len(engine.appended) >= expected_appends:
await asyncio.sleep(0)
break
await asyncio.sleep(0.01)
else:
pytest.fail(f"turn {turn_index} residual append did not complete")
pending_bytes_after_turn.append(session.pending_input_bytes)
finally:
ws.put({"type": "session.close"})
await asyncio.wait_for(handler_task, timeout=2)
assert pending_bytes_after_turn == [0, 0, 0]
assert not any(
event.get("type") == "error"
and isinstance(event.get("error"), dict)
and event["error"].get("code") == "input_backpressure"
for event in ws.sent
)
@pytest.mark.asyncio
async def test_minicpmo_auto_response_post_speak_listen_continues_same_response():
request_id = "duplex-sid-post-speak-listen-e0-stage0"
engine = FakeEngineClient()
handler = OmniDuplexSessionHandler(chat_service=FakeChatService(engine))
session = DuplexSession(
session_id="sid-post-speak-listen",
config=DuplexSessionConfig(extra_body={"auto_response": True}),
)
session.capabilities = DuplexCapabilities.minicpmo45_native()
response_id = session.begin_response(turn_id=0)
session.turn_id = 1
session.bind_request(request_id)
session.mark_audio_sent(100)
_install_direct_silence_scheduler(handler, session)
ws = TimedWebSocket()
await handler._send_one_native_duplex_event(
ws.send_json,
{
"supported": True,
"stage_role": "llm",
"is_listen": True,
"model_listen": True,
"data_plane_request_id": request_id,
"end_of_turn": False,
"uses_model_runner_scheduler": True,
"runner_kv_backed": True,
"runtime_impl": "scheduler_data_plane",
},
session=session,
expected_epoch=session.epoch,
)
await asyncio.sleep(0.01)
assert len(engine.appended) == 1
assert session.active_response_id == response_id
assert session.active_request_id == request_id
assert "response.listen" not in ws.sent_types()
assert "response.done" not in ws.sent_types()
@pytest.mark.asyncio
async def test_minicpmo_auto_response_turn_end_preserves_resumable_request():
request_id = "duplex-sid-turn-end-preserve-request-e0-stage0"
handler = OmniDuplexSessionHandler(chat_service=FakeChatService(FakeEngineClient()))
session = DuplexSession(
session_id="sid-turn-end-preserve-request",
config=DuplexSessionConfig(extra_body={"auto_response": True}),
)
session.capabilities = DuplexCapabilities.minicpmo45_native()
session.begin_response(turn_id=0)
session.bind_request(request_id)
ws = TimedWebSocket()
await handler._send_one_native_duplex_event(
ws.send_json,
{
"supported": True,
"stage_role": "tts",
"is_listen": False,
"data_plane_request_id": request_id,
"text": "done",
"audio_data": "audio",
"audio_format": "pcm16",
"audio_duration_ms": 100,
"end_of_turn": True,
"model_turn_id": 0,
"uses_model_runner_scheduler": True,
"runner_kv_backed": True,
"runtime_impl": "scheduler_data_plane",
},
session=session,
expected_epoch=session.epoch,
)
assert session.active_response_id is None
assert session.active_request_id == request_id
assert "response.done" in ws.sent_types()
@pytest.mark.asyncio
async def test_minicpmo_auto_response_empty_turn_end_emits_model_listen():
request_id = "duplex-sid-empty-turn-end-e0-stage0"
handler = OmniDuplexSessionHandler(chat_service=FakeChatService(FakeEngineClient()))
session = DuplexSession(
session_id="sid-empty-turn-end",
config=DuplexSessionConfig(extra_body={"auto_response": True}),
)
session.capabilities = DuplexCapabilities.minicpmo45_native()
session.bind_request(request_id)
ws = TimedWebSocket()
close_reason, emitted = await handler._send_one_native_duplex_event(
ws.send_json,
{
"supported": True,
"stage_role": "tts",
"is_listen": False,
"data_plane_request_id": request_id,
"text": "",
"audio_data": "",
"audio_format": "pcm16",
"end_of_turn": True,
"abort_data_plane_request": True,
"model_turn_id": 0,
"uses_model_runner_scheduler": True,
"runner_kv_backed": True,
"runtime_impl": "scheduler_data_plane",
},
session=session,
expected_epoch=session.epoch,
)
assert close_reason is None
assert emitted is True
assert session.turn_id == 1
assert session.active_request_id == request_id
assert session.active_response_id is None
assert ws.sent_types() == ["response.listen"]
assert ws.sent[0]["model_listen"] is True
assert ws.sent[0]["reason"] == "model_turn_completed_without_output"
@pytest.mark.asyncio
async def test_minicpmo_auto_response_drops_late_audio_from_completed_model_turn():
request_id = "duplex-sid-late-completed-turn-e0-stage0"
handler = OmniDuplexSessionHandler(chat_service=FakeChatService(FakeEngineClient()))
session = DuplexSession(
session_id="sid-late-completed-turn",
config=DuplexSessionConfig(extra_body={"auto_response": True}),
)
session.capabilities = DuplexCapabilities.minicpmo45_native()
session.bind_request(request_id)
session.complete_model_turn(0)
ws = TimedWebSocket()
close_reason, emitted = await handler._send_one_native_duplex_event(
ws.send_json,
{
"supported": True,
"stage_role": "tts",
"is_listen": False,
"data_plane_request_id": request_id,
"text": "late duplicate",
"audio_data": "late-audio",
"audio_format": "pcm16",
"audio_duration_ms": 100,
"end_of_turn": True,
"model_turn_id": 0,
"uses_model_runner_scheduler": True,
"runner_kv_backed": True,
"runtime_impl": "scheduler_data_plane",
},
session=session,
expected_epoch=session.epoch,
)
assert close_reason is None
assert emitted is False
assert session.turn_id == 1
assert session.active_response_id is None
assert "response.created" not in ws.sent_types()
assert "response.done" not in ws.sent_types()
def test_duplex_data_plane_drops_stale_epoch_audio_after_barge_in():
class _ChatService:
model_config = _ModelConfig()
def create_audio(self, audio_obj):
return SimpleNamespace(audio_data=f"wav-{int(audio_obj.audio_tensor.shape[0])}")
data_plane = _test_data_plane()
session = DuplexSession(
session_id="sid-auto-stale-epoch",
config=DuplexSessionConfig(extra_body={"auto_response": True}),
)
session.turn_id = 1
session.epoch = 2
output = SimpleNamespace(
request_id="duplex-sid-auto-stale-epoch-stage0",
finished=False,
outputs=[SimpleNamespace(text="old epoch", multimodal_output={})],
multimodal_output={
"audio": np.zeros(24000, dtype=np.float32),
"sr": 24000,
"meta.duplex_epoch": np.array([1], dtype=np.int32),
"meta.duplex_turn_id": np.array([1], dtype=np.int32),
"meta.llm_output_text_utf8": np.frombuffer("旧轮".encode(), dtype=np.uint8),
},
)
assert list(data_plane.project_output(output, context=_data_plane_context(session))) == []
def test_duplex_data_plane_text_delta_preserves_repeated_suffix_growth():
data_plane = _test_data_plane()
request_id = "duplex-sid-native-repeated-word-e0-stage0"
assert data_plane.segment_text_delta(request_id, "好的") == "好的"
assert data_plane.segment_text_delta(request_id, "好的好的") == "好的"
def test_duplex_data_plane_text_delta_appends_distinct_non_prefix_segments():
data_plane = _test_data_plane()
request_id = "duplex-sid-native-rewrite-e0-stage0"
deltas = [
data_plane.segment_text_delta(request_id, "It's Canberra."),
data_plane.segment_text_delta(request_id, " Next question."),
]
assert deltas == ["It's Canberra.", " Next question."]
assert "".join(deltas) == "It's Canberra. Next question."
def test_duplex_data_plane_close_session_removes_encoded_request_state():
data_plane = _test_data_plane()
target_request_id = duplex_resource_request_id(
DuplexFence("sid-to-close", incarnation=1, epoch=2),
"stage0",
)
other_request_id = duplex_resource_request_id(
DuplexFence("sid-to-keep", incarnation=1, epoch=2),
"stage0",
)
data_plane.begin_request(target_request_id)
data_plane.begin_request(other_request_id)
data_plane.close_session("sid-to-close")
assert data_plane.has_request(target_request_id) is False
assert data_plane.has_request(other_request_id) is True
def test_duplex_auto_response_segment_complete_keeps_data_plane_request_open():
import numpy as np
class _ChatService:
model_config = _ModelConfig()
def create_audio(self, audio_obj):
return SimpleNamespace(audio_data=f"wav-{int(audio_obj.audio_tensor.shape[0])}")
data_plane = _test_data_plane()
request_id = "duplex-duplex-sess-stage0"
session = DuplexSession(
session_id="sid-auto-respond",
config=DuplexSessionConfig(extra_body={"auto_response": True}),
)
session.begin_response()
session.mark_audio_sent(1280)
data_plane.slice_cumulative_audio(request_id, np.zeros(30720, dtype=np.float32))
output = SimpleNamespace(
request_id=request_id,
finished=True,
outputs=[SimpleNamespace(text="hello", multimodal_output={})],
multimodal_output={
"audio": np.zeros(30720, dtype=np.float32),
"sr": 24000,
},
)
native_results = _project_data_plane(data_plane, {"data_plane_outputs": [output]}, session=session)
assert len(native_results) == 1
assert native_results[0]["is_listen"] is True
assert native_results[0]["reason"] == "auto_response_segment_complete"
assert native_results[0]["data_plane_request_id"] == request_id
assert "abort_data_plane_request" not in native_results[0]
def test_duplex_auto_response_prior_playback_does_not_abort_before_current_response():
data_plane = _test_data_plane()
request_id = "duplex-duplex-sess-stage0"
session = DuplexSession(
session_id="sid-auto-respond-next-turn",
config=DuplexSessionConfig(extra_body={"auto_response": True}),
)
session.mark_audio_sent(1280)
session.acknowledge_playback(played_ms=1280, committed_ms=1280)
output = SimpleNamespace(
request_id=request_id,
finished=True,
outputs=[SimpleNamespace(text="", multimodal_output={})],
multimodal_output={},
)
native_results = _project_data_plane(data_plane, {"data_plane_outputs": [output]}, session=session)
assert native_results == []
def test_duplex_auto_response_text_only_flush_does_not_consume_audio_transcript():
import numpy as np
data_plane = _test_data_plane()
request_id = "duplex-duplex-sess-stage0"
session = DuplexSession(
session_id="sid-auto-respond-delayed-audio",
config=DuplexSessionConfig(extra_body={"auto_response": True}),
)
text = "你好呀,有什莫问的吗?"
text_only_output = SimpleNamespace(
request_id=request_id,
finished=False,
outputs=[SimpleNamespace(text=text, multimodal_output={})],
multimodal_output={"sr": 24000},
)
assert _project_data_plane(data_plane, {"data_plane_outputs": [text_only_output]}, session=session) == []
audio_output = SimpleNamespace(
request_id=request_id,
finished=False,
outputs=[SimpleNamespace(text=text, multimodal_output={})],
multimodal_output={
"audio": np.zeros(24000, dtype=np.float32),
"sr": 24000,
},
)
native_results = _project_data_plane(data_plane, {"data_plane_outputs": [audio_output]}, session=session)
assert len(native_results) == 1
assert native_results[0]["audio_data"].startswith("wav-")
assert native_results[0]["text"] == text
@pytest.mark.asyncio
async def test_duplex_auto_response_empty_terminal_does_not_create_empty_response():
handler = OmniDuplexSessionHandler(chat_service=FakeChatService(FakeEngineClient()))
request_id = "duplex-sid-native-empty-terminal-e0-stage0"
session = DuplexSession(
session_id="sid-native-empty-terminal",
config=DuplexSessionConfig(extra_body={"auto_response": True}),
)
session.mark_audio_sent(1280)
sent: list[dict[str, Any]] = []
async def send_json(payload: dict[str, Any]) -> None:
sent.append(payload)
output = SimpleNamespace(
request_id=request_id,
finished=False,
outputs=[SimpleNamespace(text="hello", multimodal_output={})],
multimodal_output={
"sr": 24000,
"meta": {"turn_end": True, "duplex_turn_id": 0},
},
)
native_results = list(
handler._minicpmo_data_plane.project(
{"data_plane_outputs": [output]},
context=_data_plane_context(session),
)
)
assert len(native_results) == 1
assert native_results[0]["end_of_turn"] is True
assert native_results[0]["model_turn_id"] == 0
for native_result in native_results:
await handler._send_one_native_duplex_event(send_json, native_result, session=session)
assert "response.created" not in [m.get("type") for m in sent]
assert "response.done" not in [m.get("type") for m in sent]
assert session.turn_id == 1
assert not handler._minicpmo_data_plane.is_terminal(request_id)
@pytest.mark.asyncio
async def test_minicpmo_native_duplex_session_close_cleans_auto_response_data_plane_state():
session_id = "sid-native-cleanup"
request_id = f"duplex-{session_id}-e0-stage0"
engine = FakeEngineClient()
chat_service = FakeChatService(engine)
handler = OmniDuplexSessionHandler(chat_service=chat_service, config_timeout_s=0.1, idle_timeout_s=1)
handler._minicpmo_data_plane.slice_cumulative_audio(request_id, np.zeros(24000, dtype=np.float32))
handler._minicpmo_data_plane.segment_text_delta(request_id, "hello")
handler._minicpmo_data_plane.mark_terminal(request_id)
native = MiniCPMO45ServingSessionState()
handler._minicpmo_sessions[session_id] = native
event = _native_session_create(session_id)
event["session"]["extra_body"]["auto_response"] = True
ws = TimedWebSocket()
ws.put(event)
ws.put({"type": "session.close"})
await handler.handle_session(ws)
assert not handler._minicpmo_data_plane.has_request(request_id)
assert session_id not in handler._minicpmo_sessions
@pytest.mark.asyncio
async def test_duplex_chat_audio_stream_uses_output_audio_delta_event():
engine = FakeEngineClient()
chat_service = FakeChatService(engine)
handler = OmniDuplexSessionHandler(chat_service=chat_service, config_timeout_s=0.1, idle_timeout_s=1)
session = DuplexSession(session_id="sid-chat-audio", config=DuplexSessionConfig())
response_id = session.begin_response()
sent: list[dict[str, Any]] = []
async def send_json(data: dict[str, Any]) -> None:
sent.append(data)
await handler._emit_chat_payload(
session,
{
"modality": "audio",
"choices": [
{
"delta": {
"content": "AAAA",
}
}
],
},
session.epoch,
response_id,
send_json,
)
assert sent == [
{
"type": "response.output_audio.delta",
"session_id": "sid-chat-audio",
"response_id": response_id,
"epoch": 0,
"audio": "AAAA",
"format": "wav",
}
]
@pytest.mark.asyncio
async def test_duplex_chat_rejects_unknown_output_modality():
handler = OmniDuplexSessionHandler(
chat_service=FakeChatService(FakeEngineClient()),
config_timeout_s=0.1,
idle_timeout_s=1,
)
session = DuplexSession(session_id="sid-chat-unknown", config=DuplexSessionConfig())
response_id = session.begin_response()
sent: list[dict[str, Any]] = []
async def send_json(data: dict[str, Any]) -> None:
sent.append(data)
await handler._emit_chat_payload(
session,
{
"modality": "video",
"choices": [{"delta": {"content": "not-text"}}],
},
session.epoch,
response_id,
send_json,
)
assert sent == [
{
"type": "error",
"session_id": session.session_id,
"response_id": response_id,
"epoch": session.epoch,
"code": "unsupported_response_modality",
"error": "Unsupported chat response modality: video",
}
]
assert session.assistant_text_buffer == ()
@pytest.mark.asyncio
async def test_minicpmo_model_name_does_not_auto_enable_experimental_native_duplex():
event = _session_create("sid-minicpmo-default-protocol")
event["session"]["model"] = "openbmb/MiniCPM-o-4_5"
engine = FakeEngineClient()
chat_service = FakeChatService(engine)
handler = OmniDuplexSessionHandler(chat_service=chat_service, config_timeout_s=0.1, idle_timeout_s=1)
ws = TimedWebSocket()
ws.put(event)
ws.put({"type": "session.close"})
await handler.handle_session(ws)
created = next(m for m in ws.sent if m.get("type") == "session.created")
capabilities = created["session"]["capabilities"]
assert capabilities["implementation_level"] == "serving_session_adapter"
assert capabilities["supports_input_append"] is False
@pytest.mark.asyncio
async def test_duplex_handler_aborts_current_chat_request_id_on_barge_in():
engine = FakeEngineClient()
chat_service = FakeChatService(engine)
handler = OmniDuplexSessionHandler(chat_service=chat_service, config_timeout_s=0.1, idle_timeout_s=1)
def on_send(ws: TimedWebSocket, data: dict[str, Any]) -> None:
if data.get("type") == "response.created":
ws.put({"type": "input.cancel", "reason": "test_barge_in"})
ws = TimedWebSocket(on_send=on_send)
ws.put(_session_create("sid-a"))
ws.put({"type": "input.text.append", "text": "hello"})
ws.put({"type": "input.commit"})
await handler.handle_session(ws)
assert ws.accepted
assert "response.created" in ws.sent_types()
assert "audio.cancelled" in ws.sent_types()
assert chat_service.seen_request_ids == ["duplex-sid-a-0-1"]
assert engine.aborted == ["chatcmpl-duplex-sid-a-0-1"]
assert engine.internal_abort_batches == [["chatcmpl-duplex-sid-a-0-1"]]
@pytest.mark.asyncio
async def test_duplex_handler_runtime_open_failure_is_reported_to_client():
engine = FakeEngineClient(fail_open=True)
chat_service = FakeChatService(engine)
handler = OmniDuplexSessionHandler(chat_service=chat_service, config_timeout_s=0.1, idle_timeout_s=1)
ws = TimedWebSocket()
ws.put(_session_create("sid-open-fail"))
ws.put({"type": "session.close"})
await handler.handle_session(ws)
assert ws.sent_types() == ["error"]
assert ws.sent[0]["code"] == "runtime_open_failed"
assert engine.opened == []
@pytest.mark.asyncio
async def test_duplex_handler_preserves_typed_admission_error():
class AdmissionRejectingEngine(FakeEngineClient):
async def open_duplex_session_async(self, session_id: str, **kwargs):
del kwargs
raise DuplexControlRequestError(
{
"operation": "open",
"session_id": session_id,
"error": {
"code": "resource_exhausted",
"message": "duplex session capacity exhausted",
"retryable": True,
},
"accepted_fence": None,
"lease_generation": None,
}
)
handler = OmniDuplexSessionHandler(
chat_service=FakeChatService(AdmissionRejectingEngine()),
config_timeout_s=0.1,
idle_timeout_s=1,
)
ws = TimedWebSocket()
ws.put(_session_create("sid-admission-rejected"))
await handler.handle_session(ws)
assert ws.sent_types() == ["error"]
assert ws.sent[0]["code"] == "resource_exhausted"
assert ws.sent[0]["retryable"] is True
@pytest.mark.asyncio
async def test_duplex_cancel_reports_playback_committed_cursor():
engine = FakeEngineClient()
chat_service = FakeChatService(engine)
handler = OmniDuplexSessionHandler(chat_service=chat_service, config_timeout_s=0.1, idle_timeout_s=1)
def on_send(ws: TimedWebSocket, data: dict[str, Any]) -> None:
if data.get("type") == "response.created":
ws.put({"type": "playback.ack", "played_ms": 1200, "committed_ms": 1000})
ws.put({"type": "input.cancel", "reason": "test_barge_in"})
ws = TimedWebSocket(on_send=on_send)
ws.put(_session_create("sid-playback"))
ws.put({"type": "input.text.append", "text": "hello"})
ws.put({"type": "input.commit"})
await handler.handle_session(ws)
ack = next(m for m in ws.sent if m.get("type") == "playback.acknowledged")
cancelled = next(m for m in ws.sent if m.get("type") == "audio.cancelled")
assert ack["playback"]["played_ms"] == 1200
assert ack["playback"]["committed_ms"] == 1000
assert cancelled["committed_ms"] == 1000
assert cancelled["epoch"] == 1
@pytest.mark.asyncio
async def test_playback_ack_response_id_selects_matching_pending_history_item():
handler = OmniDuplexSessionHandler(
chat_service=FakeChatService(FakeEngineClient()),
config_timeout_s=0.1,
idle_timeout_s=1,
)
session = DuplexSession(
session_id="sid-playback-response-id",
config=DuplexSessionConfig(),
)
first_item_id = "item_resp-first"
second_item_id = "item_resp-second"
session.stage_pending_history_item(
first_item_id,
{"role": "assistant", "content": "first response"},
)
session.stage_pending_history_item(
second_item_id,
{"role": "assistant", "content": "second response"},
)
ws = TimedWebSocket()
await handler._handle_playback_ack(
session,
{
"type": "playback.ack",
"response_id": "resp-second",
"played_ms": 1000,
"committed_ms": 1000,
},
ws.send_json,
)
ack = next(message for message in ws.sent if message.get("type") == "playback.acknowledged")
assert ack["item_id"] == second_item_id
assert ack["history_committed"] is True
assert first_item_id in session.pending_history_item_ids
assert second_item_id not in session.pending_history_item_ids
@pytest.mark.asyncio
async def test_late_playback_ack_does_not_advance_new_response_cursor():
handler = OmniDuplexSessionHandler(
chat_service=FakeChatService(FakeEngineClient()),
config_timeout_s=0.1,
idle_timeout_s=1,
)
session = DuplexSession(
session_id="sid-late-playback-ack",
config=DuplexSessionConfig(),
)
first_response_id = session.begin_response()
session.append_assistant_text("first response")
session.mark_audio_sent(11480, text_chars=len("first response"))
session.end_response(commit_text=False, preserve_request=True)
session.register_history_item(f"item_{first_response_id}", None)
second_response_id = session.begin_response()
session.mark_audio_sent(2200, text_chars=len("second response"))
ws = TimedWebSocket()
await handler._handle_playback_ack(
session,
{
"type": "playback.ack",
"response_id": first_response_id,
"item_id": f"item_{first_response_id}",
"played_ms": 11480,
"committed_ms": 11480,
},
ws.send_json,
)
assert session.active_response_id == second_response_id
assert session.playback.as_dict() == {
"generated_ms": 2200,
"sent_ms": 2200,
"played_ms": 0,
"committed_ms": 0,
}
ack = next(message for message in ws.sent if message.get("type") == "playback.acknowledged")
assert ack["playback"] == {
"generated_ms": 11480,
"sent_ms": 11480,
"played_ms": 11480,
"committed_ms": 11480,
}
assert first_response_id not in session.response_playbacks
assert session.response_playbacks[second_response_id] == session.playback
@pytest.mark.asyncio
async def test_late_playback_ack_truncates_history_with_matching_response_cursor():
handler = OmniDuplexSessionHandler(
chat_service=FakeChatService(FakeEngineClient()),
config_timeout_s=0.1,
idle_timeout_s=1,
)
session = DuplexSession(
session_id="sid-late-playback-history",
config=DuplexSessionConfig(),
)
first_response_id = session.begin_response()
session.append_assistant_text("abcdefghij")
session.mark_audio_sent(1000)
session.end_response(commit_text=False, preserve_request=True)
session.register_history_item(f"item_{first_response_id}", None)
session.begin_response()
session.mark_audio_sent(10000)
ws = TimedWebSocket()
await handler._handle_playback_ack(
session,
{
"type": "playback.ack",
"response_id": first_response_id,
"item_id": f"item_{first_response_id}",
"played_ms": 500,
"committed_ms": 500,
},
ws.send_json,
)
assert session.history[-1] == {"role": "assistant", "content": "abcde"}
assert first_response_id in session.response_playbacks
@pytest.mark.asyncio
async def test_cancel_active_response_commits_only_played_assistant_history():
engine = FakeEngineClient()
chat_service = FakeChatService(engine)
handler = OmniDuplexSessionHandler(chat_service=chat_service, config_timeout_s=0.1, idle_timeout_s=1)
ws = TimedWebSocket()
session = DuplexSession(
session_id="sid-playback-partial",
config=DuplexSessionConfig(playback_commit_policy=DuplexPlaybackCommitPolicy.COMMIT_ALL_ON_DONE.value),
)
response_id = session.begin_response()
session.append_assistant_text("hello world")
session.mark_audio_sent(1000, text_chars=len("hello world"))
session.acknowledge_playback(played_ms=500, committed_ms=500)
cancelled = await handler._cancel_active_response(session, None, ws.send_json, reason="barge_in")
assert cancelled is True
assert session.epoch == 1
assert session.history == ({"role": "assistant", "content": "hello"},)
assert session.history_item_ids[f"item_{response_id}"] == session.history[0]
event = next(m for m in ws.sent if m.get("type") == "audio.cancelled")
assert event["committed_ms"] == 500
@pytest.mark.asyncio
async def test_cancel_active_response_keeps_truncated_history_in_serving():
engine = FakeEngineClient()
chat_service = FakeChatService(engine)
handler = OmniDuplexSessionHandler(chat_service=chat_service, config_timeout_s=0.1, idle_timeout_s=1)
ws = TimedWebSocket()
session = DuplexSession(
session_id="sid-playback-runtime-rebuild",
config=DuplexSessionConfig(playback_commit_policy=DuplexPlaybackCommitPolicy.COMMIT_ALL_ON_DONE.value),
)
response_id = session.begin_response()
session.append_assistant_text("hello world")
session.mark_audio_sent(1000, text_chars=len("hello world"))
session.acknowledge_playback(played_ms=500, committed_ms=500)
cancelled = await handler._cancel_active_response(
session,
None,
ws.send_json,
reason="barge_in",
)
assert cancelled is True
assert session.history == ({"role": "assistant", "content": "hello"},)
assert session.history_item_ids[f"item_{response_id}"] == session.history[0]
assert engine.signals == []
assert "error" not in ws.sent_types()
@pytest.mark.asyncio
async def test_cancel_active_native_data_plane_request_aborts_stage_request_id():
engine = FakeEngineClient()
chat_service = FakeChatService(engine)
handler = OmniDuplexSessionHandler(chat_service=chat_service, config_timeout_s=0.1, idle_timeout_s=1)
ws = TimedWebSocket()
session = DuplexSession(session_id="sid-native-abort", config=DuplexSessionConfig())
session.bind_request("duplex-sid-native-abort-e0-stage0-s1")
cancelled = await handler._cancel_active_response(session, None, ws.send_json, reason="barge_in")
assert cancelled is True
assert engine.aborted == ["duplex-sid-native-abort-e0-stage0-s1"]
assert engine.internal_abort_batches == [["duplex-sid-native-abort-e0-stage0-s1"]]
assert "audio.cancelled" in ws.sent_types()
@pytest.mark.asyncio
async def test_duplex_handler_explicit_close_closes_runtime_once_with_client_reason():
engine = FakeEngineClient()
chat_service = FakeChatService(engine)
handler = OmniDuplexSessionHandler(chat_service=chat_service, config_timeout_s=0.1, idle_timeout_s=1)
ws = TimedWebSocket()
ws.put(_session_create("sid-close"))
ws.put({"type": "session.close"})
await handler.handle_session(ws)
assert ws.sent_types() == ["session.created", "session.closed"]
assert engine.closed == [("sid-close", "session_close")]
@pytest.mark.asyncio
async def test_realtime_disconnect_detaches_resumable_session_without_runtime_close():
engine = FakeEngineClient()
handler = OmniDuplexSessionHandler(
chat_service=FakeChatService(engine),
config_timeout_s=0.1,
idle_timeout_s=1,
)
ws = TimedWebSocket(receive_timeout_s=0.01)
ws.put(_native_realtime_session_update("sid-resumable-disconnect"))
await handler.handle_realtime_session(ws)
created = next(message for message in ws.sent if message.get("type") == "session.created")
assert created["session"]["id"] == "sid-resumable-disconnect"
assert isinstance(created["resume_token"], str)
assert created["resume_token"]
assert created["attachment_generation"] == 1
assert engine.closed == []
assert engine.touched == [
("sid-resumable-disconnect", DuplexLeaseActivity.DETACH),
]
assert handler._registry.get("sid-resumable-disconnect") is not None
assert "sid-resumable-disconnect" in handler._minicpmo_sessions
@pytest.mark.asyncio
async def test_realtime_initial_resume_token_delivery_failure_closes_unrecoverable_session():
class FailingCreatedWebSocket(TimedWebSocket):
async def send_json(self, data: dict[str, Any]):
if data.get("type") == "session.created":
raise WebSocketDisconnect(code=1006)
await super().send_json(data)
engine = FakeEngineClient()
handler = OmniDuplexSessionHandler(
chat_service=FakeChatService(engine),
config_timeout_s=0.1,
idle_timeout_s=1,
)
ws = FailingCreatedWebSocket(receive_timeout_s=0.01)
ws.put(_native_realtime_session_update("sid-created-delivery-failure"))
await handler.handle_realtime_session(ws)
assert engine.closed == [("sid-created-delivery-failure", "disconnect")]
assert handler._registry.get("sid-created-delivery-failure") is None
assert "sid-created-delivery-failure" not in handler._minicpmo_sessions
@pytest.mark.asyncio
async def test_realtime_send_after_created_disconnect_preserves_resumable_session():
class ClosedAfterCreatedWebSocket(TimedWebSocket):
def __init__(self) -> None:
super().__init__(receive_timeout_s=0.01)
self.transport_closed = False
async def send_json(self, data: dict[str, Any]):
if self.transport_closed:
raise RuntimeError(
"Unexpected ASGI message 'websocket.send', "
"after sending 'websocket.close' or response already completed."
)
await super().send_json(data)
if data.get("type") == "session.created":
self.transport_closed = True
engine = FakeEngineClient()
handler = OmniDuplexSessionHandler(
chat_service=FakeChatService(engine),
config_timeout_s=0.1,
idle_timeout_s=1,
)
ws = ClosedAfterCreatedWebSocket()
ws.put(_native_realtime_session_update("sid-created-then-disconnected"))
await handler.handle_realtime_session(ws)
assert engine.closed == []
assert engine.touched == [
("sid-created-then-disconnected", DuplexLeaseActivity.DETACH),
]
assert handler._registry.get("sid-created-then-disconnected") is not None
assert "sid-created-then-disconnected" in handler._minicpmo_sessions
@pytest.mark.asyncio
async def test_realtime_resume_rotates_token_replays_and_preserves_runtime_identity():
engine = FakeEngineClient()
handler = OmniDuplexSessionHandler(
chat_service=FakeChatService(engine),
config_timeout_s=0.1,
idle_timeout_s=1,
)
first = TimedWebSocket(receive_timeout_s=0.01)
first.put(_native_realtime_session_update("sid-realtime-resume"))
first.put({"type": "session.heartbeat"})
await handler.handle_realtime_session(first)
created = next(message for message in first.sent if message.get("type") == "session.created")
heartbeat = next(message for message in first.sent if message.get("type") == "session.heartbeat_ack")
token = created["resume_token"]
incarnation = created["incarnation"]
second = TimedWebSocket(receive_timeout_s=0.1)
second.query_params = {
"model": "openbmb/MiniCPM-o-4_5",
"minicpmo45_native_duplex": "1",
"resume": "1",
}
second.put(
{
"type": "session.resume",
"session_id": "sid-realtime-resume",
"incarnation": incarnation,
"resume_token": token,
"last_received_server_event_seq": heartbeat["server_event_seq"] - 1,
}
)
second.put({"type": "session.close"})
await handler.handle_realtime_session(second)
resumed = next(message for message in second.sent if message.get("type") == "session.resumed")
replayed = [
message
for message in second.sent
if message.get("type") == "session.heartbeat_ack"
and message.get("server_event_seq") == heartbeat["server_event_seq"]
]
assert resumed["session_id"] == "sid-realtime-resume"
assert resumed["incarnation"] == incarnation
assert resumed["attachment_generation"] == 2
assert resumed["resume_token"] != token
assert replayed == [heartbeat]
assert engine.opened == ["sid-realtime-resume"]
assert engine.resumed == [("sid-realtime-resume", 0)]
assert engine.resume_fences[0] == engine.opened_fences[0]
assert engine.closed == [("sid-realtime-resume", "session_close")]
@pytest.mark.asyncio
async def test_realtime_resume_preserves_append_tail_order_across_connections():
class SlowFirstAppendEngine(FakeEngineClient):
def __init__(self) -> None:
super().__init__()
self.first_append_started = asyncio.Event()
self.second_append_started = asyncio.Event()
self.release_first_append = asyncio.Event()
self.append_start_order: list[int] = []
async def append_duplex_input_async(self, session_id: str, **kwargs):
call_index = len(self.append_start_order) + 1
self.append_start_order.append(call_index)
if call_index == 1:
self.first_append_started.set()
await self.release_first_append.wait()
else:
self.second_append_started.set()
kwargs.pop("expected_epoch", None)
return await super().append_duplex_input_async(session_id, **kwargs)
engine = SlowFirstAppendEngine()
handler = OmniDuplexSessionHandler(
chat_service=FakeChatService(engine),
config_timeout_s=0.1,
idle_timeout_s=1,
)
first = TimedWebSocket(receive_timeout_s=1.0)
session_update = _native_realtime_session_update("sid-resume-append-order")
session_update["session"]["extra_body"]["auto_response"] = True
first.put(session_update)
first_task = asyncio.create_task(handler.handle_realtime_session(first))
for _ in range(100):
created = next((message for message in first.sent if message.get("type") == "session.created"), None)
if created is not None:
break
await asyncio.sleep(0.01)
else:
pytest.fail("resumable session did not open")
assert created is not None
session = handler._registry.get("sid-resume-append-order")
assert session is not None
native_state = handler._minicpmo_session_state(session)
first_scheduler = native_state.silence_continuation_scheduler
assert first_scheduler is not None
first.put(
{
"type": "input_audio_buffer.append",
"audio": _pcm16_b64(16000),
"sample_rate_hz": 16000,
"is_speech": True,
}
)
await asyncio.wait_for(engine.first_append_started.wait(), timeout=1)
await first.close(code=1006)
await asyncio.wait_for(first_task, timeout=1)
second = TimedWebSocket(receive_timeout_s=0.5)
second.query_params = {
"model": "openbmb/MiniCPM-o-4_5",
"minicpmo45_native_duplex": "1",
"resume": "1",
}
second.put(
{
"type": "session.resume",
"session_id": "sid-resume-append-order",
"incarnation": created["incarnation"],
"resume_token": created["resume_token"],
"last_received_server_event_seq": created.get("server_event_seq", 0),
}
)
second_task = asyncio.create_task(handler.handle_realtime_session(second))
for _ in range(100):
if "session.resumed" in second.sent_types():
break
await asyncio.sleep(0.01)
else:
pytest.fail("session did not resume")
assert native_state.silence_continuation_scheduler is not first_scheduler
second.put(
{
"type": "input_audio_buffer.append",
"audio": _pcm16_b64(16000),
"sample_rate_hz": 16000,
"is_speech": True,
}
)
try:
await asyncio.sleep(0.05)
assert not engine.second_append_started.is_set()
finally:
engine.release_first_append.set()
await asyncio.wait_for(engine.second_append_started.wait(), timeout=1)
second.put({"type": "session.close"})
await asyncio.wait_for(second_task, timeout=2)
assert engine.append_start_order == [1, 2]
@pytest.mark.asyncio
async def test_realtime_takeover_ignores_late_detach_from_replaced_attachment():
engine = FakeEngineClient()
handler = OmniDuplexSessionHandler(
chat_service=FakeChatService(engine),
config_timeout_s=0.1,
idle_timeout_s=1,
)
first = TimedWebSocket(receive_timeout_s=1.0)
first.put(_native_realtime_session_update("sid-takeover"))
first_task = asyncio.create_task(handler.handle_realtime_session(first))
for _ in range(100):
created = next((message for message in first.sent if message.get("type") == "session.created"), None)
if created is not None:
break
await asyncio.sleep(0.001)
else:
raise AssertionError("first attachment did not create the session")
second = TimedWebSocket(receive_timeout_s=0.03)
second.put(
{
"type": "session.resume",
"session_id": "sid-takeover",
"incarnation": created["incarnation"],
"resume_token": created["resume_token"],
"last_received_server_event_seq": 0,
}
)
await handler.handle_realtime_session(second)
await first_task
detach_touches = [item for item in engine.touched if item[1] is DuplexLeaseActivity.DETACH]
assert detach_touches == [("sid-takeover", DuplexLeaseActivity.DETACH)]
assert "session.replaced" in first.sent_types()
assert engine.resumed == [("sid-takeover", 0)]
assert engine.closed == []
@pytest.mark.asyncio
async def test_realtime_takeover_does_not_clear_new_attachment_pending_turns():
class DelayedReplacementCloseWebSocket(TimedWebSocket):
def __init__(self) -> None:
super().__init__(receive_timeout_s=1.0)
self.replacement_close_started = asyncio.Event()
self.release_replacement_close = asyncio.Event()
async def close(self, code: int = 1000, reason: str | None = None) -> None:
if not self.replacement_close_started.is_set():
self.replacement_close_started.set()
await self.release_replacement_close.wait()
await super().close(code=code, reason=reason)
engine = FakeEngineClient()
handler = OmniDuplexSessionHandler(
chat_service=FakeChatService(engine),
config_timeout_s=0.1,
idle_timeout_s=1,
)
first = DelayedReplacementCloseWebSocket()
first.put(_native_realtime_session_update("sid-takeover-pending-turn"))
first_task = asyncio.create_task(handler.handle_realtime_session(first))
for _ in range(100):
created = next((message for message in first.sent if message.get("type") == "session.created"), None)
if created is not None:
break
await asyncio.sleep(0.001)
else:
pytest.fail("first attachment did not create the session")
assert created is not None
second = TimedWebSocket(receive_timeout_s=1.0)
second.put(
{
"type": "session.resume",
"session_id": "sid-takeover-pending-turn",
"incarnation": created["incarnation"],
"resume_token": created["resume_token"],
"last_received_server_event_seq": 0,
}
)
second_task = asyncio.create_task(handler.handle_realtime_session(second))
await asyncio.wait_for(first.replacement_close_started.wait(), timeout=1)
session = handler._registry.get("sid-takeover-pending-turn")
assert session is not None
assert session.reserve_pending_turn(limit=2)
first.release_replacement_close.set()
await asyncio.wait_for(first_task, timeout=1)
assert session.pending_input_turns == 1
session.release_pending_turn()
second.put({"type": "session.close"})
await asyncio.wait_for(second_task, timeout=2)
@pytest.mark.asyncio
async def test_realtime_takeover_drops_input_queued_by_replaced_attachment():
class DelayedReplacementCloseWebSocket(TimedWebSocket):
def __init__(self) -> None:
super().__init__(receive_timeout_s=1.0)
self.replacement_close_started = asyncio.Event()
self.release_replacement_close = asyncio.Event()
self.stale_heartbeat_read = asyncio.Event()
async def receive_text(self) -> str:
payload = await super().receive_text()
if json.loads(payload).get("type") == "session.heartbeat":
self.stale_heartbeat_read.set()
return payload
async def close(self, code: int = 1000, reason: str | None = None) -> None:
if not self.replacement_close_started.is_set():
self.replacement_close_started.set()
await self.release_replacement_close.wait()
await super().close(code=code, reason=reason)
engine = FakeEngineClient()
handler = OmniDuplexSessionHandler(
chat_service=FakeChatService(engine),
config_timeout_s=0.1,
idle_timeout_s=1,
)
first = DelayedReplacementCloseWebSocket()
first.put(_native_realtime_session_update("sid-takeover-stale-input"))
first_task = asyncio.create_task(handler.handle_realtime_session(first))
for _ in range(100):
created = next((message for message in first.sent if message.get("type") == "session.created"), None)
if created is not None:
break
await asyncio.sleep(0.001)
else:
pytest.fail("first attachment did not create the session")
assert created is not None
second = TimedWebSocket(receive_timeout_s=1.0)
second.put(
{
"type": "session.resume",
"session_id": "sid-takeover-stale-input",
"incarnation": created["incarnation"],
"resume_token": created["resume_token"],
"last_received_server_event_seq": 0,
}
)
second_task = asyncio.create_task(handler.handle_realtime_session(second))
await asyncio.wait_for(first.replacement_close_started.wait(), timeout=1)
first.put({"type": "session.heartbeat"})
await asyncio.wait_for(first.stale_heartbeat_read.wait(), timeout=1)
first.release_replacement_close.set()
await asyncio.wait_for(first_task, timeout=1)
second.put({"type": "session.close"})
await asyncio.wait_for(second_task, timeout=2)
assert all(activity is not DuplexLeaseActivity.HEARTBEAT for _, activity in engine.touched)
assert "session.heartbeat_ack" not in second.sent_types()
@pytest.mark.asyncio
async def test_realtime_resume_rejects_invalid_token_before_engine_control():
engine = FakeEngineClient()
handler = OmniDuplexSessionHandler(
chat_service=FakeChatService(engine),
config_timeout_s=0.1,
idle_timeout_s=1,
)
first = TimedWebSocket(receive_timeout_s=0.01)
first.put(_native_realtime_session_update("sid-invalid-resume"))
await handler.handle_realtime_session(first)
created = next(message for message in first.sent if message.get("type") == "session.created")
second = TimedWebSocket(receive_timeout_s=0.01)
second.put(
{
"type": "session.resume",
"session_id": "sid-invalid-resume",
"incarnation": created["incarnation"],
"resume_token": "invalid-token",
"last_received_server_event_seq": 0,
}
)
await handler.handle_realtime_session(second)
error = next(message for message in second.sent if message.get("type") == "error")
assert error["error"]["code"] == "invalid_resume_token"
assert engine.resumed == []
assert engine.closed == []
@pytest.mark.asyncio
async def test_realtime_resume_recovers_when_rotated_token_delivery_is_lost():
class FailingResumeWebSocket(TimedWebSocket):
async def send_json(self, data: dict[str, Any]):
if data.get("type") == "session.resumed":
raise RuntimeError("resume transport disappeared")
await super().send_json(data)
engine = FakeEngineClient()
handler = OmniDuplexSessionHandler(
chat_service=FakeChatService(engine),
config_timeout_s=0.1,
idle_timeout_s=1,
)
first = TimedWebSocket(receive_timeout_s=0.01)
first.put(_native_realtime_session_update("sid-token-delivery"))
await handler.handle_realtime_session(first)
created = next(message for message in first.sent if message.get("type") == "session.created")
resume_event = {
"type": "session.resume",
"session_id": "sid-token-delivery",
"incarnation": created["incarnation"],
"resume_token": created["resume_token"],
"last_received_server_event_seq": 0,
}
failed_transport = FailingResumeWebSocket(receive_timeout_s=0.01)
failed_transport.put(resume_event)
await handler.handle_realtime_session(failed_transport)
recovered_transport = TimedWebSocket(receive_timeout_s=0.1)
recovered_transport.put(resume_event)
recovered_transport.put({"type": "session.close"})
await handler.handle_realtime_session(recovered_transport)
assert "session.resumed" in recovered_transport.sent_types()
assert engine.resumed == [
("sid-token-delivery", 0),
("sid-token-delivery", 1),
]
assert engine.closed == [("sid-token-delivery", "session_close")]
@pytest.mark.asyncio
async def test_realtime_disconnect_grace_cancels_only_orphan_response():
engine = FakeEngineClient()
handler = OmniDuplexSessionHandler(
chat_service=FakeChatService(engine),
config_timeout_s=0.1,
idle_timeout_s=1,
duplex_session_config=DuplexSessionRuntimeConfig(disconnect_grace_s=0.02),
)
ws = TimedWebSocket(receive_timeout_s=0.01)
ws.put(_native_realtime_session_update("sid-grace-response"))
await handler.handle_realtime_session(ws)
session = handler._registry.get("sid-grace-response")
assert session is not None
session.begin_response()
session.bind_request("orphan-request")
orphan_task = asyncio.create_task(asyncio.sleep(10))
handler._session_tasks[session.session_id].active_response_task = orphan_task
assert engine.closed == []
await asyncio.sleep(0.05)
assert engine.closed == []
assert engine.aborted == ["orphan-request"]
assert orphan_task.cancelled()
assert session.active_response_id is None
assert "sid-grace-response" in handler._minicpmo_sessions
@pytest.mark.asyncio
async def test_realtime_heartbeat_control_failure_is_non_terminal():
engine = FakeEngineClient(fail_touch=True)
handler = OmniDuplexSessionHandler(
chat_service=FakeChatService(engine),
config_timeout_s=0.1,
idle_timeout_s=1,
)
ws = TimedWebSocket(receive_timeout_s=0.1)
ws.put(_native_realtime_session_update("sid-heartbeat-failure"))
ws.put({"type": "session.heartbeat"})
ws.put({"type": "session.close"})
await handler.handle_realtime_session(ws)
error = next(message for message in ws.sent if message.get("type") == "error")
assert error["error"]["code"] == "runtime_touch_failed"
assert engine.closed == [("sid-heartbeat-failure", "session_close")]
@pytest.mark.asyncio
async def test_realtime_idle_ttl_lifecycle_removes_detached_serving_projection():
engine = FakeEngineClient()
handler = OmniDuplexSessionHandler(
chat_service=FakeChatService(engine),
config_timeout_s=0.1,
idle_timeout_s=1,
)
ws = TimedWebSocket(receive_timeout_s=0.01)
ws.put(_native_realtime_session_update("sid-idle-expired"))
await handler.handle_realtime_session(ws)
fence = engine.opened_fences[0]
assert fence is not None
await engine.duplex_lifecycle_events.put(
DuplexSessionLifecycleMessage(
fence=fence,
session_id="sid-idle-expired",
event="expired",
reason="idle_ttl_expired",
lease_generation=1,
submitted_request_ids=[],
reserved_request_ids=[],
)
)
await asyncio.sleep(0.05)
assert handler._registry.get("sid-idle-expired") is None
assert "sid-idle-expired" not in handler._minicpmo_sessions
assert "sid-idle-expired" not in handler._session_tasks
assert engine.closed == []
@pytest.mark.asyncio
async def test_realtime_resume_reports_resync_required_after_journal_ttl_gap():
engine = FakeEngineClient()
handler = OmniDuplexSessionHandler(
chat_service=FakeChatService(engine),
config_timeout_s=0.1,
idle_timeout_s=1,
duplex_session_config=DuplexSessionRuntimeConfig(resume_replay_ttl_s=0.01),
)
first = TimedWebSocket(receive_timeout_s=0.01)
first.put(_native_realtime_session_update("sid-journal-gap"))
first.put({"type": "session.heartbeat"})
await handler.handle_realtime_session(first)
created = next(message for message in first.sent if message.get("type") == "session.created")
await asyncio.sleep(0.02)
second = TimedWebSocket(receive_timeout_s=0.01)
second.put(
{
"type": "session.resume",
"session_id": "sid-journal-gap",
"incarnation": created["incarnation"],
"resume_token": created["resume_token"],
"last_received_server_event_seq": 0,
}
)
await handler.handle_realtime_session(second)
assert "session.resync_required" in second.sent_types()
assert engine.resumed == []
assert engine.closed == []
@pytest.mark.asyncio
async def test_realtime_journal_overflow_degrades_to_live_resync_without_closing_runtime():
engine = FakeEngineClient()
handler = OmniDuplexSessionHandler(
chat_service=FakeChatService(engine),
config_timeout_s=0.1,
idle_timeout_s=1,
duplex_session_config=DuplexSessionRuntimeConfig(resume_replay_max_bytes_per_session=128),
)
ws = TimedWebSocket(receive_timeout_s=0.01)
ws.put(_native_realtime_session_update("sid-journal-overflow"))
await handler.handle_realtime_session(ws)
assert "session.created" in ws.sent_types()
assert "session.resync_required" in ws.sent_types()
assert engine.closed == []
assert handler._registry.get("sid-journal-overflow") is not None
@pytest.mark.asyncio
async def test_realtime_input_backpressure_is_per_session_and_rejects_before_mutation():
engine = FakeEngineClient()
handler = OmniDuplexSessionHandler(
chat_service=FakeChatService(engine),
config_timeout_s=0.1,
idle_timeout_s=1,
duplex_session_config=DuplexSessionRuntimeConfig(
max_pending_input_bytes_per_session=100,
),
)
first = TimedWebSocket(receive_timeout_s=0.1)
first.put(_native_realtime_session_update("sid-backpressure-a"))
for _ in range(2):
first.put(
{
"type": "input_audio_buffer.append",
"audio": _pcm16_b64(20),
"format": "pcm16",
"sample_rate_hz": 16000,
}
)
first.put({"type": "session.close"})
await handler.handle_realtime_session(first)
backpressure = [
event
for event in first.sent
if event.get("type") == "error"
and isinstance(event.get("error"), dict)
and event["error"].get("code") == "input_backpressure"
]
assert len(backpressure) == 1
second = TimedWebSocket(receive_timeout_s=0.1)
second.put(_native_realtime_session_update("sid-backpressure-b"))
second.put(
{
"type": "input_audio_buffer.append",
"audio": _pcm16_b64(20),
"format": "pcm16",
"sample_rate_hz": 16000,
}
)
second.put({"type": "session.close"})
await handler.handle_realtime_session(second)
assert not any(
event.get("type") == "error"
and isinstance(event.get("error"), dict)
and event["error"].get("code") == "input_backpressure"
for event in second.sent
)
@pytest.mark.asyncio
async def test_duplex_handler_idle_timeout_close_does_not_emit_runtime_control():
control_result = {
"operation": "close",
"session_id": "sid-disconnect",
"ok": True,
"unsupported_count": 1,
"error_count": 0,
"stage_results": [{"stage_id": 0, "replica_id": 0, "result": {"supported": False}}],
}
engine = FakeEngineClient(append_result=control_result)
chat_service = FakeChatService(engine)
handler = OmniDuplexSessionHandler(chat_service=chat_service, config_timeout_s=0.1, idle_timeout_s=0.1)
ws = TimedWebSocket()
ws.put(_session_create("sid-disconnect"))
await handler.handle_session(ws)
assert engine.closed == [("sid-disconnect", "timeout")]
@pytest.mark.asyncio
async def test_idle_output_audio_clear_does_not_advance_runtime_fence():
engine = FakeEngineClient()
handler = OmniDuplexSessionHandler(
chat_service=FakeChatService(engine),
config_timeout_s=0.1,
idle_timeout_s=1,
)
ws = TimedWebSocket()
ws.put(_session_create("sid-idle-output-clear"))
ws.put({"type": "output_audio_buffer.clear"})
ws.put({"type": "session.close"})
await handler.handle_session(ws)
cancelled = next(event for event in ws.sent if event.get("type") == "audio.cancelled")
assert cancelled["cancelled_epoch"] == cancelled["epoch"] == 0
assert engine.signals == []
@pytest.mark.asyncio
async def test_duplex_handler_runtime_close_failure_is_reported_without_closed_ack():
engine = FakeEngineClient(fail_close=True)
chat_service = FakeChatService(engine)
handler = OmniDuplexSessionHandler(chat_service=chat_service, config_timeout_s=0.1, idle_timeout_s=1)
ws = TimedWebSocket()
ws.put(_session_create("sid-close-fail"))
ws.put({"type": "session.close"})
await handler.handle_session(ws)
assert ws.sent_types() == ["session.created", "error"]
assert ws.sent[-1]["code"] == "runtime_close_failed"
@pytest.mark.asyncio
async def test_duplex_handler_control_close_failure_is_reported_without_closed_ack():
control_result = {
"operation": "close",
"session_id": "sid-control-close-fail",
"ok": False,
"unsupported_count": 0,
"error_count": 1,
"stage_results": [
{
"stage_id": 0,
"replica_id": 0,
"result": {"supported": False, "error": "stage close failed"},
}
],
}
engine = FakeEngineClient(close_result=control_result)
chat_service = FakeChatService(engine)
handler = OmniDuplexSessionHandler(chat_service=chat_service, config_timeout_s=0.1, idle_timeout_s=1)
ws = TimedWebSocket()
ws.put(_session_create("sid-control-close-fail"))
ws.put({"type": "session.close"})
await handler.handle_session(ws)
assert ws.sent_types() == ["session.created", "error"]
assert ws.sent[-1]["code"] == "runtime_close_failed"
assert ws.sent[-1]["runtime_control"]["error_count"] == 1
@pytest.mark.asyncio
async def test_duplex_cancel_without_active_response_clears_pending_input_and_acks():
engine = FakeEngineClient()
chat_service = FakeChatService(engine)
handler = OmniDuplexSessionHandler(chat_service=chat_service, config_timeout_s=0.1, idle_timeout_s=1)
ws = TimedWebSocket()
ws.put(_session_create("sid-pending-cancel"))
ws.put({"type": "input.text.append", "text": "hello"})
ws.put({"type": "input.cancel", "reason": "user_cancel"})
ws.put({"type": "input.commit"})
await handler.handle_session(ws)
cancelled = next(m for m in ws.sent if m.get("type") == "input.cancelled")
assert cancelled["cancelled"] == {"text_chunks": 1, "audio_chunks": 0}
assert cancelled["epoch"] == 1
assert chat_service.seen_request_ids == []
assert "response.created" not in ws.sent_types()
@pytest.mark.asyncio
async def test_turn_signal_input_cancel_uses_epoch_fence_transition():
engine = FakeEngineClient()
handler = OmniDuplexSessionHandler(
chat_service=FakeChatService(engine),
config_timeout_s=0.1,
idle_timeout_s=1,
)
ws = TimedWebSocket()
ws.put(_session_create("sid-turn-signal-cancel"))
ws.put({"type": "input.text.append", "text": "discard me"})
ws.put({"type": "turn.signal", "event": "input.cancel"})
ws.put({"type": "session.close"})
await handler.handle_session(ws)
assert engine.signal_fences[-1] == DuplexFence("sid-turn-signal-cancel")
assert engine.signal_next_fences[-1] == DuplexFence("sid-turn-signal-cancel", epoch=1)
assert any(message.get("type") == "input.cancelled" for message in ws.sent)
@pytest.mark.asyncio
async def test_duplex_handler_local_turn_signal_does_not_round_trip_runtime():
engine = FakeEngineClient(fail_signal=True)
chat_service = FakeChatService(engine)
handler = OmniDuplexSessionHandler(chat_service=chat_service, config_timeout_s=0.1, idle_timeout_s=1)
ws = TimedWebSocket()
ws.put(_session_create("sid-signal-fail"))
ws.put({"type": "turn.signal", "event": "user_started"})
ws.put({"type": "session.close"})
await handler.handle_session(ws)
turn_event = next(m for m in ws.sent if m.get("type") == "turn.event")
assert turn_event["event"] == "user_started"
assert not engine.signals
assert "runtime_signal_failed" not in {m.get("code") for m in ws.sent}
@pytest.mark.asyncio
async def test_duplex_barge_in_aborts_active_response_when_runtime_signal_fails():
engine = FakeEngineClient(fail_signal_events={"barge_in"})
chat_service = FakeChatService(engine)
handler = OmniDuplexSessionHandler(chat_service=chat_service, config_timeout_s=0.1, idle_timeout_s=1)
def on_send(ws: TimedWebSocket, data: dict[str, Any]) -> None:
if data.get("type") == "response.created":
ws.put({"type": "input.cancel", "reason": "test_barge_in"})
ws = TimedWebSocket(on_send=on_send)
ws.put(_session_create("sid-barge-signal-fail"))
ws.put({"type": "input.text.append", "text": "hello"})
ws.put({"type": "input.commit"})
await handler.handle_session(ws)
assert "audio.cancelled" in ws.sent_types()
assert engine.aborted == ["chatcmpl-duplex-sid-barge-signal-fail-0-1"]
assert engine.internal_abort_batches == [["chatcmpl-duplex-sid-barge-signal-fail-0-1"]]
error = next(m for m in ws.sent if m.get("type") == "error")
assert error["code"] == "runtime_signal_failed"
@pytest.mark.asyncio
async def test_duplex_handler_surfaces_stage_unsupported_result_to_client():
control_result = {
"operation": "open",
"session_id": "sid-unsupported",
"ok": True,
"unsupported_count": 1,
"error_count": 0,
"stage_results": [
{
"stage_id": 0,
"replica_id": 0,
"result": {"supported": False, "reason": "not implemented"},
}
],
}
engine = FakeEngineClient(open_result=control_result)
chat_service = FakeChatService(engine)
handler = OmniDuplexSessionHandler(chat_service=chat_service, config_timeout_s=0.1, idle_timeout_s=1)
ws = TimedWebSocket()
ws.put(_session_create("sid-unsupported"))
ws.put({"type": "session.close"})
await handler.handle_session(ws)
created = next(m for m in ws.sent if m.get("type") == "session.created")
assert created["runtime_control"]["unsupported_count"] == 1
@pytest.mark.asyncio
async def test_minicpmo_native_duplex_append_without_format_defaults_to_pcm16():
engine = FakeEngineClient()
chat_service = FakeChatService(engine)
handler = OmniDuplexSessionHandler(chat_service=chat_service, config_timeout_s=0.1, idle_timeout_s=1)
ws = TimedWebSocket()
ws.put(_native_session_create("sid-native-default-pcm16"))
ws.put(
{
"type": "input_audio_buffer.append",
"audio": _pcm16_b64(16000),
"sample_rate_hz": 16000,
"is_speech": True,
}
)
ws.put({"type": "session.close"})
await handler.handle_session(ws)
assert len(engine.appended) == 1
_, mode, payload, final = engine.appended[0]
assert mode == "append_audio_chunk"
assert final is False
assert isinstance(payload, dict)
assert payload["format"] == "pcm_f32le"
assert payload["sample_rate_hz"] == 16000
samples = np.frombuffer(base64.b64decode(payload["audio"]), dtype="<f4")
assert samples.shape == (16000,)
assert samples[:4].tolist() == pytest.approx([1000 / 32768.0] * 4)
@pytest.mark.asyncio
async def test_minicpmo_native_duplex_rejects_invalid_sample_rate_without_append():
engine = FakeEngineClient()
handler = OmniDuplexSessionHandler(
chat_service=FakeChatService(engine),
config_timeout_s=0.1,
idle_timeout_s=1,
)
ws = TimedWebSocket()
ws.put(_native_session_create("sid-native-invalid-rate"))
ws.put(
{
"type": "input_audio_buffer.append",
"audio": _pcm16_b64(1),
"sample_rate_hz": 16_000.5,
"is_speech": True,
}
)
ws.put({"type": "session.close"})
await handler.handle_session(ws)
assert engine.appended == []
error = next(event for event in ws.sent if event.get("type") == "error")
assert error["code"] == "bad_event"
@pytest.mark.asyncio
async def test_native_input_commit_does_not_send_noop_runtime_signal():
engine = FakeEngineClient()
handler = OmniDuplexSessionHandler(
chat_service=FakeChatService(engine),
config_timeout_s=0.1,
idle_timeout_s=1,
)
ws = TimedWebSocket()
ws.put(_native_session_create("sid-commit-without-signal"))
ws.put(
{
"type": "input_audio_buffer.append",
"audio": _pcm16_b64(16000),
"sample_rate_hz": 16000,
"is_speech": True,
}
)
ws.put({"type": "input_audio_buffer.commit"})
ws.put({"type": "session.close"})
await handler.handle_session(ws)
assert ("sid-commit-without-signal", "input.commit") not in engine.signals
@pytest.mark.asyncio
async def test_audio_clear_preserves_later_wire_order_append():
engine = FakeEngineClient()
handler = OmniDuplexSessionHandler(
chat_service=FakeChatService(engine),
config_timeout_s=0.1,
idle_timeout_s=1,
)
ws = TimedWebSocket()
ws.put(_native_session_create("sid-clear-then-append"))
ws.put({"type": "input_audio_buffer.clear"})
ws.put(
{
"type": "input_audio_buffer.append",
"audio": _pcm16_b64(16000),
"sample_rate_hz": 16000,
"is_speech": True,
}
)
ws.put({"type": "session.close"})
await handler.handle_session(ws)
assert len(engine.appended) == 1
@pytest.mark.asyncio
async def test_native_append_is_cancelled_by_later_wire_order_input_cancel(mocker):
class SlowAppendEngine(FakeEngineClient):
def __init__(self) -> None:
super().__init__()
self.append_started = asyncio.Event()
self.append_cancelled = asyncio.Event()
self.release_append = asyncio.Event()
async def append_duplex_input_async(
self,
session_id: str,
*,
mode: str,
payload: object,
operation_id: str | None = None,
final: bool = False,
timeout: float | None = None,
collect_outputs: bool = True,
fence: DuplexFence | None = None,
) -> Any:
self.append_started.set()
try:
await self.release_append.wait()
except asyncio.CancelledError:
self.append_cancelled.set()
raise
return await super().append_duplex_input_async(
session_id,
mode=mode,
payload=payload,
operation_id=operation_id,
final=final,
timeout=timeout,
collect_outputs=collect_outputs,
fence=fence,
)
rollback = mocker.spy(MiniCPMO45PcmAppendBuffer, "_rollback_reservation")
engine = SlowAppendEngine()
handler = OmniDuplexSessionHandler(
chat_service=FakeChatService(engine),
config_timeout_s=0.1,
idle_timeout_s=1,
)
ws = TimedWebSocket()
ws.put(_native_session_create("sid-cancel-slow-append"))
ws.put(
{
"type": "input_audio_buffer.append",
"audio": _pcm16_b64(16000),
"sample_rate_hz": 16000,
"is_speech": True,
}
)
ws.put({"type": "input.cancel"})
ws.put({"type": "session.close"})
handler_task = asyncio.create_task(handler.handle_session(ws))
await asyncio.wait_for(engine.append_started.wait(), timeout=1)
try:
await asyncio.wait_for(engine.append_cancelled.wait(), timeout=0.2)
except TimeoutError:
pass
finally:
engine.release_append.set()
await asyncio.wait_for(handler_task, timeout=1)
assert engine.append_cancelled.is_set()
rollback.assert_called_once()
assert engine.signal_fences[-1] == DuplexFence("sid-cancel-slow-append")
assert engine.signal_next_fences[-1] == DuplexFence("sid-cancel-slow-append", epoch=1)
@pytest.mark.asyncio
async def test_minicpmo_native_duplex_append_control_error_does_not_emit_model_delta():
control_result = {
"operation": "append",
"session_id": "sid-native-append-error",
"ok": False,
"unsupported_count": 0,
"error_count": 1,
"stage_results": [
{
"stage_id": -1,
"replica_id": -1,
"result": {
"supported": False,
"error": "duplex_stage_handoff_target_missing:tts",
},
},
],
}
engine = FakeEngineClient(append_result=control_result)
chat_service = FakeChatService(engine)
handler = OmniDuplexSessionHandler(chat_service=chat_service, config_timeout_s=0.1, idle_timeout_s=1)
ws = TimedWebSocket()
ws.put(_native_session_create("sid-native-append-error"))
ws.put({"type": "input_audio_buffer.append", "audio": "AAAA", "format": "pcm_f32le"})
ws.put({"type": "session.close"})
await handler.handle_session(ws)
assert "response.output_audio.delta" not in ws.sent_types()
error = next(m for m in ws.sent if m.get("type") == "error")
assert error["code"] == "runtime_append_failed"
assert error["runtime_control"]["error_count"] == 1
@pytest.mark.asyncio
async def test_duplex_handler_session_update_control_failure_rolls_back_config():
control_result = {
"operation": "signal",
"session_id": "sid-control-signal-fail",
"ok": False,
"unsupported_count": 0,
"error_count": 1,
"stage_results": [
{
"stage_id": 0,
"replica_id": 0,
"result": {"supported": False, "error": "stage signal failed"},
}
],
}
engine = FakeEngineClient(signal_result=control_result)
chat_service = FakeChatService(engine)
handler = OmniDuplexSessionHandler(chat_service=chat_service, config_timeout_s=0.1, idle_timeout_s=1)
ws = TimedWebSocket()
ws.put(_session_create("sid-control-signal-fail"))
ws.put(
{
"type": "turn.signal",
"event": "session.update",
"payload": {"instructions": "must roll back"},
}
)
ws.put({"type": "session.close"})
await handler.handle_session(ws)
assert "session.updated" not in ws.sent_types()
error = next(m for m in ws.sent if m.get("type") == "error")
assert error["code"] == "runtime_signal_failed"
assert error["runtime_control"]["error_count"] == 1
signaled_config = engine.signal_session_configs[-1]
assert signaled_config is not None
assert signaled_config["instructions"] == "must roll back"
@pytest.mark.asyncio
async def test_native_session_update_commits_config_only_after_runtime_ack():
class BlockingUpdateEngine(FakeEngineClient):
def __init__(self):
super().__init__()
self.update_started = asyncio.Event()
self.release_update = asyncio.Event()
async def signal_duplex_turn_async(self, session_id: str, **kwargs):
if kwargs.get("event") == "session.update":
self.update_started.set()
await self.release_update.wait()
return await super().signal_duplex_turn_async(session_id, **kwargs)
engine = BlockingUpdateEngine()
handler = OmniDuplexSessionHandler(chat_service=FakeChatService(engine))
ws = TimedWebSocket()
protocol = NativeRealtimeSessionProtocol(ws) # type: ignore[arg-type]
ws.put(_native_realtime_session_update("sid-update-two-phase"))
ws.put(_server_vad_update("update-two-phase"))
handler_task = asyncio.create_task(handler.handle_session(ws, realtime_protocol=protocol))
await asyncio.wait_for(engine.update_started.wait(), timeout=1)
live_session = handler._registry.get("sid-update-two-phase")
assert live_session is not None
assert live_session.config.overlap_policy == DuplexOverlapPolicy.LISTEN_ONLY.value
assert protocol._pending_turn_detection_update is not None
assert protocol._server_vad is None
protocol.encode_outbound_event({"type": "error", "code": "unrelated", "error": "unrelated"})
assert protocol._pending_turn_detection_update is not None
engine.release_update.set()
ws.put({"type": "session.close"})
await asyncio.wait_for(handler_task, timeout=2)
updated = next(message for message in reversed(ws.sent) if message.get("type") == "session.updated")
assert updated["session"]["overlap_policy"] == DuplexOverlapPolicy.BARGE_IN_ON_SPEECH.value
assert isinstance(protocol._server_vad, realtime_vad.SileroStreamingVAD)
@pytest.mark.parametrize(
("append_fails", "expected_code"),
[(True, "session_update_aborted"), (False, "instructions_update_unsupported")],
)
@pytest.mark.asyncio
async def test_realtime_vad_update_rejection_does_not_stall(append_fails, expected_code):
append_result = {"operation": "append", "ok": False, "error_count": 1} if append_fails else None
handler = OmniDuplexSessionHandler(chat_service=FakeChatService(FakeEngineClient(append_result=append_result)))
ws = TimedWebSocket()
protocol = NativeRealtimeSessionProtocol(ws) # type: ignore[arg-type]
create = _native_realtime_session_update("sid-rejected-vad-update")
create["session"]["extra_body"]["auto_response"] = append_fails
ws.put(create)
if append_fails:
ws.put({"type": "input_audio_buffer.append", "audio": _pcm_f32_b64(16_000), "format": "pcm_f32le"})
update = _server_vad_update("rejected-vad-update")
if not append_fails:
update["session"]["instructions"] = "You are now a pirate."
ws.put(update)
ws.put({"type": "session.close"})
await asyncio.wait_for(handler.handle_session(ws, realtime_protocol=protocol), timeout=2)
errors = [message["error"] for message in ws.sent if isinstance(message.get("error"), dict)]
error = next(error for error in errors if error.get("code") == expected_code)
assert error["event_id"] == "rejected-vad-update"
assert protocol._pending_turn_detection_update is None
assert protocol._server_vad is None
@pytest.mark.asyncio
async def test_native_session_update_waits_for_prior_append_effect():
class OrderedUpdateEngine(FakeEngineClient):
def __init__(self):
super().__init__()
self.append_started = asyncio.Event()
self.release_append = asyncio.Event()
self.update_started = asyncio.Event()
self.effects: list[str] = []
async def append_duplex_input_async(self, session_id: str, **kwargs):
self.effects.append("append.started")
self.append_started.set()
await self.release_append.wait()
kwargs.pop("expected_epoch", None)
result = await super().append_duplex_input_async(session_id, **kwargs)
self.effects.append("append.done")
return result
async def signal_duplex_turn_async(self, session_id: str, **kwargs):
if kwargs.get("event") == "session.update":
self.effects.append("session.update")
self.update_started.set()
return await super().signal_duplex_turn_async(session_id, **kwargs)
engine = OrderedUpdateEngine()
handler = OmniDuplexSessionHandler(
chat_service=FakeChatService(engine),
config_timeout_s=0.1,
idle_timeout_s=1,
)
ws = TimedWebSocket()
ws.put(_native_session_create("sid-ordered-update"))
ws.put(
{
"type": "input_audio_buffer.append",
"audio": _pcm_f32_b64(16000, value=0.05),
"format": "pcm_f32le",
}
)
ws.put(
{
"type": "turn.signal",
"event": "session.update",
"payload": {"temperature": 0.2},
}
)
handler_task = asyncio.create_task(handler.handle_session(ws))
await asyncio.wait_for(engine.append_started.wait(), timeout=1)
try:
await asyncio.sleep(0.05)
assert not engine.update_started.is_set()
finally:
engine.release_append.set()
ws.put({"type": "session.close"})
await asyncio.wait_for(handler_task, timeout=2)
assert engine.effects.index("append.done") < engine.effects.index("session.update")
@pytest.mark.asyncio
async def test_failed_native_append_prevents_queued_append_from_running():
failed_result = {
"operation": "append",
"session_id": "sid-append-chain-failure",
"ok": False,
"unsupported_count": 0,
"error_count": 1,
"stage_results": [
{
"stage_id": 0,
"replica_id": 0,
"result": {"supported": False, "error": "append failed"},
}
],
}
class BlockingFailureEngine(FakeEngineClient):
def __init__(self):
super().__init__(append_result=failed_result)
self.first_started = asyncio.Event()
self.release_first = asyncio.Event()
async def append_duplex_input_async(self, session_id: str, **kwargs):
if not self.appended:
self.first_started.set()
await self.release_first.wait()
kwargs.pop("expected_epoch", None)
return await super().append_duplex_input_async(session_id, **kwargs)
engine = BlockingFailureEngine()
handler = OmniDuplexSessionHandler(
chat_service=FakeChatService(engine),
config_timeout_s=0.1,
idle_timeout_s=1,
)
ws = TimedWebSocket()
create = _native_session_create("sid-append-chain-failure")
create["session"]["extra_body"]["auto_response"] = True
ws.put(create)
for _ in range(2):
ws.put(
{
"type": "input_audio_buffer.append",
"audio": _pcm_f32_b64(16_000, value=0.05),
"format": "pcm_f32le",
}
)
handler_task = asyncio.create_task(handler.handle_session(ws))
await asyncio.wait_for(engine.first_started.wait(), timeout=1)
engine.release_first.set()
await asyncio.sleep(0.1)
ws.put({"type": "session.close"})
await asyncio.wait_for(handler_task, timeout=2)
assert len(engine.appended) == 1
@pytest.mark.asyncio
async def test_failed_final_append_discards_retained_audio_without_retry():
failed_result = {
"operation": "append",
"session_id": "sid-final-append-retry",
"ok": False,
"unsupported_count": 0,
"error_count": 1,
"stage_results": [],
}
engine = FakeEngineClient(append_result=failed_result)
handler = OmniDuplexSessionHandler(
chat_service=FakeChatService(engine),
config_timeout_s=0.1,
idle_timeout_s=1,
)
def retry_after_runtime_error(ws: TimedWebSocket, data: dict[str, Any]) -> None:
if data.get("type") == "error" and data.get("code") == "runtime_append_failed":
ws.put({"type": "response.create"})
ws = TimedWebSocket(on_send=retry_after_runtime_error)
ws.put(_native_session_create("sid-final-append-retry"))
ws.put(
{
"type": "input_audio_buffer.append",
"audio": _pcm_f32_b64(8_000, value=0.05),
"format": "pcm_f32le",
}
)
ws.put({"type": "input_audio_buffer.commit", "final": True})
ws.put({"type": "response.create"})
handler_task = asyncio.create_task(handler.handle_session(ws))
for _ in range(100):
state = handler._serving_runtime_adapter.session_states.get("sid-final-append-retry")
if len(engine.appended) >= 1 and state is not None and state.committed_audio_payload is None:
break
await asyncio.sleep(0.01)
state = handler._serving_runtime_adapter.session_states["sid-final-append-retry"]
assert state.committed_audio_payload is None
assert state.committed_audio_reserved_bytes == 0
assert handler._registry.get("sid-final-append-retry").pending_input_bytes == 0
ws.put({"type": "session.close"})
await asyncio.wait_for(handler_task, timeout=2)
assert len(engine.appended) == 1
@pytest.mark.asyncio
async def test_duplex_handler_signal_unsupported_workers_with_data_plane_ack_is_not_fatal():
control_result = {
"operation": "signal",
"session_id": "sid-control-signal-data-plane",
"ok": False,
"unsupported_count": 2,
"error_count": 0,
"stage_results": [
{
"stage_id": 0,
"replica_id": 0,
"result": {"supported": False, "reason": "worker_duplex_signal_not_implemented"},
},
{
"stage_id": 1,
"replica_id": 0,
"result": {"supported": False, "reason": "worker_duplex_signal_not_implemented"},
},
{
"stage_id": -1,
"replica_id": -1,
"result": {
"supported": True,
"data_plane_signal": True,
"event": "barge_in",
"epoch": 1,
},
},
],
}
engine = FakeEngineClient(signal_result=control_result)
chat_service = FakeChatService(engine)
handler = OmniDuplexSessionHandler(chat_service=chat_service, config_timeout_s=0.1, idle_timeout_s=1)
ws = TimedWebSocket()
ws.put(_session_create("sid-control-signal-data-plane"))
ws.put({"type": "turn.signal", "event": "barge_in"})
ws.put({"type": "session.close"})
await handler.handle_session(ws)
assert "runtime_signal_failed" not in {m.get("code") for m in ws.sent}
assert "input.cancelled" in ws.sent_types()
runtime_control = next(m for m in ws.sent if m.get("type") == "runtime.control")
assert runtime_control["result"]["unsupported_count"] == 2
@pytest.mark.asyncio
async def test_minicpmo_native_duplex_open_unsupported_fails_session_create():
control_result = {
"operation": "open",
"session_id": "sid-native-busy",
"ok": True,
"unsupported_count": 1,
"error_count": 0,
"stage_results": [
{
"stage_id": 0,
"replica_id": 0,
"result": {"supported": False, "reason": "native_duplex_session_busy"},
}
],
}
engine = FakeEngineClient(control_result=control_result)
chat_service = FakeChatService(engine)
handler = OmniDuplexSessionHandler(chat_service=chat_service, config_timeout_s=0.1, idle_timeout_s=1)
ws = TimedWebSocket()
ws.put(_native_session_create("sid-native-busy"))
ws.put({"type": "session.close"})
await handler.handle_session(ws)
assert ws.sent_types() == ["error"]
assert ws.sent[0]["code"] == "runtime_open_unsupported"
assert ws.sent[0]["runtime_control"]["unsupported_count"] == 1
@pytest.mark.asyncio
async def test_minicpmo_native_duplex_rejects_engine_without_fence_contract():
class LegacyEngineClient(FakeEngineClient):
async def open_duplex_session_async( # type: ignore[override]
self,
session_id: str,
*,
session_mode: str = "duplex",
capabilities: dict[str, object] | None = None,
session_config: dict[str, object] | None = None,
timeout: float | None = None,
) -> Any:
return await super().open_duplex_session_async(
session_id,
session_mode=session_mode,
capabilities=capabilities,
session_config=session_config,
timeout=timeout,
)
engine = LegacyEngineClient()
handler = OmniDuplexSessionHandler(
chat_service=FakeChatService(engine),
config_timeout_s=0.1,
idle_timeout_s=1,
)
ws = TimedWebSocket()
ws.put(_native_session_create("sid-native-no-fence-contract"))
ws.put({"type": "session.close"})
await handler.handle_session(ws)
assert ws.sent_types() == ["error"]
assert ws.sent[0]["code"] == "runtime_contract_invalid"
assert "open_duplex_session_async" in ws.sent[0]["error"]
assert "fence" in ws.sent[0]["error"]
assert engine.opened == []
@pytest.mark.asyncio
async def test_minicpmo_native_duplex_audio_append_does_not_retain_pending_audio():
engine = FakeEngineClient()
chat_service = FakeChatService(engine)
handler = OmniDuplexSessionHandler(chat_service=chat_service, config_timeout_s=0.1, idle_timeout_s=1)
ws = TimedWebSocket()
ws.put(_native_session_create("sid-native-no-pending"))
short_chunk = base64.b64encode(b"\x00" * (800 * 4)).decode("ascii")
ws.put({"type": "input_audio_buffer.append", "audio": short_chunk, "format": "pcm_f32le"})
ws.put({"type": "input.cancel", "reason": "barge_in"})
ws.put({"type": "session.close"})
await handler.handle_session(ws)
cancelled = next(m for m in ws.sent if m.get("type") == "input.cancelled")
assert cancelled["cancelled"] == {"text_chunks": 0, "audio_chunks": 0}
assert cancelled["epoch"] == 1
@pytest.mark.asyncio
async def test_minicpmo_native_duplex_rejects_text_append_before_runtime_call():
engine = FakeEngineClient()
chat_service = FakeChatService(engine)
handler = OmniDuplexSessionHandler(chat_service=chat_service, config_timeout_s=0.1, idle_timeout_s=1)
ws = TimedWebSocket()
ws.put(_native_session_create("sid-native-text"))
ws.put({"type": "input.text.append", "text": "hello"})
ws.put({"type": "session.close"})
await handler.handle_session(ws)
error = next(m for m in ws.sent if m.get("type") == "error")
assert error["code"] == "native_text_append_unsupported"
assert engine.appended == []
@pytest.mark.asyncio
async def test_minicpmo_native_duplex_separates_public_and_runtime_config(monkeypatch):
engine = FakeEngineClient()
chat_service = FakeChatService(engine)
handler = OmniDuplexSessionHandler(chat_service=chat_service, config_timeout_s=0.1, idle_timeout_s=1)
async def fake_resolve_ref_audio(_, *, model_config):
del model_config
return [0.25] * 1600, 16000
monkeypatch.setattr(
"vllm_omni.experimental.fullduplex.minicpmo45.MiniCPMO45NativeDuplexServingAdapter.resolve_ref_audio",
fake_resolve_ref_audio,
)
event = _native_session_create("sid-native-ref-audio", modalities=["text", "audio"])
event["session"]["ref_audio"] = "data:audio/wav;base64,AAAA"
ws = TimedWebSocket()
ws.put(event)
ws.put({"type": "session.close"})
await handler.handle_session(ws)
assert ws.sent_types()[0] == "session.created"
opened_config = engine.opened_configs[0]
opened_runtime_config = engine.opened_runtime_configs[0]
extra_body = opened_config["extra_body"]
assert "ref_audio_data" not in extra_body
assert "duplex_stage_max_tokens" not in extra_body
assert opened_runtime_config["ref_audio_format"] == "pcm_f32le"
assert opened_runtime_config["ref_audio_sample_rate_hz"] == 16000
assert opened_runtime_config["duplex_stage_max_tokens"] == {"0": 20, "1": 8192}
assert opened_runtime_config["duplex_stage_sampling_params"]["0"]["top_k"] == 20
assert opened_runtime_config["duplex_stage_sampling_params"]["0"]["top_p"] == 0.8
# The Talker YAML carries upstream's min_new_token=50 for chat TTS; a duplex
# chunk is 26 codec samples, so check_stop would never release the request.
assert opened_runtime_config["duplex_stage_sampling_params"]["1"] == {"min_tokens": 0}
assert base64.b64decode(opened_runtime_config["ref_audio_data"]) == struct.pack("<1600f", *([0.25] * 1600))
@pytest.mark.asyncio
async def test_minicpmo_native_duplex_rejects_client_runtime_config():
engine = FakeEngineClient()
handler = OmniDuplexSessionHandler(
chat_service=FakeChatService(engine),
config_timeout_s=0.1,
idle_timeout_s=1,
)
event = _native_session_create("sid-native-private-config")
event["session"]["extra_body"]["duplex_stage_sampling_params"] = {"0": {"temperature": 0.0}}
ws = TimedWebSocket()
ws.put(event)
await handler.handle_session(ws)
assert ws.sent_types() == ["error"]
assert ws.sent[0]["code"] == "invalid_duplex_runtime_config"
assert engine.opened == []
@pytest.mark.asyncio
async def test_minicpmo_native_duplex_audio_output_requires_ref_audio(monkeypatch):
monkeypatch.setattr(
MiniCPMO45NativeDuplexServingAdapter,
"_load_native_tokenizer",
staticmethod(lambda model_config: None),
)
event = _native_session_create(
"sid-native-ref-required",
modalities=["text", "audio"],
)
config = DuplexSessionConfig.from_event(event)
with pytest.raises(ServingRuntimeConfigError) as exc_info:
await MiniCPMO45NativeDuplexServingAdapter.prepare_runtime_config(
config,
model_config=SimpleNamespace(model="openbmb/MiniCPM-o-4_5"),
)
assert exc_info.value.code == "ref_audio_required"
@pytest.mark.asyncio
async def test_minicpmo_native_duplex_open_session_maps_missing_ref_audio_to_typed_error():
engine = FakeEngineClient()
handler = OmniDuplexSessionHandler(
chat_service=FakeChatService(engine),
config_timeout_s=0.1,
idle_timeout_s=1,
)
ws = TimedWebSocket()
ws.put(
_native_session_create(
"sid-native-ref-required-open",
modalities=["text", "audio"],
)
)
await handler.handle_session(ws)
assert ws.sent_types() == ["error"]
assert ws.sent[0]["code"] == "ref_audio_required"
assert engine.opened == []
@pytest.mark.asyncio
async def test_minicpmo_native_duplex_text_only_omits_ref_audio_when_client_does_not_provide_it(monkeypatch):
monkeypatch.setattr(
MiniCPMO45NativeDuplexServingAdapter,
"_load_native_tokenizer",
staticmethod(lambda model_config: None),
)
event = _native_session_create("sid-native-default-ref", modalities=["text"])
config = DuplexSessionConfig.from_event(event)
runtime_config = await MiniCPMO45NativeDuplexServingAdapter.prepare_runtime_config(
config,
model_config=SimpleNamespace(model="openbmb/MiniCPM-o-4_5"),
)
assert "ref_audio_data" not in runtime_config
assert "ref_audio_format" not in runtime_config
assert "ref_audio_sample_rate_hz" not in runtime_config
@pytest.mark.asyncio
async def test_minicpmo_native_duplex_preserves_ref_audio_channels_until_normalize(monkeypatch):
class FakeMediaConnector:
def __init__(self, **kwargs):
self.kwargs = kwargs
async def fetch_audio_async(self, ref_audio):
assert ref_audio == "data:audio/wav;base64,AAAA"
return np.tile(np.array([[0.25, -0.25], [0.5, -0.5]], dtype=np.float32), (800, 1)), 16000
monkeypatch.setattr(
"vllm_omni.experimental.fullduplex.minicpmo45.adapter.MediaConnector",
FakeMediaConnector,
)
engine = FakeEngineClient()
chat_service = FakeChatService(engine)
handler = OmniDuplexSessionHandler(chat_service=chat_service, config_timeout_s=0.1, idle_timeout_s=1)
event = _native_session_create("sid-native-ref-audio-stereo", modalities=["text", "audio"])
event["session"]["ref_audio"] = "data:audio/wav;base64,AAAA"
ws = TimedWebSocket()
ws.put(event)
ws.put({"type": "session.close"})
await handler.handle_session(ws)
runtime_config = engine.opened_runtime_configs[0]
assert base64.b64decode(runtime_config["ref_audio_data"]) == struct.pack("<1600f", *([0.0] * 1600))
@pytest.mark.asyncio
async def test_minicpmo_native_duplex_rejects_ref_audio_path():
engine = FakeEngineClient()
chat_service = FakeChatService(engine)
handler = OmniDuplexSessionHandler(chat_service=chat_service, config_timeout_s=0.1, idle_timeout_s=1)
event = _native_session_create("sid-native-ref-path")
event["session"]["extra_body"] = {
"minicpmo45_native_duplex": True,
"ref_audio_path": "/tmp/ref.wav",
}
ws = TimedWebSocket()
ws.put(event)
await handler.handle_session(ws)
assert ws.sent_types() == ["error"]
assert ws.sent[0]["code"] == "unsupported_ref_audio_path"
assert engine.opened == []
def test_minicpmo_native_duplex_explicit_barge_in_request_interrupts():
engine = FakeEngineClient()
handler = OmniDuplexSessionHandler(
chat_service=FakeChatService(engine),
config_timeout_s=0.1,
idle_timeout_s=1,
)
session = DuplexSession(
session_id="sid-native-explicit-barge-disabled",
config=DuplexSessionConfig(overlap_policy=DuplexOverlapPolicy.BARGE_IN_ON_SPEECH.value),
capabilities=DuplexCapabilities.minicpmo45_native(),
)
payload = {
"type": "audio",
"audio": _pcm_f32_b64(16000, value=0.05),
"format": "pcm_f32le",
"sample_rate_hz": 16000,
"is_speech": True,
}
decision = handler._overlap_decision(
session,
{"force_barge_in": True},
payload,
)
assert decision == {
"action": "barge_in",
"reason": "client_force_barge_in",
"duration_ms": 1000,
"buffer_audio": True,
}
@pytest.mark.asyncio
@pytest.mark.parametrize(
"barge_in_event",
[
{"type": "barge_in"},
{"type": "turn.signal", "event": "barge_in"},
],
)
async def test_minicpmo_native_duplex_accepts_explicit_barge_in_control(
barge_in_event: dict[str, object],
):
engine = FakeEngineClient()
handler = OmniDuplexSessionHandler(
chat_service=FakeChatService(engine),
config_timeout_s=0.1,
idle_timeout_s=1,
)
ws = TimedWebSocket()
ws.put(_native_session_create("sid-native-barge-control-disabled"))
ws.put(barge_in_event)
ws.put({"type": "session.close"})
await handler.handle_session(ws)
assert not any(message.get("code") == "barge_in_unsupported" for message in ws.sent)
assert ("sid-native-barge-control-disabled", "barge_in") in engine.signals
@pytest.mark.asyncio
async def test_minicpmo_native_duplex_explicit_non_speech_stays_listening_without_append():
engine = FakeEngineClient()
chat_service = FakeChatService(engine)
handler = OmniDuplexSessionHandler(chat_service=chat_service, config_timeout_s=0.1, idle_timeout_s=1)
ws = TimedWebSocket()
ws.put(_native_session_create("sid-native-silence"))
ws.put(
{
"type": "input_audio_buffer.append",
"audio": "AAAA",
"format": "pcm_f32le",
"is_speech": False,
}
)
ws.put({"type": "session.close"})
await handler.handle_session(ws)
assert engine.appended == []
assert "response.listen" in ws.sent_types()
assert "response.created" not in ws.sent_types()
assert "response.output_audio.delta" not in ws.sent_types()
@pytest.mark.asyncio
async def test_minicpmo_native_duplex_drains_data_plane_stream_until_done():
def _stage_output(samples: int, *, finished: bool):
return SimpleNamespace(
request_id="duplex-sid-native-stream-e0-stage0-s1",
finished=finished,
outputs=[
SimpleNamespace(
text="",
multimodal_output={
"audio": np.zeros(samples, dtype=np.float32),
"sr": 24000,
},
)
],
)
control_result = {
"operation": "append",
"session_id": "sid-native-stream",
"ok": True,
"unsupported_count": 0,
"error_count": 0,
"stage_results": [
{
"stage_id": 0,
"replica_id": 0,
"result": {
"supported": True,
"implementation_level": "model_native_duplex",
"data_plane_append": True,
"request_id": "duplex-sid-native-stream-e0-stage0-s1",
"response_stage_id": 1,
},
}
],
"data_plane_outputs": [_stage_output(10, finished=False)],
}
engine = FakeEngineClient(
append_result=control_result,
collect_outputs=[
[_stage_output(20, finished=False)],
[_stage_output(30, finished=True)],
],
)
chat_service = FakeChatService(engine)
handler = OmniDuplexSessionHandler(chat_service=chat_service, config_timeout_s=0.1, idle_timeout_s=1)
def close_on_second_created(ws: TimedWebSocket, data: dict[str, Any]) -> None:
if ws.sent_types().count("response.created") >= 2:
ws.put({"type": "session.close"})
ws = TimedWebSocket(on_send=close_on_second_created)
ws.put(_native_session_create("sid-native-stream"))
ws.put({"type": "input_audio_buffer.append", "audio": "AAAA", "format": "pcm_f32le"})
await handler.handle_session(ws)
deltas = [m for m in ws.sent if m.get("type") == "response.output_audio.delta"]
assert [m["audio"] for m in deltas] == ["wav-10", "wav-10", "wav-10"]
assert [m["end_of_turn"] for m in deltas] == [False, False, True]
assert len([m for m in ws.sent if m.get("type") == "response.done"]) == 1
assert engine.collected == [
("duplex-sid-native-stream-e0-stage0-s1", 1),
("duplex-sid-native-stream-e0-stage0-s1", 1),
]
@pytest.mark.asyncio
async def test_minicpmo_auto_response_restarts_drain_when_append_races_idle_exit(
monkeypatch: pytest.MonkeyPatch,
):
request_id = "duplex-sid-native-late-output-e0-stage0"
late_output = object()
engine = FakeEngineClient(
collect_outputs=[[], [], [], [late_output]],
collect_delay_s=0.05,
)
handler = OmniDuplexSessionHandler(
chat_service=FakeChatService(engine),
config_timeout_s=0.1,
idle_timeout_s=1,
)
session = DuplexSession(
session_id="sid-native-late-output",
config=DuplexSessionConfig(extra_body={"auto_response": True}),
)
session.capabilities = DuplexCapabilities.minicpmo45_native()
session.bind_request(request_id)
native = handler._minicpmo_session_state(session)
projected_batches: list[object] = []
projected = asyncio.Event()
async def project_late_output(_send_json, result, **_kwargs):
projected_batches.append(result)
projected.set()
session.close()
return None, True
monkeypatch.setattr(handler, "_send_native_duplex_events", project_late_output)
result = {
"stage_results": [
{
"result": {
"data_plane_append": True,
"request_id": request_id,
"response_stage_id": 1,
}
}
]
}
assert (
await handler._start_native_data_plane_stream_task(
None,
result,
session=session,
expected_epoch=session.epoch,
)
is True
)
first_drain = native.data_plane_task
assert first_drain is not None
await asyncio.sleep(0.01)
assert (
await handler._start_native_data_plane_stream_task(
None,
result,
session=session,
expected_epoch=session.epoch,
)
is False
)
await asyncio.wait_for(projected.wait(), timeout=1)
await asyncio.wait_for(first_drain, timeout=1)
assert len(engine.collected) == 4
assert projected_batches == [{"data_plane_outputs": [late_output]}]
@pytest.mark.asyncio
async def test_minicpmo_native_duplex_ignores_outputs_after_data_plane_turn_done():
request_id = "duplex-sid-native-post-done-e0-stage0-s1"
def _stage_output(samples: int, *, turn_end: bool = False):
return SimpleNamespace(
request_id=request_id,
finished=False,
outputs=[
SimpleNamespace(
text="hello",
multimodal_output={
"audio": np.zeros(samples, dtype=np.float32),
"sr": 24000,
"meta": {
"turn_end": turn_end,
"duplex_turn_id": 0,
"duplex_epoch": 0,
},
},
)
],
)
control_result = {
"operation": "append",
"session_id": "sid-native-post-done",
"ok": True,
"unsupported_count": 0,
"error_count": 0,
"stage_results": [
{
"stage_id": 0,
"replica_id": 0,
"result": {
"supported": True,
"implementation_level": "model_native_duplex",
"data_plane_append": True,
"request_id": request_id,
"response_stage_id": 1,
},
}
],
"data_plane_outputs": [_stage_output(10)],
}
engine = FakeEngineClient(
append_result=control_result,
collect_outputs=[
[_stage_output(20, turn_end=True)],
[_stage_output(30)],
],
)
chat_service = FakeChatService(engine)
handler = OmniDuplexSessionHandler(chat_service=chat_service, config_timeout_s=0.1, idle_timeout_s=1)
def close_on_done(ws: TimedWebSocket, data: dict[str, Any]) -> None:
if data.get("type") == "response.done":
ws.put({"type": "session.close"})
event = _native_session_create("sid-native-post-done")
event["session"]["extra_body"]["auto_response"] = True
ws = TimedWebSocket(on_send=close_on_done)
ws.put(event)
ws.put({"type": "input_audio_buffer.append", "audio": "AAAA", "format": "pcm_f32le"})
await handler.handle_session(ws)
assert ws.sent_types().count("response.created") == 1
assert ws.sent_types().count("response.done") == 1
deltas = [m for m in ws.sent if m.get("type") == "response.output_audio.delta"]
assert [m["audio"] for m in deltas] == ["wav-10", "wav-10"]
assert engine.collected
assert all(collected == (request_id, 1) for collected in engine.collected)
@pytest.mark.asyncio
async def test_minicpmo_native_duplex_accepts_next_append_after_response_done():
def _stage_output(*, finished: bool):
return SimpleNamespace(
request_id="duplex-sid-native-next-turn-e0-stage0-s1",
finished=finished,
outputs=[
SimpleNamespace(
text="",
multimodal_output={
"audio": np.zeros(10, dtype=np.float32),
"sr": 24000,
},
)
],
)
control_result = {
"operation": "append",
"session_id": "sid-native-next-turn",
"ok": True,
"unsupported_count": 0,
"error_count": 0,
"stage_results": [
{
"stage_id": 0,
"replica_id": 0,
"result": {
"supported": True,
"implementation_level": "model_native_duplex",
"data_plane_append": True,
"request_id": "duplex-sid-native-next-turn-e0-stage0-s1",
"response_stage_id": 1,
},
}
],
"data_plane_outputs": [_stage_output(finished=True)],
}
engine = FakeEngineClient(append_result=control_result)
chat_service = FakeChatService(engine)
handler = OmniDuplexSessionHandler(chat_service=chat_service, config_timeout_s=0.1, idle_timeout_s=1)
done_count = 0
def send_next_turn_or_close(ws: TimedWebSocket, data: dict[str, Any]) -> None:
nonlocal done_count
if data.get("type") != "response.done":
return
done_count += 1
if done_count == 1:
ws.put({"type": "input_audio_buffer.append", "audio": "BBBB", "format": "pcm_f32le"})
ws.put({"type": "input_audio_buffer.commit", "final": True})
ws.put({"type": "response.create"})
else:
ws.put({"type": "session.close"})
ws = TimedWebSocket(on_send=send_next_turn_or_close, receive_timeout_s=5)
create = _native_session_create("sid-native-next-turn")
create["session"]["idle_timeout_s"] = 5
ws.put(create)
ws.put({"type": "input_audio_buffer.append", "audio": "AAAA", "format": "pcm_f32le"})
await handler.handle_session(ws)
assert len(engine.appended) >= 2, [
(message.get("type"), message.get("code"), message.get("reason")) for message in ws.sent
]
assert len([m for m in ws.sent if m.get("type") == "response.done"]) == 2
@pytest.mark.asyncio
async def test_minicpmo_native_auto_response_commit_finalizes_after_streamed_full_chunk():
control_result = {
"operation": "append",
"session_id": "sid-native-auto-finalize",
"ok": True,
"unsupported_count": 0,
"error_count": 0,
"stage_results": [],
}
engine = FakeEngineClient(control_result=control_result)
handler = OmniDuplexSessionHandler(
chat_service=FakeChatService(engine),
config_timeout_s=0.1,
idle_timeout_s=1,
)
ws = TimedWebSocket()
event = _native_session_create("sid-native-auto-finalize")
event["session"]["extra_body"]["auto_response"] = True
ws.put(event)
ws.put(
{
"type": "input_audio_buffer.append",
"audio": _pcm_f32_b64(16000),
"format": "pcm_f32le",
"sample_rate_hz": 16000,
}
)
ws.put({"type": "input_audio_buffer.commit", "final": True})
ws.put({"type": "session.close"})
await handler.handle_session(ws)
assert len(engine.appended) == 1
assert engine.appended[0][3] is False
assert "response.created" not in ws.sent_types()
@pytest.mark.asyncio
async def test_minicpmo_native_auto_response_commit_finalizes_with_active_data_plane_stream():
request_id = "duplex-sid-native-auto-active-stream-stage0"
control_result = {
"operation": "append",
"session_id": "sid-native-auto-active-stream",
"ok": True,
"unsupported_count": 0,
"error_count": 0,
"stage_results": [
{
"result": {
"data_plane_append": True,
"request_id": request_id,
"response_stage_id": 1,
}
}
],
}
engine = FakeEngineClient(
control_result=control_result,
collect_outputs=[[], [], []],
collect_delay_s=0.05,
)
handler = OmniDuplexSessionHandler(
chat_service=FakeChatService(engine),
config_timeout_s=0.1,
idle_timeout_s=1,
)
ws = TimedWebSocket()
event = _native_session_create("sid-native-auto-active-stream")
event["session"]["extra_body"]["auto_response"] = True
ws.put(event)
ws.put(
{
"type": "input_audio_buffer.append",
"audio": _pcm_f32_b64(16000),
"format": "pcm_f32le",
"sample_rate_hz": 16000,
}
)
ws.put({"type": "input_audio_buffer.commit", "final": True})
ws.put({"type": "session.close"})
await handler.handle_session(ws)
assert len(engine.appended) == 1
assert engine.appended[0][3] is False
assert "response.created" not in ws.sent_types()
@pytest.mark.asyncio
async def test_minicpmo_native_auto_response_accepts_realtime_commit_after_stream_consumed():
engine = FakeEngineClient()
handler = OmniDuplexSessionHandler(
chat_service=FakeChatService(engine),
config_timeout_s=0.1,
idle_timeout_s=1,
)
ws = TimedWebSocket()
event = _native_session_create("sid-native-auto-consumed-stream")
event["session"]["extra_body"]["auto_response"] = True
ws.put(event)
ws.put(
{
"type": "input_audio_buffer.append",
"audio": _pcm_f32_b64(16000),
"format": "pcm_f32le",
"sample_rate_hz": 16000,
}
)
ws.put(
{
"type": "input_audio_buffer.commit",
"final": True,
"realtime_item_id": "item-consumed-stream",
}
)
ws.put({"type": "session.close"})
await handler.handle_session(ws)
assert not any(message.get("code") == "input_audio_buffer_empty" for message in ws.sent)
committed = next(message for message in ws.sent if message.get("type") == "input.committed")
assert committed["realtime_item_id"] == "item-consumed-stream"
assert committed["native_audio"] is True
@pytest.mark.asyncio
async def test_minicpmo_native_auto_response_post_response_commit_ignores_stale_progress_state():
session_id = "sid-native-auto-post-response"
engine = FakeEngineClient()
handler = OmniDuplexSessionHandler(
chat_service=FakeChatService(engine),
config_timeout_s=0.1,
idle_timeout_s=1,
)
def on_send(ws: TimedWebSocket, data: dict[str, Any]) -> None:
if data.get("type") != "session.created":
return
session = handler._registry.get(session_id)
assert session is not None
ws.put(
{
"type": "input_audio_buffer.append",
"audio": _pcm_f32_b64(8000),
"format": "pcm_f32le",
"sample_rate_hz": 16000,
}
)
ws.put({"type": "input_audio_buffer.commit", "final": True})
ws.put({"type": "session.close"})
ws = TimedWebSocket(on_send=on_send)
create = _native_session_create(session_id)
create["session"]["extra_body"]["auto_response"] = True
ws.put(create)
await handler.handle_session(ws)
assert len(engine.appended) == 1
assert engine.appended[0][3] is True
assert "input.committed" in ws.sent_types()
assert not any(message.get("code") == "response_already_active" for message in ws.sent)
def test_realtime_overlap_commit_uses_current_generation_identity():
handler, session = _auto_response_context("sid-realtime-reserved-input-turn")
session.turn_id = 1
session.bind_response_turn(1)
session.turn_id = 3
committed = handler._commit_native_audio_input(session)
assert committed.turn_id == 3
assert session.turn_id == 3
@pytest.mark.asyncio
async def test_minicpmo_native_auto_response_keeps_request_bound_for_segment_continuation():
request_id = "duplex-sid-native-auto-continuation-e0-stage0"
control_result = {
"operation": "append",
"session_id": "sid-native-auto-continuation",
"ok": True,
"unsupported_count": 0,
"error_count": 0,
"stage_results": [
{
"result": {
"data_plane_append": True,
"request_id": request_id,
"response_stage_id": 1,
}
}
],
}
terminal_segment = _duplex_tts_output(
request_id=request_id,
samples=0,
finished=True,
tts_is_last_chunk=True,
token_ids=[151645],
)
engine = FakeEngineClient(
control_result=control_result,
collect_outputs=[[terminal_segment], [], []],
collect_delay_s=0.05,
)
handler = OmniDuplexSessionHandler(
chat_service=FakeChatService(engine),
config_timeout_s=0.1,
idle_timeout_s=1,
)
def on_send(ws: TimedWebSocket, data: dict[str, Any]) -> None:
if data.get("type") != "session.created":
return
session = handler._registry.get("sid-native-auto-continuation")
assert session is not None
session.capabilities.chunk_period_ms = 50
ws.put(
{
"type": "input_audio_buffer.append",
"audio": _pcm_f32_b64(800),
"format": "pcm_f32le",
"sample_rate_hz": 16000,
}
)
ws.put({"type": "input_audio_buffer.commit", "final": True})
ws = TimedWebSocket(on_send=on_send)
event = _native_session_create("sid-native-auto-continuation")
event["session"]["extra_body"]["auto_response"] = True
ws.put(event)
await handler.handle_session(ws)
assert len(engine.appended) >= 3
_, mode, payload, final = engine.appended[2]
assert mode == "append_audio_chunk"
assert payload["duplex_turn_id"] == 0
assert final is False
@pytest.mark.asyncio
async def test_minicpmo_native_auto_response_real_input_preempts_unsent_silence():
request_id = "duplex-sid-native-real-before-silence-e0-stage0"
class SequencedAppendEngine(FakeEngineClient):
def __init__(self) -> None:
control_result = {
"operation": "append",
"session_id": "sid-native-real-before-silence",
"ok": True,
"unsupported_count": 0,
"error_count": 0,
"stage_results": [
{
"result": {
"data_plane_append": True,
"request_id": request_id,
"response_stage_id": 1,
}
}
],
}
terminal_segment = _duplex_tts_output(
request_id=request_id,
samples=0,
finished=True,
tts_is_last_chunk=True,
token_ids=[151645],
)
super().__init__(
control_result=control_result,
collect_outputs=[[terminal_segment], [], []],
collect_delay_s=0.01,
)
self.append_sequence: list[str] = []
self.first_real_started = asyncio.Event()
self.second_real_started = asyncio.Event()
self.real_count = 0
async def append_duplex_input_async(self, session_id: str, **kwargs):
kwargs.pop("expected_epoch", None)
result = await super().append_duplex_input_async(session_id, **kwargs)
payload = kwargs.get("payload")
is_silence = (
isinstance(payload, dict)
and payload.get("audio") == OmniDuplexSessionHandler._NATIVE_SILENCE_UNIT_PAYLOAD_AUDIO
)
if is_silence:
self.append_sequence.append("silence")
else:
self.real_count += 1
self.append_sequence.append(f"real-{self.real_count}")
if self.real_count == 1:
self.first_real_started.set()
elif self.real_count == 2:
self.second_real_started.set()
return result
session_created = asyncio.Event()
engine = SequencedAppendEngine()
handler = OmniDuplexSessionHandler(
chat_service=FakeChatService(engine),
config_timeout_s=0.1,
idle_timeout_s=1,
)
def on_send(ws: TimedWebSocket, data: dict[str, Any]) -> None:
if data.get("type") != "session.created":
return
session = handler._registry.get("sid-native-real-before-silence")
assert session is not None
session.capabilities.chunk_period_ms = 50
ws.put(
{
"type": "input_audio_buffer.append",
"audio": _pcm_f32_b64(800),
"format": "pcm_f32le",
"sample_rate_hz": 16000,
}
)
session_created.set()
ws = TimedWebSocket(on_send=on_send, receive_timeout_s=2.0)
event = _native_session_create("sid-native-real-before-silence")
event["session"]["extra_body"]["auto_response"] = True
ws.put(event)
handler_task = asyncio.create_task(handler.handle_session(ws))
await asyncio.wait_for(session_created.wait(), timeout=1)
await asyncio.wait_for(engine.first_real_started.wait(), timeout=1)
await asyncio.sleep(0.02)
ws.put(
{
"type": "input_audio_buffer.append",
"audio": _pcm_f32_b64(800, value=0.07),
"format": "pcm_f32le",
"sample_rate_hz": 16000,
}
)
await asyncio.wait_for(engine.second_real_started.wait(), timeout=1)
ws.put({"type": "session.close"})
await asyncio.wait_for(handler_task, timeout=2)
assert engine.append_sequence[:2] == ["real-1", "real-2"]
@pytest.mark.asyncio
async def test_minicpmo_native_auto_response_real_input_waits_for_submitted_silence_tail():
request_id = "duplex-sid-native-silence-tail-e0-stage0"
class SubmittedSilenceEngine(FakeEngineClient):
def __init__(self) -> None:
control_result = {
"operation": "append",
"session_id": "sid-native-silence-tail",
"ok": True,
"unsupported_count": 0,
"error_count": 0,
"stage_results": [
{
"result": {
"data_plane_append": True,
"request_id": request_id,
"response_stage_id": 1,
}
}
],
}
terminal_segment = _duplex_tts_output(
request_id=request_id,
samples=0,
finished=True,
tts_is_last_chunk=True,
token_ids=[151645],
)
super().__init__(
control_result=control_result,
collect_outputs=[[terminal_segment], [], []],
collect_delay_s=0.01,
)
self.silence_append_started = asyncio.Event()
self.release_silence_append = asyncio.Event()
self.append_sequence: list[str] = []
self.real_append_count = 0
async def append_duplex_input_async(self, session_id: str, **kwargs):
kwargs.pop("expected_epoch", None)
payload = kwargs.get("payload")
is_silence_continuation = (
isinstance(payload, dict)
and payload.get("format") == "pcm_f32le"
and payload.get("audio") == OmniDuplexSessionHandler._NATIVE_SILENCE_UNIT_PAYLOAD_AUDIO
)
if is_silence_continuation:
result = await super().append_duplex_input_async(session_id, **kwargs)
self.append_sequence.append("silence-started")
self.silence_append_started.set()
await self.release_silence_append.wait()
self.append_sequence.append("silence-returned")
return result
result = await super().append_duplex_input_async(session_id, **kwargs)
if (
isinstance(payload, dict)
and payload.get("audio") != OmniDuplexSessionHandler._NATIVE_SILENCE_UNIT_PAYLOAD_AUDIO
):
self.real_append_count += 1
self.append_sequence.append(f"real-{self.real_append_count}")
return result
engine = SubmittedSilenceEngine()
handler = OmniDuplexSessionHandler(
chat_service=FakeChatService(engine),
config_timeout_s=0.1,
idle_timeout_s=1,
)
def on_send(ws: TimedWebSocket, data: dict[str, Any]) -> None:
if data.get("type") != "session.created":
return
session = handler._registry.get("sid-native-silence-tail")
assert session is not None
session.capabilities.chunk_period_ms = 50
ws.put(
{
"type": "input_audio_buffer.append",
"audio": _pcm_f32_b64(800, value=0.05),
"format": "pcm_f32le",
"sample_rate_hz": 16000,
}
)
ws = TimedWebSocket(on_send=on_send, receive_timeout_s=2.0)
event = _native_session_create("sid-native-silence-tail")
event["session"]["extra_body"]["auto_response"] = True
ws.put(event)
handler_task = asyncio.create_task(handler.handle_session(ws))
await asyncio.wait_for(engine.silence_append_started.wait(), timeout=1)
ws.put(
{
"type": "input_audio_buffer.append",
"audio": _pcm_f32_b64(16000, value=0.07),
"format": "pcm_f32le",
"sample_rate_hz": 16000,
}
)
await asyncio.sleep(0.05)
assert engine.real_append_count == 1
assert engine.append_sequence == ["real-1", "silence-started"]
engine.release_silence_append.set()
ws.put({"type": "session.close"})
await asyncio.wait_for(handler_task, timeout=2)
assert engine.real_append_count == 2
assert engine.append_sequence == ["real-1", "silence-started", "silence-returned", "real-2"]
@pytest.mark.asyncio
async def test_minicpmo_native_auto_response_preserves_silence_continuations_across_append_tails():
request_id = "duplex-sid-native-continuation-tails-e0-stage0"
class SequencedAppendEngine(FakeEngineClient):
def __init__(self) -> None:
control_result = {
"operation": "append",
"session_id": "sid-native-continuation-tails",
"ok": True,
"unsupported_count": 0,
"error_count": 0,
"stage_results": [
{
"result": {
"data_plane_append": True,
"request_id": request_id,
"response_stage_id": 1,
}
}
],
}
super().__init__(control_result=control_result)
self.real_append_started = asyncio.Event()
self.release_real_append = asyncio.Event()
self.first_silence_started = asyncio.Event()
self.release_first_silence = asyncio.Event()
self.second_silence_started = asyncio.Event()
self.release_collect = asyncio.Event()
self.silence_count = 0
async def append_duplex_input_async(self, session_id: str, **kwargs):
kwargs.pop("expected_epoch", None)
payload = kwargs.get("payload")
assert isinstance(payload, dict)
result = await super().append_duplex_input_async(session_id, **kwargs)
if payload.get("audio") != OmniDuplexSessionHandler._NATIVE_SILENCE_UNIT_PAYLOAD_AUDIO:
self.real_append_started.set()
await self.release_real_append.wait()
return result
self.silence_count += 1
if self.silence_count == 1:
self.first_silence_started.set()
await self.release_first_silence.wait()
else:
self.second_silence_started.set()
return result
async def collect_duplex_data_plane_outputs_async(
self,
request_id: str,
*,
response_stage_id: int | None = None,
timeout: float | None = None,
) -> list[object]:
del timeout
self.collected.append((request_id, response_stage_id))
await self.release_collect.wait()
return []
session_created = asyncio.Event()
def on_send(_ws: TimedWebSocket, data: dict[str, Any]) -> None:
if data.get("type") == "session.created":
session_created.set()
engine = SequencedAppendEngine()
handler = OmniDuplexSessionHandler(
chat_service=FakeChatService(engine),
config_timeout_s=0.1,
idle_timeout_s=1,
)
ws = TimedWebSocket(on_send=on_send, receive_timeout_s=2.0)
event = _native_session_create("sid-native-continuation-tails")
event["session"]["extra_body"]["auto_response"] = True
ws.put(event)
ws.put(
{
"type": "input_audio_buffer.append",
"audio": _pcm_f32_b64(16000, value=0.05),
"format": "pcm_f32le",
"sample_rate_hz": 16000,
}
)
handler_task = asyncio.create_task(handler.handle_session(ws))
await asyncio.wait_for(session_created.wait(), timeout=1)
await asyncio.wait_for(engine.real_append_started.wait(), timeout=1)
session = handler._registry.get("sid-native-continuation-tails")
assert session is not None
session.capabilities.chunk_period_ms = 50
session.bind_request(request_id)
response_id = session.begin_response(turn_id=session.turn_id)
native = handler._minicpmo_session_state(session)
scheduler = native.silence_continuation_scheduler
assert scheduler is not None
payload = handler._native_silence_unit_payload()
payload["duplex_turn_id"] = session.turn_id
kwargs = {
"request_id": request_id,
"owner_id": f"response:{response_id}",
"response_id": response_id,
"response_owned": True,
"expected_epoch": session.epoch,
"expected_incarnation": session.incarnation,
"expected_model_turn_id": None,
"send_json": ws.send_json,
}
assert await scheduler(payload, **kwargs) is True
next_schedule = asyncio.create_task(scheduler(payload, **kwargs))
await asyncio.sleep(0.05)
assert not next_schedule.done()
engine.release_real_append.set()
await asyncio.wait_for(engine.first_silence_started.wait(), timeout=1)
assert not next_schedule.done()
engine.release_first_silence.set()
assert await asyncio.wait_for(next_schedule, timeout=1) is True
await asyncio.wait_for(engine.second_silence_started.wait(), timeout=1)
ws.put({"type": "session.close"})
await asyncio.wait_for(handler_task, timeout=2)
assert engine.silence_count == 2
@pytest.mark.asyncio
async def test_minicpmo_native_duplex_uses_segment_text_metadata_for_transcript_cursor():
request_id = "duplex-sid-native-repeat-text-e0-stage0"
def _stage_output(
text: str,
samples: int,
*,
segment_text: str,
turn_id: int = 0,
turn_end: bool = False,
token_ids: list[int] | None = None,
):
return SimpleNamespace(
request_id=request_id,
finished=False,
outputs=[
SimpleNamespace(
text=text,
token_ids=list(token_ids or []),
multimodal_output={
"audio": np.zeros(samples, dtype=np.float32),
"sr": 24000,
"meta.llm_output_text_utf8": np.frombuffer(segment_text.encode("utf-8"), dtype=np.uint8),
"meta": {"duplex_turn_id": turn_id, "turn_end": turn_end},
},
)
],
)
handler = OmniDuplexSessionHandler(chat_service=FakeChatService(FakeEngineClient()))
session = DuplexSession(
session_id="sid-native-repeat-text",
config=DuplexSessionConfig(extra_body={"auto_response": True}),
)
sent: list[dict[str, Any]] = []
async def send_json(payload: dict[str, Any]) -> None:
sent.append(payload)
async def emit(output: object) -> None:
result = {"data_plane_outputs": [output]}
for native_result in handler._minicpmo_data_plane.project(
result,
context=_data_plane_context(session),
):
await handler._send_one_native_duplex_event(send_json, native_result, session=session)
await emit(_stage_output("same reply", 24000, segment_text="same reply"))
await emit(
_stage_output("same replysame reply", 24000, segment_text="same reply", turn_end=True, token_ids=[151645])
)
await emit(_stage_output("same replysame replysame reply", 48000, segment_text="same reply", turn_id=1))
await emit(
_stage_output(
"same replysame replysame replysame reply",
48000,
segment_text="same reply",
turn_id=1,
turn_end=True,
token_ids=[151645],
)
)
created = [m for m in sent if m.get("type") == "response.created"]
done = [m for m in sent if m.get("type") == "response.done"]
deltas = [m for m in sent if m.get("type") == "response.output_audio.delta" and m.get("audio")]
protocol = NativeRealtimeSessionProtocol(TimedWebSocket()) # type: ignore[arg-type]
realtime_events = [event for payload in sent for event in protocol._from_duplex_event(payload)]
transcript_deltas = [
m for m in realtime_events if m.get("type") == "response.audio_transcript.delta" and m.get("delta")
]
assert len(created) == 2
assert len(done) == 2
assert len(deltas) == 2
assert [m["text"] for m in deltas] == ["same reply", "same reply"]
assert [m["audio"] for m in deltas] == ["wav-24000", "wav-24000"]
assert [m["delta"] for m in transcript_deltas] == ["same reply", "same reply"]
assert [m["response_id"] for m in deltas] == [m["response_id"] for m in created]
assert [m["response_id"] for m in done] == [m["response_id"] for m in created]
@pytest.mark.asyncio
async def test_minicpmo_native_duplex_continuous_speak_reuses_active_response_until_turn_end():
request_id = "duplex-sid-native-continuous-speak-e0-stage0"
def _stage_output(
text: str,
samples: int,
*,
turn_end: bool = False,
token_ids: list[int] | None = None,
):
return SimpleNamespace(
request_id=request_id,
finished=turn_end,
outputs=[
SimpleNamespace(
text=text,
token_ids=list(token_ids or []),
multimodal_output={},
)
],
multimodal_output={
"audio": np.zeros(samples, dtype=np.float32),
"sr": 24000,
"meta.duplex_turn_id": np.array([0], dtype=np.int32),
"meta.duplex_epoch": np.array([0], dtype=np.int32),
"meta.turn_end": np.array([int(turn_end)], dtype=np.int32),
"meta.tts_is_last_chunk": np.array([int(turn_end)], dtype=np.int32),
},
)
handler = OmniDuplexSessionHandler(chat_service=FakeChatService(FakeEngineClient()))
session = DuplexSession(
session_id="sid-native-continuous-speak",
config=DuplexSessionConfig(extra_body={"auto_response": True}),
)
sent: list[dict[str, Any]] = []
async def send_json(payload: dict[str, Any]) -> None:
sent.append(payload)
async def emit(output: object) -> None:
result = {"data_plane_outputs": [output]}
for native_result in handler._minicpmo_data_plane.project(
result,
context=_data_plane_context(session),
):
await handler._send_one_native_duplex_event(send_json, native_result, session=session)
await emit(_stage_output("hello", 24000))
await emit(_stage_output("hello again", 48000))
created = [m for m in sent if m.get("type") == "response.created"]
deltas = [m for m in sent if m.get("type") == "response.output_audio.delta" and m.get("audio")]
assert len(created) == 1
assert len(deltas) == 2
assert {m["response_id"] for m in deltas} == {created[0]["response_id"]}
assert "response.done" not in [m.get("type") for m in sent]
await emit(_stage_output("hello again", 48000, turn_end=True, token_ids=[151645]))
done = [m for m in sent if m.get("type") == "response.done"]
assert len(done) == 1
assert done[0]["response_id"] == created[0]["response_id"]
assert session.active_response_id is None
assert session.turn_id == 1
@pytest.mark.asyncio
async def test_continuous_response_metrics_accumulate_only_owned_model_units():
handler = OmniDuplexSessionHandler(chat_service=FakeChatService(FakeEngineClient()))
session = DuplexSession(
session_id="sid-response-metrics",
config=DuplexSessionConfig(extra_body={"auto_response": True}),
)
sent: list[dict[str, Any]] = []
async def send_json(payload: dict[str, Any]) -> None:
sent.append(payload)
async def emit(*, text: str, audio: str, tokens: int, ttft_ms: float, itls: list[float]) -> None:
await handler._send_one_native_duplex_event(
send_json,
{
"supported": True,
"stage_role": "tts",
"is_listen": False,
"data_plane_request_id": "duplex-sid-response-metrics-e0-stage0",
"text": text,
"audio_data": audio,
"audio_format": "pcm16",
"audio_duration_ms": 100,
"end_of_turn": False,
"model_turn_id": 0,
"stage_metrics": {
"0": {
"num_tokens_out": tokens,
"stage_gen_time_ms": 80.0,
"vllm_ttft_ms": ttft_ms,
"vllm_tpot_ms": 9.0,
"vllm_itls_ms": itls,
}
},
},
session=session,
)
await emit(text="first", audio="audio-a", tokens=4, ttft_ms=50.0, itls=[10.0, 11.0])
await emit(text="second", audio="audio-b", tokens=6, ttft_ms=70.0, itls=[12.0, 13.0, 14.0])
deltas = [payload for payload in sent if payload.get("type") == "response.output_audio.delta"]
assert len(deltas) == 2
first_metrics = deltas[0]["vllm_omni"]["stage_metrics"]["0"]
second_metrics = deltas[1]["vllm_omni"]["stage_metrics"]["0"]
assert first_metrics["num_tokens_out"] == 4
assert second_metrics["num_tokens_out"] == 10
assert second_metrics["vllm_ttft_ms"] == 50.0
assert second_metrics["vllm_itls_ms"] == [10.0, 11.0, 12.0, 13.0, 14.0]
first_response_id = deltas[0]["response_id"]
session.end_response()
await emit(text="third", audio="audio-c", tokens=3, ttft_ms=90.0, itls=[15.0, 16.0])
third_delta = [payload for payload in sent if payload.get("type") == "response.output_audio.delta"][-1]
third_metrics = third_delta["vllm_omni"]["stage_metrics"]["0"]
assert third_delta["response_id"] != first_response_id
assert third_metrics["num_tokens_out"] == 3
assert third_metrics["vllm_ttft_ms"] == 90.0
assert third_metrics["vllm_itls_ms"] == [15.0, 16.0]
@pytest.mark.asyncio
async def test_minicpmo_native_duplex_drops_old_turn_audio_while_new_response_is_active():
request_id = "duplex-sid-native-old-turn-audio-e0-stage0"
handler = OmniDuplexSessionHandler(chat_service=FakeChatService(FakeEngineClient()))
session = DuplexSession(
session_id="sid-native-old-turn-audio",
config=DuplexSessionConfig(extra_body={"auto_response": True}),
)
response_id = session.begin_response(turn_id=1)
session.turn_id = 2
session.bind_request(request_id)
ws = TimedWebSocket()
close_reason, emitted = await handler._send_one_native_duplex_event(
ws.send_json,
{
"supported": True,
"stage_role": "tts",
"is_listen": False,
"data_plane_request_id": request_id,
"text": "stale",
"audio_data": "old-audio",
"audio_format": "pcm16",
"audio_duration_ms": 100,
"end_of_turn": False,
"model_turn_id": 0,
"uses_model_runner_scheduler": True,
"runner_kv_backed": True,
"runtime_impl": "scheduler_data_plane",
},
session=session,
expected_epoch=session.epoch,
)
assert close_reason is None
assert emitted is False
assert session.active_response_id == response_id
assert ws.sent == []
@pytest.mark.asyncio
async def test_minicpmo_native_duplex_future_turn_audio_starts_next_response():
request_id = "duplex-sid-native-future-turn-audio-e0-stage0"
handler = OmniDuplexSessionHandler(chat_service=FakeChatService(FakeEngineClient()))
session = DuplexSession(
session_id="sid-native-future-turn-audio",
config=DuplexSessionConfig(extra_body={"auto_response": True}),
)
first_response_id = session.begin_response(turn_id=0)
session.bind_request(request_id)
ws = TimedWebSocket()
close_reason, emitted = await handler._send_one_native_duplex_event(
ws.send_json,
{
"supported": True,
"stage_role": "tts",
"is_listen": False,
"data_plane_request_id": request_id,
"text": "next",
"audio_data": "next-audio",
"audio_format": "pcm16",
"audio_duration_ms": 100,
"end_of_turn": False,
"model_turn_id": 1,
"uses_model_runner_scheduler": True,
"runner_kv_backed": True,
"runtime_impl": "scheduler_data_plane",
},
session=session,
expected_epoch=session.epoch,
)
assert close_reason is None
assert emitted is True
sent_types = [event["type"] for event in ws.sent]
assert sent_types == [
"response.done",
"response.created",
"response.speak",
"response.output_audio.delta",
]
assert ws.sent[0]["response_id"] == first_response_id
assert ws.sent[1]["response_id"] != first_response_id
assert ws.sent[3]["response_id"] == ws.sent[1]["response_id"]
assert ws.sent[3]["vllm_omni"]["model_turn_id"] == 1
assert session.active_response_turn_id == 1
assert session.turn_id == 1
@pytest.mark.asyncio
async def test_minicpmo_native_duplex_drops_old_turn_listen_while_new_response_is_active():
request_id = "duplex-sid-native-old-turn-listen-e0-stage0"
handler = OmniDuplexSessionHandler(chat_service=FakeChatService(FakeEngineClient()))
session = DuplexSession(
session_id="sid-native-old-turn-listen",
config=DuplexSessionConfig(extra_body={"auto_response": True}),
)
response_id = session.begin_response(turn_id=1)
session.turn_id = 2
session.bind_request(request_id)
ws = TimedWebSocket()
close_reason, emitted = await handler._send_one_native_duplex_event(
ws.send_json,
{
"supported": True,
"stage_role": "llm",
"is_listen": True,
"model_listen": True,
"data_plane_request_id": request_id,
"end_of_turn": False,
"model_turn_id": 0,
"uses_model_runner_scheduler": True,
"runner_kv_backed": True,
"runtime_impl": "scheduler_data_plane",
},
session=session,
expected_epoch=session.epoch,
)
assert close_reason is None
assert emitted is False
assert session.active_response_id == response_id
assert ws.sent == []
@pytest.mark.asyncio
async def test_minicpmo_native_duplex_silence_continuation_requires_active_request():
engine = FakeEngineClient()
chat_service = FakeChatService(engine)
handler = OmniDuplexSessionHandler(chat_service=chat_service, config_timeout_s=0.1, idle_timeout_s=1)
session = DuplexSession(
session_id="sid-native-stale-continuation",
config=DuplexSessionConfig(extra_body={"auto_response": True}),
)
session.begin_response()
session.bind_request("duplex-sid-native-stale-continuation-stage0")
session.mark_audio_sent(100)
sent: list[dict[str, Any]] = []
async def send_json(data: dict[str, Any]) -> None:
sent.append(data)
_install_direct_silence_scheduler(handler, session)
await handler._maybe_continue_native_response(send_json, session=session, expected_epoch=session.epoch)
session.clear_request()
await asyncio.sleep(0.01)
assert engine.appended == []
@pytest.mark.asyncio
async def test_minicpmo_auto_response_segment_complete_continues_until_model_listen():
engine = FakeEngineClient()
chat_service = FakeChatService(engine)
handler = OmniDuplexSessionHandler(chat_service=chat_service, config_timeout_s=0.1, idle_timeout_s=1)
session = DuplexSession(
session_id="sid-native-segment-complete",
config=DuplexSessionConfig(extra_body={"auto_response": True}),
)
session.capabilities = DuplexCapabilities.minicpmo45_native()
response_id = session.begin_response()
request_id = "duplex-sid-native-segment-complete-stage0"
session.bind_request(request_id)
session.mark_audio_sent(1000)
_install_direct_silence_scheduler(handler, session)
sent: list[dict[str, Any]] = []
async def send_json(data: dict[str, Any]) -> None:
sent.append(data)
close_reason, emitted = await handler._send_one_native_duplex_event(
send_json,
{
"supported": True,
"stage_role": "llm",
"is_listen": True,
"model_listen": False,
"listen_source": "auto_response_segment_complete",
"reason": "auto_response_segment_complete",
"data_plane_request_id": request_id,
"end_of_turn": False,
"uses_model_runner_scheduler": True,
"runner_kv_backed": True,
"runtime_impl": "scheduler_data_plane",
"owned_runtime": False,
},
session=session,
expected_epoch=session.epoch,
)
await asyncio.sleep(0.01)
assert close_reason is None
assert emitted is False
assert session.active_response_id == response_id
assert session.active_request_id == request_id
assert not handler._minicpmo_data_plane.is_terminal(request_id)
assert sent == []
assert len(engine.appended) == 1
_, mode, payload, final = engine.appended[0]
assert mode == "append_audio_chunk"
assert payload["type"] == "audio"
assert payload.get("force_listen") is not True
assert final is False
@pytest.mark.parametrize(
("steps", "expected_deltas"),
[
(
[("你好呀", 1), ("你好呀", 1), ("你好呀", 2)],
["你好呀", "", "你好呀"],
),
(
[("你好呀", 1), ("你好呀", 2)],
["你好呀", "你好呀"],
),
],
ids=["turn-scoped", "exact-repeated-turn-text"],
)
def test_minicpmo_data_plane_text_cursor(
steps: list[tuple[str, int]],
expected_deltas: list[str],
):
data_plane = _test_data_plane()
request_id = "duplex-sid-native-e0-stage0"
deltas = [data_plane.segment_text_delta(request_id, text, turn_id=turn_id) for text, turn_id in steps]
assert deltas == expected_deltas
def test_minicpmo_data_plane_text_cursor_does_not_clip_prior_turn_overlap():
data_plane = _test_data_plane()
request_id = "duplex-sid-native-e0-stage0"
assert data_plane.segment_text_delta(request_id, "你好呀。你有什么想聊的吗?", turn_id=1)
assert (
data_plane.segment_text_delta(request_id, "你有什么想聊的吗?你好呀。", turn_id=2)
== "你有什么想聊的吗?你好呀。"
)
@pytest.mark.asyncio
async def test_minicpmo_native_duplex_cancel_interrupts_background_data_plane_stream():
control_result = {
"operation": "append",
"session_id": "sid-native-cancel-stream",
"ok": True,
"unsupported_count": 0,
"error_count": 0,
"stage_results": [
{
"stage_id": 0,
"replica_id": 0,
"result": {
"supported": True,
"implementation_level": "model_native_duplex",
"data_plane_append": True,
"request_id": "duplex-sid-native-cancel-stream-e0-stage0-s1",
"response_stage_id": 1,
},
}
],
}
engine = FakeEngineClient(
append_result=control_result,
collect_delay_s=0.2,
collect_outputs=[
[
SimpleNamespace(
request_id="duplex-sid-native-cancel-stream-e0-stage0-s1",
finished=True,
outputs=[
SimpleNamespace(
text="",
multimodal_output={"audio": np.zeros(10, dtype=np.float32), "sr": 24000},
)
],
)
]
],
)
chat_service = FakeChatService(engine)
handler = OmniDuplexSessionHandler(chat_service=chat_service, config_timeout_s=0.1, idle_timeout_s=1)
ws = TimedWebSocket()
ws.put(_native_session_create("sid-native-cancel-stream"))
ws.put({"type": "input_audio_buffer.append", "audio": "AAAA", "format": "pcm_f32le"})
ws.put({"type": "input.cancel"})
ws.put({"type": "session.close"})
await handler.handle_session(ws)
assert "audio.cancelled" in ws.sent_types()
assert "response.output_audio.delta" not in ws.sent_types()
assert engine.signals == [("sid-native-cancel-stream", "barge_in")]
assert engine.signal_fences == [DuplexFence("sid-native-cancel-stream")]
@pytest.mark.asyncio
async def test_minicpmo_native_duplex_session_close_discards_partial_pcm_tail():
engine = FakeEngineClient()
chat_service = FakeChatService(engine)
handler = OmniDuplexSessionHandler(chat_service=chat_service, config_timeout_s=0.1, idle_timeout_s=1)
ws = TimedWebSocket()
ws.put(_native_session_create("sid-native-short-chunks"))
chunk = _pcm_f32_b64(3200)
for _ in range(6):
ws.put(
{
"type": "input_audio_buffer.append",
"audio": chunk,
"format": "pcm_f32le",
"sample_rate_hz": 16000,
}
)
ws.put({"type": "session.close"})
await handler.handle_session(ws)
assert len(engine.appended) == 1
_, mode, payload, final = engine.appended[0]
assert mode == "append_audio_chunk"
assert final is False
assert isinstance(payload, dict)
assert len(base64.b64decode(payload["audio"])) == 16000 * 4
assert "duplex_num_input_tokens" not in payload
@pytest.mark.asyncio
async def test_minicpmo_native_duplex_idle_timeout_closes_runtime_with_timeout_reason():
engine = FakeEngineClient()
chat_service = FakeChatService(engine)
handler = OmniDuplexSessionHandler(chat_service=chat_service, config_timeout_s=0.1, idle_timeout_s=0.1)
ws = TimedWebSocket()
ws.put(_native_session_create("sid-native-timeout"))
await handler.handle_session(ws)
assert ws.sent_types() == ["session.created", "session.closed"]
assert ws.sent[-1]["reason"] == "timeout"
assert engine.closed == [("sid-native-timeout", "timeout")]