Files
vllm-omni/tests/entrypoints/test_duplex_omni.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

327 lines
13 KiB
Python

# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""``DuplexOmni`` / ``DuplexSessionHandle``: the thin Python API over a fake engine."""
from __future__ import annotations
import asyncio
from dataclasses import replace
from typing import Any
import pytest
from tests.entrypoints.test_omni_entrypoints import FakeAsyncOmniEngine
from vllm_omni.config.stage_config import DuplexSessionRuntimeConfig
from vllm_omni.engine.duplex import commands
from vllm_omni.engine.duplex.config import DuplexCapabilities, DuplexSessionConfig
from vllm_omni.engine.duplex.events import (
AudioDelta,
DuplexEvent,
SessionClosed,
SessionCreated,
SessionExpired,
)
from vllm_omni.engine.duplex.messages import (
DuplexControlResultMessage,
DuplexSessionError,
DuplexSessionEventMessage,
)
from vllm_omni.engine.messages import ErrorMessage
from vllm_omni.entrypoints.duplex_omni import DuplexOmni, DuplexSessionHandle
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
class FakeDuplexEngine(FakeAsyncOmniEngine):
"""The engine surface ``DuplexOmni`` uses; every call is recorded."""
def __init__(self, *args: Any, **kwargs: Any) -> None:
super().__init__(*args, **kwargs)
self.duplex_session_config = DuplexSessionRuntimeConfig(max_sessions=2)
self.duplex_capabilities = DuplexCapabilities(supports_session_resume=True)
self.opened: list[tuple[str, DuplexSessionConfig]] = []
self.closed: list[tuple[str, str]] = []
self.resumed: list[tuple[str, int]] = []
self.touched: list[tuple[str, str]] = []
self.commands: list[tuple[str, commands.DuplexCommand]] = []
self.open_error: DuplexSessionError | None = None
self.close_error: DuplexSessionError | None = None
def _result(self, operation: str, session_id: str, **fields: Any) -> DuplexControlResultMessage:
return DuplexControlResultMessage(control_id="c", operation=operation, session_id=session_id, ok=True, **fields)
async def open_session_async(self, session_id, session_config, *, timeout=None):
self.opened.append((session_id, session_config))
if self.open_error is not None:
raise self.open_error
return self._result(
"open",
session_id,
capabilities=self.duplex_capabilities,
public_session={"id": session_id, "idle_timeout_s": 7},
lease_generation=0,
)
async def close_session_async(self, session_id, *, reason="client_close", timeout=None):
self.closed.append((session_id, reason))
if self.close_error is not None:
raise self.close_error
# The runner emits session.closed before answering the RPC.
self.emit(session_id, SessionClosed(reason=reason))
return self._result("close", session_id)
async def resume_session_async(self, session_id, *, expected_lease_generation, timeout=None):
self.resumed.append((session_id, expected_lease_generation))
return self._result("resume", session_id, lease_generation=expected_lease_generation + 1)
async def touch_session_async(self, session_id, *, activity, timeout=None):
self.touched.append((session_id, activity))
return self._result("touch", session_id)
async def submit_command_async(self, session_id, command):
self.commands.append((session_id, command))
def emit(self, session_id: str, event: DuplexEvent) -> None:
# The session manager binds the session identity to every event it emits.
event = replace(event, session_id=session_id)
self.output_q.put(DuplexSessionEventMessage(session_id=session_id, event=event))
def _make_omni(monkeypatch: pytest.MonkeyPatch) -> tuple[DuplexOmni, FakeDuplexEngine]:
engine = FakeDuplexEngine()
monkeypatch.setattr("vllm_omni.entrypoints.duplex_omni.DuplexOmniEngine", lambda *args, **kwargs: engine)
monkeypatch.setattr("vllm_omni.entrypoints.omni_base.omni_snapshot_download", lambda model: model)
return DuplexOmni("dummy-model"), engine
async def _collect(handle: DuplexSessionHandle, count: int, *, timeout_s: float = 2.0) -> list[DuplexEvent]:
events: list[DuplexEvent] = []
async def consume() -> None:
async for event in handle.events():
events.append(event)
if len(events) >= count:
break
await asyncio.wait_for(consume(), timeout=timeout_s)
return events
@pytest.mark.asyncio
async def test_open_session_allocates_the_id_and_ignores_client_ids(monkeypatch) -> None:
omni, engine = _make_omni(monkeypatch)
try:
handle = await omni.open_session({"session_id": "mine", "id": "mine-too", "instructions": "hi"})
assert handle.session_id.startswith("duplex-") and len(handle.session_id) == len("duplex-") + 32
assert handle.session_id != "mine"
((session_id, config),) = engine.opened
assert session_id == handle.session_id
assert isinstance(config, DuplexSessionConfig)
assert config.model == "dummy-model" and config.instructions == "hi"
assert handle.capabilities is engine.duplex_capabilities
assert handle.public_session["id"] == handle.session_id
assert omni.sessions == {handle.session_id: handle}
assert omni.get_session(handle.session_id) is handle
assert omni.active_session_count() == 1
second = await omni.open_session(DuplexSessionConfig(instructions="typed"))
assert second.session_id != handle.session_id
assert engine.opened[1][1].model == "dummy-model"
assert omni.duplex_session_config.max_sessions == 2
assert omni.duplex_capabilities.supports_session_resume is True
finally:
omni.shutdown()
@pytest.mark.asyncio
async def test_open_failure_drops_the_pending_handle_and_raises_the_engine_code(monkeypatch) -> None:
omni, engine = _make_omni(monkeypatch)
engine.open_error = DuplexSessionError("full", code="resource_exhausted", retryable=True)
try:
with pytest.raises(DuplexSessionError) as excinfo:
await omni.open_session()
assert excinfo.value.code == "resource_exhausted"
assert omni.sessions == {}
finally:
omni.shutdown()
@pytest.mark.asyncio
async def test_events_are_routed_to_their_handle_and_end_on_session_closed(monkeypatch) -> None:
omni, engine = _make_omni(monkeypatch)
try:
handle = await omni.open_session()
engine.emit("duplex-unknown", AudioDelta(delta="zzz")) # dropped
engine.emit(handle.session_id, SessionCreated(session={"id": handle.session_id}))
engine.emit(handle.session_id, AudioDelta(response_id="r1", delta="abc"))
engine.emit(handle.session_id, SessionClosed(reason="client_close"))
events = await _collect(handle, 10)
assert [event.type for event in events] == ["session.created", "response.output_audio.delta", "session.closed"]
assert events[0].session_id == handle.session_id
assert handle.closed and handle.close_reason == "client_close"
assert await handle.wait_closed() == "client_close"
assert handle.session_id not in omni.sessions
with pytest.raises(DuplexSessionError) as excinfo:
await handle.submit(commands.Heartbeat())
assert excinfo.value.code == "session_closed"
finally:
omni.shutdown()
@pytest.mark.asyncio
async def test_events_is_single_consumer_but_may_be_reentered(monkeypatch) -> None:
omni, engine = _make_omni(monkeypatch)
try:
handle = await omni.open_session()
engine.emit(handle.session_id, AudioDelta(delta="one"))
first = handle.events()
assert (await first.__anext__()).type == "response.output_audio.delta"
with pytest.raises(RuntimeError, match="active events"):
await handle.events().__anext__()
await first.aclose()
engine.emit(handle.session_id, SessionExpired(reason="idle_ttl_expired"))
events = await _collect(handle, 1)
assert events[0].type == "session.expired" and events[0].is_terminal
assert handle.closed and handle.close_reason == "idle_ttl_expired"
finally:
omni.shutdown()
@pytest.mark.asyncio
async def test_close_session_waits_for_the_typed_session_closed_event(monkeypatch) -> None:
omni, engine = _make_omni(monkeypatch)
try:
handle = await omni.open_session()
consumer = asyncio.create_task(_collect(handle, 1))
await handle.close(reason="done")
assert engine.closed == [(handle.session_id, "done")]
assert handle.closed and handle.close_reason == "done"
assert (await consumer)[0].type == "session.closed"
# Idempotent: a second close is a no-op.
await omni.close_session(handle.session_id)
assert len(engine.closed) == 1
finally:
omni.shutdown()
@pytest.mark.asyncio
async def test_close_failure_marks_the_handle_closed_and_propagates(monkeypatch) -> None:
omni, engine = _make_omni(monkeypatch)
engine.close_error = DuplexSessionError("boom", code="engine_error")
try:
handle = await omni.open_session()
with pytest.raises(DuplexSessionError, match="boom"):
await omni.close_session(handle.session_id)
assert handle.closed
# A failed close must not leave a dead handle shadowing the id.
assert omni.sessions == {}
finally:
omni.shutdown()
@pytest.mark.asyncio
async def test_engine_death_ends_every_session_handle(monkeypatch) -> None:
omni, engine = _make_omni(monkeypatch)
try:
handle = await omni.open_session()
engine.output_q.put(ErrorMessage(error="orchestrator died", fatal=True))
await asyncio.wait_for(handle.wait_closed(), timeout=2.0)
assert handle.closed
assert "engine_dead" in (handle.close_reason or "")
assert omni.sessions == {}
finally:
omni.shutdown()
@pytest.mark.asyncio
async def test_resume_detach_and_touch_forward_to_the_engine(monkeypatch) -> None:
omni, engine = _make_omni(monkeypatch)
try:
handle = await omni.open_session()
resumed = await omni.resume_session(handle.session_id, expected_lease_generation=0)
assert resumed is handle and handle.lease_generation == 1
assert engine.resumed == [(handle.session_id, 0)]
await omni.detach_session(handle.session_id)
await omni.touch_session(handle.session_id, activity="heartbeat")
assert engine.touched == [(handle.session_id, "detach"), (handle.session_id, "heartbeat")]
for call in (
omni.resume_session("duplex-nope", expected_lease_generation=0),
omni.detach_session("duplex-nope"),
):
with pytest.raises(DuplexSessionError) as excinfo:
await call
assert excinfo.value.code == "unknown_session"
finally:
omni.shutdown()
@pytest.mark.asyncio
async def test_handle_wrappers_build_typed_commands_in_caller_order(monkeypatch) -> None:
omni, engine = _make_omni(monkeypatch)
try:
handle = await omni.open_session()
await handle.append_audio(b"\x00\x01", sample_rate_hz=16000, is_speech=True)
await handle.commit(create_response=True)
await handle.create_response({"instructions": "x"})
await handle.cancel_response("resp-1")
await handle.barge_in()
await handle.ack_playback(120.7, response_id="resp-1", committed_ms=100)
await handle.update({"temperature": 0.2})
await handle.create_item({"id": "item_1", "type": "message"}, previous_item_id="root")
await handle.delete_item("item_1")
await handle.truncate_item("item_1", audio_end_ms=50)
await handle.signal_turn("user_started", {"x": 1})
await handle.heartbeat()
submitted = [command for session_id, command in engine.commands if session_id == handle.session_id]
assert [type(command) for command in submitted] == [
commands.AppendAudio,
commands.Commit,
commands.CreateResponse,
commands.CancelResponse,
commands.BargeIn,
commands.AckPlayback,
commands.UpdateSession,
commands.CreateItem,
commands.DeleteItem,
commands.TruncateItem,
commands.SignalTurn,
commands.Heartbeat,
]
append, commit, create, cancel, _, ack, update, item, delete, truncate, signal, _ = submitted
assert append.audio == b"\x00\x01" and append.sample_rate_hz == 16000 and append.is_speech is True
assert commit.create_response is True and commit.final is True
assert create.options == {"instructions": "x"}
assert cancel.response_id == "resp-1"
assert ack.played_ms == 120 and ack.committed_ms == 100 and ack.response_id == "resp-1"
assert update.patch == {"temperature": 0.2}
assert item.previous_item_id == "root" and item.item["id"] == "item_1"
assert delete.item_id == "item_1"
assert truncate.audio_end_ms == 50
assert signal.event == "user_started" and signal.signal_payload == {"x": 1}
finally:
omni.shutdown()
@pytest.mark.asyncio
async def test_shutdown_closes_every_handle(monkeypatch) -> None:
omni, engine = _make_omni(monkeypatch)
first = await omni.open_session()
second = await omni.open_session()
omni.shutdown()
assert first.closed and second.closed
assert first.close_reason == "shutdown"
assert omni.sessions == {}
assert engine.shutdown_called