442 lines
14 KiB
Python
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
|