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

560 lines
19 KiB
Python

from __future__ import annotations
import asyncio
import pytest
from livekit import rtc
from livekit.agents.llm import ChatContext, RealtimeModelFallbackAdapter
from .fake_realtime import FakeRealtimeModel, fake_capabilities
pytestmark = pytest.mark.unit
def _audio_frame() -> rtc.AudioFrame:
return rtc.AudioFrame(
data=b"\x00\x00", sample_rate=24000, num_channels=1, samples_per_channel=1
)
class _StubAgent:
def __init__(self, chat_ctx: ChatContext) -> None:
self.chat_ctx = chat_ctx
class _StubAgentSession:
"""Minimal stand-in for AgentSession that records orchestrated interrupt/generate_reply."""
def __init__(self, agent_state: str = "listening", chat_ctx: ChatContext | None = None) -> None:
self.agent_state = agent_state
self.interrupt_calls = 0
self.generate_reply_calls = 0
self.current_agent = _StubAgent(chat_ctx if chat_ctx is not None else ChatContext.empty())
def interrupt(self, *, force: bool = False) -> asyncio.Future[None]:
self.interrupt_calls += 1
fut: asyncio.Future[None] = asyncio.get_event_loop().create_future()
fut.set_result(None)
return fut
def generate_reply(self, **kwargs: object) -> object:
self.generate_reply_calls += 1
return object()
def test_requires_at_least_one_model() -> None:
with pytest.raises(ValueError):
RealtimeModelFallbackAdapter([])
def test_ands_soft_capabilities_across_models() -> None:
primary = FakeRealtimeModel(capabilities=fake_capabilities(mutable_chat_context=True))
backup = FakeRealtimeModel(capabilities=fake_capabilities(mutable_chat_context=False))
adapter = RealtimeModelFallbackAdapter([primary, backup])
# soft flags are conservatively ANDed: if any model can't mutate chat ctx, the adapter can't
assert adapter.capabilities.mutable_chat_context is False
# a flag both models support stays True
assert adapter.capabilities.message_truncation is True
def test_raises_on_mismatched_hard_capabilities() -> None:
primary = FakeRealtimeModel(capabilities=fake_capabilities(audio_output=True))
backup = FakeRealtimeModel(capabilities=fake_capabilities(audio_output=False))
with pytest.raises(ValueError):
RealtimeModelFallbackAdapter([primary, backup])
def test_proxies_calls_to_active_child() -> None:
primary = FakeRealtimeModel()
backup = FakeRealtimeModel()
session = RealtimeModelFallbackAdapter([primary, backup]).session()
session.interrupt()
session.commit_audio()
session.generate_reply()
child = primary.active_session
assert child.interrupted is True
assert child.committed is True
assert child.generate_reply_calls == 1
# the backup model has not been started
assert backup.created_sessions == []
def test_forwards_child_events_to_wrapper_subscribers() -> None:
primary = FakeRealtimeModel()
session = RealtimeModelFallbackAdapter([primary]).session()
received: list[object] = []
session.on("generation_created", lambda ev: received.append(ev))
primary.active_session.emit("generation_created", "sentinel")
assert received == ["sentinel"]
def test_proxies_say_to_active_child() -> None:
primary = FakeRealtimeModel()
session = RealtimeModelFallbackAdapter([primary]).session()
session.say("hello there")
assert primary.active_session.say_calls == ["hello there"]
def test_proxies_start_user_activity_to_active_child() -> None:
primary = FakeRealtimeModel()
session = RealtimeModelFallbackAdapter([primary]).session()
session.start_user_activity()
assert primary.active_session.user_activity_started is True
async def test_restart_session_creates_fresh_child_same_model() -> None:
primary = FakeRealtimeModel()
adapter = RealtimeModelFallbackAdapter([primary])
session = adapter.session()
old_child = primary.active_session
await adapter.restart_session()
assert old_child.closed is True
assert len(primary.created_sessions) == 2
assert session._active is primary.created_sessions[1]
assert session._active.closed is False
async def test_switch_session_moves_to_next_model() -> None:
primary = FakeRealtimeModel()
backup = FakeRealtimeModel()
adapter = RealtimeModelFallbackAdapter([primary, backup])
session = adapter.session()
old = primary.active_session
await adapter.restart_session(switch_model=True)
assert old.closed is True
assert session._active_index == 1
assert session._active is backup.active_session
async def test_switch_session_wraps_around() -> None:
primary = FakeRealtimeModel()
backup = FakeRealtimeModel()
adapter = RealtimeModelFallbackAdapter([primary, backup])
session = adapter.session()
await adapter.restart_session(switch_model=True) # primary -> backup
assert session._active_index == 1
await adapter.restart_session(switch_model=True) # backup -> wraps to primary
assert session._active_index == 0
assert session._active is primary.created_sessions[-1]
async def test_switch_session_single_model_restarts_same() -> None:
primary = FakeRealtimeModel()
adapter = RealtimeModelFallbackAdapter([primary])
session = adapter.session()
old = primary.active_session
await adapter.restart_session(switch_model=True)
# only one model: degrades to a fresh session on the same model
assert old.closed is True
assert session._active_index == 0
assert len(primary.created_sessions) == 2
async def test_restart_replays_chat_ctx_onto_new_child() -> None:
primary = FakeRealtimeModel()
adapter = RealtimeModelFallbackAdapter([primary])
session = adapter.session()
old_child = primary.active_session
ctx = ChatContext.empty()
ctx.add_message(role="user", content="remember me")
await old_child.update_chat_ctx(ctx)
await adapter.restart_session()
# the freshest chat context from the dying child is replayed onto the new child
assert session._active.chat_ctx is ctx
async def test_restart_preserves_wrapper_subscribers() -> None:
primary = FakeRealtimeModel()
adapter = RealtimeModelFallbackAdapter([primary])
session = adapter.session()
received: list[object] = []
session.on("generation_created", lambda ev: received.append(ev))
await adapter.restart_session()
# events from the NEW child still reach the original wrapper subscriber, no rebinding
session._active.emit("generation_created", "after-restart")
assert received == ["after-restart"]
async def test_restart_emits_no_error() -> None:
primary = FakeRealtimeModel()
adapter = RealtimeModelFallbackAdapter([primary])
session = adapter.session()
errors: list[object] = []
session.on("error", lambda ev: errors.append(ev))
await adapter.restart_session()
assert errors == []
async def test_auto_swaps_to_next_model_on_non_recoverable_error() -> None:
primary = FakeRealtimeModel()
backup = FakeRealtimeModel()
adapter = RealtimeModelFallbackAdapter([primary, backup])
session = adapter.session()
old = primary.active_session
old.emit_error(recoverable=False)
await session._swap_task
assert old.closed is True
assert len(backup.created_sessions) == 1
assert session._active is backup.active_session
async def test_non_recoverable_error_forwarded_as_recoverable_while_fallback_remains() -> None:
primary = FakeRealtimeModel()
backup = FakeRealtimeModel()
adapter = RealtimeModelFallbackAdapter([primary, backup])
session = adapter.session()
errors: list = []
session.on("error", lambda e: errors.append(e))
primary.active_session.emit_error(recoverable=False)
await session._swap_task
# the user is informed, but the error is re-stamped recoverable so the session is not closed
assert len(errors) == 1
assert errors[0].recoverable is True
async def test_recoverable_error_is_forwarded_unchanged_without_swap() -> None:
primary = FakeRealtimeModel()
backup = FakeRealtimeModel()
adapter = RealtimeModelFallbackAdapter([primary, backup])
session = adapter.session()
errors: list = []
session.on("error", lambda e: errors.append(e))
primary.active_session.emit_error(recoverable=True)
assert len(errors) == 1
assert errors[0].recoverable is True
assert session._swap_task is None
assert backup.created_sessions == [] # no swap on a recoverable error
async def test_exhausted_models_escalate_as_non_recoverable() -> None:
primary = FakeRealtimeModel()
adapter = RealtimeModelFallbackAdapter([primary]) # no backup
session = adapter.session()
errors: list = []
session.on("error", lambda e: errors.append(e))
primary.active_session.emit_error(recoverable=False)
# nothing left to fall back to: escalate so AgentSession can close
assert session._swap_task is None
assert len(errors) == 1
assert errors[0].recoverable is False
assert len(primary.created_sessions) == 1 # not auto-restarted
async def test_escalates_when_all_models_have_failed() -> None:
primary = FakeRealtimeModel()
backup = FakeRealtimeModel()
adapter = RealtimeModelFallbackAdapter([primary, backup])
session = adapter.session()
errors: list = []
session.on("error", lambda e: errors.append(e))
primary.active_session.emit_error(recoverable=False)
await session._swap_task
# now on backup; kill it too
backup.active_session.emit_error(recoverable=False)
assert session._swap_task.done()
assert errors[-1].recoverable is False # exhausted -> escalate
@pytest.mark.virtual_time
async def test_prefers_primary_again_after_cooldown_expires() -> None:
primary = FakeRealtimeModel()
backup = FakeRealtimeModel()
adapter = RealtimeModelFallbackAdapter([primary, backup], cooldown=5.0)
session = adapter.session()
# primary dies -> fall over to backup
primary.active_session.emit_error(recoverable=False)
await session._swap_task
assert session._active_index == 1
# let primary's cooldown expire
await asyncio.sleep(6.0)
# backup dies -> primary is available again and preferred by list order
backup.active_session.emit_error(recoverable=False)
await session._swap_task
assert session._active_index == 0
assert session._active is primary.created_sessions[-1]
async def test_regenerates_via_agent_session_when_speaking() -> None:
primary = FakeRealtimeModel()
backup = FakeRealtimeModel()
adapter = RealtimeModelFallbackAdapter([primary, backup]) # regenerate_on_swap default True
session = adapter.session()
session._agent_session = _StubAgentSession(agent_state="speaking")
primary.active_session.emit_error(recoverable=False)
await session._swap_task
# regeneration goes through the AgentSession (orchestrated), not the raw child session
assert session._agent_session.interrupt_calls == 1
assert session._agent_session.generate_reply_calls == 1
assert backup.active_session.generate_reply_calls == 0
async def test_regenerates_when_agent_thinking() -> None:
primary = FakeRealtimeModel()
backup = FakeRealtimeModel()
adapter = RealtimeModelFallbackAdapter([primary, backup])
session = adapter.session()
session._agent_session = _StubAgentSession(agent_state="thinking")
# "thinking" is a reply in progress (generating, pre-audio), so it regenerates too
primary.active_session.emit_error(recoverable=False)
await session._swap_task
assert session._agent_session.generate_reply_calls == 1
async def test_no_regenerate_when_agent_not_speaking() -> None:
primary = FakeRealtimeModel()
backup = FakeRealtimeModel()
adapter = RealtimeModelFallbackAdapter([primary, backup])
session = adapter.session()
session._agent_session = _StubAgentSession(agent_state="listening")
primary.active_session.emit_error(recoverable=False)
await session._swap_task
assert session._agent_session.generate_reply_calls == 0
async def test_no_regenerate_when_disabled() -> None:
primary = FakeRealtimeModel()
backup = FakeRealtimeModel()
adapter = RealtimeModelFallbackAdapter([primary, backup], regenerate_on_swap=False)
session = adapter.session()
session._agent_session = _StubAgentSession(agent_state="speaking")
primary.active_session.emit_error(recoverable=False)
await session._swap_task
# stale playout is still interrupted; only the re-issue of generate_reply is suppressed
assert session._agent_session.interrupt_calls == 1
assert session._agent_session.generate_reply_calls == 0
async def test_drops_audio_during_swap() -> None:
primary = FakeRealtimeModel()
backup = FakeRealtimeModel()
adapter = RealtimeModelFallbackAdapter([primary, backup])
session = adapter.session()
old = primary.active_session
gate = asyncio.Event()
old.block_aclose = gate
old.emit_error(recoverable=False)
# wait until the swap is mid-flight (inside the dying child's aclose)
await old.aclose_entered.wait()
frame = _audio_frame()
session.push_audio(frame)
# audio arriving mid-swap is dropped, not sent to the dying child
assert frame not in old.pushed_audio
gate.set()
await session._swap_task
# ...and not replayed into the new child (replaying would add permanent input latency)
assert frame not in backup.active_session.pushed_audio
async def test_swap_cascades_past_a_model_that_cannot_start() -> None:
primary = FakeRealtimeModel()
backup1 = FakeRealtimeModel()
backup2 = FakeRealtimeModel()
adapter = RealtimeModelFallbackAdapter([primary, backup1, backup2])
session = adapter.session()
errors: list = []
session.on("error", lambda e: errors.append(e))
# the first fallback fails to bring up; the swap should skip it and land on the next
backup1.bring_up_error = RuntimeError("cannot start")
primary.active_session.emit_error(recoverable=False)
await session._swap_task
assert session._active_index == 2
assert session._active is backup2.active_session
# only the recoverable hand-off was surfaced; nothing session-ending
assert all(e.recoverable for e in errors)
async def test_swap_escalates_when_no_model_can_start() -> None:
primary = FakeRealtimeModel()
backup = FakeRealtimeModel()
adapter = RealtimeModelFallbackAdapter([primary, backup])
session = adapter.session()
errors: list = []
session.on("error", lambda e: errors.append(e))
# the only fallback also fails to bring up: escalate a non-recoverable error
backup.bring_up_error = RuntimeError("cannot start")
primary.active_session.emit_error(recoverable=False)
await session._swap_task
assert any(not e.recoverable for e in errors)
async def test_emits_session_reconnected_on_swap() -> None:
primary = FakeRealtimeModel()
backup = FakeRealtimeModel()
adapter = RealtimeModelFallbackAdapter([primary, backup])
session = adapter.session()
events: list = []
session.on("session_reconnected", lambda ev: events.append(ev))
await adapter.restart_session(switch_model=True)
assert len(events) == 1
async def test_swap_replays_child_current_ctx_not_stale_pushed_ctx() -> None:
primary = FakeRealtimeModel()
backup = FakeRealtimeModel()
adapter = RealtimeModelFallbackAdapter([primary, backup])
session = adapter.session()
old = primary.active_session
# an update pushed earlier (not during a swap)
ctx1 = ChatContext.empty()
ctx1.add_message(role="user", content="old turn")
await session.update_chat_ctx(ctx1)
# the child then accumulates newer state (e.g. a server-side transcript)
ctx2 = ChatContext.empty()
ctx2.add_message(role="user", content="old turn")
ctx2.add_message(role="assistant", content="newer transcript")
await old.update_chat_ctx(ctx2)
await adapter.restart_session()
# replay must use the child's current context, not the stale earlier push
assert session._active.chat_ctx is ctx2
async def test_swap_replays_agent_chat_ctx() -> None:
primary = FakeRealtimeModel()
adapter = RealtimeModelFallbackAdapter([primary])
session = adapter.session()
agent_ctx = ChatContext.empty()
agent_ctx.add_message(role="user", content="what the user heard")
session._agent_session = _StubAgentSession(agent_state="listening", chat_ctx=agent_ctx)
await adapter.restart_session()
# the agent chat context (user-heard version) is replayed, not the child's own context
assert session._active.chat_ctx is agent_ctx
async def test_emits_availability_changed_on_failure() -> None:
primary = FakeRealtimeModel()
backup = FakeRealtimeModel()
adapter = RealtimeModelFallbackAdapter([primary, backup])
session = adapter.session()
events: list = []
adapter.on("realtime_availability_changed", lambda e: events.append(e))
primary.active_session.emit_error(recoverable=False)
await session._swap_task
assert any(e.realtime_model is primary and e.available is False for e in events)
@pytest.mark.virtual_time
def test_session_exposes_model_capabilities() -> None:
model = FakeRealtimeModel()
session = model.session()
assert session.capabilities is model.capabilities
def test_allows_mismatched_auto_tool_reply_generation() -> None:
primary = FakeRealtimeModel(capabilities=fake_capabilities(auto_tool_reply_generation=True))
backup = FakeRealtimeModel(capabilities=fake_capabilities(auto_tool_reply_generation=False))
# no longer a hard capability: the active child's value is read per-turn from the session
adapter = RealtimeModelFallbackAdapter([primary, backup])
assert adapter.capabilities.auto_tool_reply_generation is False # conservative AND on the model
def test_wrapper_capabilities_track_active_child() -> None:
primary = FakeRealtimeModel(capabilities=fake_capabilities(auto_tool_reply_generation=True))
backup = FakeRealtimeModel(capabilities=fake_capabilities(auto_tool_reply_generation=False))
adapter = RealtimeModelFallbackAdapter([primary, backup])
session = adapter.session()
assert session.capabilities.auto_tool_reply_generation is True # primary active
async def test_wrapper_capabilities_follow_swap() -> None:
primary = FakeRealtimeModel(capabilities=fake_capabilities(auto_tool_reply_generation=True))
backup = FakeRealtimeModel(capabilities=fake_capabilities(auto_tool_reply_generation=False))
adapter = RealtimeModelFallbackAdapter([primary, backup])
session = adapter.session()
primary.active_session.emit_error(recoverable=False)
await session._swap_task
assert session.capabilities.auto_tool_reply_generation is False # backup now active
async def test_emits_availability_changed_on_recovery() -> None:
primary = FakeRealtimeModel()
backup = FakeRealtimeModel()
adapter = RealtimeModelFallbackAdapter([primary, backup], cooldown=5.0)
session = adapter.session()
events: list = []
adapter.on("realtime_availability_changed", lambda e: events.append(e))
primary.active_session.emit_error(recoverable=False)
await session._swap_task
await asyncio.sleep(6.0)
backup.active_session.emit_error(recoverable=False)
await session._swap_task
assert any(e.realtime_model is primary and e.available is True for e in events)