Files
2026-07-13 13:39:38 +08:00

418 lines
15 KiB
Python

"""Integration tests for wait_for_participant, wait_for_track_publication, and wait_for_agent.
These tests require a running LiveKit server (bootstrapped via the livekit_server fixture).
"""
from __future__ import annotations
import asyncio
import contextlib
import os
import uuid
from collections.abc import Awaitable, Callable
from contextlib import asynccontextmanager
import pytest
from livekit import api, rtc
from livekit.agents.utils.participant import (
wait_for_agent,
wait_for_participant,
wait_for_track_publication,
)
from livekit.agents.voice.room_io._output import _ParticipantAudioOutput
from .lk_server import LK_API_KEY, LK_API_SECRET, LK_URL, livekit_server # noqa: F401
from .utils.audio_test import AudioEnergyMonitor, SineToneSource
from .utils.livekit_test import (
connect_room as _connect_e2e_room,
make_room_name as _e2e_room_name,
simulate_full_reconnect,
simulate_resume,
wait_for_event,
)
pytestmark = [pytest.mark.unit, pytest.mark.concurrent]
TIMEOUT = 5.0
def _make_token(
identity: str,
room: str,
*,
kind: str | None = None,
agent: bool = False,
) -> str:
token = (
api.AccessToken(LK_API_KEY, LK_API_SECRET)
.with_identity(identity)
.with_grants(
api.VideoGrants(
room_join=True,
room=room,
can_publish=True,
can_subscribe=True,
agent=agent,
)
)
)
if kind is not None:
token = token.with_kind(kind)
return token.to_jwt()
@asynccontextmanager
async def connect_room(
identity: str, room_name: str, *, kind: str | None = None, agent: bool = False
):
room = rtc.Room()
token = _make_token(identity, room_name, kind=kind, agent=agent)
await room.connect(LK_URL, token)
try:
yield room
finally:
await room.disconnect()
def _room_name() -> str:
return f"test-{uuid.uuid4()}"
async def _publish_audio_track(room: rtc.Room) -> None:
source = rtc.AudioSource(48000, 1)
track = rtc.LocalAudioTrack.create_audio_track("mic", source)
await room.local_participant.publish_track(track)
async def _test_wait_disconnect(wait_fn: Callable[[rtc.Room], Awaitable[object]]) -> None:
"""Connect a room, start waiting, disconnect, and assert RuntimeError."""
name = _room_name()
room = rtc.Room()
token = _make_token("observer", name)
await room.connect(LK_URL, token)
task = asyncio.ensure_future(wait_fn(room))
# ensure the task is running and has registered its event handlers
await asyncio.sleep(0.5)
await room.disconnect()
with pytest.raises(RuntimeError, match="disconnected"):
await asyncio.wait_for(task, timeout=TIMEOUT)
# -- wait_for_participant tests --
@pytest.mark.usefixtures("livekit_server")
class TestWaitForParticipant:
async def test_joins_after(self):
"""Room A waits, Room B joins afterwards -> returns B."""
name = _room_name()
async with connect_room("observer", name) as room_a:
task = asyncio.ensure_future(wait_for_participant(room_a))
await asyncio.sleep(0.5)
async with connect_room("joiner", name):
result = await asyncio.wait_for(task, timeout=TIMEOUT)
assert result.identity == "joiner"
async def test_already_joined(self):
"""Room B already present when A calls wait -> returns immediately."""
name = _room_name()
async with connect_room("first", name):
await asyncio.sleep(0.5)
async with connect_room("observer", name) as room_a:
await asyncio.sleep(0.5)
result = await asyncio.wait_for(wait_for_participant(room_a), timeout=TIMEOUT)
assert result.identity == "first"
async def test_with_identity(self):
"""Only returns participant matching specific identity."""
name = _room_name()
async with connect_room("observer", name) as room_a:
task = asyncio.ensure_future(wait_for_participant(room_a, identity="target"))
await asyncio.sleep(0.3)
# Join with wrong identity first
async with connect_room("decoy", name):
await asyncio.sleep(0.3)
assert not task.done()
# Now join with the target identity
async with connect_room("target", name):
result = await asyncio.wait_for(task, timeout=TIMEOUT)
assert result.identity == "target"
async def test_with_kind(self):
"""Standard participant doesn't match; agent participant does."""
name = _room_name()
async with connect_room("observer", name) as room_a:
task = asyncio.ensure_future(
wait_for_participant(
room_a,
kind=rtc.ParticipantKind.PARTICIPANT_KIND_AGENT,
)
)
await asyncio.sleep(0.3)
# Standard participant should not match
async with connect_room("standard", name):
await asyncio.sleep(0.3)
assert not task.done()
# Agent participant should match
async with connect_room("agent-p", name, kind="agent", agent=True):
result = await asyncio.wait_for(task, timeout=TIMEOUT)
assert result.identity == "agent-p"
async def test_disconnect(self):
"""Room disconnects while waiting -> RuntimeError raised."""
await _test_wait_disconnect(wait_for_participant)
# -- wait_for_track_publication tests --
@pytest.mark.usefixtures("livekit_server")
class TestWaitForTrackPublication:
async def test_published_after(self):
"""Observer waits, publisher publishes audio track -> returns publication."""
name = _room_name()
async with connect_room("observer", name) as room_a:
task = asyncio.ensure_future(wait_for_track_publication(room_a))
await asyncio.sleep(0.5)
async with connect_room("publisher", name) as room_b:
await _publish_audio_track(room_b)
result = await asyncio.wait_for(task, timeout=TIMEOUT)
assert result.kind == rtc.TrackKind.KIND_AUDIO
async def test_already_published(self):
"""Track already published before wait -> returns immediately."""
name = _room_name()
async with connect_room("publisher", name) as room_b:
await _publish_audio_track(room_b)
await asyncio.sleep(0.5)
async with connect_room("observer", name) as room_a:
await asyncio.sleep(0.5)
result = await asyncio.wait_for(wait_for_track_publication(room_a), timeout=TIMEOUT)
assert result.kind == rtc.TrackKind.KIND_AUDIO
async def test_disconnect(self):
"""Room disconnects while waiting -> RuntimeError raised."""
await _test_wait_disconnect(wait_for_track_publication)
# -- wait_for_agent tests --
@pytest.mark.usefixtures("livekit_server")
class TestWaitForAgent:
async def test_agent_joins(self):
"""Agent-kind participant detected correctly."""
name = _room_name()
async with connect_room("observer", name) as room_a:
task = asyncio.ensure_future(wait_for_agent(room_a))
await asyncio.sleep(0.5)
async with connect_room("my-agent", name, kind="agent", agent=True):
result = await asyncio.wait_for(task, timeout=TIMEOUT)
assert result.identity == "my-agent"
assert result.kind == rtc.ParticipantKind.PARTICIPANT_KIND_AGENT
# -- Reconnect E2E tests --
#
# exercise resume vs full-reconnect behavior end-to-end against a
# real LiveKit server.
_AGENT_IDENTITY = "agent"
_USER_IDENTITY = "user"
_TONE_NAME = "agent_tone"
# Threshold tuned to discriminate the steady sine wave from server-side
# silence/noise. The 440Hz tone at 0.5 amplitude lands well above 0.1.
_AUDIO_RMS_THRESHOLD = 0.05
@contextlib.asynccontextmanager
async def _agent_publishing_tone(agent_room: rtc.Room):
"""Publish a steady sine tone via the production `_ParticipantAudioOutput`
helper, so the test exercises both the SDK and the agents-framework layer."""
tone = SineToneSource(frequency=440.0, amplitude=0.5)
output = _ParticipantAudioOutput(
agent_room,
sample_rate=tone._sample_rate,
num_channels=1,
track_publish_options=rtc.TrackPublishOptions(source=rtc.TrackSource.SOURCE_MICROPHONE),
track_name=_TONE_NAME,
)
# Replace the helper's internal AudioSource with our SineToneSource so the
# published track carries the known signal. _ParticipantAudioOutput
# constructs its own AudioSource in __init__; swap it before .start() so
# the LocalAudioTrack is built against ours.
await output._audio_source.aclose()
output._audio_source = tone.source
await output.start()
await tone.start()
try:
yield output, tone
finally:
await tone.aclose()
await output.aclose()
def _agent_audio_publications(user_room: rtc.Room) -> list[rtc.RemoteTrackPublication]:
"""All audio publications the user sees from the agent."""
agent = user_room.remote_participants.get(_AGENT_IDENTITY)
if agent is None:
return []
return [
pub for pub in agent.track_publications.values() if pub.kind == rtc.TrackKind.KIND_AUDIO
]
async def _await_subscribed_audio(user_room: rtc.Room) -> rtc.RemoteAudioTrack:
pub = await asyncio.wait_for(
wait_for_track_publication(
user_room,
identity=_AGENT_IDENTITY,
kind=rtc.TrackKind.KIND_AUDIO,
wait_for_subscription=True,
),
timeout=15.0,
)
track = pub.track
assert isinstance(track, rtc.RemoteAudioTrack)
return track
async def _wait_back_to_connected(room: rtc.Room, *, timeout: float = 15.0) -> None:
await wait_for_event(
room,
"connection_state_changed",
timeout=timeout,
predicate=lambda state: state == rtc.ConnectionState.CONN_CONNECTED,
)
@pytest.mark.skipif(
not os.environ.get("LIVEKIT_URL"),
reason="LIVEKIT_URL not set; skipping reconnect E2E tests "
"(set LIVEKIT_URL/LIVEKIT_API_KEY/LIVEKIT_API_SECRET to enable)",
)
@pytest.mark.skipif(
not hasattr(rtc, "SimulateScenarioKind"),
reason="livekit-rtc lacks SimulateScenarioKind; run `make link-rtc-local`",
)
class TestReconnect:
async def test_resume_preserves_publication_and_audio(self):
"""Resume must NOT churn the publication set, should fire
`reconnected`, and audio must keep flowing."""
room_name = _e2e_room_name("resume")
async with (
_connect_e2e_room(_USER_IDENTITY, room_name) as user_room,
_connect_e2e_room(_AGENT_IDENTITY, room_name, agent=True) as agent_room,
_agent_publishing_tone(agent_room) as (_output, _tone),
):
await asyncio.wait_for(
wait_for_participant(user_room, identity=_AGENT_IDENTITY),
timeout=10.0,
)
track = await _await_subscribed_audio(user_room)
async with AudioEnergyMonitor.watch(track) as mon:
await mon.wait_for_audio(min_rms=_AUDIO_RMS_THRESHOLD, timeout=5.0)
# Snapshot publication state before the disturbance.
publications_before = _agent_audio_publications(user_room)
assert len(publications_before) == 1, publications_before
sid_before = publications_before[0].sid
reconnected_fired = asyncio.Event()
agent_room.on("reconnected", lambda: reconnected_fired.set())
await simulate_resume(agent_room)
# Engine cycles through Reconnecting -> Connected; wait for
# the second transition.
await _wait_back_to_connected(agent_room, timeout=15.0)
# Brief grace window for any Reconnected dispatch.
await asyncio.sleep(1.0)
assert reconnected_fired.is_set(), (
"RoomEvent::Reconnected should be fired on a resume"
)
# Publication identity is preserved.
publications_after = _agent_audio_publications(user_room)
assert len(publications_after) == 1, publications_after
assert publications_after[0].sid == sid_before, (
"publication SID changed on resume — engine should have preserved it"
)
# Audio continues to flow uninterrupted.
await mon.assert_audio_continuous(min_rms=_AUDIO_RMS_THRESHOLD, duration=1.0)
async def test_full_reconnect_republishes_once_and_audio_recovers(self):
"""Full reconnect must fire `reconnected` exactly once, end with
exactly one audio publication (the SDK's auto-republish — the
agents framework must not produce a duplicate), and audio must
recover."""
room_name = _e2e_room_name("full")
async with (
_connect_e2e_room(_USER_IDENTITY, room_name) as user_room,
_connect_e2e_room(_AGENT_IDENTITY, room_name, agent=True) as agent_room,
_agent_publishing_tone(agent_room) as (_output, _tone),
):
await asyncio.wait_for(
wait_for_participant(user_room, identity=_AGENT_IDENTITY),
timeout=10.0,
)
track = await _await_subscribed_audio(user_room)
async with AudioEnergyMonitor.watch(track) as mon:
await mon.wait_for_audio(min_rms=_AUDIO_RMS_THRESHOLD, timeout=5.0)
publications_before = _agent_audio_publications(user_room)
assert len(publications_before) == 1
# Bug regression check: should fire exactly once.
reconnect_count = 0
def _count(*_args) -> None:
nonlocal reconnect_count
reconnect_count += 1
agent_room.on("reconnected", _count)
await simulate_full_reconnect(agent_room)
await wait_for_event(agent_room, "reconnected", timeout=20.0)
# After the SDK's auto-republish the user observes
# unpublish -> publish on the agent. Resubscribe.
new_track = await _await_subscribed_audio(user_room)
# Bug regression: must be exactly ONE audio publication.
# Pre-fix, the agents framework's `_on_reconnected` raced
# to publish a second track on top of the SDK's
# auto-republished one.
await asyncio.sleep(0.5) # let any stray duplicate settle
publications_after = _agent_audio_publications(user_room)
assert len(publications_after) == 1, (
f"expected exactly 1 audio publication after full reconnect, "
f"saw {len(publications_after)}: {[p.sid for p in publications_after]}"
)
assert reconnect_count == 1, (
f"reconnected fired {reconnect_count} times; expected exactly 1"
)
async with AudioEnergyMonitor.watch(new_track) as new_mon:
await new_mon.wait_for_audio(min_rms=_AUDIO_RMS_THRESHOLD, timeout=10.0)
await new_mon.assert_audio_continuous(
min_rms=_AUDIO_RMS_THRESHOLD, duration=1.0
)