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

253 lines
9.8 KiB
Python

from __future__ import annotations
from collections.abc import AsyncIterable
from unittest.mock import AsyncMock, MagicMock, PropertyMock
import pytest
from livekit import rtc
from livekit.agents import Agent
from livekit.agents.types import NOT_GIVEN
from livekit.agents.utils import aio
from livekit.agents.voice.agent import ModelSettings
from livekit.agents.voice.agent_activity import AgentActivity
from livekit.agents.voice.audio_recognition import AudioRecognition, _STTPipeline
from livekit.agents.voice.turn import _StreamingTurnDetector
pytestmark = [pytest.mark.unit, pytest.mark.concurrent]
def _make_activity(agent: Agent, stt: object, turn_detection: object = None) -> MagicMock:
act = MagicMock(spec=AgentActivity)
act.agent = agent
act._audio_recognition = MagicMock()
act._audio_recognition._detach_stt = AsyncMock(return_value=MagicMock())
act._audio_recognition._detach_turn_detector = MagicMock(return_value=MagicMock())
type(act).stt = PropertyMock(return_value=stt)
# turn detector reuse checks read this; None disables the reuse branch
act._turn_detection = turn_detection
# rt session reuse checks need these
act._rt_session = None
type(act).llm = PropertyMock(return_value=None)
type(act).tools = PropertyMock(return_value=[])
return act
async def _detach_stt_if_reusable(old: MagicMock, new: MagicMock) -> object | None:
"""Call the real _detach_reusable_resources, return stt_pipeline."""
resources = await AgentActivity._detach_reusable_resources(old, new)
return resources.stt_pipeline
# ---------------------------------------------------------------------------
# STT pipeline reuse via _detach_reusable_resources
# ---------------------------------------------------------------------------
async def test_reusable_same_class_same_stt() -> None:
"""Two plain Agent instances sharing the same STT object → reusable."""
shared_stt = MagicMock()
old = _make_activity(Agent(instructions="a"), shared_stt)
new = _make_activity(Agent(instructions="b"), shared_stt)
result = await _detach_stt_if_reusable(old, new)
assert result is not None # detach_stt was called
old._audio_recognition._detach_stt.assert_awaited_once()
async def test_not_reusable_different_stt_instance() -> None:
"""Different STT instances (different connections) → not reusable."""
old = _make_activity(Agent(instructions="a"), MagicMock())
new = _make_activity(Agent(instructions="b"), MagicMock())
result = await _detach_stt_if_reusable(old, new)
assert result is None
async def test_not_reusable_no_stt() -> None:
"""Either side missing STT → not reusable."""
shared_stt = MagicMock()
old = _make_activity(Agent(instructions="a"), None)
new = _make_activity(Agent(instructions="b"), shared_stt)
result = await _detach_stt_if_reusable(old, new)
assert result is None
async def test_not_reusable_different_stt_node_override() -> None:
"""Both subclasses define their own stt_node override → not reusable."""
shared_stt = MagicMock()
class AgentA(Agent):
def stt_node(
self, audio: AsyncIterable[rtc.AudioFrame], model_settings: ModelSettings
) -> None:
return None
class AgentB(Agent):
def stt_node(
self, audio: AsyncIterable[rtc.AudioFrame], model_settings: ModelSettings
) -> None:
return None
old = _make_activity(AgentA(instructions="a"), shared_stt)
new = _make_activity(AgentB(instructions="b"), shared_stt)
result = await _detach_stt_if_reusable(old, new)
assert result is None
async def test_not_reusable_subclass_inherits_custom_stt_node() -> None:
"""Both agents share a custom stt_node via inheritance → not reusable.
The pipeline is bound to the old agent's `self`; a custom stt_node may access
self.session/activity inside the yield loop, which raises after detach.
Only the default Agent.stt_node is known to be safe to reuse.
"""
shared_stt = MagicMock()
class AgentA(Agent):
def stt_node(
self, audio: AsyncIterable[rtc.AudioFrame], model_settings: ModelSettings
) -> None:
return None
class AgentB(AgentA):
pass # inherits AgentA.stt_node without overriding
assert AgentA.stt_node is AgentB.stt_node # sanity check
old = _make_activity(AgentA(instructions="a"), shared_stt)
new = _make_activity(AgentB(instructions="b"), shared_stt)
result = await _detach_stt_if_reusable(old, new)
assert result is None
async def test_not_reusable_subclass_overrides_stt_node() -> None:
"""Old agent has stt_node; new agent's subclass overrides it differently → not reusable."""
shared_stt = MagicMock()
class AgentA(Agent):
def stt_node(
self, audio: AsyncIterable[rtc.AudioFrame], model_settings: ModelSettings
) -> None:
return None
class AgentB(AgentA):
def stt_node( # type: ignore[override]
self, audio: AsyncIterable[rtc.AudioFrame], model_settings: ModelSettings
) -> None:
return None
old = _make_activity(AgentA(instructions="a"), shared_stt)
new = _make_activity(AgentB(instructions="b"), shared_stt)
result = await _detach_stt_if_reusable(old, new)
assert result is None
async def test_not_reusable_no_audio_recognition() -> None:
"""Old activity has no audio recognition → not reusable."""
shared_stt = MagicMock()
old = _make_activity(Agent(instructions="a"), shared_stt)
old._audio_recognition = None
new = _make_activity(Agent(instructions="b"), shared_stt)
result = await _detach_stt_if_reusable(old, new)
assert result is None
# ---------------------------------------------------------------------------
# Turn detector stream reuse via _detach_reusable_resources
# ---------------------------------------------------------------------------
async def _detach_turn_detector_if_reusable(old: MagicMock, new: MagicMock) -> object | None:
"""Call the real _detach_reusable_resources, return turn_detector_stream."""
resources = await AgentActivity._detach_reusable_resources(old, new)
return resources.turn_detector_stream
async def test_turn_detector_reusable_same_instance() -> None:
"""Same TurnDetector instance carries over → live stream is detached for reuse."""
shared_detector = MagicMock(spec=_StreamingTurnDetector)
old = _make_activity(Agent(instructions="a"), MagicMock(), turn_detection=shared_detector)
new = _make_activity(Agent(instructions="b"), MagicMock(), turn_detection=shared_detector)
result = await _detach_turn_detector_if_reusable(old, new)
assert result is not None
old._audio_recognition._detach_turn_detector.assert_called_once()
async def test_turn_detector_not_reusable_different_instance() -> None:
"""Different detector instances → not reusable (old stream torn down normally)."""
old = _make_activity(
Agent(instructions="a"), MagicMock(), turn_detection=MagicMock(spec=_StreamingTurnDetector)
)
new = _make_activity(
Agent(instructions="b"), MagicMock(), turn_detection=MagicMock(spec=_StreamingTurnDetector)
)
result = await _detach_turn_detector_if_reusable(old, new)
assert result is None
old._audio_recognition._detach_turn_detector.assert_not_called()
async def test_turn_detector_not_reusable_when_new_opts_out() -> None:
"""New agent resolves to no turn detection (e.g. realtime server-side) → not reusable."""
shared_detector = MagicMock(spec=_StreamingTurnDetector)
old = _make_activity(Agent(instructions="a"), MagicMock(), turn_detection=shared_detector)
new = _make_activity(Agent(instructions="b"), MagicMock(), turn_detection=None)
result = await _detach_turn_detector_if_reusable(old, new)
assert result is None
old._audio_recognition._detach_turn_detector.assert_not_called()
# ---------------------------------------------------------------------------
# Input-time anchor (end_time=0 wall clock) survives pipeline reuse
# ---------------------------------------------------------------------------
def _stub_recognition() -> AudioRecognition:
ar = object.__new__(AudioRecognition)
ar._stt_consumer_atask = None # type: ignore[attr-defined]
ar._stt_pipeline = None # type: ignore[attr-defined]
ar._transcript_buffer = MagicMock() # type: ignore[attr-defined]
ar._ignore_user_transcript_until = NOT_GIVEN # type: ignore[attr-defined]
return ar
async def test_input_anchor_preserved_when_pipeline_reused() -> None:
"""_update_stt must not reset a reused pipeline's input anchor.
The STT ``end_time`` clock is relative to the original stream start. Re-anchoring
``input_started_at`` to the handoff time would desync the two and push the derived
speaking time minutes into the future (see the 68s end-of-turn stall).
"""
reused = object.__new__(_STTPipeline)
reused.input_started_at = 1000.0 # anchored during the previous activity
ch = aio.Chan() # type: ignore[var-annotated]
ch.close() # closed channel → the swapped-in consumer exits immediately
reused._event_ch = ch # type: ignore[attr-defined]
ar = _stub_recognition()
ar._update_stt(MagicMock(), pipeline=reused)
assert ar._input_started_at == 1000.0 # carried over, not reset to None
if ar._stt_consumer_atask is not None:
await ar._stt_consumer_atask
def test_input_anchor_reads_through_to_pipeline() -> None:
"""The anchor lives on the pipeline so it travels with the stream across handoff."""
ar = _stub_recognition()
assert ar._input_started_at is None # no pipeline attached yet
pipeline = object.__new__(_STTPipeline)
pipeline.input_started_at = 1234.5
ar._stt_pipeline = pipeline # type: ignore[attr-defined]
assert ar._input_started_at == 1234.5 # read-only view of the pipeline's anchor