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

442 lines
14 KiB
Python

from __future__ import annotations
import asyncio
from typing import Any
from unittest.mock import MagicMock
import pytest
from livekit.agents.voice.events import AgentStateChangedEvent, UserStateChangedEvent
from livekit.agents.voice.filler_scheduler import _FillerScheduler
pytestmark = [pytest.mark.unit, pytest.mark.virtual_time, pytest.mark.no_concurrent]
class _FakeSpeechHandle:
def __init__(self) -> None:
self.num_steps = 1
self.allow_interruptions = True
self._interrupt_fut: asyncio.Future[None] = asyncio.Future()
self._done_callbacks: list[Any] = []
self._done = False
@property
def interrupted(self) -> bool:
return self._interrupt_fut.done()
def mark_interrupted(self) -> None:
if not self._interrupt_fut.done():
self._interrupt_fut.set_result(None)
self.mark_done()
def mark_done(self) -> None:
if self._done:
return
self._done = True
for cb in list(self._done_callbacks):
cb(self)
def add_done_callback(self, callback: Any) -> None:
if self._done:
callback(self)
else:
self._done_callbacks.append(callback)
def remove_done_callback(self, callback: Any) -> None:
if callback in self._done_callbacks:
self._done_callbacks.remove(callback)
async def wait_if_not_interrupted(self, aw: list[asyncio.Future[Any]]) -> None:
gather_fut = asyncio.gather(*[asyncio.shield(f) for f in aw], return_exceptions=True)
done, pending = await asyncio.wait(
{gather_fut, self._interrupt_fut}, return_when=asyncio.FIRST_COMPLETED
)
if gather_fut in pending:
gather_fut.cancel()
try:
await gather_fut
except (asyncio.CancelledError, Exception):
pass
class _FakeSession:
"""Minimal AgentSession stand-in. Idle by default; tests emit state events
via ``emit_agent_state`` / ``emit_user_state``."""
def __init__(self) -> None:
self.say_calls: list[dict[str, Any]] = []
self._listeners: dict[str, list[Any]] = {}
self._idle_event = asyncio.Event()
self._idle_event.set() # idle by default
def on(self, event: str, callback: Any) -> Any:
self._listeners.setdefault(event, []).append(callback)
return callback
def emit(self, event: str, ev: Any) -> None:
for cb in list(self._listeners.get(event, [])):
cb(ev)
def off(self, event: str, callback: Any) -> None:
if callback in self._listeners.get(event, []):
self._listeners[event].remove(callback)
async def wait_for_idle(self) -> Any:
await self._idle_event.wait()
return None
def set_idle(self, idle: bool) -> None:
if idle:
self._idle_event.set()
else:
self._idle_event.clear()
def emit_agent_state(self, new_state: str, old_state: str = "idle") -> None:
ev = AgentStateChangedEvent(old_state=old_state, new_state=new_state) # type: ignore[arg-type]
for cb in list(self._listeners.get("agent_state_changed", [])):
cb(ev)
def emit_user_state(self, new_state: str, old_state: str = "listening") -> None:
ev = UserStateChangedEvent(old_state=old_state, new_state=new_state) # type: ignore[arg-type]
for cb in list(self._listeners.get("user_state_changed", [])):
cb(ev)
def say(self, text: Any, **kwargs: Any) -> Any:
self.say_calls.append({"text": text, **kwargs})
handle = MagicMock()
async def _wait_for_playout() -> None:
return None
handle.wait_for_playout = _wait_for_playout
return handle
@pytest.mark.asyncio
async def test_scheduler_can_start_and_stop_without_firing() -> None:
"""A scheduler that is started and immediately stopped before delay elapses
should fire nothing and leave no leaked tasks."""
session = _FakeSession()
handle = _FakeSpeechHandle()
scheduler = _FillerScheduler(
session=session, # type: ignore[arg-type]
speech_handle=handle, # type: ignore[arg-type]
source="should not fire",
delay=10.0,
interval=None,
max_steps=None,
)
await asyncio.sleep(0.01)
await scheduler.aclose()
assert session.say_calls == []
@pytest.mark.asyncio
async def test_scheduler_stop_is_idempotent() -> None:
"""Calling stop() twice is safe."""
session = _FakeSession()
handle = _FakeSpeechHandle()
scheduler = _FillerScheduler(
session=session, # type: ignore[arg-type]
speech_handle=handle, # type: ignore[arg-type]
source="x",
delay=10.0,
interval=None,
max_steps=None,
)
await scheduler.aclose()
await scheduler.aclose() # no-op
@pytest.mark.asyncio
async def test_scheduler_fires_after_idle_dwell() -> None:
"""When session is idle, scheduler fires after delay seconds and calls session.say."""
session = _FakeSession()
handle = _FakeSpeechHandle()
scheduler = _FillerScheduler(
session=session, # type: ignore[arg-type]
speech_handle=handle, # type: ignore[arg-type]
source="let me check",
delay=0.05,
interval=None,
max_steps=None,
)
await asyncio.sleep(0.15)
await scheduler.aclose()
assert [c["text"] for c in session.say_calls] == ["let me check"]
@pytest.mark.asyncio
async def test_scheduler_does_not_fire_before_dwell_elapses() -> None:
"""If we stop before delay elapses, no fire happens."""
session = _FakeSession()
handle = _FakeSpeechHandle()
scheduler = _FillerScheduler(
session=session, # type: ignore[arg-type]
speech_handle=handle, # type: ignore[arg-type]
source="let me check",
delay=1.0,
interval=None,
max_steps=None,
)
await asyncio.sleep(0.05)
await scheduler.aclose()
assert session.say_calls == []
@pytest.mark.asyncio
async def test_scheduler_dwell_resets_on_agent_state_change() -> None:
"""If agent becomes non-idle mid-dwell, the dwell restarts from zero."""
session = _FakeSession()
handle = _FakeSpeechHandle()
scheduler = _FillerScheduler(
session=session, # type: ignore[arg-type]
speech_handle=handle, # type: ignore[arg-type]
source="ping",
delay=0.1,
interval=None,
max_steps=None,
)
# halfway through the dwell window, emit "non-idle" → dwell must restart
await asyncio.sleep(0.05)
session.emit_agent_state("speaking")
# at this point we've used ~0.05s; if dwell didn't reset, fire would land
# around t=0.10. With reset, fire shouldn't land until at least t=0.05+0.10=0.15
await asyncio.sleep(0.08)
fire_count_before = len(session.say_calls)
await asyncio.sleep(0.05) # cross past 0.15
fire_count_after = len(session.say_calls)
await scheduler.aclose()
assert fire_count_before == 0 # didn't fire before reset+delay
assert fire_count_after == 1
@pytest.mark.asyncio
async def test_scheduler_dwell_resets_on_user_speaking() -> None:
"""User speaking also resets the dwell."""
session = _FakeSession()
handle = _FakeSpeechHandle()
scheduler = _FillerScheduler(
session=session, # type: ignore[arg-type]
speech_handle=handle, # type: ignore[arg-type]
source="ping",
delay=0.1,
interval=None,
max_steps=None,
)
await asyncio.sleep(0.05)
session.emit_user_state("speaking")
await asyncio.sleep(0.08)
assert session.say_calls == []
await asyncio.sleep(0.05)
await scheduler.aclose()
assert [c["text"] for c in session.say_calls] == ["ping"]
@pytest.mark.asyncio
async def test_scheduler_invokes_callable_source_lazily() -> None:
"""A Callable source is invoked at fire time and the returned handle awaited."""
session = _FakeSession()
handle = _FakeSpeechHandle()
invocations: list[int] = []
def factory(step: int) -> Any:
invocations.append(step)
return session.say("from callable")
scheduler = _FillerScheduler(
session=session, # type: ignore[arg-type]
speech_handle=handle, # type: ignore[arg-type]
source=factory,
delay=0.05,
interval=None,
max_steps=None,
)
# at start, factory should not have run yet
assert invocations == []
await asyncio.sleep(0.15)
await scheduler.aclose()
assert invocations == [0] # first fire, step counter starts at 0
assert [c["text"] for c in session.say_calls] == ["from callable"]
@pytest.mark.asyncio
async def test_scheduler_fires_repeatedly_with_interval() -> None:
"""interval=X means fire, sleep X wall-clock, then restart dwell."""
session = _FakeSession()
handle = _FakeSpeechHandle()
scheduler = _FillerScheduler(
session=session, # type: ignore[arg-type]
speech_handle=handle, # type: ignore[arg-type]
source="tick",
delay=0.05,
interval=0.05,
max_steps=None,
)
await asyncio.sleep(0.35) # plenty of time for several fires
await scheduler.aclose()
# at least 2 fires; exact count depends on timing but cap at a sane upper bound
assert 2 <= len(session.say_calls) <= 6
assert all(c["text"] == "tick" for c in session.say_calls)
@pytest.mark.asyncio
async def test_scheduler_interval_none_fires_once_only() -> None:
"""interval=None means at-most-once."""
session = _FakeSession()
handle = _FakeSpeechHandle()
scheduler = _FillerScheduler(
session=session, # type: ignore[arg-type]
speech_handle=handle, # type: ignore[arg-type]
source="once",
delay=0.02,
interval=None,
max_steps=None,
)
await asyncio.sleep(0.3) # would fire ~15 times if it looped
await scheduler.aclose()
assert len(session.say_calls) == 1
@pytest.mark.asyncio
async def test_reset_dwell_aborts_current_dwell() -> None:
"""reset_dwell() called mid-dwell aborts the current dwell and restarts."""
session = _FakeSession()
handle = _FakeSpeechHandle()
scheduler = _FillerScheduler(
session=session, # type: ignore[arg-type]
speech_handle=handle, # type: ignore[arg-type]
source="x",
delay=0.1,
interval=None,
max_steps=None,
)
await asyncio.sleep(0.05)
scheduler.reset_dwell()
await asyncio.sleep(0.08)
fire_before = len(session.say_calls)
await asyncio.sleep(0.05)
await scheduler.aclose()
fire_after = len(session.say_calls)
assert fire_before == 0 # didn't fire before reset+delay completed
assert fire_after == 1
@pytest.mark.asyncio
async def test_reset_dwell_is_safe_to_call_anytime() -> None:
"""Calling reset_dwell at any point is safe (it's just an event.set)."""
session = _FakeSession()
handle = _FakeSpeechHandle()
scheduler = _FillerScheduler(
session=session, # type: ignore[arg-type]
speech_handle=handle, # type: ignore[arg-type]
source="x",
delay=10.0,
interval=None,
max_steps=None,
)
scheduler.reset_dwell() # must not raise
await scheduler.aclose()
scheduler.reset_dwell() # also safe after stop
@pytest.mark.asyncio
async def test_scheduler_does_not_fire_after_speech_interrupted() -> None:
"""Once speech_handle.interrupted goes True, subsequent ticks must not fire."""
session = _FakeSession()
handle = _FakeSpeechHandle()
scheduler = _FillerScheduler(
session=session, # type: ignore[arg-type]
speech_handle=handle, # type: ignore[arg-type]
source="x",
delay=0.02,
interval=0.02,
max_steps=None,
)
await asyncio.sleep(0.05) # at least one fire happens
fires_when_interrupted = len(session.say_calls)
handle.mark_interrupted()
await asyncio.sleep(0.2) # would have fired many more times if uninterrupted
await scheduler.aclose()
# at most one additional fire that was already in flight at interrupt time
assert len(session.say_calls) <= fires_when_interrupted + 1
@pytest.mark.asyncio
async def test_run_context_with_filler_yields_and_fires() -> None:
"""async with ctx.with_filler(...) starts a scheduler and tears it down on exit."""
from livekit.agents.llm import FunctionCall
from livekit.agents.voice.events import RunContext
session = _FakeSession()
handle = _FakeSpeechHandle()
ctx: RunContext = RunContext(
session=session, # type: ignore[arg-type]
speech_handle=handle, # type: ignore[arg-type]
function_call=FunctionCall(call_id="c", name="t", arguments="{}"),
)
async with ctx.with_filler("hello", delay=0.02, interval=None):
await asyncio.sleep(0.1)
assert [c["text"] for c in session.say_calls] == ["hello"]
@pytest.mark.asyncio
async def test_run_context_with_filler_cancels_on_exit() -> None:
"""Exiting the cm before delay elapses cancels the pending fire."""
from livekit.agents.llm import FunctionCall
from livekit.agents.voice.events import RunContext
session = _FakeSession()
handle = _FakeSpeechHandle()
ctx: RunContext = RunContext(
session=session, # type: ignore[arg-type]
speech_handle=handle, # type: ignore[arg-type]
function_call=FunctionCall(call_id="c", name="t", arguments="{}"),
)
async with ctx.with_filler("nope", delay=1.0, interval=None):
await asyncio.sleep(0.01)
assert session.say_calls == []
@pytest.mark.asyncio
async def test_ctx_update_resets_pending_filler_dwell() -> None:
"""ctx.update() called mid-dwell aborts the current dwell on every active filler."""
from livekit.agents.llm import FunctionCall
from livekit.agents.voice.events import RunContext
session = _FakeSession()
handle = _FakeSpeechHandle()
ctx: RunContext = RunContext(
session=session, # type: ignore[arg-type]
speech_handle=handle, # type: ignore[arg-type]
function_call=FunctionCall(call_id="c", name="t", arguments="{}"),
)
async with ctx.with_filler("ping", delay=0.1, interval=None):
await asyncio.sleep(0.05)
# update() resets the dwell on every active filler before anything else;
# fire two back to back to confirm each call resets it
await ctx.update("first")
await ctx.update("second")
await asyncio.sleep(0.08)
# not yet fired — reset_dwell pushed the fire past 0.05+0.1=0.15
fires_mid = len(session.say_calls)
await asyncio.sleep(0.05) # cross past 0.15
fires_late = len(session.say_calls)
assert fires_mid == 0
assert fires_late == 1