Files
livekit--agents/tests/test_openai_realtime_chat_ctx.py
2026-07-13 13:39:38 +08:00

145 lines
4.8 KiB
Python

import asyncio
import types
from unittest.mock import Mock
import pytest
from livekit.agents import llm
from livekit.agents.llm import remote_chat_context
from livekit.plugins.openai.realtime import realtime_model
from livekit.plugins.openai.realtime.realtime_model import RealtimeSession
pytestmark = [pytest.mark.unit, pytest.mark.concurrent]
def _create_session() -> RealtimeSession:
session = RealtimeSession.__new__(RealtimeSession)
session._update_chat_ctx_lock = asyncio.Lock()
session._remote_chat_ctx = remote_chat_context.RemoteChatContext()
session._item_delete_future = {}
session._item_create_future = {}
session._sent_events = []
def send_event(self: RealtimeSession, event: object) -> None:
self._sent_events.append(event)
item = getattr(event, "item", None)
if item is not None and item.id in self._item_create_future:
self._item_create_future[item.id].set_result(None)
session.send_event = types.MethodType(send_event, session)
return session
async def test_update_chat_ctx_filters_new_empty_messages() -> None:
session = _create_session()
chat_ctx = llm.ChatContext.empty()
chat_ctx.add_message(role="user", content=[], id="empty-message")
await session.update_chat_ctx(chat_ctx)
assert session._sent_events == []
async def test_update_chat_ctx_keeps_existing_remote_empty_messages() -> None:
session = _create_session()
remote_message = llm.ChatMessage(role="user", content=[], id="remote-empty-message")
session._remote_chat_ctx.insert(None, remote_message)
chat_ctx = llm.ChatContext.empty()
chat_ctx.items.append(remote_message)
await session.update_chat_ctx(chat_ctx)
assert session._sent_events == []
def test_truncate_deletes_item_when_no_audio_played() -> None:
"""Interrupting before any audio frame plays (audio_end_ms == 0) must not
send a conversation.item.truncate (the Realtime API rejects it with
"Only model output audio messages can be truncated"). The item is deleted
instead so a generated-but-unplayed audio message is not left dangling in
the remote chat ctx."""
from openai.types.realtime import ConversationItemDeleteEvent
session = _create_session()
session.truncate(
message_id="item_1",
modalities=["audio", "text"],
audio_end_ms=0,
)
assert len(session._sent_events) == 1
event = session._sent_events[0]
assert isinstance(event, ConversationItemDeleteEvent)
assert event.item_id == "item_1"
def test_truncate_sends_event_when_audio_played() -> None:
"""When audio has actually played (audio_end_ms > 0) the truncate event is
still sent for audio modalities."""
from openai.types.realtime import ConversationItemTruncateEvent
session = _create_session()
session.truncate(
message_id="item_1",
modalities=["audio", "text"],
audio_end_ms=500,
)
assert len(session._sent_events) == 1
event = session._sent_events[0]
assert isinstance(event, ConversationItemTruncateEvent)
assert event.item_id == "item_1"
assert event.audio_end_ms == 500
def test_response_done_handles_string_status_details(monkeypatch) -> None:
session = _create_session()
session._realtime_model = types.SimpleNamespace(_provider_label="xAI")
event = types.SimpleNamespace(
response=types.SimpleNamespace(
id="resp_1",
status="incomplete",
status_details="incomplete",
)
)
debug = Mock()
monkeypatch.setattr(realtime_model.logger, "debug", debug)
session._handle_response_done_but_not_complete(event)
debug.assert_called_once()
assert debug.call_args.args[3] == "incomplete"
assert debug.call_args.args[4] is None
def test_long_call_id_is_shortened_consistently() -> None:
# a foreign provider's call_id (e.g. Gemini) can exceed OpenAI's 32-char limit; the call and
# its output must map to the same shortened id so they still pair on OpenAI's side
from livekit.plugins.openai.realtime.utils import livekit_item_to_openai_item
call_id = "function-call-0123456789abcdef0123456789ab" # 42 chars
assert len(call_id) > 32
call = livekit_item_to_openai_item(
llm.FunctionCall(id="item_1", call_id=call_id, name="get_weather", arguments="{}")
)
output = livekit_item_to_openai_item(
llm.FunctionCallOutput(id="item_2", call_id=call_id, output="sunny", is_error=False)
)
assert len(call.call_id) <= 32
assert call.call_id == output.call_id
def test_short_call_id_is_unchanged() -> None:
from livekit.plugins.openai.realtime.utils import livekit_item_to_openai_item
call = livekit_item_to_openai_item(
llm.FunctionCall(id="item_1", call_id="call_abc", name="get_weather", arguments="{}")
)
assert call.call_id == "call_abc"