[Bugfix][MiniCPM-o] Checkpoint playback before final input commit (#6821)
Signed-off-by: Gao Han <hgaoaf@connect.ust.hk>
This commit is contained in:
1 parent
b81aeb7b86
commit
705e781b0e
7 files changed
+334
-29
No files matched your search
@@ -155,6 +155,23 @@ def _response_in_progress(events: list[dict[str, object]]) -> bool:
|
||||
)
|
||||
|
||||
|
||||
async def _commit_input_after_playback_checkpoint(client: RealtimeDuplexClient) -> float:
|
||||
"""Commit input only after preserving already-played assistant history.
|
||||
|
||||
Native duplex can finish several responses while the user is still
|
||||
streaming. Under the ``ack_only`` playback policy, acknowledging those
|
||||
responses after the final input commit is intentionally rejected: it could
|
||||
append an old assistant message after the newly committed user message.
|
||||
Checkpoint playback first so the server commits each assistant item at its
|
||||
original history position. A second acknowledgement after the commit can
|
||||
then safely advance response-local playback that drained in the meantime.
|
||||
"""
|
||||
await client.acknowledge_playback()
|
||||
commit_sent_at_s = time.monotonic()
|
||||
await client.commit()
|
||||
return commit_sent_at_s
|
||||
|
||||
|
||||
def _event_count_after(
|
||||
events: list[dict[str, object]],
|
||||
event_type: str,
|
||||
@@ -448,8 +465,7 @@ async def run_demo(args: argparse.Namespace) -> dict[str, object]:
|
||||
chunk_period_ms=_chunk_period_ms(client.events.events),
|
||||
)
|
||||
wait_for_post_commit_decision = False
|
||||
commit_sent_at_s = time.monotonic()
|
||||
await client.commit()
|
||||
commit_sent_at_s = await _commit_input_after_playback_checkpoint(client)
|
||||
wait_error: str | None = None
|
||||
committed_index: int | None = None
|
||||
post_commit_decision: str | None = None
|
||||
|
||||
@@ -4425,6 +4425,167 @@ async def test_playback_ack_rejects_response_after_followup_user_commit():
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_precommit_zero_audio_ack_reserves_history_for_response_that_finishes_after_commit():
|
||||
handler = OmniDuplexSessionHandler(
|
||||
chat_service=FakeChatService(FakeEngineClient()),
|
||||
config_timeout_s=0.1,
|
||||
idle_timeout_s=1,
|
||||
)
|
||||
session = DuplexSession(
|
||||
session_id="sid-zero-audio-checkpoint",
|
||||
config=DuplexSessionConfig(playback_commit_policy="ack_only"),
|
||||
)
|
||||
session.mark_user_input_activity()
|
||||
response_id = session.begin_response()
|
||||
item_id = f"item_{response_id}"
|
||||
ws = TimedWebSocket()
|
||||
|
||||
await handler._handle_playback_ack(
|
||||
session,
|
||||
{
|
||||
"type": "playback.ack",
|
||||
"response_id": response_id,
|
||||
"item_id": item_id,
|
||||
"played_ms": 0,
|
||||
"committed_ms": 0,
|
||||
},
|
||||
ws.send_json,
|
||||
)
|
||||
session.commit_native_audio_input(transcript="later user input")
|
||||
session.append_assistant_text("hello world")
|
||||
session.mark_audio_sent(1000, text_chars=len("hello world"))
|
||||
committed_message = session.end_response(commit_text=True, preserve_request=True)
|
||||
session.register_history_item(item_id, committed_message)
|
||||
|
||||
await handler._handle_playback_ack(
|
||||
session,
|
||||
{
|
||||
"type": "playback.ack",
|
||||
"response_id": response_id,
|
||||
"item_id": item_id,
|
||||
"played_ms": 1000,
|
||||
"committed_ms": 1000,
|
||||
},
|
||||
ws.send_json,
|
||||
)
|
||||
|
||||
assert not any(message.get("type") == "error" for message in ws.sent)
|
||||
assert session.history == (
|
||||
{"role": "assistant", "content": "hello world"},
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{
|
||||
"type": "audio_url",
|
||||
"audio_url": {"url": "native-duplex:input-audio"},
|
||||
"transcript": "later user input",
|
||||
}
|
||||
],
|
||||
"transcript": "later user input",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_precommit_partial_ack_extends_history_after_response_finishes():
|
||||
handler = OmniDuplexSessionHandler(
|
||||
chat_service=FakeChatService(FakeEngineClient()),
|
||||
config_timeout_s=0.1,
|
||||
idle_timeout_s=1,
|
||||
)
|
||||
session = DuplexSession(
|
||||
session_id="sid-partial-checkpoint",
|
||||
config=DuplexSessionConfig(playback_commit_policy="ack_only"),
|
||||
)
|
||||
session.mark_user_input_activity()
|
||||
response_id = session.begin_response()
|
||||
item_id = f"item_{response_id}"
|
||||
session.append_assistant_text("hello world")
|
||||
session.mark_audio_sent(1000, text_chars=len("hello world"))
|
||||
ws = TimedWebSocket()
|
||||
|
||||
await handler._handle_playback_ack(
|
||||
session,
|
||||
{
|
||||
"type": "playback.ack",
|
||||
"response_id": response_id,
|
||||
"item_id": item_id,
|
||||
"played_ms": 500,
|
||||
"committed_ms": 500,
|
||||
},
|
||||
ws.send_json,
|
||||
)
|
||||
session.commit_native_audio_input(transcript="later user input")
|
||||
committed_message = session.end_response(commit_text=True, preserve_request=True)
|
||||
session.register_history_item(item_id, committed_message)
|
||||
|
||||
assert session.history[0] == {"role": "assistant", "content": "hello"}
|
||||
await handler._handle_playback_ack(
|
||||
session,
|
||||
{
|
||||
"type": "playback.ack",
|
||||
"response_id": response_id,
|
||||
"item_id": item_id,
|
||||
"played_ms": 1000,
|
||||
"committed_ms": 1000,
|
||||
},
|
||||
ws.send_json,
|
||||
)
|
||||
|
||||
assert not any(message.get("type") == "error" for message in ws.sent)
|
||||
assert session.history[0] == {"role": "assistant", "content": "hello world"}
|
||||
assert session.history[1]["role"] == "user"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_explicit_truncate_seals_snapshot_against_later_playback_ack():
|
||||
handler = OmniDuplexSessionHandler(
|
||||
chat_service=FakeChatService(FakeEngineClient()),
|
||||
config_timeout_s=0.1,
|
||||
idle_timeout_s=1,
|
||||
)
|
||||
session = DuplexSession(
|
||||
session_id="sid-hard-playback-truncate",
|
||||
config=DuplexSessionConfig(playback_commit_policy="ack_only"),
|
||||
)
|
||||
response_id = session.begin_response()
|
||||
item_id = f"item_{response_id}"
|
||||
session.append_assistant_text("hello world")
|
||||
session.mark_audio_sent(1000, text_chars=len("hello world"))
|
||||
session.end_response(commit_text=False, preserve_request=True)
|
||||
ws = TimedWebSocket()
|
||||
|
||||
await handler._handle_playback_ack(
|
||||
session,
|
||||
{
|
||||
"type": "playback.ack",
|
||||
"response_id": response_id,
|
||||
"item_id": item_id,
|
||||
"played_ms": 500,
|
||||
"committed_ms": 500,
|
||||
"truncate": True,
|
||||
},
|
||||
ws.send_json,
|
||||
)
|
||||
assert session.history == ({"role": "assistant", "content": "hello"},)
|
||||
|
||||
await handler._handle_playback_ack(
|
||||
session,
|
||||
{
|
||||
"type": "playback.ack",
|
||||
"response_id": response_id,
|
||||
"item_id": item_id,
|
||||
"played_ms": 1000,
|
||||
"committed_ms": 1000,
|
||||
},
|
||||
ws.send_json,
|
||||
)
|
||||
|
||||
assert not any(message.get("type") == "error" for message in ws.sent)
|
||||
assert session.history == ({"role": "assistant", "content": "hello"},)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cancel_does_not_append_old_assistant_after_followup_user():
|
||||
handler = OmniDuplexSessionHandler(
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import importlib.util
|
||||
import sys
|
||||
@@ -172,6 +173,29 @@ def test_open_streaming_response_requires_post_commit_drain():
|
||||
)
|
||||
|
||||
|
||||
def test_final_input_commit_checkpoints_playback_first():
|
||||
demo = _load_demo_module()
|
||||
client = demo.RealtimeDuplexClient("ws://unused")
|
||||
client.events.add(
|
||||
{
|
||||
"type": "response.created",
|
||||
"response": {"id": "resp-zero-audio"},
|
||||
}
|
||||
)
|
||||
calls = []
|
||||
|
||||
async def send(event):
|
||||
calls.append(event)
|
||||
|
||||
client.send = send
|
||||
commit_sent_at_s = asyncio.run(demo._commit_input_after_playback_checkpoint(client))
|
||||
|
||||
assert [event["type"] for event in calls] == ["playback.ack", "input_audio_buffer.commit"]
|
||||
assert calls[0]["response_id"] == "resp-zero-audio"
|
||||
assert calls[0]["played_ms"] == 0
|
||||
assert isinstance(commit_sent_at_s, float)
|
||||
|
||||
|
||||
def _asymmetric_image():
|
||||
from PIL import Image
|
||||
|
||||
|
||||
@@ -269,6 +269,11 @@ class RealtimeEventCollector:
|
||||
}
|
||||
)
|
||||
|
||||
def response_is_done(self, response_id: str) -> bool:
|
||||
return any(
|
||||
event.get("type") == "response.done" and self.response_id(event) == response_id for event in self.events
|
||||
)
|
||||
|
||||
def errors(self) -> list[dict[str, object]]:
|
||||
return [event for event in self.events if event.get("type") == "error"]
|
||||
|
||||
@@ -673,7 +678,7 @@ class RealtimeDuplexClient:
|
||||
async def acknowledge_playback(self) -> None:
|
||||
for response_id in self.events.response_ids:
|
||||
pcm16 = self.events.audio_bytes(response_id)
|
||||
if not pcm16:
|
||||
if not pcm16 and self.events.response_is_done(response_id):
|
||||
continue
|
||||
played_ms = len(pcm16) * 1000 // (self.events.output_sample_rate_hz * PCM16_BYTES_PER_SAMPLE)
|
||||
await self.send_playback_ack(response_id, played_ms)
|
||||
|
||||
@@ -412,11 +412,13 @@ class PlaybackLedger:
|
||||
class ConversationHistory:
|
||||
messages: list[dict[str, object]] = field(default_factory=list)
|
||||
item_ids: dict[str, dict[str, object]] = field(default_factory=dict)
|
||||
history_item_placeholders: dict[str, dict[str, object]] = field(default_factory=dict)
|
||||
item_audio_text_marks: dict[str, list[DuplexAssistantAudioTextMark]] = field(default_factory=dict)
|
||||
pending_item_ids: dict[str, dict[str, object]] = field(default_factory=dict)
|
||||
pending_item_audio_text_marks: dict[str, list[DuplexAssistantAudioTextMark]] = field(default_factory=dict)
|
||||
pending_item_input_commit_seqs: dict[str, int] = field(default_factory=dict)
|
||||
pending_truncations_ms: dict[str, int] = field(default_factory=dict)
|
||||
hard_truncations_ms: dict[str, int] = field(default_factory=dict)
|
||||
last_assistant_full_message: dict[str, object] | None = None
|
||||
last_assistant_audio_text_marks: list[DuplexAssistantAudioTextMark] = field(default_factory=list)
|
||||
assistant_response_snapshots: dict[str, tuple[dict[str, object], tuple[DuplexAssistantAudioTextMark, ...], int]] = (
|
||||
@@ -471,7 +473,8 @@ class DuplexSession:
|
||||
|
||||
@property
|
||||
def history(self) -> tuple[dict[str, object], ...]:
|
||||
return tuple(dict(message) for message in self._conversation.messages)
|
||||
placeholders = {id(message) for message in self._conversation.history_item_placeholders.values()}
|
||||
return tuple(dict(message) for message in self._conversation.messages if id(message) not in placeholders)
|
||||
|
||||
@property
|
||||
def active_request_id(self) -> str | None:
|
||||
@@ -521,6 +524,11 @@ class DuplexSession:
|
||||
return response_id in self._conversation.assistant_response_snapshots
|
||||
|
||||
def playback_ack_is_too_late(self, response_id: str, item_id: str) -> bool:
|
||||
# An earlier ACK reserved this item's exact history position. Later
|
||||
# ACKs update that slot in place, so they cannot append an old
|
||||
# assistant turn after a newer user input.
|
||||
if item_id in self._conversation.item_ids or item_id in self._conversation.history_item_placeholders:
|
||||
return False
|
||||
input_commit_seq = self._conversation.pending_item_input_commit_seqs.get(item_id)
|
||||
if input_commit_seq is None:
|
||||
snapshot = self._conversation.assistant_response_snapshots.get(response_id)
|
||||
@@ -530,6 +538,56 @@ class DuplexSession:
|
||||
input_commit_seq = self._response.active_response_input_commit_seq
|
||||
return input_commit_seq is not None and self.input_commit_seq > input_commit_seq
|
||||
|
||||
def reserve_history_item(self, item_id: str) -> None:
|
||||
"""Reserve the current history position for a response-owned item."""
|
||||
if item_id in self._conversation.item_ids or item_id in self._conversation.history_item_placeholders:
|
||||
return
|
||||
placeholder: dict[str, object] = {}
|
||||
self._conversation.history_item_placeholders[item_id] = placeholder
|
||||
self._conversation.messages.append(placeholder)
|
||||
|
||||
def _store_history_item_message(
|
||||
self,
|
||||
item_id: str,
|
||||
message: dict[str, object],
|
||||
) -> dict[str, object]:
|
||||
"""Materialize or update a response item without changing its order."""
|
||||
existing = self._conversation.item_ids.get(item_id)
|
||||
if existing is not None:
|
||||
if existing is not message:
|
||||
existing.clear()
|
||||
existing.update(message)
|
||||
return existing
|
||||
|
||||
placeholder = self._conversation.history_item_placeholders.pop(item_id, None)
|
||||
if placeholder is not None:
|
||||
for index, candidate in enumerate(self._conversation.messages):
|
||||
if candidate is placeholder:
|
||||
self._conversation.messages[index] = message
|
||||
break
|
||||
else:
|
||||
self._conversation.messages.append(message)
|
||||
elif not any(candidate is message for candidate in self._conversation.messages):
|
||||
self._conversation.messages.append(message)
|
||||
self._conversation.item_ids[item_id] = message
|
||||
return message
|
||||
|
||||
def _discard_history_item_placeholder(self, item_id: str) -> bool:
|
||||
placeholder = self._conversation.history_item_placeholders.pop(item_id, None)
|
||||
if placeholder is None:
|
||||
return False
|
||||
self._conversation.messages = [
|
||||
candidate for candidate in self._conversation.messages if candidate is not placeholder
|
||||
]
|
||||
return True
|
||||
|
||||
def release_response_history_snapshot(self, response_id: str | None) -> None:
|
||||
"""Release a final response snapshot after its playback fully commits."""
|
||||
if response_id is None or response_id == self.active_response_id:
|
||||
return
|
||||
self._conversation.assistant_response_snapshots.pop(response_id, None)
|
||||
self._conversation.hard_truncations_ms.pop(f"item_{response_id}", None)
|
||||
|
||||
@property
|
||||
def playback(self) -> DuplexPlaybackView:
|
||||
return self._playback.current.snapshot()
|
||||
@@ -997,7 +1055,11 @@ class DuplexSession:
|
||||
committed_text = ""
|
||||
if commit_text and committed_text:
|
||||
message = {"role": "assistant", "content": committed_text}
|
||||
self._conversation.messages.append(message)
|
||||
item_id = f"item_{response_id}" if response_id is not None else None
|
||||
if item_id is not None and item_id in self._conversation.history_item_placeholders:
|
||||
self._store_history_item_message(item_id, message)
|
||||
else:
|
||||
self._conversation.messages.append(message)
|
||||
effective_playback_policy = playback_commit_policy or self.config.playback_commit_policy
|
||||
if (
|
||||
response_id is not None
|
||||
@@ -1006,6 +1068,8 @@ class DuplexSession:
|
||||
and effective_playback_policy == DuplexPlaybackCommitPolicy.ACK_ONLY.value
|
||||
):
|
||||
self.register_history_item(f"item_{response_id}", None)
|
||||
elif response_id is not None and not assistant_text:
|
||||
self._discard_history_item_placeholder(f"item_{response_id}")
|
||||
self._response.assistant_text_buffer.clear()
|
||||
if not preserve_request:
|
||||
self._response.active_request_id = None
|
||||
@@ -1034,7 +1098,7 @@ class DuplexSession:
|
||||
self._conversation.pending_item_input_commit_seqs[item_id] = response_snapshot[2]
|
||||
if audio_text_marks:
|
||||
self._conversation.pending_item_audio_text_marks[item_id] = list(copy.deepcopy(audio_text_marks))
|
||||
pending_audio_ms = self._conversation.pending_truncations_ms.pop(item_id, None)
|
||||
pending_audio_ms = self._conversation.pending_truncations_ms.get(item_id)
|
||||
if pending_audio_ms is not None:
|
||||
self.truncate_history_item(
|
||||
item_id,
|
||||
@@ -1042,30 +1106,11 @@ class DuplexSession:
|
||||
playback=self._playback_cursor_for_item_id(item_id),
|
||||
)
|
||||
return
|
||||
pending_audio_ms = self._conversation.pending_truncations_ms.pop(item_id, None)
|
||||
if pending_audio_ms is not None:
|
||||
self._truncate_message_to_audio_ms(
|
||||
message,
|
||||
audio_end_ms=pending_audio_ms,
|
||||
marks=(
|
||||
response_audio_text_marks
|
||||
if response_audio_text_marks is not None
|
||||
else self._response.assistant_audio_text_marks or self._conversation.last_assistant_audio_text_marks
|
||||
),
|
||||
playback=self._playback_cursor_for_item_id(item_id),
|
||||
)
|
||||
if self._message_text_len(message) <= 0:
|
||||
try:
|
||||
self._conversation.messages.remove(message)
|
||||
except ValueError:
|
||||
pass
|
||||
return
|
||||
self._conversation.item_ids[item_id] = message
|
||||
message = self._store_history_item_message(item_id, message)
|
||||
pending_audio_ms = self._conversation.pending_truncations_ms.get(item_id)
|
||||
self._conversation.pending_item_ids.pop(item_id, None)
|
||||
self._conversation.pending_item_audio_text_marks.pop(item_id, None)
|
||||
self._conversation.pending_item_input_commit_seqs.pop(item_id, None)
|
||||
if response_id is not None:
|
||||
self._conversation.assistant_response_snapshots.pop(response_id, None)
|
||||
if message.get("role") == "assistant":
|
||||
marks = (
|
||||
response_audio_text_marks
|
||||
@@ -1074,6 +1119,12 @@ class DuplexSession:
|
||||
)
|
||||
if marks:
|
||||
self._conversation.item_audio_text_marks[item_id] = list(marks)
|
||||
if pending_audio_ms is not None:
|
||||
self.truncate_history_item(
|
||||
item_id,
|
||||
audio_end_ms=pending_audio_ms,
|
||||
playback=self._playback_cursor_for_item_id(item_id),
|
||||
)
|
||||
|
||||
def delete_history_item(self, item_id: str) -> bool:
|
||||
response_id = item_id.removeprefix("item_") if item_id.startswith("item_") else None
|
||||
@@ -1083,10 +1134,12 @@ class DuplexSession:
|
||||
self._conversation.pending_item_audio_text_marks.pop(item_id, None)
|
||||
self._conversation.pending_item_input_commit_seqs.pop(item_id, None)
|
||||
self._conversation.pending_truncations_ms.pop(item_id, None)
|
||||
self._conversation.hard_truncations_ms.pop(item_id, None)
|
||||
removed_placeholder = self._discard_history_item_placeholder(item_id)
|
||||
if response_id is not None:
|
||||
self._conversation.assistant_response_snapshots.pop(response_id, None)
|
||||
if message is None:
|
||||
return pending is not None
|
||||
return pending is not None or removed_placeholder
|
||||
try:
|
||||
self._conversation.messages.remove(message)
|
||||
except ValueError:
|
||||
@@ -1099,8 +1152,43 @@ class DuplexSession:
|
||||
*,
|
||||
audio_end_ms: int,
|
||||
playback: DuplexPlaybackCursor | DuplexPlaybackView | None = None,
|
||||
hard: bool = False,
|
||||
) -> bool:
|
||||
playback = playback or self._playback_cursor_for_item_id(item_id)
|
||||
audio_end_ms = max(0, int(audio_end_ms))
|
||||
if hard:
|
||||
previous_cap_ms = self._conversation.hard_truncations_ms.get(item_id)
|
||||
self._conversation.hard_truncations_ms[item_id] = (
|
||||
audio_end_ms if previous_cap_ms is None else min(previous_cap_ms, audio_end_ms)
|
||||
)
|
||||
hard_cap_ms = self._conversation.hard_truncations_ms.get(item_id)
|
||||
if hard_cap_ms is not None:
|
||||
audio_end_ms = min(audio_end_ms, hard_cap_ms)
|
||||
response_id = item_id.removeprefix("item_") if item_id.startswith("item_") else None
|
||||
response_snapshot = (
|
||||
self._conversation.assistant_response_snapshots.get(response_id) if response_id is not None else None
|
||||
)
|
||||
if response_snapshot is not None:
|
||||
full_message, full_marks, _ = response_snapshot
|
||||
message = copy.deepcopy(full_message)
|
||||
changed = self._truncate_message_to_audio_ms(
|
||||
message,
|
||||
audio_end_ms=audio_end_ms,
|
||||
marks=list(full_marks),
|
||||
playback=playback,
|
||||
)
|
||||
if not changed or self._message_text_len(message) <= 0:
|
||||
self._conversation.pending_truncations_ms[item_id] = max(0, int(audio_end_ms))
|
||||
return False
|
||||
self._store_history_item_message(item_id, message)
|
||||
if full_marks:
|
||||
self._conversation.item_audio_text_marks[item_id] = list(copy.deepcopy(full_marks))
|
||||
self._conversation.pending_item_ids.pop(item_id, None)
|
||||
self._conversation.pending_item_audio_text_marks.pop(item_id, None)
|
||||
self._conversation.pending_item_input_commit_seqs.pop(item_id, None)
|
||||
self._conversation.pending_truncations_ms.pop(item_id, None)
|
||||
return True
|
||||
|
||||
message = self._conversation.item_ids.get(item_id)
|
||||
if message is None:
|
||||
pending = self._conversation.pending_item_ids.get(item_id)
|
||||
|
||||
@@ -1709,6 +1709,10 @@ class OmniDuplexSessionHandler(
|
||||
}
|
||||
)
|
||||
return
|
||||
# Reserve the response's current history position before any later
|
||||
# input commit can append a user item. A 0 ms ACK is intentional:
|
||||
# the response may be active but have no audio delta yet.
|
||||
session.reserve_history_item(item_id)
|
||||
elif item_id is not None:
|
||||
await send_json(
|
||||
{
|
||||
@@ -1720,7 +1724,8 @@ class OmniDuplexSessionHandler(
|
||||
}
|
||||
)
|
||||
return
|
||||
if event.get("truncate") is True:
|
||||
hard_truncate = event.get("truncate") is True
|
||||
if hard_truncate:
|
||||
playback = session.acknowledge_playback(
|
||||
int(played_ms),
|
||||
committed_cursor,
|
||||
@@ -1752,6 +1757,7 @@ class OmniDuplexSessionHandler(
|
||||
item_id,
|
||||
audio_end_ms=committed_cursor,
|
||||
playback=playback,
|
||||
hard=hard_truncate,
|
||||
)
|
||||
elif session.pending_history_item_ids:
|
||||
# A plain playback ack has no OpenAI item id. Commit the only
|
||||
@@ -1764,6 +1770,7 @@ class OmniDuplexSessionHandler(
|
||||
item_id,
|
||||
audio_end_ms=committed_cursor,
|
||||
playback=playback,
|
||||
hard=hard_truncate,
|
||||
)
|
||||
elif session.active_response_id is not None:
|
||||
item_id = f"item_{session.active_response_id}"
|
||||
@@ -1771,6 +1778,7 @@ class OmniDuplexSessionHandler(
|
||||
item_id,
|
||||
audio_end_ms=committed_cursor,
|
||||
playback=playback,
|
||||
hard=hard_truncate,
|
||||
)
|
||||
elif session.last_assistant_full_message is not None:
|
||||
if item_id is None and session.history_item_ids:
|
||||
@@ -1786,6 +1794,7 @@ class OmniDuplexSessionHandler(
|
||||
item_id,
|
||||
audio_end_ms=committed_cursor,
|
||||
playback=playback,
|
||||
hard=hard_truncate,
|
||||
)
|
||||
await send_json(
|
||||
{
|
||||
@@ -1802,6 +1811,7 @@ class OmniDuplexSessionHandler(
|
||||
)
|
||||
if committed_history and committed_cursor >= max(playback.sent_ms, playback.generated_ms):
|
||||
session.release_response_playback(response_id)
|
||||
session.release_response_history_snapshot(response_id)
|
||||
|
||||
async def _cancel_active_response(
|
||||
self,
|
||||
|
||||
@@ -1261,6 +1261,7 @@ class DuplexSessionRunnerMixin:
|
||||
session.truncate_history_item(
|
||||
item_id,
|
||||
audio_end_ms=int(audio_end_ms) if isinstance(audio_end_ms, int | float) else 0,
|
||||
hard=True,
|
||||
)
|
||||
if isinstance(item_id, str)
|
||||
else False
|
||||
|
||||
Reference in new issue
Block a user