[Bugfix] Fix Qwen3-Omni realtime playback interruption and conversation history (#7791)

Signed-off-by: psv666 <2693925048@qq.com>
This commit is contained in:
psv666 authored and GitHub committed 2026-09-20 14:42:17 +00:00
1 parent d3396fc5fb
commit 2d0b03f717
13 files changed
+1292 -68

No files matched your search

+68 -1
View File
@@ -11,7 +11,7 @@ import inspect
import pytest
from vllm_omni.engine.duplex import events as events_module
from vllm_omni.engine.duplex.commands import TruncateItem
from vllm_omni.engine.duplex.commands import ClearOutputAudio, TruncateItem
from vllm_omni.engine.duplex.events import (
REALTIME_ERROR_TYPES_BY_CODE,
AudioDelta,
@@ -41,6 +41,7 @@ from vllm_omni.engine.duplex.events import (
from vllm_omni.engine.duplex.realtime_events import (
RealtimeProjectionState,
project_internal_event,
resolve_clear_output_audio,
resolve_truncate_item,
retrieve_item_events,
)
@@ -376,6 +377,72 @@ def test_projection_of_output_audio_buffer_clear_emits_cleared_before_terminals(
assert state.item_truncation_cursors["item_resp_1"] == (0, 250)
@pytest.mark.parametrize("reason", ["turn_detected", "barge_in", "output_audio_buffer_clear"])
@pytest.mark.parametrize("committed_ms", [1000, 10000])
def test_completed_response_cancellation_only_clears_pending_playback(reason, committed_ms):
state = RealtimeProjectionState(session_id="duplex-playback")
project_internal_event(state, {"type": "response.created", "response_id": "resp_1"})
project_internal_event(state, {"type": "response.done", "response_id": "resp_1"})
cancelled = project_internal_event(
state,
{
"type": "audio.cancelled",
"response_id": "resp_1",
"reason": reason,
"committed_ms": committed_ms,
"playback": {"sent_ms": 10000, "committed_ms": committed_ms},
},
)
if reason == "output_audio_buffer_clear" or committed_ms < 10000:
assert _types(cancelled) == ["output_audio_buffer.cleared"]
assert cancelled[0].response_id == "resp_1"
else:
assert cancelled == []
@pytest.mark.parametrize("explicit_response_id", [False, True])
def test_clear_latest_completed_response_reaches_playback_owner(explicit_response_id):
state = RealtimeProjectionState(session_id="duplex-clear")
project_internal_event(state, {"type": "response.created", "response_id": "resp_1"})
project_internal_event(state, {"type": "response.done", "response_id": "resp_1"})
control = resolve_clear_output_audio(
state, ClearOutputAudio(response_id="resp_1" if explicit_response_id else None, event_id="clear_1")
)
# Generation completion does not prove the client has drained its audio.
# A cleared event must follow the engine clearing its playback cursor.
assert control.events == []
assert control.payloads == [
{
"type": "output_audio_buffer.clear",
"reason": "output_audio_buffer.clear",
"response_id": "resp_1",
"realtime_event_id": "clear_1",
}
]
@pytest.mark.parametrize("new_response_finished", [False, True])
def test_late_clear_of_completed_response_does_not_touch_new_response(new_response_finished):
state = RealtimeProjectionState(session_id="duplex-clear")
project_internal_event(state, {"type": "response.created", "response_id": "resp_1"})
project_internal_event(state, {"type": "response.done", "response_id": "resp_1"})
project_internal_event(state, {"type": "response.created", "response_id": "resp_2"})
if new_response_finished:
project_internal_event(state, {"type": "response.done", "response_id": "resp_2"})
control = resolve_clear_output_audio(state, ClearOutputAudio(response_id="resp_1"))
assert control.payloads == []
assert _types(control.events) == ["output_audio_buffer.cleared"]
assert control.events[0].response_id == "resp_1"
assert state.active_response_id == (None if new_response_finished else "resp_2")
assert state.last_response_id == "resp_2"
def test_one_truncate_command_truncates_retrieved_transcript_once():
state = RealtimeProjectionState(session_id="duplex-truncate")
state.conversation_items["item_resp_1"] = {
@@ -21,6 +21,7 @@ from vllm_omni.engine.duplex.config import (
from vllm_omni.engine.duplex.contracts import DuplexFence
from vllm_omni.engine.duplex.events import TurnEvent
from vllm_omni.engine.duplex.session.engine_session import DuplexEngineSession, DuplexFenceMismatchError
from vllm_omni.engine.duplex.session.playback_ledger import apply_playback_ack
from vllm_omni.model_executor.models.minicpmo_4_5.duplex.capabilities import (
minicpmo45_native_capabilities,
)
@@ -241,6 +242,83 @@ def test_history_commit_with_ack_only_policy_defers_unacknowledged_text():
assert f"item_{response_id}" in session.pending_history_item_ids
@pytest.mark.parametrize("audio_complete", [False, True])
def test_unaligned_response_keeps_empty_turn_before_later_user_input(audio_complete):
session = _session(config=DuplexSessionConfig(playback_commit_policy="ack_only"))
first = session.commit_audio_input(transcript="recite a poem")
response_id = session.begin_response()
session.reserve_history_item(f"item_{response_id}")
session.append_assistant_text("An answer the user has only partly heard")
session.mark_audio_sent(
duration_ms=10_000,
text_requires_complete_audio=True,
audio_complete=audio_complete,
)
assert session.end_response(commit_text=True) is None
session.barge_in()
session.clear_playback_cursor()
second = session.commit_audio_input(transcript="stop")
empty_answer = {"role": "assistant", "content": ""}
assert session.history == (first.message, empty_answer, second.message)
ack = apply_playback_ack(session, {"response_id": response_id, "played_ms": 6_981})
assert ack[0].to_realtime()["event"]["history_committed"] is False
assert session.history == (first.message, empty_answer, second.message)
if audio_complete:
ack = apply_playback_ack(session, {"response_id": response_id, "played_ms": 10_000})
assert ack[0].to_realtime()["event"]["history_committed"] is True
assert session.history == (
first.message,
{"role": "assistant", "content": "An answer the user has only partly heard"},
second.message,
)
@pytest.mark.parametrize(("reserve_slot", "assistant_text"), [(True, ""), (False, "unplayed answer")])
def test_unaligned_response_only_materializes_an_existing_answer_slot(reserve_slot, assistant_text):
session = _session(config=DuplexSessionConfig(playback_commit_policy="ack_only"))
first = session.commit_audio_input(transcript="first input")
response_id = session.begin_response()
if reserve_slot:
session.reserve_history_item(f"item_{response_id}")
session.append_assistant_text(assistant_text)
session.mark_audio_sent(duration_ms=10_000, text_requires_complete_audio=True, audio_complete=True)
assert session.end_response(commit_text=reserve_slot) is None
assert session.history == (first.message,)
@pytest.mark.parametrize("operation", ["delete", "truncate"])
def test_removing_one_empty_assistant_preserves_the_other_turn_boundary(operation):
session = _session(config=DuplexSessionConfig(playback_commit_policy="ack_only"))
user_inputs = []
response_ids = []
for transcript in ("first input", "second input"):
user_inputs.append(session.commit_audio_input(transcript=transcript).message)
response_id = session.begin_response()
response_ids.append(response_id)
session.reserve_history_item(f"item_{response_id}")
session.append_assistant_text("unplayed answer")
session.mark_audio_sent(duration_ms=10_000, text_requires_complete_audio=True, audio_complete=True)
session.end_response(commit_text=True)
item_id = f"item_{response_ids[1]}"
if operation == "delete":
assert session.delete_history_item(item_id) is True
else:
# A full ACK releases the pending snapshot. Truncation then edits the
# stored message into another empty dict before removing that item.
ack = apply_playback_ack(session, {"response_id": response_ids[1], "played_ms": 10_000})
assert ack[0].to_realtime()["event"]["history_committed"] is True
assert session.truncate_history_item(item_id, audio_end_ms=0, hard=True) is True
assert session.history == (user_inputs[0], {"role": "assistant", "content": ""}, user_inputs[1])
ack = apply_playback_ack(session, {"response_id": response_ids[1], "played_ms": 10_000})
assert ack[0].to_realtime()["error"]["code"] == "playback_item_not_found"
assert session.history == (user_inputs[0], {"role": "assistant", "content": ""}, user_inputs[1])
def test_truncate_history_item_uses_response_alignment_marks():
session = _session()
response_id = session.begin_response()