Files
vllm-omni/tests/e2e/online_serving/test_duplex_client_live.py
T
chickeytonandClaude Opus 5 99ff4f3070 [Core][Frontend] Unified Full-duplex Framework (#7413)
Signed-off-by: chickeyton <ngton2014@gmail.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 15:35:36 +08:00

138 lines
5.2 KiB
Python

# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""Live E2E coverage for the public duplex client (``vllm_omni.clients.duplex``).
Drives a real MiniCPM-o 4.5 duplex session end to end through
:class:`DuplexClient`: session handshake with reference audio, paced PCM
streaming, a speak response consumed via :class:`ResponseHandle` with
incremental playback acks, a forced transport drop recovered by automatic
``session.resume``, and a clean close.
"""
from __future__ import annotations
import asyncio
from pathlib import Path
import pytest
from tests.e2e.online_serving.helpers.minicpmo_4_5_duplex import (
CORE_SERVER_PARAMS,
realtime_url,
resolve_ref_audio,
validated_input_wav,
)
from tests.helpers.mark import hardware_test
from vllm_omni.clients.duplex import (
DuplexClient,
EventCollector,
ReconnectPolicy,
audio_data_url,
read_pcm16_wav,
)
from vllm_omni.clients.minicpmo_4_5 import create_duplex_session_config
pytestmark = pytest.mark.omni
_SESSION_TIMEOUT_S = 900.0
_MAX_LISTEN_ROUNDS = 30
async def _run_live_session(*, url: str, model: str, ref_audio: Path, input_wav: Path) -> dict[str, object]:
# temperature 0.0 keeps the response-required reply short and
# deterministic (same as the validated demo driver).
config = create_duplex_session_config(ref_audio=audio_data_url(ref_audio), temperature=0.0)
client = DuplexClient(
url,
model=model,
config=config,
reconnect=ReconnectPolicy(max_attempts=5, backoff_s=(0.25, 1.0)),
# Short interval so the periodic heartbeat path runs during the
# session — including across the forced transport drop below.
heartbeat_interval_s=5.0,
)
collector = EventCollector()
pcm = read_pcm16_wav(input_wav)
summary: dict[str, object] = {}
async with client:
collector_task = asyncio.create_task(collector.consume(client))
summary["session_id"] = client.session_id
summary["resume_token_issued"] = client.resume_token is not None
# Phase 1: stream the validated utterance and consume one speak response.
await client.stream_pcm(pcm, chunk_ms=200)
await client.commit()
listens = 0
async for response in client.responses():
if response.decision == "listen":
listens += 1
if listens > _MAX_LISTEN_ROUNDS:
raise AssertionError(f"no speak response after {listens} listen decisions")
continue
chunk_count = 0
audio_byte_count = 0
async for chunk in response.audio():
chunk_count += 1
audio_byte_count += len(chunk)
await client.ack_playback(response.played_ms)
summary.update(
{
"decision": response.decision,
"listen_rounds": listens,
"audio_chunks": chunk_count,
"audio_bytes": audio_byte_count,
"played_ms": response.played_ms,
"transcript": response.transcript,
}
)
break
# Phase 2: force-drop the transport underneath the client and require
# the automatic session.resume path to bring the session back.
resumed_waiter = asyncio.create_task(client.wait_for("session.resumed", timeout_s=90.0))
await asyncio.sleep(0.1) # let the waiter subscribe before the drop
assert client._ws is not None
await client._ws.close()
resumed = await resumed_waiter
summary["resumed_session_id"] = resumed.session_id or client.session_id
# The resumed transport must be live: a heartbeat gets acknowledged.
ack_waiter = asyncio.create_task(client.wait_for("session.heartbeat_ack", timeout_s=30.0))
await asyncio.sleep(0.1)
await client.send({"type": "session.heartbeat"})
await ack_waiter
summary["post_resume_heartbeat_ok"] = True
await client.close()
await asyncio.wait_for(collector_task, timeout=10.0)
summary["error_events"] = collector.errors()
summary["closed_cleanly"] = collector.count("session.closed") > 0
summary["timing"] = collector.timing_summary(after_s=0.0)
return summary
@pytest.mark.core_model
@hardware_test(res={"cuda": "H100"}, num_cards=1)
@pytest.mark.parametrize("omni_server", CORE_SERVER_PARAMS, indirect=True)
def test_duplex_client_live_session(omni_server) -> None:
summary = asyncio.run(
asyncio.wait_for(
_run_live_session(
url=realtime_url(omni_server),
model=omni_server.model,
ref_audio=resolve_ref_audio(),
input_wav=validated_input_wav(),
),
timeout=_SESSION_TIMEOUT_S,
)
)
assert summary["decision"] == "speak", summary
assert summary["audio_chunks"] > 0, summary
assert summary["played_ms"] > 0, summary
assert summary["transcript"], summary
assert summary["resume_token_issued"] is True, summary
assert summary["post_resume_heartbeat_ok"] is True, summary
assert summary["closed_cleanly"] is True, summary
assert summary["error_events"] == [], summary