[Refactor][CI] Reorganize test tree, and move slow e2e to weekly (#4951)

Signed-off-by: wangyu <410167048@qq.com>
This commit is contained in:
wangyu authored and GitHub committed 2026-07-29 23:53:03 +08:00
1 parent 925347332f
commit 7c17111ff5
158 files changed
+3479 -4130

No files matched your search

@@ -0,0 +1,110 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
from __future__ import annotations
import pytest
from vllm_omni.experimental.fullduplex.engine.duplex_control_client import (
DuplexControlClient,
DuplexControlRequestError,
)
from vllm_omni.experimental.fullduplex.engine.lease import DuplexLeaseActivity
from vllm_omni.experimental.fullduplex.engine.messages import (
DuplexControlError,
DuplexControlResultMessage,
DuplexFence,
ResumeDuplexSessionMessage,
TouchDuplexSessionMessage,
)
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
class _Transport:
def __init__(self) -> None:
self.calls = []
def execute(self, key, message, **kwargs):
self.calls.append((key, message, kwargs))
return DuplexControlResultMessage(
control_id=message.control_id,
fence=message.fence,
operation="touch" if isinstance(message, TouchDuplexSessionMessage) else "resume",
session_id=message.session_id,
ok=True,
stage_results=[],
)
class _UnsupportedTransport:
def execute(self, key, message, **kwargs):
del key, kwargs
return DuplexControlResultMessage(
control_id=message.control_id,
fence=message.fence,
operation="touch",
session_id=message.session_id,
ok=False,
stage_results=[{"result": {"supported": False}}],
unsupported_count=1,
error=DuplexControlError(code="invalid_capability", message="unsupported", retryable=False),
accepted_fence=DuplexFence(message.session_id, epoch=2),
lease_generation=7,
)
def test_control_client_routes_touch_and_resume_by_control_id() -> None:
transport = _Transport()
control_ids = iter(("touch-id", "resume-id"))
client = DuplexControlClient(transport, control_id_factory=lambda: next(control_ids))
fence = DuplexFence("sid-client")
assert (
client.touch(
fence.session_id,
fence=fence,
activity=DuplexLeaseActivity.PLAYBACK_ACK,
timeout=2.0,
)["ok"]
is True
)
assert (
client.resume(
fence.session_id,
fence=fence,
expected_lease_generation=7,
timeout=3.0,
)["ok"]
is True
)
touch_key, touch_message, touch_kwargs = transport.calls[0]
assert touch_key == ("duplex", "touch-id")
assert isinstance(touch_message, TouchDuplexSessionMessage)
assert touch_message.activity == DuplexLeaseActivity.PLAYBACK_ACK.value
assert touch_kwargs["timeout"] == 2.0
resume_key, resume_message, resume_kwargs = transport.calls[1]
assert resume_key == ("duplex", "resume-id")
assert isinstance(resume_message, ResumeDuplexSessionMessage)
assert resume_message.expected_lease_generation == 7
assert resume_kwargs["timeout"] == 3.0
def test_control_client_treats_ok_false_as_authoritative_failure() -> None:
client = DuplexControlClient(_UnsupportedTransport(), control_id_factory=lambda: "unsupported")
fence = DuplexFence("sid-unsupported")
with pytest.raises(DuplexControlRequestError, match="duplex touch failed") as exc_info:
client.touch(
fence.session_id,
fence=fence,
activity=DuplexLeaseActivity.HEARTBEAT,
timeout=1.0,
)
assert exc_info.value.code == "invalid_capability"
assert exc_info.value.retryable is False
assert exc_info.value.accepted_fence == DuplexFence(fence.session_id, epoch=2)
assert exc_info.value.lease_generation == 7
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,68 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""Focused tests for typed deployment-only runtime configuration."""
from __future__ import annotations
from dataclasses import FrozenInstanceError
import pytest
from vllm_omni.config.stage_config import (
DuplexSessionRuntimeConfig,
load_deploy_config,
)
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
def test_duplex_session_runtime_defaults_are_typed_and_immutable(tmp_path) -> None:
deploy_path = tmp_path / "duplex.yaml"
deploy_path.write_text("duplex_session: {}\nstages: []\n", encoding="utf-8")
deploy = load_deploy_config(deploy_path)
assert isinstance(deploy.duplex_session, DuplexSessionRuntimeConfig)
assert deploy.duplex_session.idle_ttl_s == 300.0
assert deploy.duplex_session.disconnect_grace_s == 30.0
assert deploy.duplex_session.reaper_interval_s == 5.0
assert deploy.duplex_session.resume_replay_ttl_s == 60.0
assert deploy.duplex_session.resume_replay_max_bytes_per_session == 8 * 1024 * 1024
assert deploy.duplex_session.max_pending_input_bytes_per_session == 16 * 1024 * 1024
assert deploy.duplex_session.max_pending_turns_per_session == 4
assert deploy.duplex_session.max_sessions == 1
assert deploy.duplex_session.completed_append_cache_size == 256
with pytest.raises(FrozenInstanceError):
deploy.duplex_session.idle_ttl_s = 1.0 # type: ignore[misc]
def test_duplex_session_runtime_accepts_disabled_idle_expiry(tmp_path) -> None:
deploy_path = tmp_path / "duplex.yaml"
deploy_path.write_text("duplex_session:\n idle_ttl_s: null\nstages: []\n", encoding="utf-8")
deploy = load_deploy_config(deploy_path)
assert deploy.duplex_session.idle_ttl_s is None
@pytest.mark.parametrize(
("name", "value"),
[
("idle_ttl_s", 0),
("idle_ttl_s", -1),
("disconnect_grace_s", 0),
("reaper_interval_s", -1),
("resume_replay_ttl_s", 0),
("resume_replay_max_bytes_per_session", -1),
("max_pending_input_bytes_per_session", 0),
("max_pending_turns_per_session", -1),
("max_sessions", 0),
("completed_append_cache_size", 0),
],
)
def test_duplex_session_runtime_rejects_non_positive_values(tmp_path, name: str, value: int) -> None:
deploy_path = tmp_path / "duplex.yaml"
deploy_path.write_text(f"duplex_session:\n {name}: {value}\nstages: []\n", encoding="utf-8")
with pytest.raises(ValueError, match=rf"duplex_session\.{name} must be positive"):
load_deploy_config(deploy_path)
@@ -0,0 +1,14 @@
import pytest
from vllm_omni.experimental.fullduplex.engine.intermediate import get_stream_request_key
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
def test_get_stream_request_key_requires_stable_identifier():
with pytest.raises(ValueError, match="stable request id"):
get_stream_request_key({"ids": {"tts": [1, 2, 3]}})
def test_get_stream_request_key_accepts_global_request_id():
assert get_stream_request_key({"global_request_id": ["duplex-sid-stage0"]}) == "duplex-sid-stage0"
@@ -0,0 +1,224 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
from __future__ import annotations
from dataclasses import FrozenInstanceError
import pytest
from vllm_omni.experimental.fullduplex.engine.duplex_runtime import DuplexInputMode
from vllm_omni.experimental.fullduplex.engine.duplex_session import DuplexSessionRuntimeManager
from vllm_omni.experimental.fullduplex.engine.lease import (
DuplexLeaseActivity,
DuplexLeaseConfig,
)
from vllm_omni.experimental.fullduplex.engine.messages import DuplexFence
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
class FakeMonotonicClock:
def __init__(self, value: float) -> None:
self.value = value
def __call__(self) -> float:
return self.value
def advance(self, seconds: float) -> None:
self.value += seconds
def _lease_config(*, idle_ttl_s: float | None = 300.0) -> DuplexLeaseConfig:
return DuplexLeaseConfig(
idle_ttl_s=idle_ttl_s,
disconnect_grace_s=30.0,
)
def test_open_touch_detach_and_idle_expiry_use_monotonic_time() -> None:
clock = FakeMonotonicClock(100.0)
manager = DuplexSessionRuntimeManager(clock=clock)
fence = DuplexFence("sid-expiry")
session = manager.open_session(fence, lease_config=_lease_config())
assert session.lease.last_activity == 100.0
clock.advance(10.0)
session.touch(fence, DuplexLeaseActivity.HEARTBEAT)
session.detach(fence)
clock.advance(29.0)
assert session.lease.disconnect_grace_expired(clock()) is False
assert manager.collect_expired() == []
clock.advance(272.0)
expired = manager.collect_expired()
assert [item.session_id for item in expired] == ["sid-expiry"]
assert expired[0].reason == "disconnect_grace_expired"
assert manager.get("sid-expiry") is session
manager.finalize_close_session(session)
assert manager.get("sid-expiry") is None
def test_detach_grace_and_resume_advance_token_independent_lease_generation() -> None:
clock = FakeMonotonicClock(10.0)
manager = DuplexSessionRuntimeManager(clock=clock)
fence = DuplexFence("sid-resume")
session = manager.open_session(fence, lease_config=_lease_config())
session.detach(fence)
clock.advance(29.0)
assert session.lease.disconnect_grace_expired(clock()) is False
clock.advance(2.0)
assert session.lease.disconnect_grace_expired(clock()) is True
generation = session.resume(fence, expected_lease_generation=0)
assert generation == 1
assert session.lease.detached_at is None
assert session.lease.disconnect_grace_expired(clock()) is False
with pytest.raises(ValueError, match="lease generation mismatch"):
session.resume(fence, expected_lease_generation=0)
def test_active_operation_prevents_mid_transaction_expiry() -> None:
clock = FakeMonotonicClock(0.0)
manager = DuplexSessionRuntimeManager(clock=clock)
fence = DuplexFence("sid-operation")
session = manager.open_session(fence, lease_config=_lease_config(idle_ttl_s=5.0))
session.begin_operation(fence, "append-1")
clock.advance(10.0)
assert manager.collect_expired() == []
session.end_operation(fence, "append-1")
assert manager.collect_expired() == []
clock.advance(6.0)
assert [item.session_id for item in manager.collect_expired()] == ["sid-operation"]
def test_sessions_have_independent_activity_deadlines_and_resources() -> None:
clock = FakeMonotonicClock(0.0)
manager = DuplexSessionRuntimeManager(clock=clock)
fence_a = DuplexFence("sid-a")
fence_b = DuplexFence("sid-b")
session_a = manager.open_session(fence_a, lease_config=_lease_config(idle_ttl_s=10.0))
session_b = manager.open_session(fence_b, lease_config=_lease_config(idle_ttl_s=10.0))
session_a.reserve_stage_request(0, "req-a-reserved", fence=fence_a)
session_a.bind_stage_request(1, "req-a-submitted", fence=fence_a)
session_b.bind_stage_request(0, "req-b", fence=fence_b)
clock.advance(6.0)
session_b.touch(fence_b, DuplexLeaseActivity.MODEL_OUTPUT)
clock.advance(5.0)
expired = manager.collect_expired()
assert len(expired) == 1
assert expired[0].session_id == "sid-a"
assert expired[0].reserved_request_ids == ("req-a-reserved",)
assert expired[0].submitted_request_ids == ("req-a-submitted",)
assert manager.get("sid-b") is session_b
assert session_b.resource_request_ids() == ["req-b"]
def test_close_and_reaper_select_exactly_one_terminal_transition() -> None:
clock = FakeMonotonicClock(0.0)
manager = DuplexSessionRuntimeManager(clock=clock)
fence = DuplexFence("sid-race")
manager.open_session(fence, lease_config=_lease_config(idle_ttl_s=1.0))
clock.advance(2.0)
assert len(manager.collect_expired()) == 1
assert manager.close_session(fence, reason="explicit_close") is None
assert manager.collect_expired() == []
fence_2 = DuplexFence("sid-race-2")
manager.open_session(fence_2, lease_config=_lease_config(idle_ttl_s=1.0))
assert manager.close_session(fence_2, reason="explicit_close") is not None
clock.advance(2.0)
assert manager.collect_expired() == []
def test_stale_fence_cannot_touch_detach_or_resume_lease() -> None:
clock = FakeMonotonicClock(0.0)
manager = DuplexSessionRuntimeManager(clock=clock)
current = DuplexFence("sid-fence", epoch=1)
stale = DuplexFence("sid-fence", epoch=0)
session = manager.open_session(current, lease_config=_lease_config())
with pytest.raises(RuntimeError, match="fence mismatch"):
session.touch(stale, DuplexLeaseActivity.APPEND)
with pytest.raises(RuntimeError, match="fence mismatch"):
session.detach(stale)
with pytest.raises(RuntimeError, match="fence mismatch"):
session.resume(stale, expected_lease_generation=0)
def test_disabled_idle_expiry_never_collects_session() -> None:
clock = FakeMonotonicClock(0.0)
manager = DuplexSessionRuntimeManager(clock=clock)
manager.open_session(DuplexFence("sid-no-expiry"), lease_config=_lease_config(idle_ttl_s=None))
clock.advance(1_000_000.0)
assert manager.collect_expired() == []
def test_detached_session_expires_at_disconnect_grace_when_idle_ttl_is_disabled() -> None:
clock = FakeMonotonicClock(0.0)
manager = DuplexSessionRuntimeManager(clock=clock)
fence = DuplexFence("sid-disconnect-grace")
session = manager.open_session(fence, lease_config=_lease_config(idle_ttl_s=None))
session.detach(fence)
clock.advance(29.0)
assert manager.collect_expired() == []
clock.advance(1.0)
expired = manager.collect_expired()
assert [item.session_id for item in expired] == [fence.session_id]
assert expired[0].reason == "disconnect_grace_expired"
def test_runtime_manager_enforces_server_owned_session_admission() -> None:
manager = DuplexSessionRuntimeManager(max_sessions=2)
manager.open_session(DuplexFence("sid-admission-a"))
manager.open_session(DuplexFence("sid-admission-b"))
with pytest.raises(RuntimeError, match="duplex_session_capacity_exhausted"):
manager.open_session(DuplexFence("sid-admission-c"))
def test_completed_append_cache_is_bounded() -> None:
manager = DuplexSessionRuntimeManager(completed_append_limit=2)
fence = DuplexFence("sid-completed-cache")
session = manager.open_session(fence)
for index in range(3):
session.record_completed_append(
f"operation-{index}",
fence=fence,
mode=DuplexInputMode.TURN_COMMIT_ONLY,
final=True,
stage_results=[{"index": index}],
)
assert list(session.completed_appends) == ["operation-1", "operation-2"]
session.accept_fence(DuplexFence(fence.session_id, epoch=1))
assert session.completed_appends == {}
def test_lease_config_and_expiry_record_are_immutable() -> None:
config = _lease_config()
with pytest.raises(FrozenInstanceError):
config.idle_ttl_s = 1.0 # type: ignore[misc]
clock = FakeMonotonicClock(0.0)
manager = DuplexSessionRuntimeManager(clock=clock)
manager.open_session(DuplexFence("sid-record"), lease_config=_lease_config(idle_ttl_s=1.0))
clock.advance(2.0)
record = manager.collect_expired()[0]
with pytest.raises(FrozenInstanceError):
record.reason = "changed" # type: ignore[misc]
@@ -0,0 +1,480 @@
from dataclasses import FrozenInstanceError
from types import SimpleNamespace
import pytest
from vllm.sampling_params import SamplingParams
from vllm_omni.experimental.fullduplex.engine import duplex_runtime
from vllm_omni.experimental.fullduplex.engine.contracts import (
duplex_data_plane_request_info,
duplex_resource_request_belongs_to_session,
duplex_resource_request_id,
)
from vllm_omni.experimental.fullduplex.engine.duplex_runtime import (
DuplexInputMode,
DuplexOutputAction,
DuplexOutputDecision,
DuplexRuntimeCapabilities,
DuplexSessionRuntimeManager,
)
from vllm_omni.experimental.fullduplex.engine.messages import DuplexFence
from vllm_omni.experimental.fullduplex.minicpmo45.runtime import (
MiniCPMO45DuplexRuntimeExtension,
build_duplex_data_plane_prompt,
duplex_scheduler_token_budget,
)
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
def test_duplex_fence_is_immutable():
fence = DuplexFence("session")
with pytest.raises(FrozenInstanceError):
fence.epoch = 1 # type: ignore[misc]
assert not hasattr(fence, "__dict__")
def test_duplex_runtime_tracks_stage_bindings_and_barge_in_epoch():
manager = DuplexSessionRuntimeManager()
session = manager.open_session(
DuplexFence("sid-1"),
capabilities=DuplexRuntimeCapabilities(
input_modes={DuplexInputMode.APPEND_TOKENS},
),
)
session.bind_stage_request(stage_id=0, request_id="req-stage0", fence=session.fence)
session.bind_stage_request(stage_id=1, request_id="req-stage1", fence=session.fence)
update = session.append_input(mode=DuplexInputMode.APPEND_TOKENS, fence=session.fence)
next_fence = DuplexFence("sid-1", epoch=1)
stale_request_ids = session.release_fence(session.fence)
session.accept_fence(next_fence)
assert update.seq == 1
assert session.fence == next_fence
assert stale_request_ids == ["req-stage0", "req-stage1"]
assert session.stage_bindings == {}
def test_duplex_runtime_tracks_same_request_id_for_each_pipeline_stage():
manager = DuplexSessionRuntimeManager()
fence = DuplexFence("sid-shared-pipeline-request")
session = manager.open_session(fence)
session.reserve_stage_request(0, "req-shared", fence=fence)
session.bind_stage_request(0, "req-shared", fence=fence)
session.bind_stage_request(1, "req-shared", fence=fence)
assert session.stage_bindings[0].request_id == "req-shared"
assert session.stage_bindings[1].request_id == "req-shared"
assert session.resource_request_ids() == ["req-shared"]
assert session.input_seq == 0
def test_duplex_runtime_extension_validation_rejects_missing_methods():
class IncompleteExtension:
def configure_sampling_params(self, *, runtime_config, defaults):
del runtime_config
return defaults
with pytest.raises(TypeError, match="plan_append"):
duplex_runtime.validate_duplex_runtime_extension(IncompleteExtension())
def test_duplex_runtime_extension_validation_rejects_stage_count_mismatch():
class WrongStageCountExtension(MiniCPMO45DuplexRuntimeExtension):
def configure_sampling_params(self, *, runtime_config, defaults):
del runtime_config
return defaults[:1]
with pytest.raises(ValueError, match="one sampling parameter per stage"):
duplex_runtime.validate_duplex_runtime_extension(
WrongStageCountExtension(),
sampling_defaults=(SamplingParams(), SamplingParams()),
)
def test_duplex_runtime_extension_validation_rejects_sampling_type_mismatch():
class WrongSamplingTypeExtension(MiniCPMO45DuplexRuntimeExtension):
def configure_sampling_params(self, *, runtime_config, defaults):
del runtime_config
return tuple(object() for _ in defaults)
with pytest.raises(TypeError, match="sampling parameter type"):
duplex_runtime.validate_duplex_runtime_extension(
WrongSamplingTypeExtension(),
sampling_defaults=(SamplingParams(), SamplingParams()),
)
def test_duplex_output_decision_metadata_is_immutable():
decision = DuplexOutputDecision(
action=DuplexOutputAction.DIRECT_RESPONSE,
metadata={"model_listen": True},
)
with pytest.raises(TypeError):
decision.metadata["model_listen"] = False
def _decide_minicpmo_output(
output: object,
*,
segment_token_ids: tuple[int, ...] = (),
segment_output_metadata: dict | None = None,
):
return MiniCPMO45DuplexRuntimeExtension().decide_output(
stage_id=0,
final_stage_id=1,
segment_finished=True,
segment_token_ids=segment_token_ids,
segment_output_metadata=segment_output_metadata or {},
output=output,
)
def test_minicpmo_extension_owns_stage_sampling_overrides():
defaults = (
SamplingParams(max_tokens=4),
SamplingParams(max_tokens=8),
)
configured = MiniCPMO45DuplexRuntimeExtension().configure_sampling_params(
runtime_config={
"duplex_stage_max_tokens": {"0": 20},
"duplex_stage_sampling_params": {"1": {"stop_token_ids": [151645]}},
},
defaults=defaults,
)
assert configured[0].max_tokens == 20
assert configured[1].stop_token_ids == [151645]
assert defaults[0].max_tokens == 4
assert 151645 not in (defaults[1].stop_token_ids or [])
def test_minicpmo_output_decision_uses_raw_streaming_token_snapshot():
decision = _decide_minicpmo_output(
SimpleNamespace(outputs=[SimpleNamespace()]),
segment_token_ids=(151705,),
segment_output_metadata={"special_token_ids": {"listen_token_id": 151705}},
)
assert decision is not None
assert decision.action is DuplexOutputAction.DIRECT_RESPONSE
assert decision.metadata["duplex_native_decision"] == "listen"
assert decision.metadata["model_listen"] is True
@pytest.mark.parametrize("attr", ["token_ids", "cumulative_token_ids"])
def test_minicpmo_output_decision_ignores_output_level_token_history(attr):
output = SimpleNamespace(
multimodal_output={"special_token_ids": {"listen_token_id": 151705}},
outputs=[SimpleNamespace()],
**{attr: [42, 151705]},
)
assert _decide_minicpmo_output(output) is None
@pytest.mark.parametrize("attr", ["token_ids", "cumulative_token_ids"])
def test_minicpmo_output_decision_uses_completion_token_ids(attr):
output = SimpleNamespace(
multimodal_output={"special_token_ids": {"listen_token_id": 151705}},
outputs=[SimpleNamespace(**{attr: [42, 151705]})],
)
assert _decide_minicpmo_output(output) is not None
def test_minicpmo_output_decision_uses_completion_stop_reason():
output = SimpleNamespace(
multimodal_output={"special_token_ids": {"listen_token_id": 151705}},
outputs=[SimpleNamespace(stop_reason=151705)],
)
assert _decide_minicpmo_output(output) is not None
def test_duplex_runtime_cancel_fence_rejects_late_append_and_accepts_next_epoch():
manager = DuplexSessionRuntimeManager()
cancelled_fence = DuplexFence("sid-cancel-race")
next_fence = DuplexFence("sid-cancel-race", epoch=1)
session = manager.open_session(
cancelled_fence,
capabilities=DuplexRuntimeCapabilities(
input_modes={DuplexInputMode.APPEND_AUDIO_CHUNK},
),
)
session.bind_stage_request(
stage_id=0,
request_id="req-cancelled",
fence=cancelled_fence,
)
stale_request_ids = session.cancel_fence(cancelled_fence, next_fence)
assert stale_request_ids == ["req-cancelled"]
assert session.fence == next_fence
assert session.stage_bindings == {}
with pytest.raises(RuntimeError, match="fence mismatch"):
session.append_input(
mode=DuplexInputMode.APPEND_AUDIO_CHUNK,
fence=cancelled_fence,
)
update = session.append_input(
mode=DuplexInputMode.APPEND_AUDIO_CHUNK,
fence=next_fence,
)
assert update.seq == 1
def test_duplex_runtime_stale_close_preserves_live_session_and_bindings():
manager = DuplexSessionRuntimeManager()
current_fence = DuplexFence("sid-stale-close", epoch=1)
session = manager.open_session(
current_fence,
capabilities=DuplexRuntimeCapabilities(
input_modes={DuplexInputMode.APPEND_AUDIO_CHUNK},
),
)
session.bind_stage_request(0, "req-live", fence=current_fence)
with pytest.raises(RuntimeError, match="fence mismatch"):
manager.close_session(DuplexFence("sid-stale-close"))
assert manager.get("sid-stale-close") is session
assert session.stage_request_ids() == ["req-live"]
def test_duplex_runtime_reopen_rejects_late_append_from_old_incarnation():
manager = DuplexSessionRuntimeManager()
old_fence = DuplexFence("sid-reopen", incarnation=0)
old_session = manager.open_session(
old_fence,
capabilities=DuplexRuntimeCapabilities(
input_modes={DuplexInputMode.APPEND_AUDIO_CHUNK},
),
)
manager.close_session(old_fence)
new_fence = DuplexFence("sid-reopen", incarnation=1)
new_session = manager.open_session(
new_fence,
capabilities=old_session.capabilities,
)
with pytest.raises(RuntimeError, match="fence mismatch"):
new_session.append_input(
mode=DuplexInputMode.APPEND_AUDIO_CHUNK,
fence=old_fence,
)
assert (
new_session.append_input(
mode=DuplexInputMode.APPEND_AUDIO_CHUNK,
fence=new_fence,
).seq
== 1
)
def test_duplex_prompt_expands_incarnation_metadata():
fence = DuplexFence("sid-incarnation", incarnation=3)
prompt = build_duplex_data_plane_prompt(
request_id=duplex_resource_request_id(fence, "stage0"),
fence=fence,
session_config={},
runtime_config={},
seq=1,
turn_seq=1,
mode=DuplexInputMode.APPEND_AUDIO_CHUNK,
payload={"is_speech": True},
final=False,
)
assert prompt["model_intermediate_buffer"]["duplex"]["incarnation"] == 3
def test_duplex_runtime_tracks_turn_local_append_sequence():
manager = DuplexSessionRuntimeManager()
session = manager.open_session(
DuplexFence("sid-turn-seq"),
capabilities=DuplexRuntimeCapabilities(
input_modes={DuplexInputMode.APPEND_AUDIO_CHUNK},
),
)
first = session.append_input(mode=DuplexInputMode.APPEND_AUDIO_CHUNK, fence=session.fence)
second = session.append_input(mode=DuplexInputMode.APPEND_AUDIO_CHUNK, fence=session.fence)
next_turn = DuplexFence("sid-turn-seq", turn_id=1, response_seq=1)
third = session.append_input(
mode=DuplexInputMode.APPEND_AUDIO_CHUNK,
fence=next_turn,
)
fourth = session.append_input(
mode=DuplexInputMode.APPEND_AUDIO_CHUNK,
fence=next_turn,
)
assert [first.seq, second.seq, third.seq, fourth.seq] == [1, 2, 3, 4]
assert [first.turn_seq, second.turn_seq, third.turn_seq, fourth.turn_seq] == [1, 2, 1, 2]
assert [first.turn_id, second.turn_id, third.turn_id, fourth.turn_id] == [0, 0, 1, 1]
def test_duplex_runtime_rejects_unsupported_append_mode():
manager = DuplexSessionRuntimeManager()
session = manager.open_session(
DuplexFence("sid-2"),
capabilities=DuplexRuntimeCapabilities(input_modes={DuplexInputMode.TURN_COMMIT_ONLY}),
)
with pytest.raises(ValueError, match="not supported"):
session.append_input(mode=DuplexInputMode.APPEND_TOKENS, fence=session.fence)
def test_duplex_data_plane_request_info_extracts_structured_stage_result():
request_id, response_stage_id = duplex_data_plane_request_info(
{
"stage_results": [
{"result": {"supported": True}},
{
"result": {
"data_plane_append": True,
"request_id": "duplex-sid-e0-stage0-s1",
"response_stage_id": 1,
}
},
]
}
)
assert request_id == "duplex-sid-e0-stage0-s1"
assert response_stage_id == 1
def test_duplex_data_plane_request_info_rejects_missing_request_id():
assert duplex_data_plane_request_info(
{
"stage_results": [
{
"result": {
"data_plane_append": True,
"request_id": "",
"response_stage_id": 1,
}
}
]
}
) == (None, None)
def test_duplex_scheduler_token_budget_estimates_pcm_slots():
assert (
duplex_scheduler_token_budget(
{
"audio": "AAAAAA==",
"format": "pcm_f32le",
"sample_rate_hz": 16000,
}
)
== 16
)
def test_duplex_scheduler_token_budget_ignores_client_budget_fields():
assert (
duplex_scheduler_token_budget(
{
"audio": "AAAAAA==",
"format": "pcm_f32le",
"duplex_num_input_tokens": 999,
"num_input_tokens": 999,
}
)
== 16
)
def test_resource_state_rejects_fence_regression_and_requires_explicit_fence():
current = DuplexFence("sid", epoch=2, turn_id=3, response_seq=4)
manager = DuplexSessionRuntimeManager()
session = manager.open_session(
current,
capabilities=DuplexRuntimeCapabilities(input_modes={DuplexInputMode.APPEND_AUDIO_CHUNK}),
)
for stale in (
DuplexFence("sid", epoch=1, turn_id=99, response_seq=99),
DuplexFence("sid", epoch=2, turn_id=2, response_seq=4),
DuplexFence("sid", epoch=2, turn_id=3, response_seq=3),
):
with pytest.raises(RuntimeError, match="fence mismatch"):
session.accept_fence(stale)
assert session.fence == current
with pytest.raises(TypeError, match="fence"):
session.bind_stage_request(0, "request")
with pytest.raises(TypeError, match="fence"):
session.append_input(mode=DuplexInputMode.APPEND_AUDIO_CHUNK)
with pytest.raises(TypeError, match="DuplexFence"):
manager.open_session("legacy-session")
with pytest.raises(TypeError, match="DuplexFence"):
manager.close_session("legacy-session")
def test_resource_request_id_is_derived_from_fence_and_role():
fence = DuplexFence("sid-with-dashes", epoch=7, turn_id=11, response_seq=13)
assert duplex_resource_request_id(fence, "stage0") == "duplex-s.c2lkLXdpdGgtZGFzaGVz.i.0.e.7.r.stage0"
assert duplex_resource_request_id(fence, "stage1") == "duplex-s.c2lkLXdpdGgtZGFzaGVz.i.0.e.7.r.stage1"
def test_resource_request_id_codec_separates_session_id_from_incarnation():
embedded_incarnation = duplex_resource_request_id(
DuplexFence("foo-i1", incarnation=0),
"stage0",
)
actual_incarnation = duplex_resource_request_id(
DuplexFence("foo", incarnation=1),
"stage0",
)
assert embedded_incarnation != actual_incarnation
def test_resource_request_id_session_membership_uses_encoded_identity():
request_id = duplex_resource_request_id(
DuplexFence("sid-with-dashes", incarnation=2, epoch=7),
"stage0",
)
assert duplex_resource_request_belongs_to_session(request_id, "sid-with-dashes") is True
assert duplex_resource_request_belongs_to_session(request_id, "sid") is False
assert duplex_resource_request_belongs_to_session("duplex-s.invalid.i.x.e.7.r.stage0", "sid") is False
def test_placeholder_budget_is_planned_inside_omni_engine_boundary():
fence = DuplexFence("sid", turn_id=1, response_seq=1)
prompt = build_duplex_data_plane_prompt(
request_id=duplex_resource_request_id(fence, "stage0"),
fence=fence,
session_config={},
runtime_config={},
seq=2,
turn_seq=1,
mode=DuplexInputMode.APPEND_AUDIO_CHUNK,
payload={
"audio": "AAAAAA==",
"format": "pcm_f32le",
"duplex_num_input_tokens": 999,
"num_input_tokens": 999,
},
final=False,
)
assert len(prompt["prompt_token_ids"]) == 16
assert prompt["model_intermediate_buffer"]["duplex"]["fence"] == fence
assert prompt["model_intermediate_buffer"]["duplex"]["scheduler_token_budget"] == 16
Whitespace-only changes.
@@ -0,0 +1,99 @@
from __future__ import annotations
import pytest
from vllm_omni.experimental.fullduplex.openai.commit_policy import (
CommitAction,
CommitSnapshot,
decide_commit_action,
)
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
@pytest.mark.parametrize(
("case", "snapshot", "expected"),
[
(
"first idle commit",
CommitSnapshot(
auto_responds=True,
speech_since_commit=True,
active_response_id=None,
overlap_speech_ms=0,
native_response_in_progress=False,
playback_active=False,
),
CommitAction.START_AUTO_RESPONSE,
),
(
"native auto-response remains model-owned during playback",
CommitSnapshot(
auto_responds=True,
speech_since_commit=True,
active_response_id=None,
overlap_speech_ms=800,
native_response_in_progress=True,
playback_active=True,
),
CommitAction.START_AUTO_RESPONSE,
),
(
"native auto-response commit without speech",
CommitSnapshot(
auto_responds=True,
speech_since_commit=False,
active_response_id=None,
overlap_speech_ms=0,
native_response_in_progress=True,
playback_active=False,
),
CommitAction.COMMIT_ONLY,
),
(
"non-auto response overlap",
CommitSnapshot(
auto_responds=False,
speech_since_commit=True,
active_response_id=None,
overlap_speech_ms=800,
native_response_in_progress=True,
playback_active=False,
),
CommitAction.DEFER_ACTIVE_RESPONSE,
),
(
"non-auto playback overlap",
CommitSnapshot(
auto_responds=False,
speech_since_commit=True,
active_response_id=None,
overlap_speech_ms=800,
native_response_in_progress=False,
playback_active=True,
),
CommitAction.DEFER_ACTIVE_RESPONSE,
),
(
"non-auto commit without overlap",
CommitSnapshot(
auto_responds=False,
speech_since_commit=True,
active_response_id=None,
overlap_speech_ms=0,
native_response_in_progress=False,
playback_active=False,
),
CommitAction.COMMIT_ONLY,
),
],
ids=lambda value: value if isinstance(value, str) else None,
)
def test_decide_commit_action_covers_realtime_lifecycle_sequences(
case: str,
snapshot: CommitSnapshot,
expected: CommitAction,
) -> None:
del case
assert decide_commit_action(snapshot) is expected
@@ -0,0 +1,179 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
import base64
import numpy as np
import pytest
from vllm_omni.experimental.fullduplex.minicpmo45.input import MiniCPMO45PcmAppendBuffer
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
def pcm_payload(samples: int, *, speech: bool = True) -> dict[str, object]:
audio = np.ones(samples, dtype=np.float32).tobytes()
return {
"type": "audio",
"audio": base64.b64encode(audio).decode("ascii"),
"format": "pcm_f32le",
"sample_rate_hz": 16_000,
"is_speech": speech,
}
def test_commit_does_not_add_silence_after_incremental_audio_was_drained():
buffer = MiniCPMO45PcmAppendBuffer()
emitted = buffer.append(pcm_payload(16_000), chunk_period_ms=1_000)
committed = buffer.commit(chunk_period_ms=1_000)
assert emitted is not None
assert not buffer.has_pending()
assert committed is None
def test_append_emits_one_model_unit_when_multiple_units_are_buffered():
buffer = MiniCPMO45PcmAppendBuffer()
emitted = buffer.append(pcm_payload(32_000), chunk_period_ms=1_000)
assert emitted is not None
assert len(base64.b64decode(emitted["audio"])) == 16_000 * 4
assert buffer.pending_byte_count == 16_000 * 4
def test_speech_marker_does_not_leak_across_irregular_chunk_boundaries():
buffer = MiniCPMO45PcmAppendBuffer()
assert buffer.append(pcm_payload(15_000), chunk_period_ms=1_000) is None
speech_unit = buffer.append(pcm_payload(2_000, speech=False), chunk_period_ms=1_000)
silence_unit = buffer.append(pcm_payload(15_000, speech=False), chunk_period_ms=1_000)
assert speech_unit is not None
assert speech_unit["is_speech"] is True
assert silence_unit is not None
assert silence_unit["is_speech"] is False
def test_force_listen_marker_does_not_leak_across_irregular_chunk_boundaries():
buffer = MiniCPMO45PcmAppendBuffer()
forced = pcm_payload(15_000, speech=False)
forced["force_listen"] = True
assert buffer.append(forced, chunk_period_ms=1_000) is None
forced_unit = buffer.append(pcm_payload(2_000, speech=False), chunk_period_ms=1_000)
unforced_unit = buffer.append(pcm_payload(15_000, speech=False), chunk_period_ms=1_000)
assert forced_unit is not None
assert forced_unit["force_listen"] is True
assert unforced_unit is not None
assert unforced_unit["force_listen"] is False
def test_pcm_append_rollback_restores_per_span_speech_markers():
buffer = MiniCPMO45PcmAppendBuffer()
assert buffer.append(pcm_payload(15_000), chunk_period_ms=1_000) is None
reservation = buffer.prepare_append(
pcm_payload(2_000, speech=False),
operation_id="mixed-unit",
chunk_period_ms=1_000,
)
assert reservation is not None
reservation.rollback()
first = buffer.append(pcm_payload(15_000, speech=False), chunk_period_ms=1_000)
second = buffer.flush(chunk_period_ms=1_000)
assert first is not None
assert first["is_speech"] is True
assert second is not None
assert second["is_speech"] is False
def test_commit_without_speech_does_not_synthesize_terminal_audio():
buffer = MiniCPMO45PcmAppendBuffer()
buffer.append(pcm_payload(8_000, speech=False), chunk_period_ms=1_000)
committed = buffer.commit(chunk_period_ms=1_000)
assert committed is None
def test_commit_resets_cumulative_turn_accounting():
buffer = MiniCPMO45PcmAppendBuffer()
buffer.append(pcm_payload(16_000), chunk_period_ms=1_000)
buffer.commit(chunk_period_ms=1_000)
empty = buffer.commit(chunk_period_ms=1_000)
assert empty is None
def test_pcm_append_reservation_rollback_restores_emitted_audio():
buffer = MiniCPMO45PcmAppendBuffer()
original = pcm_payload(16_000)
reservation = buffer.prepare_append(
original,
operation_id="append-1",
chunk_period_ms=1_000,
)
assert reservation is not None
assert not buffer.has_pending()
reservation.rollback()
assert buffer.has_pending()
retried = buffer.flush(chunk_period_ms=1_000)
assert retried is not None
assert base64.b64decode(retried["audio"]) == base64.b64decode(original["audio"])
def test_pcm_commit_keeps_prior_append_reservation_active():
buffer = MiniCPMO45PcmAppendBuffer()
append_reservation = buffer.prepare_append(
pcm_payload(16_000),
operation_id="append-before-commit",
chunk_period_ms=1_000,
)
commit_reservation = buffer.prepare_commit(
operation_id="commit-after-append",
chunk_period_ms=1_000,
)
assert append_reservation is not None
assert append_reservation.active
assert commit_reservation.payload is None
append_reservation.commit()
commit_reservation.commit()
def test_pcm_commit_reservation_rollback_restores_residual_audio():
buffer = MiniCPMO45PcmAppendBuffer()
original = pcm_payload(8_000)
assert (
buffer.prepare_append(
original,
operation_id="buffer-half-chunk",
chunk_period_ms=1_000,
)
is None
)
reservation = buffer.prepare_commit(
operation_id="final-half-chunk",
chunk_period_ms=1_000,
)
assert reservation.payload is not None
assert reservation.payload["final"] is True
reservation.rollback()
retried = buffer.prepare_commit(
operation_id="retry-final-half-chunk",
chunk_period_ms=1_000,
)
assert retried.payload is not None
assert base64.b64decode(retried.payload["audio"]) == (base64.b64decode(original["audio"]) + b"\x00" * (8_000 * 4))
@@ -0,0 +1,54 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
import base64
import numpy as np
import pytest
from vllm_omni.experimental.fullduplex.openai.audio import convert_input_audio_with_rate
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
def test_pcm16_input_conversion_is_pure_and_reports_target_rate():
pcm16 = np.array([-32768, 0, 32767], dtype="<i2").tobytes()
encoded = base64.b64encode(pcm16).decode("ascii")
converted, fmt, sample_rate = convert_input_audio_with_rate(
encoded,
"pcm16",
sample_rate_hz=16_000,
)
assert encoded == base64.b64encode(pcm16).decode("ascii")
assert fmt == "pcm_f32le"
assert sample_rate == 16_000
samples = np.frombuffer(base64.b64decode(converted), dtype="<f4")
assert np.allclose(samples, [-1.0, 0.0, 32767 / 32768])
@pytest.mark.parametrize("sample_rate_hz", [8_000, 192_000])
def test_pcm16_input_conversion_accepts_supported_sample_rate_boundaries(sample_rate_hz: int):
encoded = base64.b64encode(np.zeros(8, dtype="<i2").tobytes()).decode("ascii")
_, fmt, converted_rate = convert_input_audio_with_rate(
encoded,
"pcm16",
sample_rate_hz=sample_rate_hz,
)
assert fmt == "pcm_f32le"
assert converted_rate == 16_000
@pytest.mark.parametrize("sample_rate_hz", [7_999, 192_001, 10**1000])
def test_pcm16_input_conversion_rejects_sample_rates_outside_supported_range(sample_rate_hz: int):
encoded = base64.b64encode(np.zeros(8, dtype="<i2").tobytes()).decode("ascii")
with pytest.raises(ValueError, match="sample_rate_hz"):
convert_input_audio_with_rate(
encoded,
"pcm16",
sample_rate_hz=sample_rate_hz,
)
@@ -0,0 +1,139 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
from __future__ import annotations
import subprocess
import sys
from pathlib import Path
from types import SimpleNamespace
import pytest
from vllm_omni.experimental.fullduplex.openai.runtime_adapter import (
validate_serving_runtime_adapter,
)
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
@pytest.mark.parametrize(
"module_name",
[
"vllm_omni.experimental.fullduplex.openai.session_runner",
"vllm_omni.experimental.fullduplex.openai.runtime_bridge",
"vllm_omni.experimental.fullduplex.openai.serving",
],
)
def test_generic_openai_runtime_import_does_not_load_minicpmo45(module_name: str) -> None:
repo_root = Path(__file__).resolve().parents[3]
script = f"""
import sys
import {module_name}
loaded = sorted(
name
for name in sys.modules
if name == "vllm_omni.experimental.fullduplex.minicpmo45"
or name.startswith("vllm_omni.experimental.fullduplex.minicpmo45.")
)
if loaded:
raise SystemExit("generic OpenAI runtime loaded MiniCPM-o: " + ", ".join(loaded))
"""
result = subprocess.run(
[sys.executable, "-c", script],
cwd=repo_root,
capture_output=True,
text=True,
timeout=60,
check=False,
)
assert result.returncode == 0, result.stderr
def test_generic_handler_requires_explicit_serving_runtime_adapter() -> None:
repo_root = Path(__file__).resolve().parents[3]
script = """
from types import SimpleNamespace
from vllm_omni.experimental.fullduplex.openai.serving import OmniDuplexSessionHandler
chat_service = SimpleNamespace(engine_client=SimpleNamespace())
try:
OmniDuplexSessionHandler(chat_service=chat_service)
except ValueError as exc:
if "serving runtime adapter" not in str(exc).lower():
raise
else:
raise SystemExit("generic handler silently selected a model serving adapter")
"""
result = subprocess.run(
[sys.executable, "-c", script],
cwd=repo_root,
capture_output=True,
text=True,
timeout=60,
check=False,
)
assert result.returncode == 0, result.stderr
def _valid_runtime_adapter() -> SimpleNamespace:
data_plane = SimpleNamespace(
begin_request=lambda request_id: None,
is_terminal=lambda request_id: False,
mark_terminal=lambda request_id: None,
close_stream=lambda request_id: None,
close_session=lambda session_id, **kwargs: None,
project=lambda result, **kwargs: (),
)
return SimpleNamespace(
adapter_id="test-adapter",
session_states={},
data_plane=data_plane,
clean_response_done_prefix="clean",
interrupted_tts_prefix="interrupted",
private_runtime_config_keys=frozenset({"private_key"}),
create_session_state=lambda: object(),
session_state=lambda session_id: object(),
remove_session_state=lambda session_id: None,
is_enabled=lambda config: True,
capabilities=lambda **kwargs: object(),
validate_client_extra_body=lambda extra_body: None,
prepare_runtime_config=lambda config, **kwargs: {},
runtime_config_for_update=lambda config, current: {},
data_plane_context=lambda **kwargs: object(),
)
@pytest.mark.parametrize(
("attribute", "invalid_value"),
[
("session_states", None),
("clean_response_done_prefix", None),
("interrupted_tts_prefix", None),
("private_runtime_config_keys", {"private_key"}),
],
)
def test_runtime_adapter_validator_rejects_invalid_protocol_attributes(
attribute: str,
invalid_value: object,
) -> None:
adapter = _valid_runtime_adapter()
setattr(adapter, attribute, invalid_value)
with pytest.raises(TypeError, match=attribute):
validate_serving_runtime_adapter(adapter)
def test_runtime_adapter_validator_rejects_incomplete_data_plane() -> None:
adapter = _valid_runtime_adapter()
adapter.data_plane.close_session = None
with pytest.raises(TypeError, match="data_plane.*close_session"):
validate_serving_runtime_adapter(adapter)
@@ -0,0 +1,145 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
import asyncio
import pytest
from vllm_omni.experimental.fullduplex.openai.websocket import (
DuplexWebSocketActor,
normalize_duplex_input_event,
)
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
class FakeWebSocket:
def __init__(self) -> None:
self.sent: list[dict[str, object]] = []
async def send_json(self, payload: dict[str, object]) -> None:
self.sent.append(dict(payload))
@pytest.mark.parametrize(
("event", "expected"),
[
({"type": "signal_turn", "event": "barge_in"}, {"type": "turn.signal", "event": "barge_in"}),
({"type": "close_session"}, {"type": "session.close"}),
({"type": "audio.playback_ack", "played_ms": 1}, {"type": "playback.ack", "played_ms": 1}),
({"type": "input_text.append", "text": "a"}, {"type": "input.text.append", "text": "a"}),
({"type": "push_text", "text": "b"}, {"type": "input.text.append", "text": "b"}),
(
{"type": "input.audio.append", "audio": "wav"},
{"type": "input_audio_buffer.append", "audio": "wav", "format": "wav"},
),
(
{"type": "push_chunk", "audio": "wav", "format": "pcm_f32le"},
{"type": "input_audio_buffer.append", "audio": "wav", "format": "pcm_f32le"},
),
],
)
def test_input_aliases_normalize_once_at_mailbox_boundary(event, expected):
assert normalize_duplex_input_event(event) == expected
def test_actor_uses_one_fifo_mailbox_for_inbound_events():
actor = DuplexWebSocketActor(FakeWebSocket())
assert isinstance(actor.mailbox, asyncio.Queue)
assert not isinstance(actor.mailbox, asyncio.PriorityQueue)
assert not hasattr(actor, "control_queue")
assert not hasattr(actor, "input_queue")
assert not hasattr(actor, "event_queue")
assert not hasattr(actor, "lifecycle_state")
assert not hasattr(actor, "last_response_id")
assert not hasattr(actor, "overlap_speech_ms")
assert not hasattr(actor, "transition")
assert not hasattr(actor, "output_generation_in_flight")
assert not hasattr(actor, "session")
assert not hasattr(actor, "runtime_opened")
assert not hasattr(actor, "runtime_closed")
assert not hasattr(actor, "drain_input_queue")
assert not hasattr(actor, "control_events_seen")
assert not hasattr(actor, "input_events_seen")
assert not hasattr(actor, "cancel_count")
@pytest.mark.asyncio
async def test_control_event_does_not_overtake_earlier_audio_input():
actor = DuplexWebSocketActor(FakeWebSocket())
await actor.enqueue_event({"type": "input_audio_buffer.append", "audio": "pcm"})
await actor.enqueue_event({"type": "response.cancel"})
assert await actor.next_event() == {"type": "input_audio_buffer.append", "audio": "pcm"}
assert await actor.next_event() == {"type": "response.cancel"}
@pytest.mark.asyncio
@pytest.mark.parametrize("event_type", ["input.cancel", "session.close", "close_session"])
async def test_terminal_control_preserves_wire_order(event_type: str):
actor = DuplexWebSocketActor(FakeWebSocket())
await actor.enqueue_event({"type": "input_audio_buffer.append", "audio": "pcm"})
await actor.enqueue_event({"type": event_type})
assert await actor.next_event() == {"type": "input_audio_buffer.append", "audio": "pcm"}
assert await actor.next_event() == {"type": event_type}
@pytest.mark.asyncio
async def test_mailbox_delivers_every_enqueued_event_exactly_once():
actor = DuplexWebSocketActor(FakeWebSocket())
events = [
{"type": "input_audio_buffer.append", "audio": str(index)}
if index % 2
else {"type": "response.created", "response_id": f"resp-{index}"}
for index in range(20)
]
await asyncio.gather(*(actor.enqueue_event(dict(event)) for event in events))
received = [await actor.next_event() for _ in events]
assert sorted(received, key=repr) == sorted(events, key=repr)
@pytest.mark.asyncio
async def test_writer_is_single_owner_of_websocket_send():
websocket = FakeWebSocket()
actor = DuplexWebSocketActor(websocket)
writer = asyncio.create_task(actor.writer_loop())
await actor.send_json({"type": "one"})
await actor.send_json({"type": "two"})
await actor.close_writer()
await writer
assert websocket.sent == [{"type": "one"}, {"type": "two"}]
@pytest.mark.asyncio
async def test_stale_fence_payload_is_dropped_before_websocket_send():
websocket = FakeWebSocket()
actor = DuplexWebSocketActor(websocket, current_epoch=lambda: 2)
writer = asyncio.create_task(actor.writer_loop())
await actor.send_json({"type": "response.audio.delta", "epoch": 1})
await actor.send_json({"type": "response.audio.delta", "epoch": 2})
await actor.close_writer()
await writer
assert websocket.sent == [{"type": "response.audio.delta", "epoch": 2}]
assert actor.stale_output_dropped == 1
@pytest.mark.asyncio
async def test_writer_does_not_revoke_accepted_terminal_after_close_starts():
websocket = FakeWebSocket()
actor = DuplexWebSocketActor(websocket, current_epoch=lambda: 1)
await actor.send_json({"type": "response.done", "epoch": 1, "response_id": "resp-1"})
actor.closing = True
await actor.close_writer()
await actor.writer_loop()
assert websocket.sent == [{"type": "response.done", "epoch": 1, "response_id": "resp-1"}]
assert actor.stale_output_dropped == 0
@@ -0,0 +1,240 @@
import base64
import wave
from urllib.parse import parse_qs, urlsplit
import pytest
from vllm_omni.experimental.fullduplex.client import (
RealtimeDuplexClient,
RealtimeEventCollector,
build_realtime_url,
read_pcm16_wav,
write_pcm16_wav,
)
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
def test_realtime_client_builds_explicit_native_duplex_url():
url = build_realtime_url(
"ws://localhost:8099/v1/realtime?custom=1",
"openbmb/MiniCPM-o-4_5",
session_id="session-a",
)
query = parse_qs(urlsplit(url).query)
assert query == {
"custom": ["1"],
"duplex": ["1"],
"model": ["openbmb/MiniCPM-o-4_5"],
"minicpmo45_native_duplex": ["1"],
"session_id": ["session-a"],
}
def test_realtime_client_builds_resume_only_url_when_autostart_disabled():
url = build_realtime_url(
"ws://localhost:8099/v1/realtime?duplex=1",
"openbmb/MiniCPM-o-4_5",
autostart=False,
)
query = parse_qs(urlsplit(url).query)
assert query["autostart"] == ["0"]
assert query["minicpmo45_native_duplex"] == ["1"]
@pytest.mark.asyncio
async def test_realtime_client_configure_omits_ref_audio_by_default():
class Client(RealtimeDuplexClient):
def __init__(self):
super().__init__("ws://unused")
self.sent = []
async def send(self, event):
self.sent.append(event)
self.events.add({"type": "session.created"})
client = Client()
await client.configure("openbmb/MiniCPM-o-4_5", timeout_s=1)
session = client.sent[0]["session"]
assert "ref_audio" not in session
@pytest.mark.asyncio
async def test_realtime_client_configure_sends_explicit_ref_audio():
class Client(RealtimeDuplexClient):
def __init__(self):
super().__init__("ws://unused")
self.sent = []
async def send(self, event):
self.sent.append(event)
self.events.add({"type": "session.created"})
client = Client()
await client.configure(
"openbmb/MiniCPM-o-4_5",
ref_audio="data:audio/wav;base64,AAAA",
timeout_s=1,
)
session = client.sent[0]["session"]
assert session["ref_audio"] == "data:audio/wav;base64,AAAA"
def test_realtime_event_collector_partitions_audio_by_response():
collector = RealtimeEventCollector()
collector.add({"type": "response.created", "response": {"id": "resp-a"}})
collector.add(
{
"type": "response.audio.delta",
"response_id": "resp-a",
"delta": base64.b64encode(b"audio-a").decode("ascii"),
"sample_rate_hz": 16_000,
}
)
assert collector.response_ids == ["resp-a"]
assert collector.audio_bytes("resp-a") == b"audio-a"
assert collector.output_sample_rate_hz == 16_000
assert collector.first_received_at("response.created") is not None
assert collector.last_received_at("response.audio.delta") is not None
def test_realtime_event_collector_reports_engine_token_and_audio_intervals():
collector = RealtimeEventCollector()
collector.add(
{"type": "response.created", "response": {"id": "resp-a"}},
received_at_s=10.0,
)
stage_metrics = {
"0": {
"num_tokens_out": 4,
"vllm_ttft_ms": 120.0,
"vllm_tpot_ms": 15.0,
"vllm_itl_ms": 14.0,
"vllm_itls_ms": [10.0, 14.0, 18.0],
}
}
for received_at_s, cumulative_audio_ms in ((10.2, 80), (10.25, 160), (10.36, 240)):
collector.add(
{
"type": "response.audio.delta",
"response_id": "resp-a",
"delta": base64.b64encode(b"audio").decode("ascii"),
"sample_rate_hz": 16_000,
"metadata": {
"audio_duration_ms": cumulative_audio_ms,
"vllm_omni": {"stage_metrics": stage_metrics},
},
},
received_at_s=received_at_s,
)
timing = collector.timing_summary(
after_s=10.0,
input_committed_at_s=9.9,
response_id="resp-a",
)
assert timing["stage0_tokens"] == {
"source": "engine_stage_metrics",
"output_token_count": 4,
"ttft_ms": 120.0,
"tpot_ms": 15.0,
"inter_token_interval_ms": {
"count": 3,
"mean": 14.0,
"p50": 14.0,
"p95": 18.0,
"max": 18.0,
},
}
assert timing["audio_output"] == {
"source": "client_monotonic_receive",
"chunk_count": 3,
"response_created_to_first_audio_ms": 200.0,
"commit_to_first_audio_ms": 300.0,
"inter_chunk_interval_ms": {
"count": 2,
"mean": 80.0,
"p50": 50.0,
"p95": 110.0,
"max": 110.0,
},
"chunk_duration_ms": {
"count": 3,
"mean": 80.0,
"p50": 80.0,
"p95": 80.0,
"max": 80.0,
},
"max_chunk_gap_ms": 110.0,
}
def test_response_timing_ignores_unowned_session_level_metrics():
collector = RealtimeEventCollector()
collector.add(
{"type": "response.created", "response": {"id": "resp-a"}},
received_at_s=10.0,
)
collector.add(
{
"type": "response.audio.delta",
"response_id": "resp-a",
"delta": base64.b64encode(b"audio").decode("ascii"),
"metadata": {
"vllm_omni": {
"stage_metrics": {
"0": {
"num_tokens_out": 20,
"vllm_ttft_ms": 157.0,
"vllm_tpot_ms": 16.0,
"vllm_itls_ms": [15.0, 17.0],
}
}
}
},
},
received_at_s=10.2,
)
collector.add(
{
"type": "response.listen",
"metadata": {
"vllm_omni": {
"stage_metrics": {
"0": {
"num_tokens_out": 2,
"vllm_ttft_ms": 106.0,
"vllm_tpot_ms": 0.0,
"vllm_itls_ms": [],
}
}
}
},
},
received_at_s=10.3,
)
timing = collector.timing_summary(after_s=10.0, response_id="resp-a")
assert timing["stage0_tokens"]["output_token_count"] == 20
assert timing["stage0_tokens"]["ttft_ms"] == 157.0
def test_realtime_client_pcm16_wav_round_trip(tmp_path):
path = tmp_path / "audio.wav"
pcm16 = b"\x01\x00\x02\x00"
write_pcm16_wav(path, pcm16, sample_rate_hz=16_000)
with wave.open(str(path), "rb") as wav_file:
assert wav_file.getnchannels() == 1
assert wav_file.getframerate() == 16_000
assert read_pcm16_wav(path) == pcm16
@@ -0,0 +1,110 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
import pytest
from vllm_omni.experimental.fullduplex.joyvl.decision.output_parser import parse_action
from vllm_omni.experimental.fullduplex.joyvl.memory.brain import InteractionBrain
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
class _FakeSummarizer:
def __init__(self):
self.chunks = 0
async def summarize_chunk(self, chunk_index, frame_range, frames):
self.chunks += 1
return f"summary-of-chunk-{chunk_index}"
async def compress_to_long_term(self, mids):
return f"compressed({len(mids)})"
class _FakeDelegation:
def __init__(self):
self._ready = {}
async def submit(self, question, note, frames):
self._ready["t1"] = question
return "t1"
async def poll(self, task_id):
from vllm_omni.experimental.fullduplex.joyvl.bridges.delegation import DelegationResult
return DelegationResult(task_id, "ready", digest=f"answer to {self._ready[task_id]}")
def test_query_freshness_and_archive_on_change():
b = InteractionBrain()
assert b.update_query("count bottles") is True
assert b.update_query("count bottles") is False
b.record_response("1 bottle")
assert b.update_query("now describe") is True
assert b.memory.qa_history[-1].query == "count bottles"
assert b.memory.qa_history[-1].responses == [("0.0 seconds", "1 bottle")]
def test_query_in_frame_on_arrival_then_prefix_on_later_chunks():
b = InteractionBrain(frame_seconds=1.0)
b.tick(3)
b.update_query("alert me if a fire breaks out")
assert b.query_in_current_chunk is True
assert "alert me if a fire breaks out" not in b.build_prefix()
b.record_response("a fire is breaking out")
b.close_chunk()
assert b.query_in_current_chunk is False
prefix = b.build_prefix()
assert "alert me if a fire breaks out" in prefix
assert "a fire is breaking out" in prefix
@pytest.mark.asyncio
async def test_flush_archives_qa_and_summarizes():
summ = _FakeSummarizer()
b = InteractionBrain(summarizer=summ, chunk_frames=3, long_term_every_n_chunks=2, frame_seconds=1.0)
b.update_query("watch the room")
b.tick(3)
assert b.should_flush() is True
b.record_response("a person enters")
await b.flush([("0.0s", "u0"), ("2.0s", "u2")])
assert summ.chunks == 1
assert b.chunk_index == 2
assert b.should_flush() is False
assert b.memory.qa_history[-1].responses == [("2.0 seconds", "a person enters")]
assert len(b.memory.mid_term_summaries) == 1
@pytest.mark.asyncio
async def test_long_term_compression_after_n_chunks():
summ = _FakeSummarizer()
b = InteractionBrain(summarizer=summ, chunk_frames=1, long_term_every_n_chunks=2, frame_seconds=1.0)
for _ in range(2):
await b.flush([("0.0s", "u")])
assert b.memory.long_term_memory.startswith("compressed(")
assert b.memory.mid_term_summaries == []
@pytest.mark.asyncio
async def test_long_term_window_caps_blocks():
# Long-term memory must not grow unbounded over hours: oldest blocks are dropped
# once the sliding window is full (reference window = 15; here 2 for the test).
summ = _FakeSummarizer()
b = InteractionBrain(
summarizer=summ, chunk_frames=1, long_term_every_n_chunks=1, long_term_window=2, frame_seconds=1.0
)
for _ in range(4):
await b.flush([("0.0s", "u")])
assert len(b.memory.long_term_blocks) == 2 # capped, not 4
assert b.memory.long_term_memory.count("compressed(") == 2
@pytest.mark.asyncio
async def test_delegation_submit_and_fold():
b = InteractionBrain(delegation=_FakeDelegation())
action = parse_action("</response> hold on <delegation> solve this")
info = await b.submit_delegation(action, [])
assert info["status"] == "submitted"
folded = await b.fold_delegations()
assert folded["status"] == "ready"
assert b.memory.qa_history[-1].responses[0][1] == "answer to solve this"
@@ -0,0 +1,282 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
import asyncio
import pytest
from vllm_omni.experimental.fullduplex.joyvl.bridges.delegation import (
DelegationResult,
ImageEditDelegationBridge,
ImageGenDelegationBridge,
OpenAIDelegationBridge,
)
from vllm_omni.experimental.fullduplex.joyvl.decision.policy import JoyVLPolicy, sample_frames
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
def test_sample_frames_keeps_recent():
assert sample_frames(["a", "b"], 4) == ["a", "b"]
out = sample_frames([str(i) for i in range(10)], 4)
assert out[-1] == "9" and len(out) == 4
def test_build_messages_stable_head_and_query():
p = JoyVLPolicy(num_frames=4)
p.tick(2)
p.set_query("count the bottles")
msgs, user = p.build_messages([{"type": "image_url", "image_url": {"url": "x"}}])
assert msgs[0]["role"] == "system" and "</silence>" in msgs[0]["content"]
assert any("count the bottles" in part.get("text", "") for part in user["content"])
def test_commit_records_only_spoken():
p = JoyVLPolicy()
p.set_query("q")
assert p.commit("</silence>").action.value == "silence"
assert p.brain.response_records == []
assert p.commit("</response> hello").action.value == "response"
assert p.brain.response_records == [("0.0 seconds", "hello")]
def test_commit_dedup_default_is_exact_match_only():
p = JoyVLPolicy() # threshold 1.0 == reference behavior
assert p.commit("</response> a red ball appears").action.value == "response"
assert p.commit("</response> a red ball appears").action.value == "silence" # exact repeat dropped
assert p.commit("</response> a red ball is appearing").action.value == "response" # near-dup kept
def test_commit_dedup_threshold_drops_near_duplicates():
p = JoyVLPolicy(response_dedup_threshold=0.8)
assert p.commit("</response> a red ball appears").action.value == "response"
assert p.commit("</response> a red ball appears!").action.value == "silence" # near-dup dropped
assert p.commit("</response> the sky is now blue").action.value == "response" # distinct kept
def test_commit_dedup_rearms_after_silence():
p = JoyVLPolicy(response_dedup_threshold=0.85)
assert p.commit("</response> someone raises a hand").action.value == "response"
# no gap: an immediate repeat is still suppressed
assert p.commit("</response> someone raises a hand").action.value == "silence"
# a genuine silent tick (hand lowered) re-arms; the recurrence speaks again
assert p.commit("</silence>").action.value == "silence"
assert p.commit("</response> someone raises a hand").action.value == "response"
class _FakeBrain(OpenAIDelegationBridge):
"""Override only the network call so submit/poll plumbing is exercised for real."""
def __init__(self, answer, gate=None):
super().__init__("http://unused/v1", "background-model")
self._answer = answer
self._gate = gate
async def _complete(self, messages):
if self._gate is not None:
await self._gate.wait()
return self._answer
@pytest.mark.asyncio
async def test_openai_delegation_bridge_is_nonblocking_and_resolves():
gate = asyncio.Event()
bridge = _FakeBrain("the bottle is on the left shelf", gate=gate)
frames = [("0.0s", "data:image/jpeg;base64,AAA")]
task_id = await bridge.submit("where is the bottle?", "let me check", frames)
# poll must not block on the slow background brain
assert (await bridge.poll(task_id)).status == "pending"
gate.set()
await asyncio.sleep(0)
for _ in range(10):
result = await bridge.poll(task_id)
if result.status != "pending":
break
await asyncio.sleep(0)
assert result.is_ready
assert "bottle is on the left shelf" in result.digest
assert (await bridge.poll("missing")).status == "error"
class _FakeImageBrain(ImageGenDelegationBridge):
async def _call_images(self, payload):
# echo the prompt back so the test can assert it was passed through
return {"data": [{"b64_json": "QUJD"}]} # "ABC"
@pytest.mark.asyncio
async def test_image_delegation_bridge_returns_media_not_text():
bridge = _FakeImageBrain("http://unused/v1")
task_id = await bridge.submit("draw a red dragon", "", [])
for _ in range(10):
result = await bridge.poll(task_id)
if result.status != "pending":
break
await asyncio.sleep(0)
assert result.is_ready
# the image rides in media as a data URL; digest is only a short text placeholder
assert result.media == "data:image/png;base64,QUJD"
assert "QUJD" not in result.digest and "red dragon" in result.digest
class _FakeEditBrain(ImageEditDelegationBridge):
def __init__(self, base_url, model):
super().__init__(base_url, model)
self.seen_frame = None
async def _call_chat(self, frame, instruction):
self.seen_frame = frame
return {"choices": [{"message": {"content": [{"image_url": {"url": "data:image/png;base64,Q0FSVA=="}}]}}]}
@pytest.mark.asyncio
async def test_image_edit_delegation_conditions_on_frame_and_returns_media():
bridge = _FakeEditBrain("http://unused/v1", "Qwen-Image-Edit")
frames = [("0.0s", "data:image/jpeg;base64,OLD"), ("1.0s", "data:image/jpeg;base64,NOW")]
task_id = await bridge.submit("把画面变成卡通风格", "", frames)
for _ in range(10):
result = await bridge.poll(task_id)
if result.status != "pending":
break
await asyncio.sleep(0)
assert result.is_ready
assert bridge.seen_frame == "data:image/jpeg;base64,NOW" # latest frame, not the old one
assert result.media == "data:image/png;base64,Q0FSVA=="
assert result.media not in result.digest # image rides in media, not context
@pytest.mark.asyncio
async def test_image_edit_delegation_errors_without_frame():
bridge = _FakeEditBrain("http://unused/v1", "Qwen-Image-Edit")
task_id = await bridge.submit("cartoonify", "", [])
for _ in range(10):
result = await bridge.poll(task_id)
if result.status != "pending":
break
await asyncio.sleep(0)
assert result.status == "error"
class _NamedBridge:
def __init__(self, name):
self.name = name
async def submit(self, question, note, frames):
return f"{self.name}-1"
async def poll(self, task_id):
return DelegationResult(task_id, "ready", digest=self.name)
@pytest.mark.asyncio
async def test_routing_delegation_dispatches_by_request():
from vllm_omni.experimental.fullduplex.joyvl.bridges.delegation import RoutingDelegationBridge
chat, image, edit = _NamedBridge("chat"), _NamedBridge("image"), _NamedBridge("edit")
r = RoutingDelegationBridge(chat=chat, image=image, edit=edit)
async def route(q):
tid = await r.submit(q, "", [])
return (await r.poll(tid)).digest
assert await route("把你看到的画面变成卡通风格") == "edit" # restyle current view
assert await route("画一只红色的龙") == "image" # generate a new image
assert await route("这道数学题的答案是什么") == "chat" # hard question -> chat brain
class _Delegation:
async def submit(self, question, note, frames):
return "t1"
async def poll(self, task_id):
return DelegationResult(task_id, "ready", digest="background answer")
def test_build_delegation_off_unless_backend_configured():
from vllm_omni.experimental.fullduplex.joyvl.serving.config import InteractionConfig
from vllm_omni.experimental.fullduplex.joyvl.serving.server import SessionManager
# No backend URL -> delegation disabled (None), NOT a silent stub that folds fake answers.
assert SessionManager._build_delegation(InteractionConfig()) is None
assert SessionManager._build_delegation(InteractionConfig(delegation_kind="router")) is None
# Stub only on explicit opt-in.
assert SessionManager._build_delegation(InteractionConfig(delegation_kind="stub")) is not None
# A real chat backend -> a live bridge.
cfg = InteractionConfig(delegation_backend_url="http://x/v1", delegation_model="m")
assert SessionManager._build_delegation(cfg) is not None
@pytest.mark.asyncio
async def test_delegation_cancel_drops_task():
from vllm_omni.experimental.fullduplex.joyvl.bridges.delegation import StubDelegationBridge
b = StubDelegationBridge(ready_after_ticks=1)
task_id = await b.submit("q", "", [])
b.cancel(task_id)
# after cancel the task is gone -> poll reports it as unknown rather than leaking
assert (await b.poll(task_id)).status == "error"
@pytest.mark.asyncio
async def test_delegation_aclose_clears_pending():
from vllm_omni.experimental.fullduplex.joyvl.bridges.delegation import StubDelegationBridge
b = StubDelegationBridge(ready_after_ticks=5)
task_id = await b.submit("q", "", [])
await b.aclose()
assert (await b.poll(task_id)).status == "error"
@pytest.mark.asyncio
async def test_session_reset_cancels_and_awaits_consolidation():
from vllm_omni.experimental.fullduplex.joyvl.serving.config import InteractionConfig
from vllm_omni.experimental.fullduplex.joyvl.serving.session import InteractionSession
class _Backend:
async def generate(self, *a, **k):
return "</silence>", None
async def aclose(self):
pass
class _Summarizer:
async def summarize_chunk(self, *a, **k):
await asyncio.sleep(100) # hang so the consolidation task is in-flight
return "x"
async def compress_to_long_term(self, *a, **k):
return ""
async def aclose(self):
pass
sess = InteractionSession("s", InteractionConfig(), _Backend(), summarizer=_Summarizer())
sess._spawn_consolidation(1, [("0.0s", "u")])
assert len(sess._consolidating) == 1
await sess.reset() # must cancel AND await the in-flight task, not just clear the set
assert len(sess._consolidating) == 0
@pytest.mark.asyncio
async def test_manager_aclose_closes_without_error():
from vllm_omni.experimental.fullduplex.joyvl.serving.config import InteractionConfig
from vllm_omni.experimental.fullduplex.joyvl.serving.server import SessionManager
mgr = SessionManager(InteractionConfig(enable_memory=False, enable_delegation=False))
await mgr.aclose()
await mgr.aclose() # idempotent
@pytest.mark.asyncio
async def test_delegation_submit_and_fold():
p = JoyVLPolicy(delegation=_Delegation())
action = p.commit("</response> hold on <delegation> hard question")
assert action.action.value == "delegate"
info = await p.submit_if_delegate(action)
assert info["status"] == "submitted"
folded = await p.fold_delegations()
assert folded["status"] == "ready"
assert p.brain.memory.qa_history[-1].responses[0][1] == "background answer"
@@ -0,0 +1,172 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
import asyncio
from collections.abc import AsyncIterator
import pytest
from vllm_omni.experimental.fullduplex.core import protocol as ev
from vllm_omni.experimental.fullduplex.core.adapter import DuplexCapability, OutputChunk
from vllm_omni.experimental.fullduplex.core.runtime import DuplexRuntime
from vllm_omni.experimental.fullduplex.core.session import DuplexSession, DuplexSessionConfig
from vllm_omni.experimental.fullduplex.joyvl.adapter import JoyVLDuplexAdapter
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
async def _feed(events):
for e in events:
yield e
def _collector():
out: list[dict] = []
async def emit(event: dict) -> None:
out.append(event)
return out, emit
class _FakeAdapter:
def __init__(self, chunks, barge_after=None):
self._chunks = chunks
self._barge_after = barge_after
def capabilities(self):
return DuplexCapability(frozenset({"text"}), frozenset({"text"}), proactive=False)
async def on_input(self, session, modality, data):
pass
def should_respond(self, session):
return True
async def respond(self, session) -> AsyncIterator[OutputChunk]:
for i, c in enumerate(self._chunks):
if self._barge_after is not None and i == self._barge_after:
session.barge_in()
yield OutputChunk("text", c)
async def on_barge_in(self, session):
pass
async def on_playback_ack(self, session, cursor):
pass
@pytest.mark.asyncio
async def test_runtime_basic_response():
session = DuplexSession("s", DuplexSessionConfig(output_modalities=("text",)))
rt = DuplexRuntime(session, _FakeAdapter(["a", "b"]))
out, emit = _collector()
await rt.run(_feed([{"type": ev.INPUT_COMMIT}, {"type": ev.CLOSE}]), emit)
types = [e["type"] for e in out]
assert types == [ev.RESPONSE_CREATED, ev.RESPONSE_DELTA, ev.RESPONSE_DELTA, ev.RESPONSE_DONE]
assert [e["data"] for e in out if e["type"] == ev.RESPONSE_DELTA] == ["a", "b"]
@pytest.mark.asyncio
async def test_runtime_barge_in_drops_stale_output():
session = DuplexSession("s")
rt = DuplexRuntime(session, _FakeAdapter(["a", "b", "c"], barge_after=1))
out, emit = _collector()
await rt.run(_feed([{"type": ev.INPUT_COMMIT}, {"type": ev.CLOSE}]), emit)
data = [e["data"] for e in out if e["type"] == ev.RESPONSE_DELTA]
assert data == ["a"]
assert ev.RESPONSE_DONE not in [e["type"] for e in out]
class _SlowAdapter:
def capabilities(self):
return DuplexCapability(frozenset({"text"}), frozenset({"text"}), proactive=False)
async def on_input(self, session, modality, data):
pass
def should_respond(self, session):
return True
async def respond(self, session) -> AsyncIterator[OutputChunk]:
for c in ["a", "b", "c", "d", "e"]:
await asyncio.sleep(0.02)
yield OutputChunk("text", c)
async def on_barge_in(self, session):
pass
async def on_playback_ack(self, session, cursor):
pass
@pytest.mark.asyncio
async def test_runtime_cancel_event_interrupts_active_response():
session = DuplexSession("s")
rt = DuplexRuntime(session, _SlowAdapter())
out, emit = _collector()
async def feed():
yield {"type": ev.INPUT_COMMIT}
await asyncio.sleep(0.03)
yield {"type": ev.RESPONSE_CANCEL}
yield {"type": ev.CLOSE}
await rt.run(feed(), emit)
deltas = [e["data"] for e in out if e["type"] == ev.RESPONSE_DELTA]
types = [e["type"] for e in out]
assert len(deltas) < 5
assert ev.RESPONSE_CANCELLED in types
assert ev.RESPONSE_DONE not in types
@pytest.mark.asyncio
async def test_runtime_new_response_supersedes_inflight_without_blocking():
# A second response trigger arriving while the first is still streaming must
# supersede it (barge-in) rather than block the input loop until it finishes.
session = DuplexSession("s")
rt = DuplexRuntime(session, _SlowAdapter())
out, emit = _collector()
async def feed():
yield {"type": ev.INPUT_COMMIT}
await asyncio.sleep(0.03) # let the first response emit ~one chunk
yield {"type": ev.INPUT_COMMIT} # supersedes the in-flight first response
await asyncio.sleep(0.2) # let the second response finish
yield {"type": ev.CLOSE}
await rt.run(feed(), emit)
created = [e for e in out if e["type"] == ev.RESPONSE_CREATED]
done = [e for e in out if e["type"] == ev.RESPONSE_DONE]
# two responses created; only the latest one completes (the first was cancelled)
assert len(created) == 2
assert len(done) == 1
assert done[0]["response_index"] == created[-1]["response_index"]
@pytest.mark.asyncio
async def test_joyvl_adapter_speaks_then_silences():
replies = iter(["</response> a fire is breaking out", "</silence>"])
async def fake_generate(messages):
return next(replies)
adapter = JoyVLDuplexAdapter(fake_generate, num_frames=4)
cfg = DuplexSessionConfig(input_modalities=("video", "text"), output_modalities=("text",), proactive=True)
rt = DuplexRuntime(DuplexSession("s", cfg), adapter)
out, emit = _collector()
await rt.run(
_feed(
[
{"type": ev.INPUT_APPEND, "modality": "text", "data": "alert me if a fire breaks out"},
{"type": ev.INPUT_APPEND, "modality": "video", "data": "data:image/jpeg;base64,AAA"},
{"type": ev.INPUT_APPEND, "modality": "video", "data": "data:image/jpeg;base64,BBB"},
{"type": ev.CLOSE},
]
),
emit,
)
deltas = [e["data"] for e in out if e["type"] == ev.RESPONSE_DELTA]
assert "a fire is breaking out" in deltas
assert deltas.count("a fire is breaking out") == 1